diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1ff0c42..0000000 --- a/.gitattributes +++ /dev/null @@ -1,63 +0,0 @@ -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### -* text=auto - -############################################################################### -# Set default behavior for command prompt diff. -# -# This is need for earlier builds of msysgit that does not have it on by -# default for csharp files. -# Note: This is only used by command line -############################################################################### -#*.cs diff=csharp - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just uncomment the entries below -############################################################################### -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain diff --git a/.github/workflows/dotnet-build-test.yml b/.github/workflows/dotnet-build-test.yml deleted file mode 100644 index b143aae..0000000 --- a/.github/workflows/dotnet-build-test.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: ci build and test - -on: - push: - branches: [ "master" ] - tags: - - 'v*' - pull_request: - branches: [ "master" ] - -jobs: - build: - runs-on: windows-latest - env: - Solution_Name: CharacterRecordsGenerator.sln - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - name: Add MSBuild to path - uses: microsoft/setup-msbuild@v1.1 - - name: Setup NuGet - uses: NuGet/setup-nuget@v1 - - name: Restore nuget - run: nuget restore $env:Solution_Name - - name: Build - run: msbuild $env:Solution_Name /t:Rebuild -property:Configuration=Release - - name: Unit tests - run: dotnet test --no-build --verbosity normal - - name: Archive - uses: actions/upload-artifact@v3 - with: - name: character-records-generator - path: | - CharacterRecordsGenerator/bin/Release/*.exe - CharacterRecordsGenerator/bin/Release/*.dll - deploy: - name: Create release - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - needs: build - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - name: Create release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - draft: false - prerelease: false - - uses: actions/download-artifact@v3 - with: - name: character-records-generator - path: character-records-generator - - run: ls - - run: zip -r character-records-generator.zip character-records-generator - - name: Upload character-records-generator - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./character-records-generator.zip - asset_name: character-records-generator.zip - asset_content_type: application/zip diff --git a/.gitignore b/.gitignore index 3c4efe2..3b462cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,261 +1,23 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. +node_modules -# User-specific files -*.suo -*.user -*.userosscache -*.sln.docstates +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs +# OS +.DS_Store +Thumbs.db -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ +# Env +.env +.env.* +!.env.example +!.env.test -# Visual Studio 2015 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUNIT -*.VisualState.xml -TestResult.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# DNX -project.lock.json -project.fragment.lock.json -artifacts/ - -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# JustCode is a .NET coding add-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# The packages folder can be ignored because of Package Restore -**/packages/* -# except build/, which is used as an MSBuild target. -!**/packages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -node_modules/ -orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files -*.mdf -*.ldf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml - -# CodeRush -.cr/ - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/CharacterRecordsGenerator.Tests/CharacterRecordsGenerator.Tests.csproj b/CharacterRecordsGenerator.Tests/CharacterRecordsGenerator.Tests.csproj deleted file mode 100644 index 2a90304..0000000 --- a/CharacterRecordsGenerator.Tests/CharacterRecordsGenerator.Tests.csproj +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - Debug - AnyCPU - {195A07A8-03F7-4A1F-932F-73F934EF324F} - Library - Properties - CharacterRecordsGeneratorTests - CharacterRecordsGeneratorTests - v4.5.2 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 15.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\packages\MSTest.TestFramework.2.1.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll - - - ..\packages\MSTest.TestFramework.2.1.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll - - - - - - - - - - - - - - {2e1295c2-7bd9-454e-b13e-8a22448dd5f6} - CharacterRecordsGenerator - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - \ No newline at end of file diff --git a/CharacterRecordsGenerator.Tests/CharacterRecordsUtilitiesTests.cs b/CharacterRecordsGenerator.Tests/CharacterRecordsUtilitiesTests.cs deleted file mode 100644 index 05fa216..0000000 --- a/CharacterRecordsGenerator.Tests/CharacterRecordsUtilitiesTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using CharacterRecordsGenerator; - -namespace CharacterRecordsGeneratorTests -{ - [TestClass] - public class CharacterRecordsUtilitiesTests - { - [TestMethod] - public void Utility_CmToFeet_WithValidNumber() - { - double cm = 150.0; - string expected = "4'11\""; - Assert.AreEqual(expected, Utility.CmToFeet(cm)); - } - } -} diff --git a/CharacterRecordsGenerator.Tests/Properties/AssemblyInfo.cs b/CharacterRecordsGenerator.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 0dda8c2..0000000 --- a/CharacterRecordsGenerator.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("CharacterRecordsGeneratorTests")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("CharacterRecordsGeneratorTests")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -[assembly: ComVisible(false)] - -[assembly: Guid("195a07a8-03f7-4a1f-932f-73f934ef324f")] - -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/CharacterRecordsGenerator.Tests/packages.config b/CharacterRecordsGenerator.Tests/packages.config deleted file mode 100644 index 506206f..0000000 --- a/CharacterRecordsGenerator.Tests/packages.config +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/CharacterRecordsGenerator.sln b/CharacterRecordsGenerator.sln deleted file mode 100644 index 742d3df..0000000 --- a/CharacterRecordsGenerator.sln +++ /dev/null @@ -1,54 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31702.278 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterRecordsGenerator", "CharacterRecordsGenerator\CharacterRecordsGenerator.csproj", "{2E1295C2-7BD9-454E-B13E-8A22448DD5F6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterRecordsGenerator.Tests", "CharacterRecordsGenerator.Tests\CharacterRecordsGenerator.Tests.csproj", "{195A07A8-03F7-4A1F-932F-73F934EF324F}" - ProjectSection(ProjectDependencies) = postProject - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6} = {2E1295C2-7BD9-454E-B13E-8A22448DD5F6} - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|x64.ActiveCfg = Debug|x64 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|x64.Build.0 = Debug|x64 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|x86.ActiveCfg = Debug|x86 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Debug|x86.Build.0 = Debug|x86 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|Any CPU.Build.0 = Release|Any CPU - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|x64.ActiveCfg = Release|x64 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|x64.Build.0 = Release|x64 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|x86.ActiveCfg = Release|x86 - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6}.Release|x86.Build.0 = Release|x86 - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|x64.ActiveCfg = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|x64.Build.0 = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|x86.ActiveCfg = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Debug|x86.Build.0 = Debug|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|Any CPU.Build.0 = Release|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|x64.ActiveCfg = Release|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|x64.Build.0 = Release|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|x86.ActiveCfg = Release|Any CPU - {195A07A8-03F7-4A1F-932F-73F934EF324F}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {3F94E0AF-3102-424C-A6C2-B930212E7CD8} - EndGlobalSection -EndGlobal diff --git a/CharacterRecordsGenerator/App.config b/CharacterRecordsGenerator/App.config deleted file mode 100644 index 88fa402..0000000 --- a/CharacterRecordsGenerator/App.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CharacterRecordsGenerator/App.xaml b/CharacterRecordsGenerator/App.xaml deleted file mode 100644 index 186554e..0000000 --- a/CharacterRecordsGenerator/App.xaml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/CharacterRecordsGenerator/App.xaml.cs b/CharacterRecordsGenerator/App.xaml.cs deleted file mode 100644 index f03a881..0000000 --- a/CharacterRecordsGenerator/App.xaml.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Configuration; -using System.Data; -using System.Linq; -using System.Threading.Tasks; -using System.Windows; - -namespace CharacterRecordsGenerator -{ - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application - { - } -} diff --git a/CharacterRecordsGenerator/CRG.ico b/CharacterRecordsGenerator/CRG.ico deleted file mode 100644 index 11f3ebe..0000000 Binary files a/CharacterRecordsGenerator/CRG.ico and /dev/null differ diff --git a/CharacterRecordsGenerator/CharacterRecordsGenerator.csproj b/CharacterRecordsGenerator/CharacterRecordsGenerator.csproj deleted file mode 100644 index 6d9d674..0000000 --- a/CharacterRecordsGenerator/CharacterRecordsGenerator.csproj +++ /dev/null @@ -1,220 +0,0 @@ - - - - - Debug - AnyCPU - {2E1295C2-7BD9-454E-B13E-8A22448DD5F6} - WinExe - Properties - CharacterRecordsGenerator - Character Records Generator - v4.5.2 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 4 - true - false - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 2.0.0.%2a - false - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - true - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - true - - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - true - - - CRG.ico - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - 7.3 - prompt - MinimumRecommendedRules.ruleset - true - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - 7.3 - prompt - MinimumRecommendedRules.ruleset - true - - - - ..\packages\ControlzEx.3.0.2.4\lib\net45\ControlzEx.dll - - - ..\packages\Humanizer.Core.2.5.16\lib\netstandard1.0\Humanizer.dll - - - ..\packages\MahApps.Metro.1.6.5\lib\net45\MahApps.Metro.dll - - - ..\packages\protobuf-net.2.4.0\lib\net40\protobuf-net.dll - - - - - - - - - - ..\packages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll - - - - - ..\packages\ControlzEx.3.0.2.4\lib\net45\System.Windows.Interactivity.dll - - - - - - - - - 4.0 - - - - - - - - MSBuild:Compile - Designer - - - GeneratedResultWindow.xaml - - - - - - - - Designer - MSBuild:Compile - - - MSBuild:Compile - Designer - - - App.xaml - Code - - - RecordEditor.xaml - Code - - - - - Code - - - True - True - Resources.resx - - - True - Settings.settings - True - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - - - - - - - False - Microsoft .NET Framework 4.5.2 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 - false - - - - - - - - \ No newline at end of file diff --git a/CharacterRecordsGenerator/GeneratedResultWindow.xaml b/CharacterRecordsGenerator/GeneratedResultWindow.xaml deleted file mode 100644 index 349c0c7..0000000 --- a/CharacterRecordsGenerator/GeneratedResultWindow.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/CharacterRecordsGenerator/GeneratedResultWindow.xaml.cs b/CharacterRecordsGenerator/GeneratedResultWindow.xaml.cs deleted file mode 100644 index 4400c82..0000000 --- a/CharacterRecordsGenerator/GeneratedResultWindow.xaml.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CharacterRecordsGenerator -{ - public partial class GeneratedResultWindow - { - public GeneratedResultWindow() - { - InitializeComponent(); - } - - public GeneratedResultWindow(Record record) : this() - { - var formatter = new RecordFormatter(record); - EmploymentBox.Text = formatter.EmploymentRecords; - MedicalBox.Text = formatter.MedicalRecords; - SecurityBox.Text = formatter.SecurityRecords; - } - } -} diff --git a/CharacterRecordsGenerator/Properties/AssemblyInfo.cs b/CharacterRecordsGenerator/Properties/AssemblyInfo.cs deleted file mode 100644 index 1570435..0000000 --- a/CharacterRecordsGenerator/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; -using System.Windows; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("CRG")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Character Records Generator")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CharacterRecordsGeneratorTests")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -//In order to begin building localizable applications, set -//CultureYouAreCodingWith in your .csproj file -//inside a . For example, if you are using US english -//in your source files, set the to en-US. Then uncomment -//the NeutralResourceLanguage attribute below. Update the "en-US" in -//the line below to match the UICulture setting in the project file. - -//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] - - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] - - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("2.2.*")] -[assembly: AssemblyFileVersion("1.1.0.0")] diff --git a/CharacterRecordsGenerator/Properties/Resources.Designer.cs b/CharacterRecordsGenerator/Properties/Resources.Designer.cs deleted file mode 100644 index 7bb5997..0000000 --- a/CharacterRecordsGenerator/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace CharacterRecordsGenerator.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("CharacterRecordsGenerator.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/CharacterRecordsGenerator/Properties/Resources.resx b/CharacterRecordsGenerator/Properties/Resources.resx deleted file mode 100644 index af7dbeb..0000000 --- a/CharacterRecordsGenerator/Properties/Resources.resx +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/CharacterRecordsGenerator/Properties/Settings.Designer.cs b/CharacterRecordsGenerator/Properties/Settings.Designer.cs deleted file mode 100644 index 1f16b29..0000000 --- a/CharacterRecordsGenerator/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace CharacterRecordsGenerator.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.10.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/CharacterRecordsGenerator/Properties/Settings.settings b/CharacterRecordsGenerator/Properties/Settings.settings deleted file mode 100644 index 033d7a5..0000000 --- a/CharacterRecordsGenerator/Properties/Settings.settings +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/CharacterRecordsGenerator/Record.cs b/CharacterRecordsGenerator/Record.cs deleted file mode 100644 index 65e6df5..0000000 --- a/CharacterRecordsGenerator/Record.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using ProtoBuf; - -namespace CharacterRecordsGenerator -{ - [ProtoContract] - public class Record - { - // Defaults defined here will automatically populate the form on program load - [ProtoMember(1)] - public string FirstName { get; set; } = string.Empty; - - [ProtoMember(2)] - public string MiddleName { get; set; } = string.Empty; - - [ProtoMember(3)] - public string LastName { get; set; } = string.Empty; - - [ProtoMember(4)] - public string SpokenLanguages { get; set; } = string.Empty; - - [ProtoMember(5)] - public SpeciesType Species { get; set; } = SpeciesType.Human; - - - [ProtoMember(6)] - public string Pronouns { get; set; } = string.Empty; - - [ProtoMember(7)] - public DateTime BirthDate { get; set; } = Info.IcDate; - - [ProtoMember(8)] - public double? CharHeight { get; set; } = null; - - [ProtoMember(9)] - public double? Weight { get; set; } = null; - - [ProtoMember(10)] - public string SkinColor { get; set; } = string.Empty; - - [ProtoMember(11)] - public string EyeColor { get; set; } = string.Empty; - - [ProtoMember(12)] - public string DistinguishingFeatures { get; set; } = string.Empty; - - [ProtoMember(13)] - public string HairColor { get; set; } = string.Empty; - - [ProtoMember(14)] - public string EmployedAs { get; set; } = string.Empty; - - [ProtoMember(15)] - public string Citizenship { get; set; } = string.Empty; - - [ProtoMember(16)] - public SpeciesSubType Subspecies { get; set; } = SpeciesSubType.None; - - [ProtoMember(17)] - public string PublicNotes { get; set; } = string.Empty; - - [ProtoMember(18)] - public string NextOfKin { get; set; } = string.Empty; - - [ProtoMember(19)] - public string MedicalPostmortem { get; set; } = string.Empty; - - [ProtoMember(20)] - public string MedicalAllergies { get; set; } = string.Empty; - - [ProtoMember(21)] - public string MedicalCurrentPrescriptions { get; set; } = string.Empty; - - [ProtoMember(22)] - public string MedicalHistory { get; set; } = string.Empty; - - [ProtoMember(23)] - public string MedicalSurgicalHistory { get; set; } = string.Empty; - - [ProtoMember(24)] - public bool NoBorg { get; set; } = false; - - [ProtoMember(25)] - public string MedicalPsychDisorders { get; set; } = string.Empty; - - [ProtoMember(26)] - public bool NoRevive { get; set; } = false; - - [ProtoMember(27)] - public bool NoProsthetic { get; set; } = false; - - [ProtoMember(28)] - public string MedicalPhysicalEvaluations { get; set; } = string.Empty; - - [ProtoMember(29)] - public string MedicalPsychEvaluations { get; set; } = string.Empty; - - [ProtoMember(30)] - public string SecurityRecords { get; set; } = string.Empty; - - [ProtoMember(31)] - public string SecurityNotes { get; set; } = string.Empty; - - // 32 was used for EmploymentPublicRecord, now empty - - [ProtoMember(33)] - public string EmploymentExperience { get; set; } = string.Empty; - - [ProtoMember(35)] - public string EmploymentFormalEducation { get; set; } = string.Empty; - - [ProtoMember(36)] - public string EmploymentNtEmploymentHistory { get; set; } = string.Empty; - - [ProtoMember(37)] - public string EmploymentSkills { get; set; } = string.Empty; - - [ProtoMember(38)] - public string SecurityAttitudeScc { get; set; } = string.Empty; - - [ProtoMember(39)] - public string SecurityAttitudeCrew { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/CharacterRecordsGenerator/RecordEditor.xaml b/CharacterRecordsGenerator/RecordEditor.xaml deleted file mode 100644 index f99d9a2..0000000 --- a/CharacterRecordsGenerator/RecordEditor.xaml +++ /dev/null @@ -1,465 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + {#if openDropdown === 'add'} + + {/if} + + + {#if roster.active} + + + toggleDropdown('share')} /> + {/if} + +
+ + {#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if} + + + + + +
+ + + + + +{#if showPicker} + { showPicker = false; }} /> +{/if} + +{#if confirmDelete && roster.active} + { confirmDelete = false; }}> +

Delete Character

+

Delete {displayName()}? This can't be undone.

+
+ + +
+
+{/if} + +{#if showHelp} + { showHelp = false; }}> + +
+ +
+
+{/if} + + { openDropdown = null; }} /> diff --git a/src/lib/components/HelpText.svelte b/src/lib/components/HelpText.svelte new file mode 100644 index 0000000..bac9a7a --- /dev/null +++ b/src/lib/components/HelpText.svelte @@ -0,0 +1,17 @@ +
+

+ Pick a template and fill in the form. Each section covers a different record. Blank fields are omitted from the output automatically, so no rush to finish everything. +

+

+ Characters save to your browser. You can also export to a file or generate a share link: the link itself encodes the full set of records, so functionally it's a save file. +

+

+ Share links let the recipient see a preview of your records, with the option to import the character into their own roster. +

+

+ This tool is entirely data-driven in XML, and it's already set up for template sharing. A visual template editor is coming soon, so anybody can create their own templates and share them between one another. +

+

+ Cheers. +

+
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte new file mode 100644 index 0000000..2296da8 --- /dev/null +++ b/src/lib/components/ImportModal.svelte @@ -0,0 +1,140 @@ + + +
+
+ {#if error} +
+

{error}

+ +
+ {:else if type === 'character' && charData} +
+
+

{charName()}

+

Shared character — {charData.template.name} template

+
+
+ + +
+
+ +
+
+ {#each tabs as tab} + + {/each} +
+ +
+ {:else if type === 'template' && tmplData} +
+

Shared Template: {tmplData.name}

+

{tmplData.records.length} records, {tmplData.records.reduce((n: number, r: any) => n + r.fields.length, 0)} fields

+
+ + +
+
+ {/if} +
+
diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte new file mode 100644 index 0000000..1aad129 --- /dev/null +++ b/src/lib/components/Modal.svelte @@ -0,0 +1,27 @@ + + + + + +
+
+ +
e.stopPropagation()} + > + {@render children()} +
+
diff --git a/src/lib/components/OutputPanel.svelte b/src/lib/components/OutputPanel.svelte new file mode 100644 index 0000000..813c7b2 --- /dev/null +++ b/src/lib/components/OutputPanel.svelte @@ -0,0 +1,42 @@ + + +
+
+ {#each tabs as tab} + + {/each} +
+ + +
diff --git a/src/lib/components/OutputTab.svelte b/src/lib/components/OutputTab.svelte new file mode 100644 index 0000000..f379f4c --- /dev/null +++ b/src/lib/components/OutputTab.svelte @@ -0,0 +1,31 @@ + + +
+
+ {wordCount} words + +
+
{output}
+
diff --git a/src/lib/components/RecordCard.svelte b/src/lib/components/RecordCard.svelte new file mode 100644 index 0000000..9c52302 --- /dev/null +++ b/src/lib/components/RecordCard.svelte @@ -0,0 +1,88 @@ + + +
+ + + {#if expanded} +
+ {#each record.fields as field} + {@const key = slugify(field.label)} + {@const hasError = isRequired(field) && touched[key] && isFieldEmpty(data[key])} +
{ + if (isRequired(field) && !(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) { + touched[key] = true; + } + }} + > + onFieldChange(key, v)} + /> + {#if hasError} +

This field is required

+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/SchemaForm.svelte b/src/lib/components/SchemaForm.svelte new file mode 100644 index 0000000..9654bd9 --- /dev/null +++ b/src/lib/components/SchemaForm.svelte @@ -0,0 +1,212 @@ + + +
+ +
+ + + {#if showTemplateSwitcher} + + {/if} + + {#if pendingMigration} + + {/if} +
+ + + {#if suggestion} +
+

+ {suggestion.reason} + Switching will keep your existing data. +

+
+ + +
+
+ {/if} + + {#each character.template.records as record} + { + character.data[key] = value; + if (speciesKeys.has(key)) { + for (const depKey of speciesDependentKeys) { + character.data[depKey] = ''; + } + } + roster.scheduleSave(character); + }} + /> + {/each} +
+ +{#if showMigrationModal && pendingMigration} + { showMigrationModal = false; }}> +

Template Update

+

The {pendingMigration.preset.name} template has been updated:

+
    + {#each pendingMigration.diff.renamedFields as r} +
  • {r.from} → {r.to}
  • + {/each} + {#each pendingMigration.diff.addedRecords as r} +
  • + New record: {r}
  • + {/each} + {#each pendingMigration.diff.removedRecords as r} +
  • - Removed record: {r}
  • + {/each} + {#each pendingMigration.diff.addedFields as f} +
  • + New field: {f}
  • + {/each} + {#each pendingMigration.diff.removedFields as f} +
  • - Removed field: {f}
  • + {/each} +
+

Your existing data will be preserved.

+
+ + +
+
+{/if} + + { + if (showTemplateSwitcher) { + showTemplateSwitcher = false; + } +}} /> diff --git a/src/lib/components/ShareMenu.svelte b/src/lib/components/ShareMenu.svelte new file mode 100644 index 0000000..2da40a2 --- /dev/null +++ b/src/lib/components/ShareMenu.svelte @@ -0,0 +1,70 @@ + + + + + + {#if open} + + {/if} + diff --git a/src/lib/components/TemplatePicker.svelte b/src/lib/components/TemplatePicker.svelte new file mode 100644 index 0000000..5089c74 --- /dev/null +++ b/src/lib/components/TemplatePicker.svelte @@ -0,0 +1,31 @@ + + + +

New Character

+
+ {#each presets as preset} + + {/each} +
+
diff --git a/src/lib/components/fields/CheckboxField.svelte b/src/lib/components/fields/CheckboxField.svelte new file mode 100644 index 0000000..8964798 --- /dev/null +++ b/src/lib/components/fields/CheckboxField.svelte @@ -0,0 +1,73 @@ + + +
+ {field.label}{#if field.required} *{/if} +
+ {#each field.options as opt} + + {/each} + {#each customValues as cv} + + removeCustom(cv)} /> + {cv} + + + {/each} +
+
+ { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }} + class="rounded px-3 py-1.5 text-sm" + style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);" + /> + +
+
diff --git a/src/lib/components/fields/CitizenshipField.svelte b/src/lib/components/fields/CitizenshipField.svelte new file mode 100644 index 0000000..d15c9a2 --- /dev/null +++ b/src/lib/components/fields/CitizenshipField.svelte @@ -0,0 +1,69 @@ + + + diff --git a/src/lib/components/fields/DateField.svelte b/src/lib/components/fields/DateField.svelte new file mode 100644 index 0000000..aa5002e --- /dev/null +++ b/src/lib/components/fields/DateField.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/fields/DynamicField.svelte b/src/lib/components/fields/DynamicField.svelte new file mode 100644 index 0000000..3893c85 --- /dev/null +++ b/src/lib/components/fields/DynamicField.svelte @@ -0,0 +1,59 @@ + + +{#if field.type === 'name'} + +{:else if field.type === 'text'} + +{:else if field.type === 'textarea'} + +{:else if field.type === 'list'} + +{:else if field.type === 'number'} + +{:else if field.type === 'select'} + +{:else if field.type === 'multi-select'} + +{:else if field.type === 'checkbox'} + +{:else if field.type === 'date'} + +{:else if field.type === 'height'} + +{:else if field.type === 'weight'} + +{:else if field.type === 'species'} + +{:else if field.type === 'subspecies'} + +{:else if field.type === 'citizenship'} + +{:else if field.type === 'languages'} + +{:else if field.type === 'separator'} + +{/if} diff --git a/src/lib/components/fields/HeightField.svelte b/src/lib/components/fields/HeightField.svelte new file mode 100644 index 0000000..a0e308e --- /dev/null +++ b/src/lib/components/fields/HeightField.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/components/fields/LanguagesField.svelte b/src/lib/components/fields/LanguagesField.svelte new file mode 100644 index 0000000..d69b2a7 --- /dev/null +++ b/src/lib/components/fields/LanguagesField.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/lib/components/fields/ListField.svelte b/src/lib/components/fields/ListField.svelte new file mode 100644 index 0000000..4a0e6a0 --- /dev/null +++ b/src/lib/components/fields/ListField.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/fields/MultiSelectField.svelte b/src/lib/components/fields/MultiSelectField.svelte new file mode 100644 index 0000000..d055993 --- /dev/null +++ b/src/lib/components/fields/MultiSelectField.svelte @@ -0,0 +1,100 @@ + + +
+ {field.label}{#if field.required} *{/if} + +
+ {#each value as val} + + {displayLabel(val)} + + + {/each} +
+ +
+ { open = true; }} + onblur={() => { setTimeout(() => { open = false; }, 150); }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (available.length) add(available[0].value); + else addCustom(); + } + }} + class="block w-full rounded px-3 py-2 text-sm" + style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);" + /> + {#if open && (available.length || input.trim())} +
    + {#each available as opt} +
  • + +
  • + {/each} + {#if input.trim() && !field.options.some((o) => o.label.toLowerCase() === input.trim().toLowerCase())} +
  • + +
  • + {/if} +
+ {/if} +
+
diff --git a/src/lib/components/fields/NumberField.svelte b/src/lib/components/fields/NumberField.svelte new file mode 100644 index 0000000..3e8986b --- /dev/null +++ b/src/lib/components/fields/NumberField.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/fields/SelectField.svelte b/src/lib/components/fields/SelectField.svelte new file mode 100644 index 0000000..2fa6951 --- /dev/null +++ b/src/lib/components/fields/SelectField.svelte @@ -0,0 +1,54 @@ + + + diff --git a/src/lib/components/fields/SeparatorField.svelte b/src/lib/components/fields/SeparatorField.svelte new file mode 100644 index 0000000..573f435 --- /dev/null +++ b/src/lib/components/fields/SeparatorField.svelte @@ -0,0 +1,15 @@ + + +{#if field.label} +
+
+ {field.label} +
+
+{:else} +
+{/if} diff --git a/src/lib/components/fields/SpeciesField.svelte b/src/lib/components/fields/SpeciesField.svelte new file mode 100644 index 0000000..4cdb979 --- /dev/null +++ b/src/lib/components/fields/SpeciesField.svelte @@ -0,0 +1,28 @@ + + +
+ + {#if selected?.description} +

{selected.description}

+ {/if} +
diff --git a/src/lib/components/fields/SubspeciesField.svelte b/src/lib/components/fields/SubspeciesField.svelte new file mode 100644 index 0000000..d0a36d0 --- /dev/null +++ b/src/lib/components/fields/SubspeciesField.svelte @@ -0,0 +1,73 @@ + + +{#if subs.length > 0 || isCustom} +
+ + {#if selected?.description} +

{selected.description}

+ {/if} +
+{/if} diff --git a/src/lib/components/fields/TextField.svelte b/src/lib/components/fields/TextField.svelte new file mode 100644 index 0000000..f10a138 --- /dev/null +++ b/src/lib/components/fields/TextField.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/fields/TextareaField.svelte b/src/lib/components/fields/TextareaField.svelte new file mode 100644 index 0000000..16446d6 --- /dev/null +++ b/src/lib/components/fields/TextareaField.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/fields/WeightField.svelte b/src/lib/components/fields/WeightField.svelte new file mode 100644 index 0000000..063fe94 --- /dev/null +++ b/src/lib/components/fields/WeightField.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts new file mode 100644 index 0000000..6d05e8c --- /dev/null +++ b/src/lib/data/index.ts @@ -0,0 +1,13 @@ +import { parseSpecies, parseCitizenships, parseLanguages } from './parse'; +import citizenshipsXml from '../../../data/citizenships.xml?raw'; +import languagesXml from '../../../data/languages.xml?raw'; + +const speciesModules = import.meta.glob('../../../data/species/*.xml', { + query: '?raw', + import: 'default', + eager: true +}); + +export const species = Object.values(speciesModules).map((xml) => parseSpecies(xml as string)); +export const citizenships = parseCitizenships(citizenshipsXml); +export const languages = parseLanguages(languagesXml); diff --git a/src/lib/data/parse.ts b/src/lib/data/parse.ts new file mode 100644 index 0000000..0379da4 --- /dev/null +++ b/src/lib/data/parse.ts @@ -0,0 +1,122 @@ +import { XMLParser } from 'fast-xml-parser'; +import type { SpeciesData, CitizenshipData, LanguageData } from './types'; +import type { Template, RecordDef, FieldDef, SelectOption } from '../types'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => ['entry', 'ref', 'field', 'record', 'option', 'citizenship', 'language'].includes(name), + trimValues: true +}); + +function extractRefs(container: any): string[] { + if (!container?.ref) return []; + return container.ref.map((r: any) => r['@_id']); +} + +export function parseSpecies(xml: string): SpeciesData { + const root = parser.parse(xml).species; + const subspecies = root.subspecies?.entry ?? []; + + return { + id: root['@_id'], + name: root['@_name'], + description: root.description?.trim(), + subspeciesLabel: root['@_subspeciesLabel'], + languages: extractRefs(root.languages), + citizenships: extractRefs(root.citizenships), + subspecies: subspecies.map((e: any) => ({ + id: e['@_id'], + name: e['@_name'], + description: e.description?.trim() + })) + }; +} + +export function parseCitizenships(xml: string): CitizenshipData[] { + const root = parser.parse(xml).citizenships; + return root.citizenship.map((c: any) => ({ + id: c['@_id'], + name: c['@_name'], + description: c.description?.trim() + })); +} + +export function parseLanguages(xml: string): LanguageData[] { + const root = parser.parse(xml).languages; + return root.language.map((l: any) => ({ + id: l['@_id'], + name: l['@_name'], + description: l.description?.trim() + })); +} + +function parseOptions(field: any): SelectOption[] { + if (!field.option) return []; + return field.option.map((o: any) => ({ + value: o['@_value'], + label: o['@_label'] + })); +} + +function parseField(raw: any): FieldDef { + const base = { + label: raw['@_label'], + ...(raw['@_required'] === 'true' && { required: true }), + ...(raw['@_from'] && { from: raw['@_from'] }) + }; + const type = raw['@_type']; + + switch (type) { + case 'name': + case 'text': + case 'textarea': + case 'date': + return { ...base, type, placeholder: raw['@_placeholder'] }; + case 'list': + case 'height': + case 'weight': + case 'species': + case 'subspecies': + case 'citizenship': + case 'languages': + return { ...base, type }; + case 'separator': + return { type: 'separator', label: raw['@_label'] ?? '' }; + case 'number': + return { + ...base, + type, + min: raw['@_min'] != null ? Number(raw['@_min']) : undefined, + max: raw['@_max'] != null ? Number(raw['@_max']) : undefined, + unit: raw['@_unit'] + }; + case 'select': + case 'multi-select': + case 'checkbox': + return { ...base, type, options: parseOptions(raw) }; + default: + return { ...base, type: 'text' }; + } +} + +export function parseTemplate(xml: string, id: string): Template { + const root = parser.parse(xml).template; + + const records: RecordDef[] = root.record.map((r: any) => ({ + type: r['@_type'], + preamble: r.preamble?.trim(), + note: r.note?.trim(), + fields: r.field.map(parseField) + })); + + const speciesAttr = root['@_species']; + return { + id, + name: root['@_name'], + description: root.description ?? '', + schemaVersion: Number(root['@_schemaVersion'] ?? 1), + ...(speciesAttr && { species: speciesAttr.split(',').map((s: string) => s.trim()) }), + records + }; +} diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts new file mode 100644 index 0000000..cbeff51 --- /dev/null +++ b/src/lib/data/types.ts @@ -0,0 +1,21 @@ +export interface SpeciesData { + id: string; + name: string; + description?: string; + subspeciesLabel: string; + subspecies: { id: string; name: string; description?: string }[]; + languages: string[]; + citizenships: string[]; +} + +export interface CitizenshipData { + id: string; + name: string; + description?: string; +} + +export interface LanguageData { + id: string; + name: string; + description?: string; +} diff --git a/src/lib/file.test.ts b/src/lib/file.test.ts new file mode 100644 index 0000000..d475bf6 --- /dev/null +++ b/src/lib/file.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { exportCharacter, parseCharacterFile } from './file'; +import { presets } from './presets'; +import type { Character } from './types'; + +const standardPreset = presets.find((p) => p.id === 'preset:standard')!; + +const testCharacter: Character = { + id: 'abc-123', + template: standardPreset, + data: { + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + } +}; + +describe('exportCharacter', () => { + it('returns valid JSON with version, templateId, template, and data', () => { + const json = exportCharacter(testCharacter); + const parsed = JSON.parse(json); + expect(parsed.version).toBe(1); + expect(parsed.templateId).toBe('preset:standard'); + expect(parsed.template).toBeDefined(); + expect(parsed.template.name).toBe('General'); + expect(parsed.data).toEqual({ + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + }); + }); + + it('strips template id from embedded template', () => { + const json = exportCharacter(testCharacter); + const parsed = JSON.parse(json); + expect(parsed.template).not.toHaveProperty('id'); + }); + + it('prunes empty values from data', () => { + const char: Character = { + ...testCharacter, + data: { name: 'Yury Zakharov', species: '', 'hair-color': '' } + }; + const json = exportCharacter(char); + const parsed = JSON.parse(json); + expect(parsed.data).toEqual({ name: 'Yury Zakharov' }); + }); + + it('omits templateId for non-preset templates', () => { + const char: Character = { + ...testCharacter, + template: { + id: 'custom:test', + name: 'Custom', + description: 'Test', + schemaVersion: 1, + records: [] + } + }; + const json = exportCharacter(char); + const parsed = JSON.parse(json); + expect(parsed.templateId).toBeUndefined(); + expect(parsed.template.name).toBe('Custom'); + }); +}); + +describe('parseCharacterFile', () => { + it('resolves preset template by templateId', () => { + const json = exportCharacter(testCharacter); + const result = parseCharacterFile(json); + expect(result.template).toHaveProperty('id', 'preset:standard'); + expect(result.data.name).toBe('Yury Zakharov'); + }); + + it('falls back to embedded template for unknown preset', () => { + const payload = { + version: 1, + templateId: 'preset:nonexistent', + template: { name: 'Fallback', description: '', schemaVersion: 1, records: [] }, + data: { name: 'Test' } + }; + const result = parseCharacterFile(JSON.stringify(payload)); + expect(result.template.name).toBe('Fallback'); + expect(result.template).not.toHaveProperty('id'); + }); + + it('uses embedded template when no templateId', () => { + const payload = { + version: 1, + template: { name: 'Custom', description: '', schemaVersion: 1, records: [] }, + data: { name: 'Test' } + }; + const result = parseCharacterFile(JSON.stringify(payload)); + expect(result.template.name).toBe('Custom'); + }); + + it('throws on invalid JSON', () => { + expect(() => parseCharacterFile('not json')).toThrow(); + }); + + it('throws on missing data field', () => { + const payload = { version: 1, template: { name: 'X', description: '', schemaVersion: 1, records: [] } }; + expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow(); + }); + + it('throws on missing template and templateId', () => { + const payload = { version: 1, data: { name: 'Test' } }; + expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow(); + }); +}); diff --git a/src/lib/file.ts b/src/lib/file.ts new file mode 100644 index 0000000..5a60c17 --- /dev/null +++ b/src/lib/file.ts @@ -0,0 +1,53 @@ +import type { Character, Template } from './types'; +import { pruneEmpty } from './sharing'; +import { presets } from './presets'; +import { slugify } from './utils/slugify'; + +interface CharacterFilePayload { + version: number; + templateId?: string; + template: Omit; + data: Record; +} + +export function exportCharacter(char: Character): string { + const isPreset = char.template.id.startsWith('preset:'); + const { id, ...templateWithoutId } = char.template; + const payload: CharacterFilePayload = { + version: 1, + template: templateWithoutId, + data: pruneEmpty(char.data) + }; + if (isPreset) { + payload.templateId = char.template.id; + } + return JSON.stringify(payload, null, 2); +} + +export function parseCharacterFile(json: string): { template: Template | Omit; data: Record } { + const payload = JSON.parse(json); + if (!payload.data || typeof payload.data !== 'object') { + throw new Error('Invalid character file: missing data'); + } + if (!payload.template && !payload.templateId) { + throw new Error('Invalid character file: missing template'); + } + if (payload.templateId) { + const preset = presets.find((p) => p.id === payload.templateId); + if (preset) { + return { template: preset, data: payload.data }; + } + } + if (payload.template) { + return { template: payload.template, data: payload.data }; + } + throw new Error('Invalid character file: could not resolve template'); +} + +export function characterFileName(char: Character): string { + const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name'); + const key = nameField ? slugify(nameField.label) : 'name'; + const name = char.data[key] as string | undefined; + if (!name || !name.trim()) return 'character.json'; + return name.trim().replace(/[^a-zA-Z0-9'-]/g, '-').replace(/-+/g, '-') + '.json'; +} diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts new file mode 100644 index 0000000..824bfbf --- /dev/null +++ b/src/lib/output.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { formatFieldOutput, generateRecord } from './output'; +import type { FieldDef, Template } from './types'; +import type { SpeciesData } from './data/types'; + +const stubSpecies: SpeciesData[] = [ + { + id: 'human', + name: 'Human', + description: '', + subspeciesLabel: 'Variant', + subspecies: [{ id: 'offworlder', name: 'Offworlder', description: '' }], + languages: ['tau-ceti-basic'], + citizenships: ['biesel'] + }, + { + id: 'tajara', + name: 'Tajara', + description: '', + subspeciesLabel: 'Ethnicity', + subspecies: [ + { id: 'hharar', name: 'Hharar', description: '' }, + { id: 'zhan-khazan', name: 'Zhan-Khazan', description: '' } + ], + languages: ['siik-maas'], + citizenships: ['pra'] + } +]; + +describe('formatFieldOutput', () => { + it('formats text fields', () => { + const field: FieldDef = { label: 'Pronouns', type: 'text' }; + expect(formatFieldOutput(field, 'she/her')).toBe('Pronouns: she/her'); + }); + + it('returns null for empty text', () => { + const field: FieldDef = { label: 'Pronouns', type: 'text' }; + expect(formatFieldOutput(field, '')).toBeNull(); + expect(formatFieldOutput(field, undefined)).toBeNull(); + }); + + it('formats textarea with header', () => { + const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' }; + expect(formatFieldOutput(field, 'Scar across left eye')).toBe( + 'Distinguishing Features:\nScar across left eye' + ); + }); + + it('returns null for empty textarea', () => { + const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' }; + expect(formatFieldOutput(field, '')).toBeNull(); + }); + + it('formats list as bullet points', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, 'Shaft Miner')).toBe( + 'Employment History:\n - Shaft Miner' + ); + }); + + it('returns null for empty list', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, '')).toBeNull(); + }); + + it('filters blank lines from list', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, 'Line 1\n\nLine 2\n')).toBe( + 'Employment History:\n - Line 1\n - Line 2' + ); + }); + + it('formats height with conversion', () => { + const field: FieldDef = { label: 'Height', type: 'height' }; + expect(formatFieldOutput(field, 180)).toBe('Height: 180 cm (5\'11")'); + }); + + it('returns null for zero/undefined height', () => { + const field: FieldDef = { label: 'Height', type: 'height' }; + expect(formatFieldOutput(field, 0)).toBeNull(); + expect(formatFieldOutput(field, undefined)).toBeNull(); + }); + + it('formats weight with conversion', () => { + const field: FieldDef = { label: 'Weight', type: 'weight' }; + expect(formatFieldOutput(field, 75)).toBe('Weight: 75 kg (165 lb)'); + }); + + it('returns null for zero/undefined weight', () => { + const field: FieldDef = { label: 'Weight', type: 'weight' }; + expect(formatFieldOutput(field, 0)).toBeNull(); + }); + + it('formats species with display name', () => { + const field: FieldDef = { label: 'Species', type: 'species' }; + expect(formatFieldOutput(field, 'tajara', stubSpecies)).toBe('Species: Tajara'); + }); + + it('formats subspecies with dynamic label', () => { + const field: FieldDef = { label: 'Subspecies', type: 'subspecies' }; + expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar'); + }); + + it('returns null for empty subspecies', () => { + const field: FieldDef = { label: 'Subspecies', type: 'subspecies' }; + expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull(); + }); + + it('formats languages as comma list', () => { + const field: FieldDef = { label: 'Spoken Languages', type: 'languages' }; + expect(formatFieldOutput(field, ['Tau Ceti Basic', 'Siik\'maas'])).toBe( + 'Spoken Languages: Tau Ceti Basic, Siik\'maas' + ); + }); + + it('returns null for empty languages', () => { + const field: FieldDef = { label: 'Spoken Languages', type: 'languages' }; + expect(formatFieldOutput(field, [])).toBeNull(); + }); + + it('formats checkbox as bullet list of selected', () => { + const field: FieldDef = { + label: 'Opt-Outs', + type: 'checkbox', + options: [ + { value: 'no-borg', label: 'Do Not Borgify' }, + { value: 'no-revive', label: 'Do Not Revive' }, + { value: 'no-prosthetic', label: 'Do Not Prostheticize' } + ] + }; + expect(formatFieldOutput(field, ['no-borg', 'no-revive'])).toBe( + 'Opt-Outs:\n - Do Not Borgify\n - Do Not Revive' + ); + }); + + it('returns null for empty checkbox', () => { + const field: FieldDef = { + label: 'Opt-Outs', + type: 'checkbox', + options: [{ value: 'no-borg', label: 'Do Not Borgify' }] + }; + expect(formatFieldOutput(field, [])).toBeNull(); + }); + + it('formats select fields', () => { + const field: FieldDef = { + label: 'Citizenship', + type: 'select', + options: [{ value: 'biesel', label: 'Republic of Biesel' }] + }; + expect(formatFieldOutput(field, 'biesel')).toBe('Citizenship: Republic of Biesel'); + }); + + it('formats date fields', () => { + const field: FieldDef = { label: 'Date of Birth', type: 'date' }; + expect(formatFieldOutput(field, 'March 15th, 2438')).toBe('Date of Birth: March 15th, 2438'); + }); + + it('formats number fields', () => { + const field: FieldDef = { label: 'Age', type: 'number' }; + expect(formatFieldOutput(field, 30)).toBe('Age: 30'); + }); + + it('formats citizenship type', () => { + const field: FieldDef = { label: 'Citizenship', type: 'citizenship' }; + expect(formatFieldOutput(field, 'Republic of Biesel')).toBe('Citizenship: Republic of Biesel'); + }); + + it('formats multi-select as comma list', () => { + const field: FieldDef = { + label: 'Other Skills', + type: 'multi-select', + options: [ + { value: 'engineering', label: 'Engineering' }, + { value: 'medical', label: 'Medical' } + ] + }; + expect(formatFieldOutput(field, ['engineering', 'medical'])).toBe( + 'Other Skills: Engineering, Medical' + ); + }); +}); + +const testTemplate: Template = { + id: 'test', + name: 'Test Template', + description: '', + schemaVersion: 1, + records: [ + { + type: 'public', + fields: [ + { label: 'Name', type: 'text' }, + { label: 'Species', type: 'species' }, + { label: 'Pronouns', type: 'text' } + ] + }, + { + type: 'employment', + preamble: 'This information has been verified by employment agents.', + fields: [ + { label: 'Employment History', type: 'list' }, + { label: 'Formal Education', type: 'list' } + ] + }, + { + type: 'medical', + preamble: 'Protected by doctor-patient confidentiality.', + fields: [ + { + label: 'Opt-Outs', + type: 'checkbox', + options: [{ value: 'no-borg', label: 'Do Not Borgify' }] + }, + { label: 'Allergies', type: 'list' } + ] + }, + { + type: 'security', + preamble: 'This information has been verified by employment agents.', + fields: [ + { label: 'Attitude Towards SCC', type: 'textarea' }, + { label: 'Arrest History', type: 'list' } + ] + } + ] +}; + +describe('generateRecord', () => { + it('includes public header and employment body', () => { + const data = { + name: 'Yury Zakharov', + species: 'tajara', + 'employment-history': 'Janitor' + }; + const out = generateRecord(testTemplate, data, 'employment', stubSpecies); + expect(out).toContain('/// PUBLIC RECORD ///'); + expect(out).toContain('Name: Yury Zakharov'); + expect(out).toContain('Species: Tajara'); + expect(out).toContain('/// EMPLOYMENT RECORD ///'); + expect(out).toContain('This information has been verified by employment agents.'); + expect(out).toContain(' - Janitor'); + expect(out).toContain('LAST UPDATED:'); + }); + + it('shows NO RECORD FOUND when body is empty', () => { + const data = { name: 'Yury Zakharov' }; + const out = generateRecord(testTemplate, data, 'medical', stubSpecies); + expect(out).toContain('/// NO MEDICAL RECORD FOUND ///'); + }); + + it('includes preamble in medical record', () => { + const data = { + name: 'Yury Zakharov', + allergies: 'Peanuts' + }; + const out = generateRecord(testTemplate, data, 'medical', stubSpecies); + expect(out).toContain('Protected by doctor-patient confidentiality.'); + expect(out).toContain(' - Peanuts'); + }); + + it('includes preamble in security record', () => { + const data = { + name: 'Yury Zakharov', + 'attitude-towards-scc': 'Loyal employee' + }; + const out = generateRecord(testTemplate, data, 'security', stubSpecies); + expect(out).toContain('/// SECURITY RECORD ///'); + expect(out).toContain('This information has been verified by employment agents.'); + expect(out).toContain('Loyal employee'); + }); +}); diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..da0647c --- /dev/null +++ b/src/lib/output.ts @@ -0,0 +1,164 @@ +import type { FieldDef, Template } from './types'; +import type { SpeciesData } from './data/types'; +import { cmToFeetInches, kgToLb } from './utils/conversions'; +import { formatICDate } from './utils/dates'; +import { slugify } from './utils/slugify'; + +export function formatFieldOutput( + field: FieldDef, + value: unknown, + speciesData?: SpeciesData[], + currentSpecies?: string +): string | null { + switch (field.type) { + case 'name': + case 'text': + case 'date': + case 'citizenship': + return value ? `${field.label}: ${value}` : null; + + case 'textarea': + return value ? `${field.label}:\n${value}` : null; + + case 'list': { + const lines = splitLines(value as string); + return lines.length ? `${field.label}:\n${formatBullets(lines)}` : null; + } + + case 'number': + return value != null && value !== 0 ? `${field.label}: ${value}` : null; + + case 'height': + return value ? `${field.label}: ${value} cm (${cmToFeetInches(value as number)})` : null; + + case 'weight': { + if (!value) return null; + const lb = Math.round(kgToLb(value as number)); + return `${field.label}: ${value} kg (${lb} lb)`; + } + + case 'species': { + if (!value || !speciesData) return null; + const sp = speciesData.find((s) => s.id === value); + return sp ? `${field.label}: ${sp.name}` : `${field.label}: ${value}`; + } + + case 'subspecies': { + if (!value || !speciesData || !currentSpecies) return null; + const sp = speciesData.find((s) => s.id === currentSpecies); + if (!sp) return null; + const sub = sp.subspecies.find((s) => s.id === value); + return sub ? `${sp.subspeciesLabel}: ${sub.name}` : null; + } + + case 'languages': { + const arr = value as string[] | undefined; + return arr?.length ? `${field.label}: ${arr.join(', ')}` : null; + } + + case 'checkbox': { + const selected = value as string[] | undefined; + if (!selected?.length) return null; + const labels = selected + .map((v) => field.options.find((o) => o.value === v)?.label ?? v) + return `${field.label}:\n${formatBullets(labels)}`; + } + + case 'select': { + if (!value) return null; + const opt = field.options.find((o) => o.value === value); + return `${field.label}: ${opt?.label ?? value}`; + } + + case 'multi-select': { + const vals = value as string[] | undefined; + if (!vals?.length) return null; + const labels = vals.map((v) => field.options.find((o) => o.value === v)?.label ?? v); + return `${field.label}: ${labels.join(', ')}`; + } + } +} + +export function generateRecord( + template: Template, + data: Record, + recordType: string, + speciesData?: SpeciesData[] +): string { + const publicRecord = template.records.find((r) => r.type === 'public'); + const targetRecord = template.records.find((r) => r.type === recordType); + const currentSpecies = data['species'] as string | undefined; + + const parts: string[] = []; + + // Public section + if (publicRecord) { + const publicLines = renderFields(publicRecord.fields, data, speciesData, currentSpecies); + if (publicLines.length) { + parts.push('/// PUBLIC RECORD ///'); + parts.push(publicLines.join('\n')); + } + } + + // Target record section + if (targetRecord) { + const bodyLines = renderFields(targetRecord.fields, data, speciesData, currentSpecies); + const typeLabel = recordType.toUpperCase(); + + if (!bodyLines.length) { + parts.push(`/// NO ${typeLabel} RECORD FOUND ///`); + } else { + parts.push(`/// ${typeLabel} RECORD ///`); + if (targetRecord.preamble) { + parts.push(targetRecord.preamble); + } + parts.push(bodyLines.join('\n\n')); + } + } + + parts.push(`LAST UPDATED: ${formatICDate(new Date())}`); + return parts.join('\n\n'); +} + +function renderFields( + fields: FieldDef[], + data: Record, + speciesData?: SpeciesData[], + currentSpecies?: string +): string[] { + // Split fields into groups by separator boundaries + const groups: FieldDef[][] = [[]]; + for (const field of fields) { + if (field.type === 'separator') { + groups.push([]); + } else { + groups[groups.length - 1].push(field); + } + } + + const rendered = groups.map((group) => { + const lines: string[] = []; + for (const field of group) { + const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies); + if (out) lines.push(out); + } + return lines; + }); + + const result: string[] = []; + for (const lines of rendered) { + if (!lines.length) continue; + if (result.length) result.push(''); + result.push(...lines); + } + return result; +} + +function splitLines(text: string | undefined): string[] { + if (!text) return []; + return text.split('\n').map((l) => l.trim()).filter(Boolean); +} + +function formatBullets(items: string[]): string { + return items.map((item) => ` - ${item}`).join('\n'); +} diff --git a/src/lib/presets.ts b/src/lib/presets.ts new file mode 100644 index 0000000..ab0b49a --- /dev/null +++ b/src/lib/presets.ts @@ -0,0 +1,12 @@ +import { parseTemplate } from './data/parse'; + +const templateModules = import.meta.glob('../../data/templates/*.xml', { + query: '?raw', + import: 'default', + eager: true +}); + +export const presets = Object.entries(templateModules).map(([path, xml]) => { + const filename = path.split('/').pop()!.replace('.xml', ''); + return parseTemplate(xml as string, `preset:${filename}`); +}); diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts new file mode 100644 index 0000000..19b138d --- /dev/null +++ b/src/lib/schema.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { buildCharacterSchema } from './schema'; +import type { Template } from './types'; + +function makeTemplate(fields: any[]): Template { + return { + id: 'test', + name: 'Test', + description: '', + schemaVersion: 1, + records: [{ type: 'public', fields }] + }; +} + +describe('buildCharacterSchema', () => { + it('validates text fields as optional strings', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Pronouns', type: 'text' }]) + ); + expect(schema.parse({})).toEqual({}); + expect(schema.parse({ pronouns: 'She/her' })).toEqual({ pronouns: 'She/her' }); + }); + + it('validates textarea fields as optional strings', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { label: 'Distinguishing Features', type: 'textarea' } + ]) + ); + expect(schema.parse({ 'distinguishing-features': 'Scar across left eye' })).toEqual({ + 'distinguishing-features': 'Scar across left eye' + }); + expect(schema.parse({})).toEqual({}); + }); + + it('validates list fields as optional strings', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { label: 'Employment History', type: 'list' } + ]) + ); + expect(schema.parse({ 'employment-history': 'NanoTrasen Intern\nShaft Miner' })).toEqual({ + 'employment-history': 'NanoTrasen Intern\nShaft Miner' + }); + expect(schema.parse({})).toEqual({}); + }); + + it('validates date fields as optional strings', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { label: 'Date of Birth', type: 'date', placeholder: 'March 15th, 2438' } + ]) + ); + expect(schema.parse({ 'date-of-birth': 'March 15th, 2438' })).toEqual({ + 'date-of-birth': 'March 15th, 2438' + }); + }); + + it('validates select fields as optional strings', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { + label: 'Citizenship', + type: 'select', + options: [{ value: 'biesel', label: 'Republic of Biesel' }] + } + ]) + ); + expect(schema.parse({ citizenship: 'biesel' })).toEqual({ citizenship: 'biesel' }); + }); + + it('validates number fields as optional numbers', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Age', type: 'number', min: 0, max: 999 }]) + ); + expect(schema.parse({ age: 30 })).toEqual({ age: 30 }); + expect(schema.parse({})).toEqual({}); + }); + + it('validates height as optional number', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Height', type: 'height' }]) + ); + expect(schema.parse({ height: 180 })).toEqual({ height: 180 }); + }); + + it('validates weight as optional number', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Weight', type: 'weight' }]) + ); + expect(schema.parse({ weight: 75 })).toEqual({ weight: 75 }); + }); + + it('validates name as optional string', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Name', type: 'text' }]) + ); + expect(schema.parse({ name: 'Ka\'Akaix\'Lak Zo\'ra' })).toEqual({ + name: 'Ka\'Akaix\'Lak Zo\'ra' + }); + expect(schema.parse({})).toEqual({}); + }); + + it('validates multi-select as optional string array', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { + label: 'Spoken Languages', + type: 'multi-select', + options: [ + { value: 'tau-ceti-basic', label: 'Tau Ceti Basic' }, + { value: 'sol-common', label: 'Sol Common' } + ] + } + ]) + ); + expect(schema.parse({ 'spoken-languages': ['tau-ceti-basic', 'sol-common'] })).toEqual({ + 'spoken-languages': ['tau-ceti-basic', 'sol-common'] + }); + expect(schema.parse({})).toEqual({}); + }); + + it('validates checkbox as optional string array', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { + label: 'Opt-Outs', + type: 'checkbox', + options: [ + { value: 'no-borg', label: 'Do Not Borgify' }, + { value: 'no-revive', label: 'Do Not Revive' }, + { value: 'no-prosthetic', label: 'Do Not Prostheticize' } + ] + } + ]) + ); + expect(schema.parse({ 'opt-outs': ['no-borg', 'no-revive'] })).toEqual({ + 'opt-outs': ['no-borg', 'no-revive'] + }); + }); + + it('validates languages as optional string array', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { label: 'Spoken Languages', type: 'languages' } + ]) + ); + expect( + schema.parse({ 'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas'] }) + ).toEqual({ + 'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas'] + }); + }); + + it('validates species as optional string', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Species', type: 'species' }]) + ); + expect(schema.parse({ species: 'tajara' })).toEqual({ species: 'tajara' }); + }); + + it('validates subspecies as optional string', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Subspecies', type: 'subspecies' }]) + ); + expect(schema.parse({ subspecies: 'zhan-khazan' })).toEqual({ + subspecies: 'zhan-khazan' + }); + }); + + it('validates citizenship type as optional string', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Citizenship', type: 'citizenship' }]) + ); + expect(schema.parse({ citizenship: 'sol-alliance' })).toEqual({ + citizenship: 'sol-alliance' + }); + }); + + it('allows all fields to be missing', () => { + const schema = buildCharacterSchema( + makeTemplate([ + { label: 'Name', type: 'text' }, + { label: 'Height', type: 'height' }, + { label: 'Spoken Languages', type: 'languages' }, + { label: 'Skin Color', type: 'text' } + ]) + ); + expect(schema.parse({})).toEqual({}); + }); + + it('rejects wrong types', () => { + const schema = buildCharacterSchema( + makeTemplate([{ label: 'Height', type: 'height' }]) + ); + expect(() => schema.parse({ height: 'tall' })).toThrow(); + }); +}); diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..fce4df7 --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import type { FieldDef, Template } from './types'; +import { slugify } from './utils/slugify'; + +function zodForField(field: FieldDef): z.ZodTypeAny { + switch (field.type) { + case 'name': + case 'text': + case 'textarea': + case 'list': + case 'date': + case 'select': + case 'species': + case 'subspecies': + case 'citizenship': + return z.string().optional(); + + case 'number': + case 'height': + case 'weight': + return z.number().optional(); + + case 'multi-select': + case 'checkbox': + case 'languages': + return z.array(z.string()).optional(); + } +} + +export function buildCharacterSchema(template: Template): z.ZodObject> { + const shape: Record = {}; + for (const record of template.records) { + for (const field of record.fields) { + if (field.type === 'separator') continue; + shape[slugify(field.label)] = zodForField(field); + } + } + return z.object(shape).partial(); +} diff --git a/src/lib/sharing.test.ts b/src/lib/sharing.test.ts new file mode 100644 index 0000000..f34d0a9 --- /dev/null +++ b/src/lib/sharing.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeCharacterURL, + decodeCharacterURL, + encodeTemplateURL, + decodeTemplateURL +} from './sharing'; +import { presets } from './presets'; +import type { Character, Template } from './types'; + +const standardPreset = presets.find((p) => p.id === 'preset:standard')!; + +const testCharacter: Character = { + id: 'abc-123', + template: standardPreset, + data: { + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + } +}; + +const customTemplate: Template = { + id: 'custom:test', + name: 'Custom', + description: 'A custom template.', + schemaVersion: 1, + records: [ + { + type: 'public', + fields: [ + { label: 'Name', type: 'text' }, + { label: 'Species', type: 'species' } + ] + } + ] +}; + +describe('character URL encoding', () => { + it('round-trips preset character data', () => { + const encoded = encodeCharacterURL(testCharacter); + const decoded = decodeCharacterURL(encoded); + expect(decoded.data).toEqual(testCharacter.data); + expect(decoded.template.name).toBe('General'); + }); + + it('uses short encoding for preset templates', () => { + const encoded = encodeCharacterURL(testCharacter); + const customChar = { ...testCharacter, template: customTemplate }; + const customEncoded = encodeCharacterURL(customChar); + expect(encoded.length).toBeLessThan(customEncoded.length); + }); + + it('round-trips custom template character', () => { + const char: Character = { ...testCharacter, template: customTemplate }; + const encoded = encodeCharacterURL(char); + const decoded = decodeCharacterURL(encoded); + expect(decoded.data).toEqual(testCharacter.data); + expect(decoded.template.name).toBe('Custom'); + }); + + it('starts with c1. prefix', () => { + const encoded = encodeCharacterURL(testCharacter); + expect(encoded.startsWith('c1.')).toBe(true); + }); + + it('prunes empty values from data', () => { + const char: Character = { + ...testCharacter, + data: { name: 'Yury Zakharov', species: '', 'hair-color': '' } + }; + const encoded = encodeCharacterURL(char); + const decoded = decodeCharacterURL(encoded); + expect(decoded.data).toEqual({ name: 'Yury Zakharov' }); + }); +}); + +describe('template URL encoding', () => { + it('round-trips template structure', () => { + const encoded = encodeTemplateURL(customTemplate); + const decoded = decodeTemplateURL(encoded); + expect(decoded.name).toBe('Custom'); + expect(decoded.records).toEqual(customTemplate.records); + }); + + it('starts with t1. prefix', () => { + const encoded = encodeTemplateURL(customTemplate); + expect(encoded.startsWith('t1.')).toBe(true); + }); + + it('strips id', () => { + const encoded = encodeTemplateURL(customTemplate); + const decoded = decodeTemplateURL(encoded); + expect(decoded).not.toHaveProperty('id'); + }); +}); + +describe('unicode support', () => { + it('round-trips unicode content', () => { + const char: Character = { + ...testCharacter, + data: { name: "Ka'Akaix'Lak Zo'ra", species: 'vaurca' } + }; + const encoded = encodeCharacterURL(char); + const decoded = decodeCharacterURL(encoded); + expect(decoded.data.name).toBe("Ka'Akaix'Lak Zo'ra"); + }); +}); + +describe('error handling', () => { + it('throws on invalid character input', () => { + expect(() => decodeCharacterURL('c1.invaliddata!!!')).toThrow(); + }); + + it('throws on wrong prefix', () => { + const encoded = encodeCharacterURL(testCharacter); + expect(() => decodeTemplateURL(encoded)).toThrow(); + }); +}); diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts new file mode 100644 index 0000000..ca6345a --- /dev/null +++ b/src/lib/sharing.ts @@ -0,0 +1,75 @@ +import pako from 'pako'; +import type { Character, Template } from './types'; +import { presets } from './presets'; + +function toBase64url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64url(str: string): Uint8Array { + const padded = str.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function pruneEmpty(data: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (v === '' || v === undefined || v === null) continue; + if (Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return out; +} + +export function encodeCharacterURL(char: Character): string { + const isPreset = char.template.id.startsWith('preset:'); + const payload: any = { + data: pruneEmpty(char.data) + }; + if (isPreset) { + payload.templateId = char.template.id; + } else { + payload.template = stripId(char.template); + } + const json = JSON.stringify(payload); + const compressed = pako.deflate(new TextEncoder().encode(json)); + return 'c1.' + toBase64url(compressed); +} + +export function decodeCharacterURL(encoded: string): { template: Template | Omit; data: Record } { + if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix'); + const bytes = fromBase64url(encoded.slice(3)); + const json = new TextDecoder().decode(pako.inflate(bytes)); + const payload = JSON.parse(json); + + if (payload.templateId) { + const preset = presets.find((p) => p.id === payload.templateId); + if (!preset) throw new Error(`Unknown template: ${payload.templateId}`); + return { template: preset, data: payload.data }; + } + return { template: payload.template, data: payload.data }; +} + +export function encodeTemplateURL(template: Template): string { + const payload = stripId(template); + const json = JSON.stringify(payload); + const compressed = pako.deflate(new TextEncoder().encode(json)); + return 't1.' + toBase64url(compressed); +} + +export function decodeTemplateURL(encoded: string): Omit { + if (!encoded.startsWith('t1.')) throw new Error('Invalid template URL prefix'); + const bytes = fromBase64url(encoded.slice(3)); + const json = new TextDecoder().decode(pako.inflate(bytes)); + return JSON.parse(json); +} + +function stripId(obj: Record): Record { + const { id, ...rest } = obj; + return rest; +} diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts new file mode 100644 index 0000000..85d48d3 --- /dev/null +++ b/src/lib/state.svelte.ts @@ -0,0 +1,147 @@ +import { getAllCharacters, saveCharacter, deleteCharacter } from './storage'; +import { isBlankCharacter } from './utils/blank'; +import { slugify } from './utils/slugify'; +import type { Character, Template } from './types'; + +let characters = $state([]); +let activeId = $state(null); +let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); +let saveTimer: ReturnType | null = null; +let statusTimer: ReturnType | null = null; + +const SINGLETON_TYPES = new Set([ + 'name', 'species', 'subspecies', 'citizenship', 'languages', 'height', 'weight' +]); + +function allFields(template: Template) { + return template.records.flatMap((r) => r.fields).filter((f) => f.type !== 'separator'); +} + +function migrateData(char: Character, preset: Template) { + for (const record of preset.records) { + for (const field of record.fields) { + if (!field.from) continue; + const newKey = slugify(field.label); + if (char.data[newKey] !== undefined) continue; + const oldNames = field.from.split(',').map((s) => s.trim()); + for (const oldName of oldNames) { + const oldKey = slugify(oldName); + if (char.data[oldKey] !== undefined) { + char.data[newKey] = char.data[oldKey]; + delete char.data[oldKey]; + break; + } + } + } + } + + const oldByType = new Map(); + for (const f of allFields(char.template)) { + if (SINGLETON_TYPES.has(f.type)) { + oldByType.set(f.type, slugify(f.label)); + } + } + for (const f of allFields(preset)) { + if (!SINGLETON_TYPES.has(f.type)) continue; + const newKey = slugify(f.label); + if (char.data[newKey] !== undefined) continue; + const oldKey = oldByType.get(f.type); + if (oldKey && oldKey !== newKey && char.data[oldKey] !== undefined) { + char.data[newKey] = char.data[oldKey]; + } + } +} + +export const roster = { + get characters() { return characters; }, + get active() { return characters.find((c) => c.id === activeId) ?? null; }, + get saveStatus() { return saveStatus; }, + + async migrateToPreset(char: Character, preset: Template) { + migrateData(char, preset); + if (preset.species?.length === 1) { + char.data[slugify('Species')] = preset.species[0]; + } + char.template = $state.snapshot(preset); + await saveCharacter($state.snapshot(char)); + }, + + async load() { + const all = await getAllCharacters(); + const kept: Character[] = []; + + for (const char of all) { + if (isBlankCharacter(char)) { + await deleteCharacter(char.id); + } else { + kept.push(char); + } + } + + characters = kept; + + const stored = localStorage.getItem('activeCharacterId'); + if (stored && characters.some((c) => c.id === stored)) { + activeId = stored; + } else if (characters.length) { + activeId = characters[0].id; + } + }, + + async create(template: Template, data: Record = {}) { + const initial: Record = { ...data }; + if (template.species?.length === 1) { + initial[slugify('Species')] ??= template.species[0]; + } + const char: Character = { + id: crypto.randomUUID(), + template: $state.snapshot(template), + data: initial + }; + characters.push(char); + activeId = char.id; + localStorage.setItem('activeCharacterId', char.id); + await saveCharacter($state.snapshot(char)); + return char; + }, + + async remove(id: string) { + characters = characters.filter((c) => c.id !== id); + await deleteCharacter(id); + if (activeId === id) { + activeId = characters[0]?.id ?? null; + if (activeId) localStorage.setItem('activeCharacterId', activeId); + else localStorage.removeItem('activeCharacterId'); + } + }, + + async duplicate(id: string) { + const source = characters.find((c) => c.id === id); + if (!source) return; + const copy: Character = { + id: crypto.randomUUID(), + template: $state.snapshot(source.template), + data: $state.snapshot(source.data) + }; + characters.push(copy); + activeId = copy.id; + localStorage.setItem('activeCharacterId', copy.id); + await saveCharacter($state.snapshot(copy)); + }, + + setActive(id: string) { + activeId = id; + localStorage.setItem('activeCharacterId', id); + }, + + scheduleSave(char: Character) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + saveStatus = 'saving'; + await saveCharacter($state.snapshot(char)); + saveStatus = 'saved'; + if (statusTimer) clearTimeout(statusTimer); + statusTimer = setTimeout(() => { saveStatus = 'idle'; }, 1500); + }, 300); + } +}; diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..02e4671 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,46 @@ +import { openDB, type DBSchema } from 'idb'; +import type { Character, Template } from './types'; + +interface RecordsDB extends DBSchema { + characters: { key: string; value: Character }; + templates: { key: string; value: Template }; +} + +const dbPromise = openDB('aurora-records', 1, { + upgrade(db) { + db.createObjectStore('characters', { keyPath: 'id' }); + db.createObjectStore('templates', { keyPath: 'id' }); + } +}); + +export async function getAllCharacters() { + return (await dbPromise).getAll('characters'); +} + +export async function getCharacter(id: string) { + return (await dbPromise).get('characters', id); +} + +export async function saveCharacter(char: Character) { + await (await dbPromise).put('characters', char); +} + +export async function deleteCharacter(id: string) { + await (await dbPromise).delete('characters', id); +} + +export async function getAllTemplates() { + return (await dbPromise).getAll('templates'); +} + +export async function getTemplate(id: string) { + return (await dbPromise).get('templates', id); +} + +export async function saveTemplate(tmpl: Template) { + await (await dbPromise).put('templates', tmpl); +} + +export async function deleteTemplate(id: string) { + await (await dbPromise).delete('templates', id); +} diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts new file mode 100644 index 0000000..e177190 --- /dev/null +++ b/src/lib/theme.svelte.ts @@ -0,0 +1,25 @@ +let dark = $state(false); + +export const theme = { + get dark() { return dark; }, + + init() { + const stored = localStorage.getItem('theme'); + if (stored) { + dark = stored === 'dark'; + } else { + dark = window.matchMedia('(prefers-color-scheme: dark)').matches; + } + apply(); + }, + + toggle() { + dark = !dark; + localStorage.setItem('theme', dark ? 'dark' : 'light'); + apply(); + } +}; + +function apply() { + document.documentElement.classList.toggle('dark', dark); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..4aaf7a3 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,125 @@ +export interface SelectOption { + value: string; + label: string; +} + +export interface BaseFieldDef { + label: string; + required?: boolean; + from?: string; +} + +export interface NameField extends BaseFieldDef { + type: 'name'; + placeholder?: string; +} + +export interface TextField extends BaseFieldDef { + type: 'text'; + placeholder?: string; +} + +export interface TextareaField extends BaseFieldDef { + type: 'textarea'; + placeholder?: string; +} + +export interface ListField extends BaseFieldDef { + type: 'list'; +} + +export interface NumberField extends BaseFieldDef { + type: 'number'; + min?: number; + max?: number; + unit?: string; +} + +export interface SelectField extends BaseFieldDef { + type: 'select'; + options: SelectOption[]; +} + +export interface MultiSelectField extends BaseFieldDef { + type: 'multi-select'; + options: SelectOption[]; +} + +export interface CheckboxField extends BaseFieldDef { + type: 'checkbox'; + options: SelectOption[]; +} + +export interface DateField extends BaseFieldDef { + type: 'date'; + placeholder?: string; +} + +export interface HeightField extends BaseFieldDef { + type: 'height'; +} + +export interface WeightField extends BaseFieldDef { + type: 'weight'; +} + +export interface SpeciesField extends BaseFieldDef { + type: 'species'; +} + +export interface SubspeciesField extends BaseFieldDef { + type: 'subspecies'; +} + +export interface CitizenshipField extends BaseFieldDef { + type: 'citizenship'; +} + +export interface LanguagesField extends BaseFieldDef { + type: 'languages'; +} + +export interface SeparatorField { + type: 'separator'; + label: string; +} + +export type FieldDef = + | NameField + | TextField + | TextareaField + | ListField + | NumberField + | SelectField + | MultiSelectField + | CheckboxField + | DateField + | HeightField + | WeightField + | SpeciesField + | SubspeciesField + | CitizenshipField + | LanguagesField + | SeparatorField; + +export interface RecordDef { + type: string; + preamble?: string; + note?: string; + fields: FieldDef[]; +} + +export interface Template { + id: string; + name: string; + description: string; + schemaVersion: number; + species?: string[]; + records: RecordDef[]; +} + +export interface Character { + id: string; + template: Template; + data: Record; +} diff --git a/src/lib/utils/blank.test.ts b/src/lib/utils/blank.test.ts new file mode 100644 index 0000000..8aaebc6 --- /dev/null +++ b/src/lib/utils/blank.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { isBlankCharacter } from './blank'; +import type { Character } from '../types'; + +const template = { + id: 'preset:standard', + name: 'Standard', + description: '', + schemaVersion: 1, + records: [ + { + type: 'public', + fields: [ + { label: 'Name', type: 'text' as const }, + { label: 'Species', type: 'species' as const }, + { label: 'Spoken Languages', type: 'languages' as const } + ] + } + ] +}; + +function makeChar(data: Record): Character { + return { id: 'test', template, data }; +} + +describe('isBlankCharacter', () => { + it('returns true for empty data', () => { + expect(isBlankCharacter(makeChar({}))).toBe(true); + }); + + it('returns true when all values are empty strings', () => { + expect(isBlankCharacter(makeChar({ name: '', species: '' }))).toBe(true); + }); + + it('returns false when any string has a value', () => { + expect(isBlankCharacter(makeChar({ name: 'Yury Zakharov' }))).toBe(false); + }); + + it('returns true for empty arrays', () => { + expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true); + }); + + it('returns false when languages has any value', () => { + expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(false); + }); + + it('returns false when languages has custom values', () => { + expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic', "Siik'maas"] }))).toBe(false); + }); + + it('returns true for zero numbers', () => { + expect(isBlankCharacter(makeChar({ height: 0, weight: 0 }))).toBe(true); + }); + + it('returns false for non-zero numbers', () => { + expect(isBlankCharacter(makeChar({ height: 180 }))).toBe(false); + }); +}); diff --git a/src/lib/utils/blank.ts b/src/lib/utils/blank.ts new file mode 100644 index 0000000..9501903 --- /dev/null +++ b/src/lib/utils/blank.ts @@ -0,0 +1,14 @@ +import type { Character } from '../types'; + +export function isBlankCharacter(char: Character): boolean { + if (!char.data) return true; + for (const value of Object.values(char.data)) { + if (value === '' || value === undefined || value === null || value === 0) continue; + if (Array.isArray(value)) { + if (value.length === 0) continue; + return false; + } + return false; + } + return true; +} diff --git a/src/lib/utils/conversions.test.ts b/src/lib/utils/conversions.test.ts new file mode 100644 index 0000000..1a7c532 --- /dev/null +++ b/src/lib/utils/conversions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { cmToFeetInches, feetInchesToCm, kgToLb, lbToKg } from './conversions'; + +describe('cmToFeetInches', () => { + it('converts 180 cm', () => { + expect(cmToFeetInches(180)).toBe('5\'11"'); + }); + + it('converts 152 cm', () => { + expect(cmToFeetInches(152)).toBe('5\'0"'); + }); + + it('converts 0 cm', () => { + expect(cmToFeetInches(0)).toBe('0\'0"'); + }); + + it('converts 30 cm (just inches)', () => { + expect(cmToFeetInches(30)).toBe('1\'0"'); + }); +}); + +describe('feetInchesToCm', () => { + it('converts 5\'11" back', () => { + expect(feetInchesToCm(5, 11)).toBeCloseTo(180.34, 0); + }); + + it('converts 0\'0"', () => { + expect(feetInchesToCm(0, 0)).toBe(0); + }); +}); + +describe('kgToLb', () => { + it('converts 75 kg', () => { + expect(kgToLb(75)).toBeCloseTo(165.35, 0); + }); + + it('converts 0 kg', () => { + expect(kgToLb(0)).toBe(0); + }); +}); + +describe('lbToKg', () => { + it('converts 165 lb', () => { + expect(lbToKg(165)).toBeCloseTo(74.84, 0); + }); + + it('converts 0 lb', () => { + expect(lbToKg(0)).toBe(0); + }); +}); diff --git a/src/lib/utils/conversions.ts b/src/lib/utils/conversions.ts new file mode 100644 index 0000000..9655ccf --- /dev/null +++ b/src/lib/utils/conversions.ts @@ -0,0 +1,22 @@ +const CM_PER_INCH = 2.54; +const INCHES_PER_FOOT = 12; +const LB_PER_KG = 2.20462; + +export function cmToFeetInches(cm: number): string { + const totalInches = Math.round(cm / CM_PER_INCH); + const feet = Math.floor(totalInches / INCHES_PER_FOOT); + const inches = totalInches % INCHES_PER_FOOT; + return `${feet}'${inches}"`; +} + +export function feetInchesToCm(feet: number, inches: number): number { + return (feet * INCHES_PER_FOOT + inches) * CM_PER_INCH; +} + +export function kgToLb(kg: number): number { + return kg * LB_PER_KG; +} + +export function lbToKg(lb: number): number { + return lb / LB_PER_KG; +} diff --git a/src/lib/utils/dates.test.ts b/src/lib/utils/dates.test.ts new file mode 100644 index 0000000..06b148e --- /dev/null +++ b/src/lib/utils/dates.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { icYear, formatICDate } from './dates'; + +describe('icYear', () => { + it('adds 442 to the real year', () => { + expect(icYear(2026)).toBe(2468); + }); + + it('works for other years', () => { + expect(icYear(2000)).toBe(2442); + }); +}); + +describe('formatICDate', () => { + it('formats with ordinal suffixes', () => { + expect(formatICDate(new Date(2026, 2, 1))).toBe('March 1st, 2468'); + expect(formatICDate(new Date(2026, 2, 2))).toBe('March 2nd, 2468'); + expect(formatICDate(new Date(2026, 2, 3))).toBe('March 3rd, 2468'); + expect(formatICDate(new Date(2026, 2, 4))).toBe('March 4th, 2468'); + }); + + it('handles 11th, 12th, 13th', () => { + expect(formatICDate(new Date(2026, 0, 11))).toBe('January 11th, 2468'); + expect(formatICDate(new Date(2026, 0, 12))).toBe('January 12th, 2468'); + expect(formatICDate(new Date(2026, 0, 13))).toBe('January 13th, 2468'); + }); + + it('handles 21st, 22nd, 23rd', () => { + expect(formatICDate(new Date(2026, 5, 21))).toBe('June 21st, 2468'); + expect(formatICDate(new Date(2026, 5, 22))).toBe('June 22nd, 2468'); + expect(formatICDate(new Date(2026, 5, 23))).toBe('June 23rd, 2468'); + }); +}); diff --git a/src/lib/utils/dates.ts b/src/lib/utils/dates.ts new file mode 100644 index 0000000..c68a1df --- /dev/null +++ b/src/lib/utils/dates.ts @@ -0,0 +1,24 @@ +const IC_OFFSET = 442; + +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +]; + +function ordinal(n: number): string { + if (n >= 11 && n <= 13) return n + 'th'; + switch (n % 10) { + case 1: return n + 'st'; + case 2: return n + 'nd'; + case 3: return n + 'rd'; + default: return n + 'th'; + } +} + +export function icYear(realYear: number): number { + return realYear + IC_OFFSET; +} + +export function formatICDate(date: Date): string { + return `${MONTHS[date.getMonth()]} ${ordinal(date.getDate())}, ${icYear(date.getFullYear())}`; +} diff --git a/src/lib/utils/slugify.ts b/src/lib/utils/slugify.ts new file mode 100644 index 0000000..0a822e7 --- /dev/null +++ b/src/lib/utils/slugify.ts @@ -0,0 +1,3 @@ +export function slugify(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} diff --git a/src/lib/utils/template-diff.ts b/src/lib/utils/template-diff.ts new file mode 100644 index 0000000..f825b44 --- /dev/null +++ b/src/lib/utils/template-diff.ts @@ -0,0 +1,51 @@ +import type { Template } from '../types'; + +export interface TemplateDiff { + addedFields: string[]; + removedFields: string[]; + renamedFields: { from: string; to: string }[]; + addedRecords: string[]; + removedRecords: string[]; +} + +export function diffTemplates(old: Template, current: Template): TemplateDiff { + const oldRecordTypes = new Set(old.records.map((r) => r.type)); + const newRecordTypes = new Set(current.records.map((r) => r.type)); + + const oldFields = new Set(old.records.flatMap((r) => r.fields.map((f) => f.label))); + const newFields = new Set(current.records.flatMap((r) => r.fields.map((f) => f.label))); + + // Detect renames via `from` attribute + const renamedFields: { from: string; to: string }[] = []; + const renamedOld = new Set(); + const renamedNew = new Set(); + + for (const record of current.records) { + for (const field of record.fields) { + if (!field.from) continue; + const fromNames = field.from.split(',').map((s) => s.trim()); + const match = fromNames.find((f) => oldFields.has(f)); + if (match && !newFields.has(match)) { + renamedFields.push({ from: match, to: field.label }); + renamedOld.add(match); + renamedNew.add(field.label); + } + } + } + + return { + addedFields: [...newFields].filter((f) => !oldFields.has(f) && !renamedNew.has(f)), + removedFields: [...oldFields].filter((f) => !newFields.has(f) && !renamedOld.has(f)), + renamedFields, + addedRecords: [...newRecordTypes].filter((r) => !oldRecordTypes.has(r)), + removedRecords: [...oldRecordTypes].filter((r) => !newRecordTypes.has(r)) + }; +} + +export function hasChanges(diff: TemplateDiff): boolean { + return diff.addedFields.length > 0 + || diff.removedFields.length > 0 + || diff.renamedFields.length > 0 + || diff.addedRecords.length > 0 + || diff.removedRecords.length > 0; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..75bdeca --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,19 @@ + + +{#if ready} + {@render children()} +{/if} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..781b0a2 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,170 @@ + + +
+
+ + {#if importError} +
+ {importError} + +
+ {/if} + + {#if fileImportData} +
+ +
+ {:else if importData} +
+ +
+ {:else if roster.active} + {@const char = roster.active} + +
+ {#each modes as mode} + + {/each} +
+ + +
+
+ +
+
+ +
+
+ + +
+ {#if mobileView === 'edit'} +
+
+ +
+
+ {:else if mobileView === 'preview'} +
+ +
+ {:else} +
+
+ +
+
+
+ +
+ {/if} +
+ {:else} +
+
+ + +
+ + +
+
+
+ + + + {#if showPicker} + { showPicker = false; }} /> + {/if} + {/if} +
diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..0b663ed --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + fallback: 'index.html' + }) + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e57805b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + test: { + include: ['src/**/*.test.ts'], + environment: 'jsdom' + } +});