diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 97f9708..a9405a8 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -31,6 +31,14 @@ jobs: - name: Test run: dotnet test ChapterTool.Avalonia.slnx --configuration Release --no-build --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + - name: Validate PowerShell publish script + shell: pwsh + run: | + $null = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PWD 'scripts/publish.ps1'), + [ref]$null, + [ref]$null) + - name: Pack ChapterTool.Core run: dotnet pack src/ChapterTool.Core/ChapterTool.Core.csproj --configuration Release --no-restore --output artifacts/nuget @@ -76,6 +84,9 @@ jobs: - name: Publish run: ./scripts/publish.sh -Runtime ${{ matrix.runtime }} -NoRestore -PublishSingleFile + - name: Verify publish artifact + run: ./scripts/verify-publish-artifact.sh -Runtime ${{ matrix.runtime }} -Path artifacts/publish/framework-dependent/${{ matrix.runtime }} + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index 5d031fc..2e96098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,8 @@ # AGENTS.md -## Dev Environment Tips +## Repository Overview -- This repo contains the legacy WinForms project under `Time_Shift/` and the current .NET 10 Avalonia/Core/Infrastructure solution under `src/`. -- Use `ChapterTool.Avalonia.slnx` for the current Avalonia/Core/Infrastructure solution. +- This repo is the current .NET 10 ChapterTool codebase. Use `ChapterTool.Avalonia.slnx` as the main solution. - Main projects: - `src/ChapterTool.Core` - `src/ChapterTool.Infrastructure` @@ -12,12 +11,21 @@ - `tests/ChapterTool.Infrastructure.Tests` - `tests/ChapterTool.Avalonia.Tests` - Prefer `rg` for searching files and text. -- When reading files with PowerShell, explicitly use UTF-8, for example: - - `Get-Content -Raw -Encoding utf8 path\to\file` - - `[System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)` -- Keep this file focused on durable repository guidance. Do not add one-off implementation notes, completed change records, or transient archive paths here. -- Do not port WinForms absolute positioning into Avalonia. Use responsive Avalonia layout panels and stable sizing constraints. +- Use `docs/code-map/` as the primary navigation index for the current codebase. Update the relevant files there when feature work changes module ownership, entry points, runtime wiring, or the main tests a maintainer should inspect. - Keep user-facing Chinese strings as valid UTF-8. Validate localization through behavior, rendered UI, or resource-level checks rather than hard-coding incidental mojibake examples. +- CLI argument entry points must be defined, parsed, and bound through `DotMake.CommandLine`; do not hand-write logic in `Program.cs` or CLI support code to iterate, recognize, or dispatch raw `args`. +- Keep this file focused on durable repository guidance. Do not add one-off implementation notes, completed change records, or transient archive paths here. + +## PowerShell Guidance + +- On Windows, prefer `pwsh.exe` over `powershell.exe` unless Windows PowerShell 5.1 is explicitly required. +- For short native-command invocations, pass the executable and arguments separately. Store the executable path in a variable, keep each native argument as one array item, invoke with `&`, and capture `$LASTEXITCODE` immediately. +- For cmdlets and file operations, prefer PowerShell-native commands with splatting and `-LiteralPath` when working with real paths. +- For file operations, prefer explicit path and encoding handling. Use `-LiteralPath` for real paths, specify UTF-8 when reading or writing text, and avoid relying on implicit wildcard expansion or default encodings. + - `Get-Content -Raw -Encoding utf8 -LiteralPath $path` + - `[System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)` +- For multiline scripts, complex quoting, JSON, XML, regular expressions, or non-ASCII paths, write a temporary `.ps1` file and run it with `pwsh.exe -NoLogo -NoProfile -NonInteractive -File script.ps1`. +- Do not use `Invoke-Expression` for normal task execution. ## OpenSpec Workflow @@ -31,27 +39,30 @@ - After completing and archiving a change, validate all specs: - `openspec validate --all` -## Testing Instructions +## Testing And Build - Run the focused Avalonia tests after XAML or UI shell changes: - `dotnet test tests\ChapterTool.Avalonia.Tests\ChapterTool.Avalonia.Tests.csproj --no-restore` -- Keep Avalonia Headless tests isolated without serializing the entire Avalonia test assembly. Do not reintroduce assembly-level `CollectionBehavior(DisableTestParallelization = true)` for this project; instead put every class containing `[AvaloniaFact]` or `[AvaloniaTheory]` in `AvaloniaHeadlessTestCollection`. The guard test `HeadlessTestCollectionGuardTests` exists to catch missed classes. -- When a test constructs `SettingsToolViewModel` and then calls `LoadAsync` explicitly, pass `autoLoad: false`. Otherwise the constructor starts a background load and the test performs the same initialization twice, which slows Headless runs and can introduce races. -- Do not test source/configuration files by reading them as text and asserting strings. This includes `.cs`, `.axaml`, `.csproj`, scripts, CI YAML, README, and docs. Prefer compiled coverage, behavior tests, runtime verification, structured public APIs, or integration checks. - Run the full solution tests before finalizing broader changes: - `dotnet test ChapterTool.Avalonia.slnx --no-restore` -- Do not run multiple `dotnet test` commands for projects in this solution in parallel. The test projects share referenced project `obj/` outputs, and parallel external test processes can fail with locked files such as `src/ChapterTool.Core/obj/Debug/net10.0/ChapterTool.Core.dll`. Prefer the full solution test command above, or run individual test projects sequentially. - Build the Avalonia app when changing app project files: - `dotnet build src\ChapterTool.Avalonia\ChapterTool.Avalonia.csproj --no-restore` +- If dependencies, target frameworks, or generated project assets change, restore/build once before running no-restore test commands. - The CI workflow is in `.github/workflows/dotnet-ci.yml`. - If a test/build fails because `ChapterTool.Avalonia.exe` is locked, close the running app or run: - `Get-Process ChapterTool.Avalonia -ErrorAction SilentlyContinue | Stop-Process` +- Do not run multiple `dotnet test` commands for projects in this solution in parallel. The test projects share referenced project `obj/` outputs, and parallel external test processes can fail with locked files such as `src/ChapterTool.Core/obj/Debug/net10.0/ChapterTool.Core.dll`. Prefer the full solution test command above, or run individual test projects sequentially. +- Keep Avalonia Headless tests isolated without serializing the entire Avalonia test assembly. Do not reintroduce assembly-level `CollectionBehavior(DisableTestParallelization = true)` for this project; instead put every class containing `[AvaloniaFact]` or `[AvaloniaTheory]` in `AvaloniaHeadlessTestCollection`. The guard test `HeadlessTestCollectionGuardTests` exists to catch missed classes. +- Keep Avalonia Headless tests focused on UI behavior and workflow outcomes. Prefer tests that drive user actions or state changes and then verify the resulting UI state, command routing, localization refresh, selection changes, or persisted behavior. +- Do not add Headless tests that only assert a control exists, a static label renders, a window opens, a screenshot file was written, or a layout has non-zero size unless that assertion is part of a broader user-facing behavior change being verified. +- When a test constructs `SettingsToolViewModel` and then calls `LoadAsync` explicitly, pass `autoLoad: false`. Otherwise the constructor starts a background load and the test performs the same initialization twice, which slows Headless runs and can introduce races. +- Do not test source/configuration files by reading them as text and asserting strings. This includes `.cs`, `.axaml`, `.csproj`, scripts, CI YAML, README, and docs. Prefer compiled coverage, behavior tests, runtime verification, structured public APIs, or integration checks. - Add or update tests for changed behavior, especially UI layout constraints, UTF-8 labels, import/export behavior, and platform-service boundaries. -- If dependencies, target frameworks, or generated project assets change, restore/build once before running no-restore test commands. -## UI Implementation Notes +## Avalonia UI Guidelines -- The Avalonia main window should preserve workflow zones, not WinForms pixel geometry: +- Use responsive Avalonia layout panels and stable sizing constraints. Do not rely on absolute positioning for normal workflow controls. +- The Avalonia main window should preserve these workflow zones: - top load/save and frame controls - central chapter grid - bottom options area @@ -62,13 +73,13 @@ - Keep DataGrid columns protected with sensible `MinWidth` values so headers and content do not overlap when resized. - Buttons should center content horizontally and vertically. - Do not expose Windows registry-dependent actions, such as file association, as always-visible primary UI. -- When verifying visual layout changes, capture screenshots at default, wide, and narrow sizes and store them under `artifacts/`. -- Avoid static string assertions over source/configuration layout. Validate UI through Avalonia compilation, behavior-level tests, and screenshots/runtime checks instead. +- When verifying visual layout changes manually, capture screenshots at default, wide, and narrow sizes and store them under `artifacts/`. Do not treat screenshot generation by itself as an automated test assertion. - Preserve accessible names, keyboard navigation, focus behavior, and localization boundaries when changing controls. -## PR Instructions +## Change And PR Expectations - Keep changes scoped to the current feature or fix. - Mention the primary test commands run in the PR or final summary. - For UI changes, include screenshot artifact paths when available. +- When a feature change affects code ownership or lookup paths, update the relevant files under `docs/code-map/` in the same change. - Do not revert unrelated user or generated changes in a dirty worktree. diff --git a/dist/README.md b/dist/README.md new file mode 100644 index 0000000..74ac258 --- /dev/null +++ b/dist/README.md @@ -0,0 +1,12 @@ +# Distribution + +ChapterTool's maintained distribution path is the .NET 10 Avalonia publish flow: + +- `scripts/publish.sh` +- `scripts/publish.ps1` +- `.github/workflows/dotnet-ci.yml` + +The legacy Windows NSIS installer inputs were retired because they targeted the old +`Time_Shift`/`ChapterTool.exe` layout and carried an independent hard-coded version. +Any future installer must consume the current `src/ChapterTool.Avalonia` publish +output and derive metadata from the MSBuild version source in `Directory.Build.props`. diff --git a/dist/windows/UAC.nsh b/dist/windows/UAC.nsh deleted file mode 100644 index 08979ab..0000000 --- a/dist/windows/UAC.nsh +++ /dev/null @@ -1,299 +0,0 @@ -/*** UAC Plug-in *** - -Interactive User (MediumIL) Admin user (HighIL) -***[Setup.exe]************* ***[Setup.exe]************** -* * * * -* +++[.OnInit]+++++++++++ * * +++[.OnInit]++++++++++++ * -* + UAC_RunElevated >---+-+----> * + + * -* + NSIS.Quit + * * + + * -* +++++++++++++++++++++++ * * ++++++++++++++++++++++++ * -* * * * -* * * * -* +++[Section]+++++++++++ * * +++[Section]++++++++++++ * -* + + * /--+-+- -** -** Get integrity level of current process -** -**/ -!macro UAC_GetIntegrityLevel outvar -UAC::_ 6 -!if "${outvar}" != "s" - Pop ${outvar} -!endif -!macroend - - - -/* UAC_IsAdmin -** -** Is the current process running with administrator privileges? Result in $0 -** -** ${If} ${UAC_IsAdmin} ... -** -**/ -!macro UAC_IsAdmin -UAC::_ 2 -!macroend -!define UAC_IsAdmin `"" UAC_IsAdmin ""` -!macro _UAC_IsAdmin _a _b _t _f -!insertmacro _UAC_MakeLL_Cmp _!= 0 2s -!macroend - - - -/* UAC_IsInnerInstance -** -** Does the current process have a NSIS/UAC parent process that is part of the elevation operation? -** -** ${If} ${UAC_IsInnerInstance} ... -** -**/ -!macro UAC_IsInnerInstance -UAC::_ 3 -!macroend -!define UAC_IsInnerInstance `"" UAC_IsInnerInstance ""` -!macro _UAC_IsInnerInstance _a _b _t _f -!insertmacro _UAC_MakeLL_Cmp _!= 0 3s -!macroend - - - -/* UAC_PageElevation_OnInit, UAC_PageElevation_OnGuiInit, -** -** Helper macros for elevation on a custom elevation page, see the DualMode example for more information. -** -**/ -!macro UAC_Notify_OnGuiInit -UAC::_ 4 -!macroend -!macro UAC_PageElevation_OnGuiInit -!insertmacro UAC_Notify_OnGuiInit -!macroend -!macro UAC_PageElevation_OnInit -UAC::_ 5 -${IfThen} ${Errors} ${|} Quit ${|} -!macroend - - - -/* UAC_AsUser_Call -** -** Calls a function or label in the user process instance. -** All the UAC_AsUser_* macros use this helper macro. -** -**/ -!define UAC_SYNCREGISTERS 0x1 -;define UAC_SYNCSTACK 0x2 -!define UAC_SYNCOUTDIR 0x4 -!define UAC_SYNCINSTDIR 0x8 -;define UAC_CLEARERRFLAG 0x10 -!macro UAC_AsUser_Call type name flags -push $0 -Get${type}Address $0 ${name} -!verbose push -!verbose ${UAC_VERBOSE} -!insertmacro _UAC_ParseDefineFlagsToInt _UAC_AsUser_Call__flags ${flags} -!verbose pop -StrCpy $0 "1$0:${_UAC_AsUser_Call__flags}" -!undef _UAC_AsUser_Call__flags -Exch $0 -UAC::_ -!macroend - - - -/* -** UAC_AsUser_GetSection -*/ -!macro UAC_AsUser_GetSection secprop secidx outvar -!insertmacro _UAC_AsUser_GenOp ${outvar} SectionGet${secprop} ${secidx} "" -!macroend - - - -/* -** UAC_AsUser_GetGlobalVar -** UAC_AsUser_GetGlobal -*/ -!macro UAC_AsUser_GetGlobalVar var -!insertmacro _UAC_AsUser_GenOp ${var} StrCpy "" ${var} -!macroend -!macro UAC_AsUser_GetGlobal outvar srcvar -!insertmacro _UAC_AsUser_GenOp ${outvar} StrCpy "" ${srcvar} -!macroend - - - -/* -** UAC_AsUser_ExecShell -** -** Call ExecShell in the user process instance. -** -*/ -!macro UAC_AsUser_ExecShell verb command params workdir show -!insertmacro _UAC_IncL -goto _UAC_L_E_${__UAC_L} -_UAC_L_F_${__UAC_L}: -ExecShell "${verb}" "${command}" '${params}' ${show} -return -_UAC_L_E_${__UAC_L}: -!if "${workdir}" != "" - push $outdir - SetOutPath "${workdir}" -!endif -!insertmacro UAC_AsUser_Call Label _UAC_L_F_${__UAC_L} ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR} #|${UAC_CLEARERRFLAG} -!if "${workdir}" != "" - pop $outdir - SetOutPath $outdir -!endif -!macroend - - - -!macro _UAC_MakeLL_Cmp cmpop cmp pluginparams -!insertmacro _LOGICLIB_TEMP -UAC::_ ${pluginparams} -pop $_LOGICLIB_TEMP -!insertmacro ${cmpop} $_LOGICLIB_TEMP ${cmp} `${_t}` `${_f}` -!macroend -!macro _UAC_definemath def val1 op val2 -!define /math _UAC_definemath "${val1}" ${op} ${val2} -!ifdef ${def} - !undef ${def} -!endif -!define ${def} "${_UAC_definemath}" -!undef _UAC_definemath -!macroend -!macro _UAC_ParseDefineFlags_orin parse outflags -!searchparse /noerrors ${${parse}} "" _UAC_ParseDefineFlags_orin_f1 "|" _UAC_ParseDefineFlags_orin_f2 -!define _UAC_ParseDefineFlags_orin_this ${_UAC_ParseDefineFlags_orin_f1} -!undef ${parse} -!define ${parse} ${_UAC_ParseDefineFlags_orin_f2} -!define _UAC_ParseDefineFlags_orin_saveout ${${outflags}} -!undef ${outflags} -!define /math ${outflags} "${_UAC_ParseDefineFlags_orin_saveout}" | "${_UAC_ParseDefineFlags_orin_this}" -!undef _UAC_ParseDefineFlags_orin_saveout -!undef _UAC_ParseDefineFlags_orin_this -!ifdef _UAC_ParseDefineFlags_orin_f1 - !undef _UAC_ParseDefineFlags_orin_f1 -!endif -!ifdef _UAC_ParseDefineFlags_orin_f2 - !undef _UAC_ParseDefineFlags_orin_f2 -!endif -!macroend -!macro _UAC_ParseDefineFlags_Begin _outdef _in -!define _UAC_PDF${_outdef}_parse "${_in}" -!define _UAC_PDF${_outdef}_flags "" -!define _UAC_PDF${_outdef}_r 0 -!insertmacro _UAC_ParseDefineFlags_orin _UAC_PDF${_outdef}_parse _UAC_PDF${_outdef}_flags ;0x1 -!insertmacro _UAC_ParseDefineFlags_orin _UAC_PDF${_outdef}_parse _UAC_PDF${_outdef}_flags ;0x2 -!insertmacro _UAC_ParseDefineFlags_orin _UAC_PDF${_outdef}_parse _UAC_PDF${_outdef}_flags ;0x4 -!insertmacro _UAC_ParseDefineFlags_orin _UAC_PDF${_outdef}_parse _UAC_PDF${_outdef}_flags ;0x8 -!insertmacro _UAC_ParseDefineFlags_orin _UAC_PDF${_outdef}_parse _UAC_PDF${_outdef}_flags ;0x10 -!macroend -!macro _UAC_ParseDefineFlags_End _outdef -!define ${_outdef} ${_UAC_PDF${_outdef}_r} -!undef _UAC_PDF${_outdef}_r -!undef _UAC_PDF${_outdef}_flags -!undef _UAC_PDF${_outdef}_parse -!macroend -!macro _UAC_ParseDefineFlags_IncludeFlag _outdef flag -!if ${_UAC_PDF${_outdef}_flags} & ${flag} - !insertmacro _UAC_definemath _UAC_PDF${_outdef}_r ${_UAC_PDF${_outdef}_r} | ${flag} -!endif -!macroend -!macro _UAC_ParseDefineFlagsToInt _outdef _in -!insertmacro _UAC_ParseDefineFlags_Begin _UAC_ParseDefineFlagsToInt_tmp "${_in}" -!define ${_outdef} ${_UAC_PDF_UAC_ParseDefineFlagsToInt_tmp_flags} -!insertmacro _UAC_ParseDefineFlags_End _UAC_ParseDefineFlagsToInt_tmp -!undef _UAC_ParseDefineFlagsToInt_tmp -!macroend -!macro _UAC_IncL -!insertmacro _UAC_definemath __UAC_L "${__UAC_L}" + 1 -!macroend -!macro _UAC_AsUser_GenOp outvar op opparam1 opparam2 -!define _UAC_AUGOGR_ID _UAC_AUGOGR_OP${outvar}${op}${opparam1}${opparam2} -!ifndef ${_UAC_AUGOGR_ID} ;Has this exact action been done before? - !if ${outvar} == $0 - !define ${_UAC_AUGOGR_ID} $1 - !else - !define ${_UAC_AUGOGR_ID} $0 - !endif - !if "${opparam1}" == "" - !define _UAC_AUGOGR_OPP1 ${${_UAC_AUGOGR_ID}} - !define _UAC_AUGOGR_OPP2 ${opparam2} - !else - !define _UAC_AUGOGR_OPP1 ${opparam1} - !define _UAC_AUGOGR_OPP2 ${${_UAC_AUGOGR_ID}} - !endif - goto ${_UAC_AUGOGR_ID}_C - ${_UAC_AUGOGR_ID}_F: - ${op} ${_UAC_AUGOGR_OPP1} ${_UAC_AUGOGR_OPP2} - return - ${_UAC_AUGOGR_ID}_C: - !undef _UAC_AUGOGR_OPP1 - !undef _UAC_AUGOGR_OPP2 -!endif -push ${${_UAC_AUGOGR_ID}} -!insertmacro UAC_AsUser_Call Label ${_UAC_AUGOGR_ID}_F ${UAC_SYNCREGISTERS} -StrCpy ${outvar} ${${_UAC_AUGOGR_ID}} -pop ${${_UAC_AUGOGR_ID}} -!undef _UAC_AUGOGR_ID -!macroend - - - -!verbose pop -!endif /* UAC_HDR__INC */ \ No newline at end of file diff --git a/dist/windows/chaptertool.nsi b/dist/windows/chaptertool.nsi deleted file mode 100644 index 03a263b..0000000 --- a/dist/windows/chaptertool.nsi +++ /dev/null @@ -1,4 +0,0 @@ -!include options.nsi -!include translations.nsi -!include installer.nsi -!include uninstaller.nsi diff --git a/dist/windows/installer-translations/english.nsi b/dist/windows/installer-translations/english.nsi deleted file mode 100644 index a2405c6..0000000 --- a/dist/windows/installer-translations/english.nsi +++ /dev/null @@ -1,25 +0,0 @@ -;Installer strings - -;LangString inst_ct_req ${LANG_ENGLISH} "ChapterTool (required)" -LangString inst_ct_req ${LANG_ENGLISH} "ChapterTool (required)" -;LangString inst_dekstop ${LANG_ENGLISH} "Create Desktop Shortcut" -LangString inst_dekstop ${LANG_ENGLISH} "Create Desktop Shortcut" -;LangString inst_startmenu ${LANG_ENGLISH} "Create Start Menu Shortcut" -LangString inst_startmenu ${LANG_ENGLISH} "Create Start Menu Shortcut" -;LangString inst_uninstall_question ${LANG_ENGLISH} "A previous installation was detected. It will be uninstalled without deleting user settings." -LangString inst_uninstall_question ${LANG_ENGLISH} "A previous installation was detected. It will be uninstalled without deleting user settings." -;LangString inst_unist ${LANG_ENGLISH} "Uninstalling previous version." -LangString inst_unist ${LANG_ENGLISH} "Uninstalling previous version." -;LangString launch_ct ${LANG_ENGLISH} "Launch ChapterTool." -LangString launch_ct ${LANG_ENGLISH} "Launch ChapterTool." - - -;------------------------------------ -;Uninstaller strings - -;LangString remove_files ${LANG_ENGLISH} "Remove files" -LangString remove_files ${LANG_ENGLISH} "Remove files" -;LangString remove_shortcuts ${LANG_ENGLISH} "Remove shortcuts" -LangString remove_shortcuts ${LANG_ENGLISH} "Remove shortcuts" -;LangString remove_registry ${LANG_ENGLISH} "Remove registry keys" -LangString remove_registry ${LANG_ENGLISH} "Remove registry keys" diff --git a/dist/windows/installer-translations/simpchinese.nsi b/dist/windows/installer-translations/simpchinese.nsi deleted file mode 100644 index 173d5fd..0000000 --- a/dist/windows/installer-translations/simpchinese.nsi +++ /dev/null @@ -1,25 +0,0 @@ -;Installer strings - -;LangString inst_ct_req ${LANG_ENGLISH} "ChapterTool (required)" -LangString inst_ct_req ${LANG_SIMPCHINESE} "ChapterTool (必要)" -;LangString inst_dekstop ${LANG_ENGLISH} "Create Desktop Shortcut" -LangString inst_dekstop ${LANG_SIMPCHINESE} "创建桌面快捷方式" -;LangString inst_startmenu ${LANG_ENGLISH} "Create Start Menu Shortcut" -LangString inst_startmenu ${LANG_SIMPCHINESE} "创建开始菜单快捷方式" -;LangString inst_uninstall_question ${LANG_ENGLISH} "A previous installation was detected. It will be uninstalled without deleting user settings." -LangString inst_uninstall_question ${LANG_SIMPCHINESE} "检测到以前的安装。 它将被卸载但不删除用户设置。" -;LangString inst_unist ${LANG_ENGLISH} "Uninstalling previous version." -LangString inst_unist ${LANG_SIMPCHINESE} "卸载以前的版本。" -;LangString launch_ct ${LANG_ENGLISH} "Launch ChapterTool." -LangString launch_ct ${LANG_SIMPCHINESE} "启动 ChapterTool." - - -;------------------------------------ -;Uninstaller strings - -;LangString remove_files ${LANG_ENGLISH} "Remove files" -LangString remove_files ${LANG_SIMPCHINESE} "删除文件" -;LangString remove_shortcuts ${LANG_ENGLISH} "Remove shortcuts" -LangString remove_shortcuts ${LANG_SIMPCHINESE} "删除快捷方式" -;LangString remove_registry ${LANG_ENGLISH} "Remove registry keys" -LangString remove_registry ${LANG_SIMPCHINESE} "删除注册表键" diff --git a/dist/windows/installer.nsi b/dist/windows/installer.nsi deleted file mode 100644 index f00eae1..0000000 --- a/dist/windows/installer.nsi +++ /dev/null @@ -1,105 +0,0 @@ -Var uninstallerPath - -Section "-hidden" - - ;Search if ChapterTool is already installed. - FindFirst $0 $1 "$uninstallerPath\uninst.exe" - FindClose $0 - StrCmp $1 "" done - - ;Run the uninstaller of the previous install. - DetailPrint $(inst_unist) - ExecWait '"$uninstallerPath\uninst.exe" /S _?=$uninstallerPath' - Delete "$uninstallerPath\uninst.exe" - RMDir "$uninstallerPath" - - done: - -SectionEnd - - -Section $(inst_ct_req) ;"ChapterTool (required)" - - SectionIn RO - - ; Set output path to the installation directory. - SetOutPath $INSTDIR - - ;Create 'en-US' directory - CreateDirectory $INSTDIR\en-US - - ; Put file there - File "..\..\Time_Shift\bin\Release\ChapterTool.exe" - File "..\..\Time_Shift\bin\Release\ChapterTool.exe.config" - File "..\..\Time_Shift\libmp4v2.dll" - File /oname=en-US\ChapterTool.resources.dll "..\..\Time_Shift\bin\Release\en-US\ChapterTool.resources.dll" - - - ; Write the installation path into the registry - WriteRegStr HKLM "Software\ChapterTool" "InstallLocation" "$INSTDIR" - - ; Write the uninstall keys for Windows - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "DisplayName" "ChapterTool ${PROG_VERSION}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "UninstallString" '"$INSTDIR\uninst.exe"' - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "DisplayIcon" '"$INSTDIR\ChapterTool.exe",0' - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "Publisher" "TautCony" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "URLInfoAbout" "https://github.com/tautcony/ChapterTool" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "DisplayVersion" "${PROG_VERSION}" - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "NoModify" 1 - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "NoRepair" 1 - WriteUninstaller "uninst.exe" - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" "EstimatedSize" "$0" - -SectionEnd - -; Optional section (can be disabled by the user) -Section /o $(inst_dekstop) ;"Create Desktop Shortcut" - - CreateShortCut "$DESKTOP\ChapterTool.lnk" "$INSTDIR\ChapterTool.exe" - -SectionEnd - -Section $(inst_startmenu) ;"Create Start Menu Shortcut" - - CreateDirectory "$SMPROGRAMS\ChapterTool" - CreateShortCut "$SMPROGRAMS\ChapterTool\ChapterTool.lnk" "$INSTDIR\ChapterTool.exe" - CreateShortCut "$SMPROGRAMS\ChapterTool\Uninstall.lnk" "$INSTDIR\uninst.exe" - -SectionEnd - - -;-------------------------------- - -Function .onInit - - !insertmacro Init "installer" - !insertmacro MUI_LANGDLL_DISPLAY - - ;Search if ChapterTool is already installed. - FindFirst $0 $1 "$INSTDIR\uninst.exe" - FindClose $0 - StrCmp $1 "" done - - ;Copy old value to var so we can call the correct uninstaller - StrCpy $uninstallerPath $INSTDIR - - ;Inform the user - MessageBox MB_OKCANCEL|MB_ICONINFORMATION $(inst_uninstall_question) /SD IDOK IDOK done - Quit - - done: - -FunctionEnd - - -Function PageFinishRun - - !insertmacro UAC_AsUser_ExecShell "" "$INSTDIR\ChapterTool.exe" "" "" "" - -FunctionEnd - -Function .onInstSuccess - SetErrorLevel 0 -FunctionEnd \ No newline at end of file diff --git a/dist/windows/nsis plugins/UAC.zip b/dist/windows/nsis plugins/UAC.zip deleted file mode 100644 index 66489aa..0000000 Binary files a/dist/windows/nsis plugins/UAC.zip and /dev/null differ diff --git a/dist/windows/options.nsi b/dist/windows/options.nsi deleted file mode 100644 index f813c78..0000000 --- a/dist/windows/options.nsi +++ /dev/null @@ -1,118 +0,0 @@ -Unicode true -ManifestDPIAware true -;Compress the header too -!packhdr "$%TEMP%\exehead.tmp" 'upx.exe -9 --best --ultra-brute "$%TEMP%\exehead.tmp"' - -;Setting the compression -SetCompressor /SOLID LZMA -SetCompressorDictSize 64 -XPStyle on - -!include "MUI.nsh" -!include "UAC.nsh" -!include "FileFunc.nsh" -!include "WinVer.nsh" - -;For the file association -!define SHCNE_ASSOCCHANGED 0x8000000 -!define SHCNF_IDLIST 0 - -;For special folder detection -!define CSIDL_APPDATA '0x1A' ;Application Data path -!define CSIDL_LOCALAPPDATA '0x1C' ;Local Application Data path - -; Program specific -!define PROG_VERSION "2.33.33.331" - -!define MUI_FINISHPAGE_RUN -!define MUI_FINISHPAGE_RUN_FUNCTION PageFinishRun -!define MUI_FINISHPAGE_RUN_TEXT $(launch_ct) - -; The name of the installer -Name "ChapterTool ${PROG_VERSION}" -; The file to write -OutFile "ChapterTool_${PROG_VERSION}_setup.exe" - -;Installer Version Information -VIAddVersionKey "ProductName" "ChapterTool" -VIAddVersionKey "CompanyName" "TautCony" -VIAddVersionKey "LegalCopyright" "Copyright ©2015-2020 TautCony" -VIAddVersionKey "FileDescription" "ChapterTool - A Simple tool for video chapter" -VIAddVersionKey "FileVersion" "${PROG_VERSION}" - -VIProductVersion "${PROG_VERSION}.0" - -; The default installation directory. It changes depending if we install in the 64bit dir or not. -; A caveat of this is if a user has installed a 32bit version and then runs the 64bit installer -; (which in turn launches the 32bit uninstaller first) the value will still point to the 32bit location. -; The user has to manually uninstall the old version and THEN run the 64bit installer -!ifndef APP64BIT - InstallDir $PROGRAMFILES32\ChapterTool -!else - InstallDir $PROGRAMFILES64\ChapterTool -!endif - -; Registry key to check for directory (so if you install again, it will -; overwrite the old one automatically) -InstallDirRegKey HKLM Software\ChapterTool InstallLocation - -; Request application privileges for Windows Vista -RequestExecutionLevel user - -;-------------------------------- -;General Settings -!define MUI_ABORTWARNING -!define MUI_HEADERIMAGE -!define MUI_COMPONENTSPAGE_NODESC -;!define MUI_ICON "ChapterTool.ico" -!define MUI_LICENSEPAGE_CHECKBOX -!define MUI_LANGDLL_ALLLANGUAGES - -;-------------------------------- -;Remember the unistaller/installer language -!define MUI_LANGDLL_REGISTRY_ROOT "HKLM" -!define MUI_LANGDLL_REGISTRY_KEY "Software\ChapterTool" -!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" - -;-------------------------------- -;Installer Pages -!insertmacro MUI_PAGE_WELCOME -!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE" -!insertmacro MUI_PAGE_COMPONENTS -!insertmacro MUI_PAGE_DIRECTORY -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH - -;-------------------------------- -;Uninstaller Pages -!insertmacro MUI_UNPAGE_CONFIRM -!insertmacro MUI_UNPAGE_COMPONENTS -!insertmacro MUI_UNPAGE_INSTFILES - -!insertmacro MUI_RESERVEFILE_LANGDLL -ReserveFile "${NSISDIR}\Plugins\x86-unicode\UAC.dll" - -!macro Init thing -uac_tryagain: -!insertmacro UAC_RunElevated -${Switch} $0 -${Case} 0 - ${IfThen} $1 = 1 ${|} Quit ${|} ;we are the outer process, the inner process has done its work, we are done - ${IfThen} $3 <> 0 ${|} ${Break} ${|} ;we are admin, let the show go on - ${If} $1 = 3 ;RunAs completed successfully, but with a non-admin user - MessageBox mb_YesNo|mb_IconExclamation|mb_TopMost|mb_SetForeground "This ${thing} requires admin privileges, try again" /SD IDNO IDYES uac_tryagain IDNO 0 - ${EndIf} - ;fall-through and die -${Case} 1223 - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "This ${thing} requires admin privileges, aborting!" - Quit -${Case} 1062 - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Logon service not running, aborting!" - Quit -${Default} - MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Unable to elevate , error $0" - Quit -${EndSwitch} - -SetShellVarContext all -!macroend diff --git a/dist/windows/translations.nsi b/dist/windows/translations.nsi deleted file mode 100644 index 16310b7..0000000 --- a/dist/windows/translations.nsi +++ /dev/null @@ -1,11 +0,0 @@ -;Nsis translations - -!insertmacro MUI_LANGUAGE "English" -!insertmacro MUI_LANGUAGE "SimpChinese" - -;Installer/Uninstaller translations -!addincludedir installer-translations - -;The languages should be in alphabetical order -!include english.nsi -!include simpchinese.nsi diff --git a/dist/windows/uninstaller.nsi b/dist/windows/uninstaller.nsi deleted file mode 100644 index 7b912a4..0000000 --- a/dist/windows/uninstaller.nsi +++ /dev/null @@ -1,48 +0,0 @@ -Section "un.$(remove_files)" ;"un.Remove files" - SectionIn RO - -; Remove files and uninstaller - Delete "$INSTDIR\ChapterTool.exe" - Delete "$INSTDIR\ChapterTool.exe.config" - Delete "$INSTDIR\libmp4v2.dll" - Delete "$INSTDIR\en-US\ChapterTool.resources.dll" - Delete "$INSTDIR\LICENSE" - Delete "$INSTDIR\uninst.exe" - - ; Remove directories used - RMDir /r "$INSTDIR\en-US" - RMDir "$INSTDIR" -SectionEnd - -Section "un.$(remove_shortcuts)" ;"un.Remove shortcuts" - SectionIn RO -; Remove shortcuts, if any - RMDir /r "$SMPROGRAMS\ChapterTool" - Delete "$DESKTOP\ChapterTool.lnk" -SectionEnd - - -Section "un.$(remove_registry)" ;"un.Remove registry keys" - SectionIn RO - ; Remove registry keys - DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ChapterTool" - DeleteRegKey HKLM "Software\ChapterTool" - DeleteRegKey HKLM "Software\Classes\ChapterTool" - - System::Call 'Shell32::SHChangeNotify(i ${SHCNE_ASSOCCHANGED}, i ${SHCNF_IDLIST}, i 0, i 0)' -SectionEnd - - -;-------------------------------- -;Uninstaller Functions - -Function un.onInit - - !insertmacro Init "uninstaller" - !insertmacro MUI_UNGETLANGUAGE - -FunctionEnd - -Function un.onUninstSuccess - SetErrorLevel 0 -FunctionEnd \ No newline at end of file diff --git a/docs/code-map/README.md b/docs/code-map/README.md new file mode 100644 index 0000000..1a1d1d2 --- /dev/null +++ b/docs/code-map/README.md @@ -0,0 +1,33 @@ +# ChapterTool Code Map + +This directory is the maintainer navigation index for the current codebase. + +Use it when you need to quickly locate the code behind a feature without repository-wide searching first. + +## Documents + +- `core.md` + - domain models, import/edit/transform/export logic +- `infrastructure.md` + - external tools, process execution, settings persistence, platform services +- `avalonia.md` + - desktop shell, CLI entrypoints, view/viewmodel/runtime service wiring +- `testing.md` + - which test project and test files verify each code area + +## How To Use + +1. Start from the feature you need to change or debug. +2. Open the module document that owns the behavior. +3. Follow the listed entry points before using repository-wide search. +4. Use `testing.md` to find the fastest verification path. + +## Maintenance Rule + +Update these documents in the same change when feature work alters: + +- module ownership +- key entry points +- runtime wiring between modules +- the primary files a maintainer should inspect first +- the primary tests used to verify that area diff --git a/docs/code-map/avalonia.md b/docs/code-map/avalonia.md new file mode 100644 index 0000000..a4f106d --- /dev/null +++ b/docs/code-map/avalonia.md @@ -0,0 +1,166 @@ +# Avalonia Code Map + +`src/ChapterTool.Avalonia` owns the desktop shell, CLI entrypoints, view/viewmodel coordination, runtime orchestration, localization, and theme application. + +## Ownership + +### Application shell + +Startup and main shell entry points: + +- `src/ChapterTool.Avalonia/Program.cs` +- `src/ChapterTool.Avalonia/Diagnostics/SentryStartupConfiguration.cs` +- `src/ChapterTool.Avalonia/App.axaml` +- `src/ChapterTool.Avalonia/App.axaml.cs` +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml` +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs` +- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` + +Role split: + +- `MainWindow.axaml`: shell layout and bindings +- `MainWindow.axaml.cs`: drag/drop, picker triggers, keyboard/UI-only behavior +- `MainWindowViewModel.cs`: commands, state, workflow orchestration, status/progress, tool windows + +### Composition root + +Runtime wiring is centralized in: + +- `src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs` + +This is the first file to inspect when dependency wiring or service registration changes. + +### Views + +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml` +- `src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/ColorSettingsView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/LanguageToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/TemplateNamesToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/ForwardShiftToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml` + +### ViewModels + +- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` +- `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs` +- `src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs` +- `src/ChapterTool.Avalonia/ViewModels/ChapterRowViewModel.cs` +- `src/ChapterTool.Avalonia/ViewModels/UiCommand.cs` +- `src/ChapterTool.Avalonia/ViewModels/ShortcutRouter.cs` + +### Runtime and UI services + +- `src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs` +- `src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs` +- `src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaSettingsPickerService.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaThemeApplicationService.cs` + +### CLI + +- `src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs` +- `src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs` +- `src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs` +- `src/ChapterTool.Avalonia/Cli/CliConsole.cs` + +### Localization + +- `src/ChapterTool.Avalonia/Localization/AppLocalizationManager.cs` +- `src/ChapterTool.Avalonia/Localization/IAppLocalizer.cs` +- `src/ChapterTool.Avalonia/Localization/AppLocalizationResources.cs` +- `src/ChapterTool.Avalonia/Localization/AppLanguage.cs` +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx` +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx` +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx` + +## Feature Lookup + +### Main window layout, binding, workflow zones + +Start with: + +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml` +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs` +- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` + +### Main command workflow + +Start with: + +- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` + +If keyboard routing matters: + +- `src/ChapterTool.Avalonia/ViewModels/ShortcutRouter.cs` + +If command execution semantics change: + +- `src/ChapterTool.Avalonia/ViewModels/UiCommand.cs` + +### Tool windows + +Start with: + +- `src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs` + +Then inspect the matching pair in: + +- `src/ChapterTool.Avalonia/Views/Tools/` +- `src/ChapterTool.Avalonia/ViewModels/` + +### Load/save/import behavior exposed in UI + +Start with: + +- `src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs` +- `src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs` +- `src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs` + +`RuntimeChapterSaveService` applies UI save-file concerns such as output directory selection, generated file path diagnostics, and `ChapterExportOptions.EmitBom` UTF-8 encoding behavior around Core export content. + +If the wiring looks wrong, inspect: + +- `src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs` + +### Expression editor UI + +Start with: + +- `src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml` +- `src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs` + +### Settings / theme / language UI + +Start with: + +- `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs` +- `src/ChapterTool.Avalonia/Services/AvaloniaThemeApplicationService.cs` +- `src/ChapterTool.Avalonia/Localization/AppLocalizationManager.cs` + +Output defaults such as save format, XML language, UTF-8 BOM emission, and frame tolerance live in `SettingsToolViewModel` and flow into `MainWindowViewModel.ApplySettings`. + +### CLI behavior + +Start with: + +- `src/ChapterTool.Avalonia/Program.cs` +- `src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs` + +Use `ChapterToolCliCommands.cs` and `ChapterToolCliSupport.cs` for DotMake.CommandLine command definitions, bound launch-plan analysis, and supported format definitions. + +### Localization changes + +Start with: + +- `src/ChapterTool.Avalonia/Localization/Resources/` + +If resource projection or language switching behavior changes, inspect: + +- `src/ChapterTool.Avalonia/Localization/AppLocalizationManager.cs` diff --git a/docs/code-map/core.md b/docs/code-map/core.md new file mode 100644 index 0000000..459d27d --- /dev/null +++ b/docs/code-map/core.md @@ -0,0 +1,130 @@ +# Core Code Map + +`src/ChapterTool.Core` owns the chapter domain model and pure business behavior. + +This layer is where import normalization, chapter editing, frame/time transforms, and export formatting belong. + +## Ownership + +### Models + +Canonical data contracts shared across the pipeline: + +- `src/ChapterTool.Core/Models/Chapter.cs` +- `src/ChapterTool.Core/Models/ChapterSet.cs` +- `src/ChapterTool.Core/Models/ChapterImportFormat.cs` +- `src/ChapterTool.Core/Models/ChapterImportFormats.cs` +- `src/ChapterTool.Core/Models/ChapterImportSource.cs` +- `src/ChapterTool.Core/Models/ChapterImportEntry.cs` +- `src/ChapterTool.Core/Models/MediaFileReference.cs` + +`ChapterSet` is the main unit passed between import, edit, transform, and export flows. + +### Diagnostics + +Shared diagnostic contracts: + +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnostic.cs` +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCode.cs` +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnosticSource.cs` +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnosticReason.cs` +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCodeExtensions.cs` +- `src/ChapterTool.Core/Diagnostics/DiagnosticSeverity.cs` + +`ChapterDiagnostic.Code` is structured as `ChapterDiagnosticSource + ChapterDiagnosticReason`; `DisplayCode` renders the stable localization/log code as `Source.Reason`. + +### Importing + +Import contracts and format-specific parsers: + +- `src/ChapterTool.Core/Importing/IChapterImporter.cs` +- `src/ChapterTool.Core/Importing/ChapterImportRequest.cs` +- `src/ChapterTool.Core/Importing/ChapterImportResult.cs` +- `src/ChapterTool.Core/Importing/ChapterImportProgress.cs` + +Important format entry points: + +- Text dispatcher: `src/ChapterTool.Core/Importing/Text/TextChapterImporter.cs` +- OGM text: `src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs` +- Premiere marker CSV: `src/ChapterTool.Core/Importing/Text/PremiereMarkerListImporter.cs` +- Matroska XML: `src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs` +- WebVTT: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs` +- CUE sheet parsing: `src/ChapterTool.Core/Importing/Cue/CueChapterImporter.cs` +- Embedded FLAC/TAK CUE: `src/ChapterTool.Core/Importing/Cue/FlacCueImporter.cs`, `src/ChapterTool.Core/Importing/Cue/TakCueImporter.cs` +- DVD/Blu-ray playlist parsing: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs`, `src/ChapterTool.Core/Importing/Disc/MplsChapterImporter.cs`, `src/ChapterTool.Core/Importing/Disc/MplsPlaylistFile.cs`, `src/ChapterTool.Core/Importing/Disc/XplChapterImporter.cs` +- Media normalization contract: `src/ChapterTool.Core/Importing/Media/MediaChapterImporter.cs`, `src/ChapterTool.Core/Importing/Media/IMediaChapterReader.cs` + +### Editing + +In-memory chapter mutations: + +- `src/ChapterTool.Core/Editing/IChapterEditingService.cs` +- `src/ChapterTool.Core/Editing/ChapterEditingService.cs` +- `src/ChapterTool.Core/Editing/ChapterSegmentService.cs` +- `src/ChapterTool.Core/Editing/ChapterEditResult.cs` + +### Transform + +Frame/time and expression logic: + +- `src/ChapterTool.Core/Transform/FrameRateService.cs` +- `src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs` +- `src/ChapterTool.Core/Transform/ChapterExpressionService.cs` +- `src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs` +- `src/ChapterTool.Core/Transform/Expressions/Lua/LuaExpressionScriptService.cs` +- `src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs` +- `src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs` +- `src/ChapterTool.Core/Transform/ChapterRounding.cs` + +### Exporting + +Output projection and format serialization: + +- `src/ChapterTool.Core/Exporting/ChapterExportService.cs` +- `src/ChapterTool.Core/Exporting/ChapterExportOptions.cs` +- `src/ChapterTool.Core/Exporting/ChapterExportFormat.cs` +- `src/ChapterTool.Core/Exporting/ChapterExportFormats.cs` +- `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` +- `src/ChapterTool.Core/Exporting/ChapterConversionService.cs` +- `src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs` + +## Feature Lookup + +### Import behavior + +Start in the matching importer under `Importing/`. + +Use these shortcuts: + +- `.txt` source detection and dispatch: `Importing/Text/TextChapterImporter.cs` +- disc binary parsing: `Importing/Disc/MplsPlaylistFile.cs` or the matching disc importer +- media chapter normalization after raw reader output: `Importing/Media/MediaChapterImporter.cs` + +### Chapter row editing + +Start with: + +- `src/ChapterTool.Core/Editing/ChapterEditingService.cs` + +For multi-part behavior, segment combining, or append flows: + +- `src/ChapterTool.Core/Editing/ChapterSegmentService.cs` + +### Frame rate and time transforms + +Start with: + +- detection: `src/ChapterTool.Core/Transform/FrameRateService.cs` +- FPS conversion: `src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs` +- expression-driven rewrites: `src/ChapterTool.Core/Transform/ChapterExpressionService.cs` +- expression engine contract: `src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs` +- Lua expression engine: `src/ChapterTool.Core/Transform/Expressions/Lua/LuaExpressionScriptService.cs` +- time parse/format bugs: `src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs` + +### Export behavior + +Start with: + +- projection before serialization: `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` +- format-specific serialization: `src/ChapterTool.Core/Exporting/ChapterExportService.cs` +- text-to-QP/celltimes conversion: `src/ChapterTool.Core/Exporting/ChapterConversionService.cs` diff --git a/docs/code-map/infrastructure.md b/docs/code-map/infrastructure.md new file mode 100644 index 0000000..4845194 --- /dev/null +++ b/docs/code-map/infrastructure.md @@ -0,0 +1,118 @@ +# Infrastructure Code Map + +`src/ChapterTool.Infrastructure` owns process execution, external tool discovery, settings persistence, filesystem/platform integration, and import adapters that depend on native tools or container libraries. + +## Ownership + +### Media and tool-backed import adapters + +- ffprobe-backed media chapters: + - `src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs` +- ATL-backed MP4 chapters: + - `src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs` +- Matroska chapter extraction: + - `src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs` +- BDMV / eac3to path: + - `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` + +### External tool discovery and process execution + +- tool lookup: + - `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs` + - `src/ChapterTool.Infrastructure/Tools/ExternalToolPathResolver.cs` + - `src/ChapterTool.Infrastructure/Tools/MkvToolNixInstallProbe.cs` +- process execution: + - `src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs` +- service contracts: + - `src/ChapterTool.Infrastructure/Services/IExternalToolLocator.cs` + - `src/ChapterTool.Infrastructure/Services/IProcessRunner.cs` + - `src/ChapterTool.Infrastructure/Services/ProcessRunRequest.cs` + - `src/ChapterTool.Infrastructure/Services/ProcessRunResult.cs` + - `src/ChapterTool.Infrastructure/Services/ExternalToolLocation.cs` + +### Settings and configuration persistence + +- schema: + - `src/ChapterTool.Infrastructure/Configuration/AppSettings.cs` +- storage: + - `src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs` + - `src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs` +- corrupt-file handling: + - `src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs` + - `src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFileException.cs` +- JSON source generation: + - `src/ChapterTool.Infrastructure/Configuration/AppJsonSerializerContext.cs` + +`AppSettings` stores runtime-safe output defaults including save format, XML language, UTF-8 BOM emission, and frame-accuracy tolerance; the Avalonia settings tool applies these live and persists them through `AppSettingsStore`. + +### Platform services + +- shell/OS launch behavior: + - `src/ChapterTool.Infrastructure/Platform/ShellService.cs` +- native dependency lookup: + - `src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs` + - `src/ChapterTool.Infrastructure/Platform/INativeDependencyService.cs` +- app log surface: + - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- test/dummy platform services: + - `src/ChapterTool.Infrastructure/Platform/MemoryClipboardService.cs` + - `src/ChapterTool.Infrastructure/Platform/ScriptedDialogService.cs` + - `src/ChapterTool.Infrastructure/Platform/RecordingWindowService.cs` + +## Feature Lookup + +### ffprobe import issues + +Start with: + +- `src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs` + +Then inspect: + +- `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs` +- `src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs` + +### MP4 embedded chapter issues + +Start with: + +- `src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs` + +### MKV / mkvextract issues + +Start with: + +- `src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs` + +Then inspect: + +- `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs` +- `src/ChapterTool.Infrastructure/Tools/MkvToolNixInstallProbe.cs` + +### BDMV / eac3to issues + +Start with: + +- `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` + +### External tool path resolution + +Start with: + +- `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs` + +Use `ExternalToolPathResolver.cs` when path expansion, executable name, or default candidate rules are involved. + +### Settings persistence and corruption handling + +Start with: + +- `src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs` +- `src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs` + +### Shell, terminal, file reveal, and app log issues + +Start with: + +- `src/ChapterTool.Infrastructure/Platform/ShellService.cs` +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` diff --git a/docs/code-map/testing.md b/docs/code-map/testing.md new file mode 100644 index 0000000..6a1e84e --- /dev/null +++ b/docs/code-map/testing.md @@ -0,0 +1,120 @@ +# Test Code Map + +This file maps production areas to the test projects and high-signal test files that verify them. + +## Test Projects + +- Core behavior: + - `tests/ChapterTool.Core.Tests` +- Infrastructure behavior: + - `tests/ChapterTool.Infrastructure.Tests` +- Avalonia shell, runtime UI services, localization, CLI: + - `tests/ChapterTool.Avalonia.Tests` + +## Core Test Map + +Use `tests/ChapterTool.Core.Tests` when changing pure parsing, editing, transform, or export behavior. + +High-signal test files: + +- importing + - `tests/ChapterTool.Core.Tests/Importing/TextImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/CueImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/DiscImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/IfoImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/MplsImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/XplImporterTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs` +- editing + - `tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs` +- transform + - `tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/ChapterRoundingTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs` +- exporting + - `tests/ChapterTool.Core.Tests/Exporting/ChapterExportServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Exporting/ChapterConversionServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Exporting/XmlChapterLanguageCatalogTests.cs` + +Fixtures: + +- `tests/ChapterTool.Core.Tests/Fixtures/` + +## Infrastructure Test Map + +Use `tests/ChapterTool.Infrastructure.Tests` when changing process/tool/platform/settings behavior or tool-backed import adapters. + +High-signal test files: + +- tool lookup: + - `tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs` +- ffprobe: + - `tests/ChapterTool.Infrastructure.Tests/FfprobeMediaChapterReaderTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/Importing/FfprobeMediaChapterIntegrationTests.cs` +- MP4 / ATL: + - `tests/ChapterTool.Infrastructure.Tests/AtlMp4ChapterReaderTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/Importing/Mp4IntegrationTests.cs` +- Matroska / mkvextract: + - `tests/ChapterTool.Infrastructure.Tests/MatroskaChapterImporterTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs` +- BDMV / eac3to: + - `tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs` +- process runner: + - `tests/ChapterTool.Infrastructure.Tests/ProcessRunnerTests.cs` +- platform services: + - `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs` +- settings persistence: + - `tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs` + +Fixtures: + +- `tests/ChapterTool.Infrastructure.Tests/Fixtures/Importing/Media/` + +## Avalonia Test Map + +Use `tests/ChapterTool.Avalonia.Tests` when changing UI shell, view models, runtime UI services, localization, headless interaction flows, or CLI behavior. + +High-signal test files: + +- view models + - `tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs` + - `tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs` + - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs` +- commands and services + - `tests/ChapterTool.Avalonia.Tests/Commands/UiCommandTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Services/` +- CLI + - `tests/ChapterTool.Avalonia.Tests/Cli/ChapterToolCliApplicationTests.cs` +- localization + - `tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs` +- headless shell/interaction/integration + - `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowStateHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/SettingsToolHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs` + +## Quick Routing + +- parsing or export semantics changed: start in `tests/ChapterTool.Core.Tests` +- external tool, settings, process, or platform boundary changed: start in `tests/ChapterTool.Infrastructure.Tests` +- view, viewmodel, CLI, localization, or runtime UI orchestration changed: start in `tests/ChapterTool.Avalonia.Tests` + +## Distribution Verification + +- Maintained publish entry points: + - `scripts/publish.sh` + - `scripts/publish.ps1` + - `.github/workflows/dotnet-ci.yml` +- Current distribution notes: + - `dist/README.md` +- The legacy Windows NSIS installer inputs are retired. Future installer work should consume the `src/ChapterTool.Avalonia` publish output and derive version metadata from `Directory.Build.props`. diff --git a/docs/code-review-2026-07-06.md b/docs/code-review-2026-07-06.md deleted file mode 100644 index eb785e7..0000000 --- a/docs/code-review-2026-07-06.md +++ /dev/null @@ -1,280 +0,0 @@ -# Code Review - 2026-07-06 - -## Scope - -This review inspected the current `src/` and `tests/` code for: - -- fake or incomplete stub behavior exposed as real functionality -- low-value or misleading tests -- unusual compatibility or platform handling -- deviations from the repository guidance and common .NET/Avalonia practices - -Four subagents reviewed Core, Infrastructure, Avalonia/UI, and cross-repository suspicious patterns in parallel. The review was read-only; no tests were run. - -Existing working tree note: `scripts/publish.sh` was already modified before this review and was not touched. - -## Summary - -No critical issues were found. The highest-risk findings are: - -- WebVTT import drops cue end times and calculates duration incorrectly. -- Windows terminal fallback in `ShellService` builds a command string from a path. -- File association support exists as a partial service surface but is not complete enough to be trustworthy. -- Preview UI is available when no chapter data exists and opens an empty window. - -## High Severity - -### WebVTT cue end times are parsed but discarded - -- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:40` -- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:48` -- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:62` - -The importer validates the cue end time with `TimeSpan.TryParse(parts[1], out _)`, but the parsed value is discarded. `Chapter.End` is never populated, and `ChapterInfo.Duration` is set to the last chapter start time. - -Impact: imported WebVTT chapters lose end times. Any later export or duration-dependent workflow can produce wrong final segment timing. - -Recommendation: keep the parsed end value, pass it as `End` when creating `Chapter`, and calculate duration from the last valid cue end. - -### Windows terminal fallback is command-injection prone - -- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:62` - -The Windows fallback path builds a command string: - -```text -cmd /c start cmd /k "cd /d {directoryPath}" -``` - -Because `directoryPath` is user/path data, characters such as `&` or quotes can change the command. Exceptions around this path are also swallowed. - -Impact: malformed or adversarial paths can execute unintended shell commands; failures are invisible to the user. - -Recommendation: avoid command-string composition. Start `cmd.exe` directly with fixed arguments and set `ProcessStartInfo.WorkingDirectory = directoryPath`, or use a safer platform abstraction that returns a structured failure. - -### File association service is incomplete and not wired to the documented command surface - -- File: `src/ChapterTool.Infrastructure/Platform/WindowsFileAssociationService.cs:27` -- File: `src/ChapterTool.Infrastructure/Platform/WindowsFileAssociationService.cs:60` -- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:520` -- Related spec: `openspec/specs/avalonia-ui-shell/spec.md:60` - -`WindowsFileAssociationService` writes only the ProgID description and extension default value. It does not write `shell\open\command`, icon metadata, ownership markers, or shell change notifications. `UnregisterAsync` deletes the extension key without confirming it is still owned by ChapterTool. - -The spec says the main window ViewModel shall expose a file association command, but the current command list has no such command. The composition root can create the service, but no user-visible workflow appears to consume it. - -Impact: registration can report success without making files open correctly, and unregister can remove another app's association. The implementation also looks like a feature but lacks an end-to-end user path. - -Recommendation: either complete the service and UI workflow, or remove/hide the feature surface until it is ready. A complete implementation should write an open command, track ownership, guard unregistration, refresh shell associations, and have tests through an injectable registry abstraction. - -### Preview opens an empty window with no loaded chapters - -- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:260` -- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:148` -- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1304` - -The preview command is an unconditional `WindowCommand("preview")`. With no current chapter data, `BuildPreview()` returns empty text, so the user can open a blank preview window. - -Impact: this is a visible UI stub: the control appears usable but has no meaningful behavior in the empty state. Keyboard access such as `F11` can trigger the same result. - -Recommendation: make `PreviewCommand.CanExecute` depend on loaded chapter data, and ensure buttons, menus, and shortcuts share that state. Alternatively, show an explicit empty-state message. - -## Medium Severity - -### `CueChapterImporter` has a fake injectable parser - -- File: `src/ChapterTool.Core/Importing/Cue/CueChapterImporter.cs:3` -- File: `src/ChapterTool.Core/Importing/Cue/CueChapterImporter.cs:29` - -The constructor accepts `CueSheetParser? parser` and stores it, but `ImportAsync` calls the static `CueSheetParser.Parse` method. - -Impact: callers and tests may think parser behavior is replaceable, but the injected object is ignored. - -Recommendation: remove the constructor parameter and field, or introduce a real parser interface/instance method and use it. - -### `IfoChapterImporter` is fake-async and ignores request content - -- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:16` -- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:18` -- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:21` - -`ImportAsync` awaits `Task.CompletedTask`, then reads only from `request.Path`. It ignores `request.Content`. - -Impact: IFO behaves differently from other importers that support in-memory content. The fake await also obscures the synchronous file-only behavior. - -Recommendation: parse from a seekable stream and prefer `request.Content` when present. If file-only sync parsing is intentional for now, remove the fake await and document the limitation. - -### Core tests depend on Infrastructure - -- File: `tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj:30` -- File: `tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs:4` - -`ChapterTool.Core.Tests` references `ChapterTool.Infrastructure` and instantiates `FfprobeMediaChapterReader`. - -Impact: Core tests become mixed integration tests and can fail because of Infrastructure parser behavior rather than Core importer behavior. - -Recommendation: test Core's `MediaChapterImporter` using a fake `IMediaChapterReader` that returns `MediaChapterEntry` values. Keep ffprobe JSON/process parsing tests in `ChapterTool.Infrastructure.Tests`. - -### External tool locator treats any file as an executable - -- File: `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs:37` -- File: `tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs:15` - -Tool resolution uses `File.Exists` only, and tests use empty text files as successful tool candidates. - -Impact: on Linux/macOS, non-executable files can be reported as found. The settings UI can show success, then runtime execution fails later. - -Recommendation: add executable validation. On Unix-like systems, check execute permissions. On Windows, validate expected executable naming, and consider an optional lightweight `--version` probe for configured tools. - -### Corrupt settings silently reset to defaults - -- File: `src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs:43` -- File: `src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs:29` - -Invalid JSON is caught and replaced with default settings without logging or surfacing a diagnostic. - -Impact: user settings can appear to reset, and a later save can overwrite the corrupt file with defaults, making recovery harder. - -Recommendation: log the parse failure, preserve the corrupt file as `.corrupt`, and expose a user-visible warning before defaults are saved back. - -### Shell failures are silently swallowed - -- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:41` -- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:74` -- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:105` - -Reveal/open-terminal helper paths catch broad exceptions and ignore them. - -Impact: users see an action that does nothing, with no status or log entry. - -Recommendation: catch expected process/platform exceptions and return or log structured failure information. - -### FFmpeg directory setting actually validates ffprobe - -- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:248` -- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:255` -- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:577` -- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:593` - -`FfmpegPath` is presented as an FFmpeg directory setting, but validation and discovery check `ffprobe`. - -Impact: users may think they configured FFmpeg, while the app actually validates ffprobe. The setting overlaps semantically with `FfprobePath`. - -Recommendation: rename it to ffprobe directory if that is the intended behavior, or validate/discover `ffmpeg` if it truly represents FFmpeg. - -### Native file picker strings are hard-coded English - -- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:11` -- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:16` -- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:37` -- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:54` -- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:71` - -File picker titles and file type labels are English string literals. - -Impact: localized UI still opens English system dialogs. - -Recommendation: inject `IAppLocalizer` into `AvaloniaFilePickerService`, or have callers pass localized titles/filter names. - -### Icon buttons lack accessible names - -- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:260` -- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:267` -- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:275` -- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:505` -- File: `src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml:101` - -Several icon buttons have tooltip text and automation IDs, but no localized `AutomationProperties.Name`. - -Impact: screen readers may not announce a useful action name. Automation IDs are not a substitute for accessible names. - -Recommendation: add localized `AutomationProperties.Name` and, where useful, `AutomationProperties.HelpText`. Headless tests should verify the accessible name, not only command binding. - -### BDMV parser moves data through diagnostics and narrow regexes - -- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:160` -- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:344` - -The BDMV importer stores eac3to stdout in a diagnostic message and then parses it later with narrow text regexes. - -Impact: adding another diagnostic can break assumptions, and small eac3to output/version/localization changes can stop detection. - -Recommendation: return an internal structured result that carries stdout separately from diagnostics. Add fixture coverage from real eac3to outputs and handle known format variants. - -## Low Severity - -### Test fakes live in production Infrastructure - -- File: `src/ChapterTool.Infrastructure/Platform/MemoryClipboardService.cs:5` -- File: `src/ChapterTool.Infrastructure/Platform/ScriptedDialogService.cs:5` -- File: `src/ChapterTool.Infrastructure/Platform/RecordingWindowService.cs:5` -- File: `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs:63` - -These are test-double style services in the production Infrastructure assembly. The test name calls them skeletons. - -Impact: they can be accidentally injected into production paths and silently record operations instead of performing real platform behavior. - -Recommendation: move them to test projects, or mark them internal/test-only and avoid exposing them as production services. - -### `UiCommandTests` waits with a fixed delay - -- File: `tests/ChapterTool.Avalonia.Tests/Commands/UiCommandTests.cs:47` - -The test waits for `async void` command completion with `Task.Delay(50)`. - -Impact: slow CI machines or scheduling delays can make the test flaky. - -Recommendation: use a `TaskCompletionSource`, property changed event, or polling with a bounded timeout for `ExecutionError`. - -### Matroska integration setup blocks on async - -- File: `tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs:27` - -`IAsyncLifetime.InitializeAsync` uses `.AsTask().Result`. - -Impact: this is a sync-over-async pattern with deadlock and cancellation risks. - -Recommendation: make `InitializeAsync` an `async ValueTask` and `await LocateAsync(...)`. - -### Screenshot tests are weak regression guards - -- File: `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs:190` -- File: `tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs:117` - -The tests mainly capture screenshots and assert the files exist/non-empty. - -Impact: a badly misaligned or semantically blank UI can still pass. - -Recommendation: keep screenshots as artifacts, but add behavioral/layout assertions such as key controls visible, no bounds overflow, and meaningful non-background pixel regions. - -### MKVToolNix registry DisplayIcon parsing misses quoted paths - -- File: `src/ChapterTool.Infrastructure/Tools/MkvToolNixInstallProbe.cs:78` - -The install probe trims a trailing comma suffix but does not normalize quoted `DisplayIcon` values. - -Impact: common registry values such as `"C:\Program Files\...\mkvtoolnix-gui.exe",0` may fail to resolve to the install directory. - -Recommendation: strip quotes after trimming the icon suffix, then take the directory. Add a quoted DisplayIcon test. - -### eac3to export can show a console window - -- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:171` -- File: `tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs:51` - -The eac3to chapter export request explicitly sets `CreateNoWindow: false`, and the test locks in that behavior. - -Impact: GUI import can flash or show a console window. - -Recommendation: default to hidden process windows unless eac3to requires visibility. If visibility is required, document the reason and test that reason rather than the raw flag. - -## Non-Issues / Filtered Results - -The review did not find meaningful hits for: - -- `NotImplementedException` -- `PlatformNotSupportedException` -- `Thread.Sleep` -- global test parallelization disablement in Avalonia tests -- tests reading source/configuration files and asserting incidental source strings - diff --git a/docs/code-review-fix-todo-2026-07-06.md b/docs/code-review-fix-todo-2026-07-06.md deleted file mode 100644 index b1def27..0000000 --- a/docs/code-review-fix-todo-2026-07-06.md +++ /dev/null @@ -1,40 +0,0 @@ -# Code Review Fix TODO - 2026-07-06 - -Source report: `docs/code-review-2026-07-06.md` - -## Rules - -- Fix one issue at a time. -- Run focused tests after each fix. -- Commit each completed fix separately. -- Do not include unrelated working tree changes, especially the pre-existing `scripts/publish.sh` modification. - -## High Priority - -- [x] Fix WebVTT import so cue end times and duration are preserved. -- [x] Remove command-string path interpolation from `ShellService.OpenTerminalAsync`. -- [x] Complete Windows file association registry behavior so registration writes an open command and unregistration protects existing associations. -- [ ] Add or explicitly defer a non-primary settings/command surface for file association. -- [x] Prevent preview from opening as an empty stub when no chapters are loaded. - -## Medium Priority - -- [x] Remove the fake injectable parser from `CueChapterImporter`. -- [x] Remove fake async behavior from `IfoChapterImporter` and handle `request.Content` consistently. -- [x] Remove the Infrastructure dependency from Core tests. -- [x] Validate external tools as executables, not just existing files. -- [x] Preserve and surface corrupt settings files instead of silently resetting. -- [x] Return or log shell service failures instead of swallowing them. -- [x] Clarify whether `FfmpegPath` means ffmpeg or ffprobe directory and validate accordingly. -- [x] Localize native file picker titles and file type labels. -- [x] Add accessible names for icon-only buttons. -- [x] Refactor BDMV parsing so stdout is not passed through diagnostics. - -## Low Priority - -- [x] Move production test-double services out of Infrastructure or mark them test-only. -- [x] Replace fixed `Task.Delay` in `UiCommandTests`. -- [x] Remove sync-over-async from Matroska integration setup. -- [x] Strengthen screenshot tests with layout/content assertions. -- [x] Handle quoted MKVToolNix `DisplayIcon` registry values. -- [x] Hide eac3to export process windows unless visibility is required. diff --git a/docs/i18n-audit-and-fix-plan.md b/docs/i18n-audit-and-fix-plan.md deleted file mode 100644 index 92cc0df..0000000 --- a/docs/i18n-audit-and-fix-plan.md +++ /dev/null @@ -1,165 +0,0 @@ -# 国际化(i18n)排查与修复计划 - -> 生成日期:2026-07-04 -> 范围:`ChapterTool.Avalonia` / `ChapterTool.Core` / `ChapterTool.Infrastructure` -> 现象:选择中文(或日文)后,部分日志、状态、诊断信息仍显示英文。 - -## 执行结果(截至 2026-07-04) - -| 任务 | 状态 | 说明 | -|------|------|------| -| Task 1 — Diagnostic.* 本地化 | ✅ 已完成 | 新增 73 + 18 个 `Diagnostic.*` 键(三语);`ChapterDiagnostic` 增加 `Arguments`;`LocalizeDiagnostic` 支持 `{message}` 自动回填与命名占位符;`LogDiagnostics` 改用本地化消息。 | -| Task 2 — operation/action 本地化 | ✅ 已完成 | 新增 `Action.*`(7)/`EditKind.*`(3)/`Operation.*`(6) 键;`ApplyEdit`、`LogDiagnostics` 全部调用点改用本地化字符串。 | -| Task 3 — ExpressionService 错误本地化 | ✅ 已完成 | 引入 `ExpressionException(code,message,args)`,18 个错误码 `InvalidExpression.*` 三语;测试断言改为 `StartsWith`。 | -| Task 4 — 外部工具/依赖缺失消息 | ✅ 已随 Task 1 完成 | `MissingDependency`/`MatroskaMissingDependency`/`FfprobeMissingDependency` 为静态本地化键(内嵌工具名),显示时覆盖定位器英文消息。 | -| Task 5 — 清理废弃本地化抽象 | ✅ 已完成 | 删除 `ILocalizationService`/`LocalizationService`(Core/Infrastructure);从 `PlatformServiceTests` 移除对应测试块。无悬空引用。 | - -**资源键总数**:每语言由 126 → **233** 键(三语严格对齐,占位符一致性测试守护)。 -**测试**:Core 276 / Infrastructure 88 / Avalonia.Localization 11 全绿;Avalonia 全量(含 headless)在 Task 1-2 阶段已全绿(Task 3/5 仅触及 Core/Infrastructure)。 - ---- - -## 一、现状架构概览 - -| 维度 | 说明 | -|------|------| -| 资源文件 | `src/ChapterTool.Avalonia/Localization/Resources/Strings.{zh-CN,en-US,ja-JP}.resx`(各 126 键,键/占位符一致性有测试守护) | -| 默认/回退文化 | **zh-CN**(`AppLanguage.DefaultCultureName = "zh-CN"`,无中性 `Strings.resx`) | -| 活跃本地化服务 | `IAppLocalizer` / `AppLocalizationManager`(Avalonia 层),`SetCulture` 同时写入 `Application.Current.Resources` 供 `{DynamicResource}` 使用 | -| XAML | 全量使用 `{DynamicResource Key}`,UI 文本无硬编码英文 ✅ | -| 日志通道 | `MainWindowViewModel.Log(key,...)` 注入 `MessageKey`;显示时由 `FormatLogEntry` 经 `Localizer.Format` 重新本地化。**只有传入真实 `Log.*`/`Status.*` 键才会本地化**,传字面量英文则绕过本地化 | - -### 文化切换链路(已正确) -`AppSettings.Language` → 启动 `MainWindowViewModel.LoadSettings` → `Localizer.SetCulture` → `CultureChanged` 事件 → 各 ViewModel 刷新 + XAML `{DynamicResource}` 自动更新 + 持久化。 - -## 二、缺陷清单(按优先级) - -### P0 — 诊断信息(Diagnostic)完全不翻译 ⭐ 最大问题 -- **位置**:`MainWindowViewModel.LocalizeDiagnostic`(`MainWindowViewModel.cs:1365`) -- **机制**:代码已构造 `Diagnostic.{Code}` 键查找,但 **三个 resx 文件中 `Diagnostic.*` 键数为 0**。 -- **后果**:状态栏、日志面板对所有诊断一律回退到 `diagnostic.Message`(硬编码英文)。 -- **涉及诊断码(约 60 个)**:`InvalidFrameRate`、`NoSegments`、`UnsupportedCombineSource`、`InvalidChapterIndex`、`InvalidExpression`、`PartialParse`、`EmptyCueFile`、`MalformedCueSyntax`、`MatroskaProcessFailed`、`FfprobeProcessFailed`、`Mp4ReadFailed`、`MissingDependency`、`Stdout` 等(完整清单见附录 A)。 -- **来源文件**:`src/ChapterTool.Core/{Editing,Transform,Exporting,Importing}/...`、`src/ChapterTool.Infrastructure/{Platform,Importing,Tools}/...` - -### P1 — 日志 operation/action 参数硬编码英文 -- **A. `ApplyEdit(result, action)`**(`MainWindowViewModel.cs:890`) - - `action` 作为本地化模板 `Log.EditChapters` 的 `{action}` 占位符,但调用方传入全英文:`$"Delete rows: indexes=..."`、`$"Insert row: index=..."`、`$"Shift frames forward: ..."`、`$"Edit {kind}: row=..."`、`$"Combine segments: ..."`。 - - → 模板被翻译,但内嵌的 `{action}` 片段恒为英文。 -- **B. `LogDiagnostics(operation, ...)`**(`MainWindowViewModel.cs:1516`) - - 多处调用传入英文字面量:`"Load"`、`"Save"`、`"Create zones"`、`"Append load"`、`"Append edit"`、`"Output projection"` 等(`{operation}` 占位符)。 - - 同时该函数把 `diagnostic.Message`(英文)直接塞入 `Log.Diagnostic` 的 `{message}` 占位符,**未走 `LocalizeDiagnostic`**。 - -### P2 — ExpressionService 异常消息全英文 -- **位置**:`src/ChapterTool.Core/Transform/ExpressionService.cs:140,261,271,283,293,303,320,338,348` -- 抛出的英文消息(`"Misplaced comma."`、`"Unbalanced parentheses."`、`"Expression did not reduce to one value."` 等)经 `InvalidExpression` 诊断原样显示给用户。 - -### P3 — 外部工具/原生依赖缺失消息全英文 -- `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs:77`:`$"External tool '{toolId}' was not found."` -- `src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs:21`:`$"Native dependency '{dependencyId}' was not found."` -- 被各 importer 原样透传为 `ChapterDiagnostic.Message`(code=`MissingDependency`)。 - -### P4 — 重复/废弃的本地化抽象(清理项) -- `src/ChapterTool.Core/Services/ILocalizationService.cs` + `src/ChapterTool.Infrastructure/Platform/LocalizationService.cs` -- 构造时资源字典为空,**未被 `AppCompositionRoot` 接入**,仅出现在测试中。与 `IAppLocalizer` 重复,易致混淆。 - -### 已验证为正常(无需改动) -- 所有 `.axaml` 用户文本均走 `{DynamicResource}`。 -- `SetStatus` / `SetProgressStatus` 一律使用 `Status.*` 键。 -- `SettingsToolViewModel`、`AvaloniaSettingsCloseConfirmationService` 全本地化。 -- 无 `Console/Debug/Trace.WriteLine`。 - -## 三、修复任务(subagent 顺序执行,避免并发改 resx 冲突) - -> ⚠️ 所有任务共享 `Strings.*.resx`,**必须串行**;每个 subagent 完成后由主控做构建/测试验证再启动下一个。 - -### Task 1 — 诊断信息本地化(P0)⭐ 核心 -**目标**:状态栏与日志面板的诊断消息随当前语言切换。 - -**改动**: -1. `ChapterDiagnostic` 增加 `IReadOnlyDictionary? Arguments = null`(可选,向后兼容)。 -2. 三个 resx 新增全部 `Diagnostic.{Code}` 键(含 `{占位符}`,见附录 A 的带参清单)。 -3. `LocalizeDiagnostic` 改为 `Localizer.Format(diagnosticKey, diagnostic.Arguments)`。 -4. `LogDiagnostics` 中 `{message}` 改用 `LocalizeDiagnostic(diagnostic)`,`{code}` 保留原始码(技术标识)。 -5. 对带插值的诊断生产点(`InvalidChapterIndex`、`PartialParse(line)`、`OrderShiftNormalized` 等)改为传 `Arguments`,`Message` 保留英文作技术回退。 -6. `LocalizationTests` 增加 `Diagnostic.*` 三语键/占位符一致性校验。 - -**验收**:`dotnet test ChapterTool.Avalonia.slnx --no-restore` 通过;切换语言后诊断消息随之变化。 - -### Task 2 — 日志 operation/action 参数本地化(P1) -**目标**:消除日志行中内嵌的英文片段。 - -**改动**: -1. 新增 `Operation.*` 键(`Operation.Load/Save/CreateZones/AppendLoad/AppendEdit/OutputProjection/AppendLoadEdit` 等)三语。 -2. 新增 `Log.EditChapters.*` 子键或 `Action.*` 键(`DeleteRows/InsertRow/ShiftFramesForward/EditCell/CombineSegments/EditChapters`)三语,带 `{indexes}`/`{index}`/`{frames}`/`{kind}`/`{row}`/`{value}`/`{count}`/`{sourceType}` 占位符。 -3. `ApplyEdit` 签名改为接收 `LocalizedMessage` 或 `(string key, args)` 而非原始英文字符串;更新全部调用点。 -4. `LogDiagnostics` 的 `operation` 参数改为本地化键路径;更新全部调用点。 - -**验收**:构建通过;日志面板在中文/日文下不再出现 "Delete rows"、"Load" 等英文片段。 - -### Task 3 — ExpressionService 错误消息本地化(P2) -**目标**:表达式解析错误随语言切换。 - -**改动**: -1. 为 `ExpressionService` 中每种解析失败新增 `Expression.*` 键三语(或扩展 `Diagnostic.InvalidExpression.*`)。 -2. 改造抛错路径:以「错误码 + 参数」形式携带,最终在 `InvalidExpression` 诊断处本地化;或令 `ExpressionService` 接收本地化查表回调。 -3. 同步附录 A 的 `InvalidExpression` 处理。 - -**验收**:构造错误表达式,中文/日文下提示为对应语言。 - -### Task 4 — 外部工具/依赖缺失消息本地化(P3) -**目标**:`MissingDependency` 诊断消息本地化。 - -**改动**: -1. `ExternalToolLocator` / `FileSystemNativeDependencyService` 返回结构化位置(`ToolId`/`DependencyId`)而非预格式化英文 `Message`。 -2. 由 importer 在生成 `MissingDependency` 诊断时携带 `Arguments={toolId/dependencyId}`,复用 Task 1 的 `Diagnostic.MissingDependency` 键。 -3. 校验 `BdmvChapterImporter`、`MatroskaChapterImporter`、`FfprobeMediaChapterReader` 的透传链路。 - -**验收**:缺依赖场景下提示为当前语言。 - -### Task 5 — 清理废弃本地化抽象(P4) -**目标**:消除 `ILocalizationService`/`LocalizationService` 死代码与潜在误用。 - -**改动**: -1. 确认仅 `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs` 使用。 -2. 删除接口与实现 + 测试引用,或统一并入 `IAppLocalizer`(视依赖最小化原则择一)。 - -**验收**:构建+全量测试通过,无悬空引用。 - -## 四、执行顺序与验证 -1. Task 1(P0)→ `dotnet build` + `dotnet test ChapterTool.Avalonia.slnx --no-restore` -2. Task 2(P1)→ 同上 -3. Task 3(P2)→ 同上 -4. Task 4(P3)→ 同上 -5. Task 5(P4)→ 同上 -6. 终验:`openspec validate --all`(如涉及 spec)+ 全量测试 - -## 附录 A — 诊断码完整清单(待补 Diagnostic.* 键) - -### 静态消息(无占位符) -``` -InvalidFrameRate, InvalidFrameText, NoRowsSelected, NoSegments, -UnsupportedCombineSource, UnsupportedAppendSource, UnsupportedExportFormat, -NoChapters, NoChaptersFound, EmptyCueFile, MalformedCueSyntax, -InvalidContainerHeader, FlacEmbeddedCueNotFound, EmbeddedCueNotFound, -InvalidMpls, InvalidStructure, InvalidIfo, InvalidXml, InvalidEntryElement, -InvalidChapterPair, EmptyXml, XmlInvalidRoot, XmlNoChapters, XplNoChapters, -XplParseFailed, EmptyChapters, OgmInvalidFirstLine, PremiereMarkerListInvalid, -WebVttInvalidHeader, WebVttMalformedCue, InvalidTimeText, InvalidTimecodeText, -InvalidChapterText, InvalidExpressionTime, UnsupportedPlatform, -DependencyExecutionCancelled, DependencyExecutionFailed, DependencyExecutionTimedOut, -DependencyOutputUnrecognized, DependencyOutputMissing, DependencyOutputEmpty, -MatroskaCannotStart, MatroskaProcessCancelled, MatroskaProcessFailed, -MatroskaProcessTimedOut, FfprobeEmptyOutput, FfprobeParseFailed, -FfprobeProcessCancelled, FfprobeProcessFailed, FfprobeProcessTimedOut, -Mp4FileInaccessible, Mp4FileNotFound, Mp4InvalidPath, Mp4MalformedMetadata, -Mp4ReadFailed, Mp4UnsupportedMetadata, Stdout, MissingDependency -``` - -### 带占位符消息 -| Code | 占位符 | 示例 | -|------|--------|------| -| `InvalidChapterIndex` | `{index}` | Chapter index {index} is out of range. | -| `PartialParse` | `{line}` | Parsing stopped at line: {line} | -| `OrderShiftNormalized` | (待核) | 订单位移已归一化 | -| `MissingDependency` | `{toolId}` / `{dependencyId}` | External tool '{toolId}' was not found. | -| `Stdout` | `{output}` | (原样透传命令输出) | diff --git a/docs/avalonia-modernization-review.md b/docs/migrations/avalonia-modernization-review.md similarity index 100% rename from docs/avalonia-modernization-review.md rename to docs/migrations/avalonia-modernization-review.md diff --git a/docs/avalonia-rewrite-spec.md b/docs/migrations/avalonia-rewrite-spec.md similarity index 100% rename from docs/avalonia-rewrite-spec.md rename to docs/migrations/avalonia-rewrite-spec.md diff --git a/docs/coverage-matrix.md b/docs/migrations/coverage-matrix.md similarity index 100% rename from docs/coverage-matrix.md rename to docs/migrations/coverage-matrix.md diff --git a/docs/feature-implementation-progress-report.md b/docs/migrations/feature-implementation-progress-report.md similarity index 100% rename from docs/feature-implementation-progress-report.md rename to docs/migrations/feature-implementation-progress-report.md diff --git a/docs/gui-verification.md b/docs/migrations/gui-verification.md similarity index 100% rename from docs/gui-verification.md rename to docs/migrations/gui-verification.md diff --git a/docs/legacy-new-implementation-diff.md b/docs/migrations/legacy-new-implementation-diff.md similarity index 100% rename from docs/legacy-new-implementation-diff.md rename to docs/migrations/legacy-new-implementation-diff.md diff --git a/docs/modules/01-ui-shell-and-interactions.md b/docs/migrations/modules/01-ui-shell-and-interactions.md similarity index 100% rename from docs/modules/01-ui-shell-and-interactions.md rename to docs/migrations/modules/01-ui-shell-and-interactions.md diff --git a/docs/modules/02-core-model-transform-export.md b/docs/migrations/modules/02-core-model-transform-export.md similarity index 100% rename from docs/modules/02-core-model-transform-export.md rename to docs/migrations/modules/02-core-model-transform-export.md diff --git a/docs/modules/03-text-xml-matroska-vtt-importers.md b/docs/migrations/modules/03-text-xml-matroska-vtt-importers.md similarity index 100% rename from docs/modules/03-text-xml-matroska-vtt-importers.md rename to docs/migrations/modules/03-text-xml-matroska-vtt-importers.md diff --git a/docs/modules/04-disc-playlist-media-importers.md b/docs/migrations/modules/04-disc-playlist-media-importers.md similarity index 100% rename from docs/modules/04-disc-playlist-media-importers.md rename to docs/migrations/modules/04-disc-playlist-media-importers.md diff --git a/docs/modules/05-cue-flac-tak-importers.md b/docs/migrations/modules/05-cue-flac-tak-importers.md similarity index 100% rename from docs/modules/05-cue-flac-tak-importers.md rename to docs/migrations/modules/05-cue-flac-tak-importers.md diff --git a/docs/modules/06-supporting-ui-platform-services.md b/docs/migrations/modules/06-supporting-ui-platform-services.md similarity index 100% rename from docs/modules/06-supporting-ui-platform-services.md rename to docs/migrations/modules/06-supporting-ui-platform-services.md diff --git a/docs/modules/07-tests-build-distribution-assets.md b/docs/migrations/modules/07-tests-build-distribution-assets.md similarity index 100% rename from docs/modules/07-tests-build-distribution-assets.md rename to docs/migrations/modules/07-tests-build-distribution-assets.md diff --git a/docs/openspec-avalonia-dotnet10-spec-split.md b/docs/migrations/openspec-avalonia-dotnet10-spec-split.md similarity index 100% rename from docs/openspec-avalonia-dotnet10-spec-split.md rename to docs/migrations/openspec-avalonia-dotnet10-spec-split.md diff --git a/docs/packaging-strategy.md b/docs/migrations/packaging-strategy.md similarity index 100% rename from docs/packaging-strategy.md rename to docs/migrations/packaging-strategy.md diff --git a/docs/progress.md b/docs/migrations/progress.md similarity index 100% rename from docs/progress.md rename to docs/migrations/progress.md diff --git a/docs/spec-implementation-verification.md b/docs/migrations/spec-implementation-verification.md similarity index 100% rename from docs/spec-implementation-verification.md rename to docs/migrations/spec-implementation-verification.md diff --git a/docs/review-2026-07-05/agent-findings.md b/docs/review-2026-07-05/agent-findings.md deleted file mode 100644 index 834e39a..0000000 --- a/docs/review-2026-07-05/agent-findings.md +++ /dev/null @@ -1,83 +0,0 @@ -# Agent Findings — Sub-agent Dispatch Log - -> Record of sub-agent dispatches, candidate findings, adoption/rejection decisions, and verification notes. - -## Dispatch Summary - -| Phase | Task ID | Duration | Candidate Findings | Adopted | Rejected | Downgraded | -|-------|---------|----------|--------------------|---------|----------|-------------| -| 1 Core | bg_b9a2378f | 9m21s | 2 + 1 suspect | 2 (P2,P3) | 1 (P1 FP) | — | -| 2 Infra | bg_5f185fb5 | 9m47s | 2 + 3 suspects | 2 (P2,P2) | — | 1 (P1→P2) | -| 3 VM | bg_cbd33c95 | 7m52s | 4 + 3 suspects | 2 (P2,P3) | 1 (P1 FP) | 1 (P2→P3) | -| 4 Views | bg_1c8af626 | 6m31s | 3 + 3 suspects | 3 (P2,P3,P3) | — | 1 (P1→P2), 1 (P2→P3) | -| 5 Scripts | bg_183f5248 | 4m57s | 3 + 3 suspects | 4 (P2,P2,P3,P3) | — | — | -| 6 L10n | bg_0defc2e9 | 5m47s | 0 | 0 | — | — | - -## Rejected Findings (False Positives) - -### Phase 1 — ~~P1: MissingOperatorBeforeFunction missing resx key~~ - -**Sub-agent claim:** The code `InvalidExpression.MissingOperatorBeforeFunction` thrown at `ExpressionService.cs:262` has no corresponding `Diagnostic.InvalidExpression.MissingOperatorBeforeFunction` key in the resx. - -**Main-agent verification:** -``` -$ grep -n "MissingOperatorBeforeFunction" Strings.en-US.resx -:161: - -$ comm -23 <(all codes thrown) <(all keys present in resx) -(empty) -``` - -**Verdict: REJECTED.** Key exists at line 161 in all three resx files. All 18 `InvalidExpression.*` codes have complete coverage. - -**Root cause of sub-agent error:** The sub-agent checked for key presence by reading a narrow line range (`:158-166`) and didn't find the key in that window, but it was at `:161` — likely a parsing/line-counting discrepancy in the sub-agent's resx read. - ---- - -### Phase 3 — ~~P1: Selector identity mismatch (recomputed list + reference equality)~~ - -**Sub-agent claim:** `XmlLanguageDisplayOptions` returns a new array every getter call; `SelectorDisplayOption` has no value equality → UI selection desync. - -**Main-agent verification:** -- `XmlLanguageDisplayOptions => xmlLanguageDisplayOptions` returns the **same persistent `ObservableCollection`** (`MainWindowViewModel.cs:289`), not a new array. -- `RefreshXmlLanguageDisplayOptions` (`:1463-1487`) preserves identity: - - Count unchanged → `UpdateFrom()` mutates in place. - - Count changed → clear + re-add on same collection instance. -- `SelectedXmlLanguageDisplayOption` setter matches by `MainText` value (`:304`), not reference. -- Getter indexes by `XmlLanguageIndex` integer (`:296`), not reference. - -**Verdict: REJECTED.** The code explicitly handles identity preservation — this is deliberate defensive design, not a bug. - -**Root cause of sub-agent error:** The sub-agent inferred "recomputed array" from the `XmlLanguageDisplay.Options()` factory method without reading the `RefreshXmlLanguageDisplayOptions` caller that bridges the factory output into a persistent collection with identity-preserving mutation. - -## Downgraded Findings (Severity Calibration) - -| Original | Calibrated | Rationale | -|----------|------------|-----------| -| Phase 2 P1 (event race) | **P2** | Real C# event race, but impact bounded to logging path in a desktop app. Worst case: rare NRE in `ILogger.Log`, not data corruption. | -| Phase 3 P1 (culture mutation) | **P2** | Real design anti-pattern, but `Options()` call sites are UI-thread-bound and infrequent (culture change only). Cross-thread impact requires a concurrent background operation reading `CurrentCulture` during the brief `using` window. | -| Phase 4 P1 (CFBundleExecutable) | **P2** | Names match by default (no `AssemblyName` override). The real defect is the silent chmod skip, not the name itself. | -| Phase 3 P2 (live refresh leak) | **P3** | `TextToolView.axaml.cs:40` explicitly calls `DetachLiveRefresh()` on DataContext change — the View handles the lifecycle. | -| Phase 4 P2 (no unsubscribe) | **P3** | Main window owns its ViewModel; subscription graph is self-contained and collectable together. | - -## Verification Commands Run by Main Agent - -1. `grep -n "MissingOperatorBeforeFunction" Strings.*.resx` — key presence in all 3 files. -2. `comm -23 <(thrown codes) <(resx keys)` — zero missing diagnostic codes. -3. `grep -rn "ILocalizationService|LocalizationService" --type cs` repo-wide — zero dangling refs. -4. `dotnet build ChapterTool.Avalonia.slnx` — 0 warnings, 0 errors. -5. Full read of `ApplicationLogPanelProvider.cs`, `Info.plist`, `publish.sh` macOS block, `ToolWindowViewModels.cs`, `RefreshXmlLanguageDisplayOptions`. -6. `grep "IsChapterGridEmpty"` — overlay binding verified. -7. `grep "DetachLiveRefresh"` — View calls detach. -8. Empty-catch / `dynamic` / unsafe-cast scan — clean. - -## Assessment of Sub-agent Quality - -- **Phase 6 (Localization):** Excellent — thorough, programmatic, all claims verified. No false positives. -- **Phase 5 (Scripts):** Strong — accurate shell analysis, correct severity calls. -- **Phase 4 (Views):** Good on Info.plist audit and AGENTS.md compliance; slightly over-conservative on severity calibration. -- **Phase 2 (Infra):** Correct dangling-ref audit; P1 was technically right but over-calibrated for a desktop logging path. -- **Phase 3 (VM):** Correct XmlLanguageDisplay culture analysis; **produced a false positive** on selector identity by not reading the refresh method. -- **Phase 1 (Core):** Correct ExpressionService evaluation spot-checks; **produced a false positive** on the missing key by misreading the resx. - -**False-positive rate:** 2 of 13 candidate findings rejected (15%). Both were P1 claims that would have been the highest-severity items — highlighting the importance of main-agent verification for P0/P1 candidates per the skill protocol. diff --git a/docs/review-2026-07-05/fixes-plan.md b/docs/review-2026-07-05/fixes-plan.md deleted file mode 100644 index 0ded5ce..0000000 --- a/docs/review-2026-07-05/fixes-plan.md +++ /dev/null @@ -1,122 +0,0 @@ -# Fixes Plan - -> Execution-oriented fix plan for findings from the 2026-07-05 code review. -> Ordered by impact × effort. Each fix is independently shippable. - -## Batch 1 — Pre-release Hardening (recommend before next tag) - -### Fix A: Remove ambient culture mutation from XmlLanguageDisplay -- **Addresses:** P2-1 -- **Files:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs` -- **Complexity:** Medium (1 file, ~30 lines rewritten) -- **Change:** - 1. Delete `TemporaryCurrentUiCulture` class entirely. - 2. In `LanguageDisplayName`, call `CultureInfo.GetCultureInfo(language.Code).DisplayName` **without** swapping `CurrentCulture`/`CurrentUICulture`. For .NET, `CultureInfo.DisplayName` returns the display name in the culture's own language regardless of ambient UI culture — so the result is deterministic. - 3. For the `"und"` code, keep the existing `localizer.GetString("XmlLanguage.Undetermined")` lookup. - 4. Cache results per `localizer.CurrentCultureName` to avoid repeated `GetCultureInfo` calls. -- **Verification:** Open Settings → XML language selector; verify display names are correct in all 3 UI languages. Run `dotnet test ChapterTool.Avalonia.slnx --no-restore`. - -### Fix B: publish.sh interrupt resilience + executable validation -- **Addresses:** P2-5, P2-6, P3-4 -- **Files:** `scripts/publish.sh` -- **Complexity:** Low (~15 lines added) -- **Change:** - ```bash - # 1. Arg-value guard (line ~19, ~23): - case "$1" in - -Configuration) [[ $# -ge 2 ]] || { echo "ERROR: -Configuration requires a value" >&2; exit 2; }; Configuration="$2"; shift 2 ;; - -Runtime) [[ $# -ge 2 ]] || { echo "ERROR: -Runtime requires a value" >&2; exit 2; }; Runtime="$2"; shift 2 ;; - ... - esac - - # 2. Trap + stage-then-rename for macOS bundle: - trap 'rm -rf "$app_bundle"' INT TERM - # ... stage into "$app_bundle.tmp", then mv "$app_bundle.tmp" "$app_bundle" at the end - trap - INT TERM - - # 3. Hard error on missing executable: - [[ -f "$exe_path" ]] || { echo "ERROR: executable '$exe_path' not found" >&2; exit 1; } - chmod +x "$exe_path" - - # 4. Glob guard: - shopt -s nullglob - items=("$output"/*) - shopt -u nullglob - (( ${#items[@]} > 0 )) || { echo "ERROR: no publish output to bundle" >&2; exit 1; } - ``` -- **Verification:** `./scripts/publish.sh -Runtime osx-arm64` on macOS; verify `.app` launches. Test `-Runtime` with no value → clean error. - -## Batch 2 — Observability & Correctness Hardening - -### Fix C: ExpressionException innerException + OverflowException catch -- **Addresses:** P2-3, P3-1 -- **Files:** `src/ChapterTool.Core/Transform/ExpressionService.cs` -- **Complexity:** Low (~10 lines) -- **Change:** - 1. Add ctor: `ExpressionException(string code, string message, IReadOnlyDictionary? args = null, Exception? innerException = null) : base(message, innerException)`. - 2. Add `OverflowException` to the catch list at the two evaluator catch sites (`:89`, `:147`), mapping to a diagnostic (e.g., `InvalidExpression.Overflow`). -- **Verification:** `dotnet test tests/ChapterTool.Core.Tests --no-restore`. Add a test: `ExpressionService_EvaluatesOverflowExpression_ReturnsDiagnostic`. - -### Fix D: ApplicationLogPanelProvider event invocation local-copy -- **Addresses:** P2-2 -- **Files:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` -- **Complexity:** Trivial (2 lines) -- **Change:** - ```csharp - // Line 106, replace: - EntryAdded?.Invoke(this, entry); - // With: - var handler = EntryAdded; - handler?.Invoke(this, entry); - ``` -- **Verification:** `dotnet test tests/ChapterTool.Infrastructure.Tests --no-restore`. - -### Fix E: IApplicationLogService default interface implementation -- **Addresses:** P2-4 -- **Files:** `src/ChapterTool.Core/Services/IApplicationLogService.cs` -- **Complexity:** Low (3 lines) -- **Change:** Add default impl to the event: - ```csharp - event EventHandler? EntryAdded - { - add { } - remove { } - } - ``` - This makes the event optional for implementers. `ApplicationLogPanelProvider` overrides it with a real event. -- **Verification:** Build passes; existing tests pass. - -## Batch 3 — Minor Lifecycle Hardening (can defer) - -### Fix F: MainWindow unsubscribe on close -- **Addresses:** P3-2 -- **Files:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs` -- **Complexity:** Low -- **Change:** Add `UnsubscribeViewModelCommandState()` method mirroring `SubscribeViewModelCommandState()`, call from `OnClosed` override. - -### Fix G: publish.ps1 try/finally cleanup -- **Addresses:** P3-5 -- **Files:** `scripts/publish.ps1` -- **Complexity:** Low - -### Fix H: Diagnostic placeholder backfill -- **Addresses:** P3-3 -- **Files:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` -- **Complexity:** Low -- **Change:** In `LocalizeDiagnostic`, after format, strip any remaining `{token}` patterns with empty string or `[?]`. - -### Fix I: TextToolView detach on DetachedFromVisualTree -- **Addresses:** P3-6 -- **Files:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs` -- **Complexity:** Low - -## Verification Commands (per AGENTS.md) - -After all fixes: -```powershell -dotnet build ChapterTool.Avalonia.slnx --no-restore -dotnet test ChapterTool.Avalonia.slnx --no-restore -openspec validate --all -``` - -For macOS bundle fix: `./scripts/publish.sh -Runtime osx-arm64` on a macOS host, verify `.app` launches. diff --git a/docs/review-2026-07-05/phase-0-baseline.md b/docs/review-2026-07-05/phase-0-baseline.md deleted file mode 100644 index 2924b9b..0000000 --- a/docs/review-2026-07-05/phase-0-baseline.md +++ /dev/null @@ -1,86 +0,0 @@ -# Phase 0 — Baseline - -> Code review of `feat/improve-struct` vs `master`, including uncommitted changes. -> Generated: 2026-07-05 (Asia/Shanghai) - -## Branch & Change Surface - -- **Branch**: `feat/improve-struct` -- **Commits ahead of master**: 5 - - `2e2f18f` feat: Add macOS app bundle support and update application icon - - `c8522ba` test: speed up Avalonia unit test execution - - `25e4752` fix: Correct case of 'Full' in blame-hang-dump-type for CI - - `260c053` feat: Enhance ChapterDiagnostic with Arguments and improve error handling in expression evaluation - - `eb42d91` feat: Update store selection process and enhance log service integration -- **Uncommitted**: 12 modified + 3 untracked files (i18n follow-ups + new `XmlLanguageDisplay` ViewModel + new `show-chapter-empty-state` OpenSpec change). -- **Combined diff vs master (committed + uncommitted)**: 73 files changed, +4,161 / −810. - -## Tech Stack & Module Boundaries - -- .NET 10 / Avalonia 11 desktop app for chapter editing. -- Solution: `ChapterTool.Avalonia.slnx` - - `src/ChapterTool.Core` — domain: models, transformations (ExpressionService), importers (text/XML/OGM/cue/etc.), exporters, diagnostics, editing. - - `src/ChapterTool.Infrastructure` — external tools, process exec, settings, infra-backed importers (BDMV/Matroska/MP4 via ffprobe). - - `src/ChapterTool.Avalonia` — UI: ViewModels, Views (.axaml + code-behind), Services, Localization resx. - - `tests/` — Core.Tests, Infrastructure.Tests, Avalonia.Tests (incl. headless). -- Build verified at baseline: **`dotnet build ChapterTool.Avalonia.slnx` → 0 warnings, 0 errors.** - -## Intent of This Branch (per `docs/i18n-audit-and-fix-plan.md` & OpenSpec change) - -1. **i18n refactor** (largest portion of the diff): - - P0: Add ~91 new `Diagnostic.*` keys (3 languages), wire `ChapterDiagnostic.Arguments`, route `LocalizeDiagnostic` through `Localizer.Format`. - - P1: Add `Action.*`, `EditKind.*`, `Operation.*` keys; refactor `ApplyEdit` / `LogDiagnostics` to take localized keys instead of raw English strings. - - P2: New `ExpressionException(code, message, args)` in `ExpressionService`; 18 `InvalidExpression.*` codes. - - P3: `MissingDependency` / external-tool messages localized at the importer boundary. - - P4: **Delete** `ILocalizationService` + `LocalizationService` (Core/Infrastructure) — was unused dead code. -2. **macOS app bundle support**: new `Assets/MacOS/Info.plist`, app-icon assets (`icns`/`ico`/updated `svg`), `csproj` bundle config, new `scripts/publish.{ps1,sh}` helpers. -3. **Empty-grid UI feature** (uncommitted, OpenSpec change `show-chapter-empty-state`): render `chapter-empty.svg` overlay when grid is empty; new headless tests. -4. **New `XmlLanguageDisplay` ViewModel** (untracked): provides display-name localization for the XML chapter language `` selector. - -## Phase Plan (parallel sub-agents) - -| Phase | Layer | Scope | -|---|---|---| -| 1 | Core domain | `src/ChapterTool.Core/**` | -| 2 | Infrastructure | `src/ChapterTool.Infrastructure/**` (incl. deleted-abstraction dangling-ref audit) | -| 3 | Avalonia ViewModels | `ViewModels/**` (incl. new `XmlLanguageDisplay`) | -| 4 | Avalonia Views + bundle config | `Views/**`, `.axaml.cs`, `csproj`, `Info.plist`, `Assets/`, `Services/` | -| 5 | Build / publish | `scripts/**`, `.github/workflows/**`, `.gitignore` | -| 6 | Localization | 3× `resx`, `LocalizationTests.cs` | -| 7 (me) | Cross-cutting differential disproof | Horizontal sweep across all phases | -| 8 (me) | Aggregation | `summary.md`, `fixes-plan.md` | - -## High-Risk Surface Map (preliminary — to be confirmed/denied by phases) - -The following are the most likely defect clusters, listed before reading the diffs so sub-agent findings can be checked against this expectation: - -1. **`XmlLanguageDisplay` global culture mutation** (Phase 3): `TemporaryCurrentUiCulture : IDisposable` mutates `CultureInfo.CurrentCulture`/`CurrentUICulture` process-wide inside a `using`. Race-prone, exception-unsafe if `GetCultureInfo` throws after assignment, and impacts any concurrent culture-sensitive code. **Pre-rated P1 pending Phase 3 evidence.** -2. **`ILocalizationService` deletion dangling references** (Phase 2): If DI registration or any test still references the deleted type → runtime ActivatorException or compile failure. (Build passed → compile is clean; DI/runtime resolution still needs evidence.) -3. **Localization key/placeholder parity** (Phase 6): 91 new keys × 3 languages = lots of room for missing keys or mismatched `{name}` placeholders → runtime `FormatException` or English fallback. -4. **macOS `Info.plist` correctness** (Phase 4): Missing `CFBundleIdentifier`/`CFBundleExecutable`/version keys → bundle won't launch on macOS. -5. **`publish.sh` shell quoting** (Phase 5): Unquoted `$VAR` expansions on paths with spaces → silent partial artifacts. -6. **`ExpressionService` evaluation regression** (Phase 1): The refactor touches the core expression evaluator; verify precedence, div-by-zero, NaN, and that all throw sites now use `ExpressionException` (no dropped error cases). - -## Anti-Pattern Coverage Plan - -The following high-leakage patterns will be checked by every phase AND in the final cross-cutting pass: - -- Default branch on unknown input / unknown diagnostic code / unknown lang code. -- Async failure propagation (cancellation, IO failure → status bar / log panel). -- Half-committed state across persistence + cache + observable collections. -- Deferred-execution context invalidation (culture swap, captured closures, event handlers firing after window close). -- Single-point utility assumptions: encoding (resx UTF-8), time (frame-rate math), collision (lang codes), empty (null `Arguments`). -- Re-entrant UI (rapid button clicks, window close mid-async). - -## Out of Scope (explicitly excluded) - -- `.codex/skills/**` markdown files (tooling docs, no runtime behavior). -- `openspec/changes/**` proposal/design/tasks markdown (planning artifacts). -- Binary icon assets (`icns`/`ico`) — only their wiring (csproj `BuildAction`) is reviewed. -- Subjective translation quality — only structural/behavioral defects (missing keys, placeholder mismatches, UTF-8 mojibake). -- Style/formatting nits. - -## Pre-existing Review Context - -- `docs/review-2026-06-10/` exists but `summary.md` is empty; not a useful baseline. -- `docs/i18n-audit-and-fix-plan.md` documents the i18n refactor as "completed" with green tests; Phase 6 will verify that claim structurally rather than trust it. diff --git a/docs/review-2026-07-05/phase-1-core.md b/docs/review-2026-07-05/phase-1-core.md deleted file mode 100644 index 736b6f2..0000000 --- a/docs/review-2026-07-05/phase-1-core.md +++ /dev/null @@ -1,50 +0,0 @@ -# Phase 1 — Core Domain Review - -> Scope: `src/ChapterTool.Core/**` changes vs `master` (committed + uncommitted). -> Reviewer: sub-agent bg_b9a2378f + main-agent verification. - -## Phase Summary - -The ExpressionService refactor to structured `ExpressionException(code, message, args)` is consistent across all 18 throw sites. **All 18 `InvalidExpression.*` codes have corresponding `Diagnostic.InvalidExpression.*` keys in all three resx files** — verified programmatically by the main agent (`comm -23` between thrown codes and resx keys returned empty). Importer and diagnostic-arguments changes are structurally safe. One observability gap (no innerException) and one pre-existing overflow edge case noted. - -## Files Reviewed - -- `src/ChapterTool.Core/Diagnostics/ChapterDiagnostic.cs` -- `src/ChapterTool.Core/Editing/ChapterEditingService.cs` -- `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` -- `src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs` -- `src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs` -- `src/ChapterTool.Core/Transform/ExpressionService.cs` -- `src/ChapterTool.Core/Services/IApplicationLogService.cs` -- `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` (boundary) - -## Sub-agent Finding Rejected (FALSE POSITIVE) - -### ~~[P1] MissingOperatorBeforeFunction code missing localization key~~ — **REJECTED** - -The sub-agent claimed `Diagnostic.InvalidExpression.MissingOperatorBeforeFunction` was absent from the resx. **Main-agent verification disproved this**: the key exists at line 161 in all three resx files (`en-US`, `zh-CN`, `ja-JP`). Furthermore, a programmatic `comm -23` between all 18 codes thrown by `ExpressionService.cs` and all `Diagnostic.InvalidExpression.*` keys in `Strings.en-US.resx` returned **zero missing codes**. The i18n refactor's diagnostic-code-to-resx-key coverage is complete. - -## Findings - -### [P2] ExpressionException lacks innerException channel -- **Location:** `src/ChapterTool.Core/Transform/ExpressionService.cs:166-171` -- **Trigger:** Any future throw site that wraps a lower-level exception (parser/format helper) into `ExpressionException` cannot carry the root cause. -- **Impact:** Reduced debuggability. Current throw sites are all direct validation errors (no wrapping needed today), so immediate functional impact is low — but the refactor established a new exception type without the standard `.innerException` ctor, violating the "no swallow of inner exceptions" contract for future maintainers. -- **Fix:** Add `ExpressionException(string code, string message, IReadOnlyDictionary? args = null, Exception? innerException = null)` passing to `base(message, innerException)`. -- **Confidence:** High - -### [P3] Pre-existing overflow risk in double→decimal cast (not introduced by this diff) -- **Location:** `src/ChapterTool.Core/Transform/ExpressionService.cs:~505,508` -- **Trigger:** Extreme-domain inputs (very large exponents, NaN-producing trig/log) cast `double` results to `decimal`, which can throw `OverflowException` — not caught by the existing `InvalidOperationException|FormatException|KeyNotFoundException|DivideByZeroException` handlers. -- **Impact:** Unhandled exception propagates to caller; user sees a crash instead of a diagnostic. -- **Fix:** Add `OverflowException` to the catch list, or clamp NaN/Infinity before cast. -- **Confidence:** Medium (pre-existing, not a regression) - -## 漏检复盘 (Missed-pattern Retrospective) - -- **Unknown input default branch**: Checked `Tokenize`, `ToPostfix`, `EvaluatePostfix` — all unsupported tokens map to explicit diagnostics. Clean. -- **Downstream failure propagation**: Importers still return `ChapterImportResult.Failed(...)` on error. No silent success-on-error introduced. Clean. -- **Half-committed state**: Chapter lists only published on success paths. Clean. -- **Deferred-execution context invalidation**: Expression evaluation is eager over token stream; no captured mutable context. Clean. -- **Single-point utility assumptions**: Placeholder substitution null-safe; diagnostic argument transport null-guarded at call sites. Clean. -- **Evaluation spot-checks**: `1 + 2 * 3` → 7 ✅; `0 ? 2 : 3 + 1` → 4 ✅; `1/0` → DivideByZeroException caught ✅. diff --git a/docs/review-2026-07-05/phase-2-infrastructure.md b/docs/review-2026-07-05/phase-2-infrastructure.md deleted file mode 100644 index 6b02245..0000000 --- a/docs/review-2026-07-05/phase-2-infrastructure.md +++ /dev/null @@ -1,52 +0,0 @@ -# Phase 2 — Infrastructure Review - -> Scope: `src/ChapterTool.Infrastructure/**` changes vs `master`. -> Reviewer: sub-agent bg_5f185fb5 + main-agent verification. - -## Phase Summary - -`ILocalizationService` / `LocalizationService` deletion leaves **zero C# dangling references** repo-wide (confirmed via `git grep`). One event-invocation race found in `ApplicationLogPanelProvider` (calibrated P2 — real but low-impact in a logging path). The new `IApplicationLogService.EntryAdded` event is a breaking interface change for out-of-tree implementers (P2). - -## Files Reviewed - -- `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` (full read + verified) -- `src/ChapterTool.Infrastructure/Services/IApplicationLogService.cs` -- `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` - -## Dangling-Reference Audit - -``` -$ git grep -n -E "ILocalizationService|LocalizationService" -- "*.cs" -(no output) -``` - -**Verdict: CLEAN.** Zero C# references to the deleted types remain in production or test code. Mentions in docs/OpenSpec markdown are historical narrative, not runtime risk. - -## Findings - -### [P2] Event invocation race in ApplicationLogPanelProvider -- **Location:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs:106` -- **Trigger:** Thread A calls `Capture()` → reaches `EntryAdded?.Invoke(this, entry)`. Thread B unsubscribes (`EntryAdded -= handler`) between the null-check and the delegate invocation. -- **Impact:** Intermittent `NullReferenceException` from the logging path. In practice, low probability (desktop app, logging is near-sequential), but it's the textbook C# event race. An unhandled NRE from `ILogger.Log` could propagate depending on the logging pipeline's exception guards. -- **Fix:** Copy delegate to local before invoking: - ```csharp - var handler = EntryAdded; - handler?.Invoke(this, entry); - ``` - Note: invocation is correctly outside the lock (avoids reentrancy deadlock) — keep it there. -- **Confidence:** High (the race is real; severity calibrated from sub-agent's P1 to P2 given low-impact logging context) - -### [P2] IApplicationLogService.EntryAdded is a breaking interface change -- **Location:** `src/ChapterTool.Core/Services/IApplicationLogService.cs:7` -- **Trigger:** Any external implementer of `IApplicationLogService` compiled against the old interface. -- **Impact:** Compile-time break for out-of-tree consumers. In-repo, the single implementer (`ApplicationLogPanelProvider`) is updated. -- **Fix:** If backward-compat matters, add a default interface implementation: `event EventHandler? EntryAdded { add { } remove { } }` or document the contract change. -- **Confidence:** Medium (repo-local correctness is fine) - -## 漏检复盘 (Missed-pattern Retrospective) - -- **悬空引用**: 全仓 C# 搜索确认删除类型零残留。Clean. -- **接口新增成员**: 仓内实现已覆盖;对外破坏已报 P2。 -- **异步阻塞**: 未引入 `.Result` / `.Wait()`。Clean. -- **类型安全**: 无 `dynamic`、危险强转、空 `catch`。Clean. -- **事件并发反路径**: 命中 `EntryAdded?.Invoke` 竞态(P2)。 diff --git a/docs/review-2026-07-05/phase-3-viewmodels.md b/docs/review-2026-07-05/phase-3-viewmodels.md deleted file mode 100644 index a608994..0000000 --- a/docs/review-2026-07-05/phase-3-viewmodels.md +++ /dev/null @@ -1,55 +0,0 @@ -# Phase 3 — Avalonia ViewModels Review - -> Scope: `src/ChapterTool.Avalonia/ViewModels/**` changes vs `master`. -> Reviewer: sub-agent bg_cbd33c95 + main-agent verification. - -## Phase Summary - -The `MainWindowViewModel` i18n refactor is coherent — `LocalizeDiagnostic` uses replace-based formatting (no `FormatException` vector), and all `ApplyEdit`/`LogDiagnostics` call sites within the file now pass localized keys. The new `XmlLanguageDisplay` has a legitimate ambient-culture-mutation design concern (P2). The sub-agent's "selector identity mismatch" finding was **rejected as a false positive** after code inspection. - -## Files Reviewed - -- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` (full) -- `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs` (full) -- `src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs` (full) -- `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs` (full, untracked new file) - -## Sub-agent Finding Rejected (FALSE POSITIVE) - -### ~~[P1] XML language selected-item identity mismatch~~ — **REJECTED** - -The sub-agent claimed `XmlLanguageDisplayOptions` "returns a new array every getter call." **Main-agent verification disproved this:** - -- `XmlLanguageDisplayOptions => xmlLanguageDisplayOptions` returns the **same persistent `ObservableCollection`** every call (`MainWindowViewModel.cs:289`). -- `RefreshXmlLanguageDisplayOptions` preserves identity by design (`MainWindowViewModel.cs:1463-1487`): - - If count unchanged: mutates items in-place via `UpdateFrom()` — item identity preserved. - - If count changed: clears + re-adds — collection identity preserved. -- The `SelectedXmlLanguageDisplayOption` setter matches by **value** (`option.MainText`, `StringComparison.OrdinalIgnoreCase`), not by reference (`:304-305`). -- The getter indexes by `XmlLanguageIndex` integer (`:296-298`), not by reference. - -This is deliberate defensive code that avoids the exact problem the sub-agent flagged. - -## Findings - -### [P2] Ambient culture mutation in XmlLanguageDisplay static helper -- **Location:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs:27, 63-82` -- **Trigger:** `XmlLanguageDisplay.Options(localizer)` temporarily sets `CultureInfo.CurrentCulture` / `CurrentUICulture` (process-global) inside a `using var _ = new TemporaryCurrentUiCulture(...)`. -- **Impact:** During the `using` window, ALL threads in the process see the swapped culture. If a background operation (chapter save, expression eval, formatting) runs concurrently and reads `CurrentCulture`, it gets the wrong culture transiently. The `Options()` call itself is safe on the UI thread (exception-safe: `GetCultureInfo` runs before assignment in the ctor), but the side-effect scope is process-wide. In practice, severity is bounded because `Options()` is called only during culture-change refresh and tool-window construction — but it's an anti-pattern in a static helper. -- **Fix:** Remove `TemporaryCurrentUiCulture` entirely. Compute display labels via explicit resource lookup (`localizer.GetString("XmlLanguage." + code)`) with `CultureInfo.GetCultureInfo(code).DisplayName` called WITHOUT mutating ambient state — `DisplayName` respects the culture's own display name regardless of `CurrentUICulture` for neutral cultures. Cache results per UI culture. -- **Confidence:** High (the design issue is real; severity calibrated from sub-agent's P1 to P2) - -### [P3] Localized diagnostic template can leave unresolved placeholders -- **Location:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1378-1393` -- **Trigger:** Localization template expects placeholders absent from `diagnostic.Arguments` (non-null dictionary missing keys). -- **Impact:** User-visible unresolved `{token}` in status/log text. No crash (replace-based formatter). -- **Fix:** Backfill missing keys from a known-defaults map, or strip unresolved tokens. -- **Confidence:** Medium - -## 漏检复盘 (Missed-pattern Retrospective) - -- **默认分支/未知输入**: `LocalizeDiagnostic` key-miss fallback handled; XmlLanguage index bounds checked. Clean. -- **异步失败路径**: `TextToolViewModel.OnEntryAdded` marshals to UI thread via `Dispatcher.UIThread.Post`. Clean. -- **半完成状态窗口**: `TemporaryCurrentUiCulture` constructor reads culture before assignment — no partial swap on ctor failure. But ambient-mutation design itself is the systemic risk (P2). -- **延迟执行上下文失效**: `Options()` static call lacks call-context guard — flagged as P2. -- **隐式协议/兼容前提**: `SelectorDisplayOption` reference-equality concern investigated and **rejected** — code uses `UpdateFrom` + value-matching. -- **ApplyEdit/LogDiagnostics call sites**: All updated to localized keys; no remaining raw-English callers in-file. diff --git a/docs/review-2026-07-05/phase-4-views.md b/docs/review-2026-07-05/phase-4-views.md deleted file mode 100644 index 50404a4..0000000 --- a/docs/review-2026-07-05/phase-4-views.md +++ /dev/null @@ -1,80 +0,0 @@ -# Phase 4 — Avalonia Views, Code-Behind & Bundle Config Review - -> Scope: `src/ChapterTool.Avalonia/Views/**`, `csproj`, `Assets/`, `Services/`. -> Reviewer: sub-agent bg_1c8af626 + main-agent verification. - -## Phase Summary - -No P0 found. The macOS `Info.plist` has all required keys with valid values; `CFBundleExecutable=ChapterTool.Avalonia` matches the default assembly name. The real risk is the **silent chmod skip** in `publish.sh` (calibrated P2). Empty-grid overlay binds correctly to `IsChapterGridEmpty` and respects AGENTS.md layout rules. Code-behind async handlers have try/catch. - -## Files Reviewed - -- `src/ChapterTool.Avalonia/Views/MainWindow.axaml` + `.axaml.cs` -- `src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml` -- `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml` + `.axaml.cs` -- `src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj` -- `src/ChapterTool.Avalonia/Assets/MacOS/Info.plist` -- `src/ChapterTool.Avalonia/Services/{AvaloniaWindowService,RuntimeChapterLoadService,RuntimeChapterSaveService}.cs` - -## Info.plist & Bundle Config Audit - -All required keys present and valid: - -| Key | Value | Status | -|-----|-------|--------| -| `CFBundleName` | `ChapterTool` | ✅ | -| `CFBundleDisplayName` | `ChapterTool` | ✅ | -| `CFBundleIdentifier` | `com.tautcony.chaptertool` | ✅ reverse-DNS | -| `CFBundleVersion` | `1.0.0` | ✅ numeric | -| `CFBundleShortVersionString` | `1.0.0` | ✅ numeric | -| `CFBundlePackageType` | `APPL` | ✅ | -| `CFBundleExecutable` | `ChapterTool.Avalonia` | ⚠️ matches default assembly name, but no validation in publish script | -| `CFBundleIconFile` | `app-icon.icns` | ✅ | -| `LSMinimumSystemVersion` | `10.15` | ✅ | -| `NSHighResolutionCapable` | `true` | ✅ | -| `LSApplicationCategoryType` | `public.app-category.developer-tools` | ✅ | - -**csproj**: No explicit `AssemblyName` → defaults to `ChapterTool.Avalonia` (matches `CFBundleExecutable`). No `PublishSingleFile` (no rename risk). `ApplicationIcon` → `Assets\Icons\app-icon.ico` ✅. - -## AGENTS.md UI Compliance - -- No `Canvas`/absolute positioning in reviewed XAML ✅ -- DataGrid columns have `MinWidth` (56, 105.6, 144, 76.8) ✅ -- Empty-grid overlay: `HorizontalAlignment/VerticalAlignment=Center`, `IsHitTestVisible=False`, has automation id ✅ -- Overlay binds `IsVisible="{Binding IsChapterGridEmpty}"` → `Rows.Count == 0` with `OnPropertyChanged` at row mutation ✅ -- No visible Canvas regression ✅ - -## Findings - -### [P2] CFBundleExecutable name consistency + silent chmod skip in publish.sh -- **Location:** `src/ChapterTool.Avalonia/Assets/MacOS/Info.plist:19-20` + `scripts/publish.sh:94-95` -- **Trigger:** `publish.sh` does `[[ -f "$exe_path" ]] && chmod +x "$exe_path"` — if the executable doesn't exist with the expected name `ChapterTool.Avalonia`, the `chmod` is **silently skipped** and the bundle ships without a valid executable. LaunchServices will refuse to launch the `.app`. -- **Impact:** macOS bundle launch failure with no diagnostic at publish time. The names match by default today (no `AssemblyName` override), but there's no assertion/validation to catch drift if assembly naming changes. -- **Fix:** Make the executable check a hard error: - ```bash - [[ -f "$exe_path" ]] || { echo "ERROR: expected executable '$exe_path' not found" >&2; exit 1; } - chmod +x "$exe_path" - ``` -- **Confidence:** High (the silent-skip is real; the name-match risk is conditional on future changes) - -### [P3] MainWindow subscribes commands/Rows without unsubscribe on close -- **Location:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs:498-506` -- **Trigger:** `SubscribeViewModelCommandState()` attaches `CanExecuteChanged` to 18 commands + `Rows.CollectionChanged`. No `OnClosed` / `Detached` unsubscribe in the file. -- **Impact:** For the **main window** (which owns its ViewModel), the subscription graph is self-contained — when the window closes, both subscriber and source are collectable together. Risk only materializes if a ViewModel outlives the window (multi-window scenario). Low practical impact for single-window desktop app. -- **Fix:** Override `OnClosed` and detach, or use weak-event pattern. Defensive but not urgent. -- **Confidence:** High (the missing unsubscribe is real; severity calibrated from sub-agent's P2 to P3) - -### [P3] TextToolView control-level event handler permanently attached -- **Location:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs:18` -- **Trigger:** `DataContextChanged += OnDataContextChanged` attached without corresponding detach. -- **Impact:** Low in Avalonia control lifecycle. The view does call `subscribedViewModel.DetachLiveRefresh()` on DataContext change (`:40`), which handles the subscription leak the sub-agent flagged. Residual reference risk only in complex host scenarios. -- **Fix:** Add detach in `DetachedFromVisualTree` if control can outlive host transitions. -- **Confidence:** Medium - -## 漏检复盘 (Missed-pattern Retrospective) - -- **默认分支/非法输入**: `RuntimeChapterLoadService` has explicit failure diagnostics for invalid paths/unsupported extensions. Clean. -- **异步失败路径**: `OnDrop` has try/catch with status writeback (`:230-255`). Load/Save services propagate IO exceptions. Clean. -- **半提交状态窗口**: `RuntimeChapterSaveService` only appends "Saved" diagnostic after successful file write. Clean. -- **延迟执行上下文失效**: `ScheduleWindowCommandRefresh` uses `Dispatcher.UIThread.Post` — but no close-time unsubscribe (P3). -- **UI 布局规则**: Compliant with AGENTS.md (no Canvas, MinWidth present, overlay responsive). diff --git a/docs/review-2026-07-05/phase-5-scripts.md b/docs/review-2026-07-05/phase-5-scripts.md deleted file mode 100644 index a8ab244..0000000 --- a/docs/review-2026-07-05/phase-5-scripts.md +++ /dev/null @@ -1,86 +0,0 @@ -# Phase 5 — Build / Publish Scripts Review - -> Scope: `scripts/**`, `.github/workflows/dotnet-ci.yml`, `.gitignore`. -> Reviewer: sub-agent bg_183f5248 + main-agent verification. - -## Phase Summary - -No P0/P1 found. `publish.sh` has `set -euo pipefail`, no `eval`/backticks, mostly correct quoting. Main risks are argument-parse robustness under `set -u` and interrupt resilience during macOS bundle restructuring. `publish.ps1` is solid PowerShell. CI workflow change (blame-hang flags) is benign. - -## Files Reviewed - -- `scripts/publish.sh` (new, 103 lines) -- `scripts/publish.ps1` (modified, 63 lines) -- `.github/workflows/dotnet-ci.yml` (1 line changed) -- `.gitignore` (1 line: `.omo/`) - -## Shell-script Audit (publish.sh) - -| Line | Pattern | Verdict | -|------|---------|---------| -| `:8` | `set -euo pipefail` | ✅ | -| throughout | quoting | ✅ mostly correct (`"$repo_root"`, `"$output"`) | -| `:8` | no `eval`/backticks | ✅ | -| `:76` | `mkdir -p` | ✅ idempotent | -| `:38-39` | `BASH_SOURCE[0]` for CWD-independence | ✅ | -| `:50-55` | `dotnet publish` args | ✅ valid | -| `:19,23` | arg value guard | ⚠️ missing `$#` check before consuming `$2` | -| `:79` | `for item in "$output"/*` | ⚠️ no `nullglob` → literal `*` on empty | -| `:82` | `mv "$item"` loop | ⚠️ non-atomic, no trap | -| `:94-95` | `[[ -f ... ]] && chmod +x` | ⚠️ silent skip if exe missing (see Phase 4) | - -No secrets/credentials found. RID list consistent with CI matrix (`win-x64`, `linux-x64`, `osx-x64`, `osx-arm64`). - -## PowerShell Audit (publish.ps1) - -- `param()` + `$ErrorActionPreference = "Stop"` ✅ -- CWD-independent via `$PSScriptRoot` ✅ -- `Test-Path` guard before `Remove-Item` ✅ -- `Copy-Item -Force`, `New-Item -ItemType Directory -Force` ✅ -- Missing `[CmdletBinding()]` and `#Requires -Version` (minor) -- No rollback on interrupt during move (`:36-38`) — P3 - -## Findings - -### [P2] Missing value guard in bash arg parsing -- **Location:** `scripts/publish.sh:19, 23` -- **Trigger:** `./scripts/publish.sh -Runtime` (trailing key without value). Under `set -u`, `$2` is unbound → abrupt exit with cryptic shell error. -- **Impact:** Poor operator UX; brittle automation. -- **Fix:** - ```bash - [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 2; } - ``` -- **Confidence:** High - -### [P2] No trap/cleanup on interrupt during macOS bundle restructuring -- **Location:** `scripts/publish.sh:75-83` -- **Trigger:** SIGINT/termination or command failure mid-loop while moving files into `.app/Contents/MacOS/`. -- **Impact:** Partial bundle state (some files in root, some in `.app`). Re-runs fail or require manual cleanup. -- **Fix:** Add `trap` for `INT TERM` with cleanup of partially built `.app`, or stage into temp dir then atomic `mv` rename. -- **Confidence:** Medium-High - -### [P3] Glob-empty edge case on literal pattern -- **Location:** `scripts/publish.sh:79-83` -- **Trigger:** Empty output dir (interrupted prior step). -- **Impact:** `mv` tries literal `*` → noisy failure. -- **Fix:** `shopt -s nullglob` in local scope or guard with array-length check. -- **Confidence:** High - -### [P3] publish.ps1 no rollback on interrupt during move -- **Location:** `scripts/publish.ps1:36-38` -- **Trigger:** Interrupt during `Copy-Item`/`Remove-Item` sequence. -- **Impact:** Partial artifact state. -- **Fix:** `try/finally` with cleanup, or stage-then-rename pattern. -- **Confidence:** Medium - -## CI Workflow Diff - -`.github/workflows/dotnet-ci.yml:30`: `dotnet test` gains `--blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full`. Improves hang diagnostics. Does not alter matrix, runtime list, or publish wiring. **Benign.** - -## 漏检复盘 (Missed-pattern Retrospective) - -- **默认分支/未知输入**: 参数解析覆盖;发现 bash 缺 `$2` 存在性保护 (P2)。 -- **异步/中断路径**: 串行命令链无竞态;中断导致半成品状态风险存在 (P2/P3)。 -- **半提交状态窗口**: macOS bundle 重组存在"先删后搬"窗口 (P2)。 -- **协议/隐式约定**: README `-Runtime`/`-SelfContained` 与脚本一致;CI matrix 未破坏。 -- **安全边界**: 无 `eval`/反引号/未引用命令替换/密钥泄露。 diff --git a/docs/review-2026-07-05/phase-6-localization.md b/docs/review-2026-07-05/phase-6-localization.md deleted file mode 100644 index fbae1c5..0000000 --- a/docs/review-2026-07-05/phase-6-localization.md +++ /dev/null @@ -1,77 +0,0 @@ -# Phase 6 — Localization Consistency Review - -> Scope: 3× `Strings.*.resx` + `LocalizationTests.cs`. -> Reviewer: sub-agent bg_0defc2e9 + main-agent verification. - -## Phase Summary - -**No structural i18n defects found.** All three resx files have exactly 234 keys each, with zero missing/extra keys across languages. All 62 keys containing placeholders have identical placeholder name-sets across all three languages. No duplicate keys, no XML malformation, no UTF-8 mojibake detected. The i18n refactor's structural integrity is solid. - -## Files Reviewed - -- `src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx` (719 lines) -- `src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx` (719 lines) -- `src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx` (719 lines) -- `tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs` (163 lines) - -## Key Parity Table - -| Culture | Key Count | Missing vs en-US | Extra vs en-US | Duplicates | -|---------|-----------|-------------------|-----------------|------------| -| en-US | 234 | — | — | 0 | -| ja-JP | 234 | 0 | 0 | 0 | -| zh-CN | 234 | 0 | 0 | 0 | - -**Verdict: PERFECT PARITY.** - -## Placeholder Parity - -- Total keys with placeholders: **62** -- Placeholder mismatches across languages: **0** -- Positional `{0}` placeholders: **0** (all use named `{name}` convention) -- Named-placeholder consistency: **100%** - -Spot-checked high-risk keys: `Log.Diagnostic` (6 placeholders: code/details/location/message/operation/severity) — identical in all 3 languages. `Log.ImportOption` (9 placeholders) — identical in all 3 languages. - -## Main-Agent Cross-Verification - -The main agent independently verified ExpressionService diagnostic-code coverage: - -``` -$ comm -23 <(grep -oE 'InvalidExpression\.[A-Za-z]+' ExpressionService.cs | sort -u) \ - <(grep -oE 'name="Diagnostic\.InvalidExpression\.[A-Za-z]+"' en-US.resx | ... | sort -u) -(empty — zero missing codes) -``` - -All 18 `InvalidExpression.*` codes thrown by `ExpressionService.cs` have corresponding `Diagnostic.InvalidExpression.*` keys in the resx. **Coverage complete.** - -## XML / UTF-8 Health - -- All three files: well-formed XML ✅ -- CJK spot-check (byte-level): `日本語`, `簡体字中国語`, `简体中文` — all valid UTF-8, no mojibake signatures (`Ã`, `Â`, U+FFFD) ✅ -- No empty `` where English counterpart is non-empty ✅ - -## Test Coverage - -`LocalizationTests.cs` enforces: -- Key parity: `SupportedCulturesHaveMatchingResourceKeys()` ✅ -- Placeholder parity: `LocalizedFormatStringsUseCompatiblePlaceholders()` ✅ -- Encoding artifacts: `NonEnglishResourcesDoNotContainEncodingArtifacts()` ✅ -- Runtime formatting: `LocalizerFallsBackAndFormatsMessages()` ✅ -- New diagnostic smoke test: `DiagnosticKeysLocalizeAcrossCultures()` ✅ - -**No coverage gap requiring a defect ticket.** - -## Findings - -**None.** This phase is clean. - -## 漏检复盘 (Missed-pattern Retrospective) - -- 键集合不一致: 三语 234 键一致,差集为空。Clean. -- 占位符不一致: 62 键逐一比对,完全一致。Clean. -- 命名/位置占位符混用: 未发现 `{0}` 位置参数。Clean. -- 重复 key: 三文件均无重复。Clean. -- XML 结构损坏: 三文件解析通过。Clean. -- UTF-8 损坏: CJK 字节级 spot-check 正常。Clean. -- 空翻译值: 无非英文空值。Clean. diff --git a/docs/review-2026-07-05/phase-7-crosscutting.md b/docs/review-2026-07-05/phase-7-crosscutting.md deleted file mode 100644 index 521446d..0000000 --- a/docs/review-2026-07-05/phase-7-crosscutting.md +++ /dev/null @@ -1,74 +0,0 @@ -# Phase 7 — Cross-cutting Differential Disproof Pass - -> Horizontal sweep across all phases, independent of phase ordering. Goal: catch systemic patterns that individual phase reviews might miss. - -## Methodology - -Instead of reviewing by layer, this pass sweeps by **anti-pattern category** across the entire diff, looking for instances that might have fallen between phase boundaries. - -## Sweep Results - -### 1. All dispatch/command/protocol entries: default branch, param validation, failure回传 - -| Entry point | Default branch | Param validation | Failure propagation | Verdict | -|-------------|----------------|-------------------|---------------------|---------| -| `ExpressionService.Tokenize/ToPostfix/EvaluatePostfix` | ✅ all token types → explicit diagnostic | ✅ | ✅ `ExpressionException` | Clean | -| Importers (OGM/XML/BDMV) | ✅ unknown format → `ChapterImportResult.Failed` | ✅ extension check | ✅ diagnostics returned | Clean | -| `LocalizeDiagnostic` | ✅ key-miss → fallback to `diagnostic.Message` | ✅ null args guard | ✅ replace-based (no FormatException) | Clean | -| `XmlLanguageDisplay.LanguageDisplayName` | ✅ unknown code → `EnglishDisplayName` fallback | ✅ CultureNotFoundException catch | ✅ | Clean | -| `publish.sh` arg parser | ⚠️ missing `$2` guard | ❌ | `set -u` abort (P2) | Flagged | - -### 2. All async chains: failure, cancellation, timeout, idempotency, context invalidation - -| Async path | Failure handling | Cancellation | Reentrancy | Verdict | -|------------|-----------------|--------------|------------|---------| -| `MainWindow.OnDrop` (async void) | ✅ try/catch → status | N/A (no token) | ✅ command CanExecute | Clean | -| `MainWindow.OnOpened` (async void) | ✅ per Phase 4 | — | — | Clean | -| `MainWindow.OnKeyDown` (async void) | ✅ per Phase 4 | — | command-level | Clean | -| `TextToolViewModel.OnEntryAdded` | ✅ UI-thread marshalled | N/A | ✅ refresh is idempotent | Clean | -| `ColorSettingsViewModel.LoadAsync` | ✅ store-null guard | `CancellationToken.None` | ✅ | Clean | - -**No new `.Result` / `.Wait()` introduced anywhere in the diff.** - -### 3. All state-write chains: "half-committed state from ordering errors" - -| State write | Atomicity | Rollback | Verdict | -|-------------|-----------|----------|---------| -| `RuntimeChapterSaveService` | ✅ "Saved" diagnostic only after successful write | N/A (file write) | Clean | -| `RefreshXmlLanguageDisplayOptions` | ✅ count-check → either clear+add or in-place UpdateFrom | N/A | Clean | -| `ApplicationLogPanelProvider.Capture` | ✅ entry built before lock; added under lock; capacity trim under same lock | N/A | Clean | -| `publish.sh` macOS bundle move | ❌ non-atomic multi-file `mv` loop, no trap | ❌ no rollback (P2) | Flagged | - -### 4. All rebuild/batch/cleanup/migration chains: "remove-then-rebuild data window" - -| Rebuild path | Window risk | Verdict | -|--------------|-------------|---------| -| `RefreshXmlLanguageDisplayOptions` clear+add (count change) | Brief empty collection during clear+add — but UI binding reads on `PropertyChanged` which fires AFTER rebuild completes | Clean | -| `LanguageToolViewModel.ReplaceLanguages` clear+add | Same pattern — `OnPropertyChanged` after rebuild | Clean | -| `publish.sh` `rm -rf "$app_bundle"` then rebuild | Window exists but acceptable for publish artifact (not runtime data) | Acceptable | - -### 5. All content-rendering / rich-text / export chains: security boundary, scale - -| Render point | Input source | Scale boundary | Verdict | -|--------------|-------------|----------------|---------| -| `TextToolViewModel.Format` (JSON/XML pretty-print) | User chapter export text | ✅ catches `JsonException`/`XmlException` → returns raw text | Clean | -| `HighlightJson`/`HighlightXml` | Line-level text | O(n) per line, no regex backtracking | Clean | -| `LocalizeDiagnostic` | Diagnostic args (bounded set) | Replace-based, no composite format injection | Clean | - -### 6. All high-leverage utility functions: encoding, time, collision, naming, compatibility - -| Utility | Risk | Verdict | -|---------|------|---------| -| `XmlLanguageDisplay` culture swap | Process-global mutation (P2) | Flagged | -| `ExpressionService` double→decimal cast | OverflowException uncaught (P3, pre-existing) | Flagged | -| `Uri.IsHexDigit` usage in color parsing | ✅ correct hex validation | Clean | -| `AppLanguage.Normalize` | ✅ culture-name normalization | Clean | -| resx placeholder formatting | ✅ named-only, verified parity | Clean | - -## New Findings from This Pass - -**None.** All patterns flagged in this sweep were already captured by the phase reviews. No cross-cutting issue fell between phase boundaries. - -## Verdict - -The phase decomposition + this horizontal sweep provide **complete coverage** of the diff's high-risk surface. The two highest-risk categories (async/concurrency and state-write atomicity) are clean except for the already-flagged `publish.sh` interrupt window and `XmlLanguageDisplay` ambient mutation. diff --git a/docs/review-2026-07-05/summary.md b/docs/review-2026-07-05/summary.md deleted file mode 100644 index a5b6ea6..0000000 --- a/docs/review-2026-07-05/summary.md +++ /dev/null @@ -1,107 +0,0 @@ -# Code Review Summary — `feat/improve-struct` vs `master` - -> Review date: 2026-07-05 -> Scope: all changes (committed + uncommitted) on `feat/improve-struct` vs `master` -> 73 files changed, +4,161 / −810 lines - -## Verdict - -**No P0 (critical/blocker) defects found.** The branch is structurally sound and the build passes (0 warnings / 0 errors). The i18n refactor — the largest portion of the diff — is **structurally complete**: all 234 keys × 3 languages are in parity, all 62 placeholder keys match across languages, and all 18 `InvalidExpression.*` diagnostic codes have resx coverage. The `ILocalizationService` deletion leaves zero dangling references. - -The 12 confirmed findings are all P2/P3 and cluster around three themes: (1) a culture-mutation anti-pattern in a new helper, (2) interrupt-resilience gaps in publish scripts, and (3) minor lifecycle/observability hardening. - -## Finding Count by Severity - -| Severity | Count | Theme | -|----------|-------|-------| -| **P0** | 0 | — | -| **P1** | 0 | — | -| **P2** | 6 | Culture mutation, event race, interface break, arg guard, interrupt cleanup, bundle validation | -| **P3** | 6 | Overflow edge case, unsub lifecycle, unresolved placeholders, glob edge case, ps1 rollback, view detach | -| **Rejected FPs** | 2 | Sub-agent false positives caught by main-agent verification | - -## Findings (P2 → P3) - -### P2-1: Ambient culture mutation in `XmlLanguageDisplay` -- **File:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs:27, 63-82` -- `TemporaryCurrentUiCulture` mutates `CultureInfo.CurrentCulture`/`CurrentUICulture` process-wide inside a `using`. Concurrent background operations reading `CurrentCulture` during this window get the wrong culture. -- **Fix:** Remove ambient mutation; use explicit resource lookup + `CultureInfo.DisplayName` without swapping ambient state. - -### P2-2: Event invocation race in `ApplicationLogPanelProvider` -- **File:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs:106` -- `EntryAdded?.Invoke(this, entry)` without local-copy pattern — standard C# event race on concurrent unsubscribe. -- **Fix:** `var handler = EntryAdded; handler?.Invoke(this, entry);` - -### P2-3: `ExpressionException` lacks `innerException` channel -- **File:** `src/ChapterTool.Core/Transform/ExpressionService.cs:166-171` -- New exception type has no `innerException` constructor — future wrapping sites can't carry root cause. -- **Fix:** Add `Exception? innerException = null` ctor overload. - -### P2-4: Breaking interface change on `IApplicationLogService` -- **File:** `src/ChapterTool.Core/Services/IApplicationLogService.cs:7` -- New `EntryAdded` event breaks out-of-tree implementers. In-repo implementer updated. -- **Fix:** Default interface implementation or version the contract if external consumers exist. - -### P2-5: `publish.sh` missing arg-value guard -- **File:** `scripts/publish.sh:19, 23` -- `-Runtime` without value → `set -u` abort with cryptic error. -- **Fix:** `[[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 2; }` - -### P2-6: No interrupt cleanup during macOS bundle restructuring + silent chmod skip -- **File:** `scripts/publish.sh:75-83, 94-95` -- Non-atomic multi-file `mv` loop with no `trap`; `chmod +x` silently skipped if executable missing. -- **Fix:** Add `trap` cleanup or stage-then-rename; make exe-existence a hard error. - -### P3-1: Pre-existing `OverflowException` uncaught in ExpressionService -- **File:** `src/ChapterTool.Core/Transform/ExpressionService.cs:~505` -- `double`→`decimal` cast on extreme-domain inputs; not in catch list. Pre-existing, not a regression. - -### P3-2: MainWindow command subscriptions not detached on close -- **File:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs:498-506` -- Self-contained for single-window app; risk only if ViewModel outlives window. - -### P3-3: Localized diagnostic template can leave unresolved placeholders -- **File:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1378-1393` -- Non-null but incomplete `Arguments` dict → unresolved `{token}` in UI text. No crash. - -### P3-4: `publish.sh` glob-empty literal pattern -- **File:** `scripts/publish.sh:79-83` -- Empty output dir → `mv` tries literal `*`. - -### P3-5: `publish.ps1` no rollback on interrupt -- **File:** `scripts/publish.ps1:36-38` - -### P3-6: TextToolView permanent DataContextChanged handler -- **File:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs:18` - -## Rejected False Positives - -Two sub-agent P1 candidates were **rejected after main-agent verification**: - -1. ~~"MissingOperatorBeforeFunction resx key missing"~~ — key exists at line 161 in all 3 resx files; all 18 `InvalidExpression.*` codes have complete coverage. -2. ~~"Selector identity mismatch from rebuilt list"~~ — code deliberately preserves identity via `UpdateFrom()` in-place mutation + value-based selection matching. - -See `agent-findings.md` for full disproof evidence. - -## Cross-module Systemic Issues - -None identified. The branch's changes are well-contained within their layers. The i18n refactor touches Core (diagnostics), Infrastructure (deleted abstraction), and Avalonia (ViewModels + resx) coherently — the boundary contracts (diagnostic codes → resx keys → UI formatting) are intact end-to-end. - -## Patterns Checked and Confirmed Clean (Cross-cutting Pass) - -- All dispatch entries have default branches and param validation (except `publish.sh` arg guard). -- No new `.Result` / `.Wait()` / `async void` outside event handlers. -- No empty catches, no `dynamic`, no unsafe casts. -- No half-committed state in runtime data paths (chapter save, log capture, option refresh). -- resx key/placeholder parity verified programmatically (234 keys × 3 languages, 0 mismatches). -- Zero dangling references to deleted `ILocalizationService`/`LocalizationService` (repo-wide). -- Build: 0 warnings, 0 errors. - -## Coverage - -- **Fully reviewed:** Core (ExpressionService, diagnostics, importers, editing), Infrastructure (log provider, deleted abstraction), Avalonia ViewModels (all 4), Views (MainWindow + tool views), Info.plist + csproj, publish scripts, CI workflow, 3× resx, localization tests. -- **Out of scope:** `.codex/skills/**` markdown, `openspec/changes/**` planning docs, binary icon assets (only wiring reviewed). - -## Recommended Merge Posture - -**Mergeable as-is for functional correctness.** The P2 items are quality/hardening improvements that don't block the branch's stated goals (i18n completion + macOS bundle support). Recommend addressing P2-1 (culture mutation) and P2-6 (publish interrupt resilience) before the next release tag, as they affect runtime correctness and release-artifact integrity respectively. All other items can be follow-up. diff --git a/openspec/specs/avalonia-ui-shell/spec.md b/openspec/specs/avalonia-ui-shell/spec.md index 486eb65..404a836 100644 --- a/openspec/specs/avalonia-ui-shell/spec.md +++ b/openspec/specs/avalonia-ui-shell/spec.md @@ -14,6 +14,10 @@ The Avalonia main window SHALL be driven by a ViewModel rather than by direct co - **WHEN** a load service returns a successful chapter result - **THEN** the ViewModel SHALL update current path, display path, clip options, current chapter rows, status text, and progress from the result +#### Scenario: Older load results are ignored +- **WHEN** a source load is still running and a newer source load starts +- **THEN** progress and result updates from the older load SHALL NOT overwrite the newer load's current path, chapter rows, status, or progress state after the newer load has become current + ### Requirement: Main window load progress The main window SHALL present bounded progress during source loading when the load pipeline reports intermediate progress. @@ -64,6 +68,11 @@ The UI shell SHALL expose documented main-window actions through commands. - **WHEN** save is invoked - **THEN** the ViewModel SHALL synchronize current rows and call the save service with selected save type, language, naming, template, order shift, expression, and directory options +#### Scenario: Save write failures return diagnostics +- **WHEN** the runtime save service cannot create, write, move, or replace the target output file because of an I/O, path, or permission failure +- **THEN** save SHALL return a structured failure diagnostic +- **AND** the shell SHALL keep the application usable rather than allowing the file-system exception to escape the command + ### Requirement: Keyboard and menu routing The Avalonia shell SHALL preserve documented shortcuts and context menu actions. diff --git a/openspec/specs/chapter-core-transform-export/spec.md b/openspec/specs/chapter-core-transform-export/spec.md index 3a851cf..dc351bd 100644 --- a/openspec/specs/chapter-core-transform-export/spec.md +++ b/openspec/specs/chapter-core-transform-export/spec.md @@ -64,6 +64,10 @@ The system SHALL transform chapter times through Lua scripts using structured su - **WHEN** Lua compilation, Lua execution, or Lua return conversion fails - **THEN** Core SHALL return a failure diagnostic and preserve original chapter time behavior +#### Scenario: Lua execution is bounded +- **WHEN** a Lua expression or transform function does not complete within the execution budget +- **THEN** Core SHALL stop the script, return a structured timeout diagnostic, and preserve original chapter time behavior + #### Scenario: Postfix expressions are not a required expression target - **WHEN** expression transform behavior is implemented for this change - **THEN** Core SHALL NOT require postfix expression authoring or postfix token-list evaluation as part of the user-facing expression workflow @@ -111,7 +115,7 @@ The system SHALL expose chapter editing as Core operations callable by ViewModel - **THEN** Core SHALL insert a chapter named `New Chapter` before it and renumber the chapter list ### Requirement: Export formats -The system SHALL export TXT/OGM, Matroska XML, QPFile, TimeCodes, tsMuxeR meta, CUE, and JSON through UI-independent exporter contracts. +The system SHALL export TXT/OGM, Matroska XML, QPFile, TimeCodes, tsMuxeR meta, CUE, JSON, WebVTT, celltimes, and Chapter-to-QPFile through UI-independent exporter contracts. #### Scenario: TXT export writes OGM pairs - **WHEN** TXT export runs @@ -134,10 +138,19 @@ The system SHALL export TXT/OGM, Matroska XML, QPFile, TimeCodes, tsMuxeR meta, - **WHEN** QPFile or celltimes output is generated - **THEN** each exported frame number SHALL use the configured compatibility rounding policy for integer-frame output +#### Scenario: Frame-number exporters reject non-finite frame rates +- **WHEN** QPFile, celltimes, Chapter-to-QPFile, or expression projection needs frame-rate arithmetic and the selected frame rate is NaN or infinity +- **THEN** Core SHALL return a structured invalid-frame-rate diagnostic instead of throwing or producing invalid frame numbers + #### Scenario: JSON export handles MPLS source name - **WHEN** JSON export runs for an MPLS source - **THEN** `sourceName` SHALL be `{SourceName}.m2ts` +#### Scenario: WebVTT export preserves explicit chapter ends +- **WHEN** WebVTT export runs for chapters with explicit end timestamps +- **THEN** each cue SHALL use the chapter's explicit end timestamp +- **AND** chapters without an explicit end SHALL fall back to the next chapter start or the chapter set duration + ### Requirement: Legacy-compatible rounding policies The system SHALL use documented legacy compatibility rounding policies for chapter timestamp formatting and frame-number conversions. diff --git a/openspec/specs/chapter-importers-text-xml-matroska-vtt/spec.md b/openspec/specs/chapter-importers-text-xml-matroska-vtt/spec.md index f87ad19..8fbfc4c 100644 --- a/openspec/specs/chapter-importers-text-xml-matroska-vtt/spec.md +++ b/openspec/specs/chapter-importers-text-xml-matroska-vtt/spec.md @@ -35,6 +35,7 @@ The system SHALL import simple WebVTT cue files as chapter sets. #### Scenario: Valid WebVTT imports cues - **WHEN** a `.vtt` file has a `WEBVTT` header and cue timing lines containing `-->` - **THEN** the importer SHALL use cue start times as chapter times and the first following text line as chapter name +- **AND** it SHALL preserve cue end times as chapter end metadata when present #### Scenario: Invalid WebVTT fails - **WHEN** the header, timing line, or cue text is missing or malformed @@ -67,12 +68,18 @@ The system SHALL import Matroska chapter XML documents. #### Scenario: Editions become selectable chapter sets - **WHEN** an XML document has root `Chapters` and child `EditionEntry` elements -- **THEN** each edition SHALL produce one chapter set with default edition index zero +- **THEN** each edition SHALL produce one selectable chapter set +- **AND** the default edition index SHALL be zero when no edition is explicitly flagged as default +- **AND** when one or more editions have `EditionFlagDefault` set to `1`, the first flagged edition SHALL be the default #### Scenario: Nested atoms are flattened - **WHEN** `ChapterAtom` elements are nested - **THEN** the importer SHALL flatten them into the documented legacy order +#### Scenario: XML chapter end metadata is preserved +- **WHEN** a `ChapterAtom` contains `ChapterTimeEnd` +- **THEN** the importer SHALL preserve it as chapter end metadata without assuming it equals the next chapter start + #### Scenario: Adjacent duplicate times are removed - **WHEN** adjacent emitted chapters have equal times - **THEN** the importer SHALL remove the previous duplicate entry @@ -129,4 +136,3 @@ The system SHALL import `.mkv`, `.mka`, `.mks`, and `.webm` chapters through mkv #### Scenario: XML file import remains independent - **WHEN** a standalone `.xml` Matroska chapter document is imported - **THEN** the existing XML importer SHALL continue to parse it without requiring mkvextract or ffprobe - diff --git a/openspec/specs/supporting-ui-platform-services/spec.md b/openspec/specs/supporting-ui-platform-services/spec.md index 73059cb..1e05bcb 100644 --- a/openspec/specs/supporting-ui-platform-services/spec.md +++ b/openspec/specs/supporting-ui-platform-services/spec.md @@ -29,6 +29,11 @@ The system SHALL use typed cross-platform settings while reading compatible lega - **WHEN** legacy `color-config.json` exists in compatible locations - **THEN** theme settings SHALL read valid six-slot color values and ignore invalid values safely +#### Scenario: Settings load failures fall back safely +- **WHEN** application or theme settings cannot be loaded because the settings file is corrupt, unreadable, or inaccessible +- **THEN** startup and settings ViewModels SHALL fall back to typed default settings +- **AND** the failed asynchronous load SHALL be observed rather than surfacing as an unhandled fire-and-forget exception + ### Requirement: Platform service abstractions The application SHALL access dialogs, clipboard, shell, process execution, settings, localization, windows, privileges, file association, and native dependencies through injectable services. @@ -135,6 +140,11 @@ Auxiliary UI tools SHALL be implemented as dedicated Avalonia views with ViewMod - **WHEN** a secondary window is opened, closed, and reopened - **THEN** it SHALL preserve the documented reusable, modal, or result-returning behavior for that tool +#### Scenario: Secondary window view models are released +- **WHEN** a secondary window closes or the window service replaces its content +- **THEN** any disposable DataContext SHALL be disposed +- **AND** tool ViewModels that subscribe to localization or other long-lived services SHALL unsubscribe during disposal + ### Requirement: Avalonia localization resources are complete The application SHALL package complete Simplified Chinese, English, and Japanese localization resources for Avalonia UI, prompts, and user-facing message formatting. @@ -217,6 +227,11 @@ The settings system SHALL persist all settings exposed by the unified settings p - **WHEN** existing `appsettings.json` files or migrated `chaptertool.json` files omit newly added settings fields - **THEN** the settings store SHALL load successfully and use defaults matching the current application startup behavior +#### Scenario: Settings saves are serialized +- **WHEN** multiple application or theme settings saves are requested concurrently +- **THEN** each store SHALL serialize writes through a single save path +- **AND** temporary file names SHALL be unique per write so one save cannot delete or replace another save's in-progress temporary file + #### Scenario: Workflow defaults persist - **WHEN** the user saves default save format or default XML language from the settings panel - **THEN** those defaults SHALL be written to typed settings and applied when a new main window ViewModel loads settings diff --git a/openspec/specs/tests-build-distribution-assets/spec.md b/openspec/specs/tests-build-distribution-assets/spec.md index 5bb20ea..6b5e571 100644 --- a/openspec/specs/tests-build-distribution-assets/spec.md +++ b/openspec/specs/tests-build-distribution-assets/spec.md @@ -182,6 +182,11 @@ The rewrite SHALL account for installer strategy, native dependencies, assets, l - **WHEN** packaging is implemented - **THEN** icons, UI images, native DLLs, and third-party license files SHALL be migrated, replaced, or explicitly retired with rationale +#### Scenario: Stale legacy installer inputs are retired +- **WHEN** legacy installer scripts, plugins, or distribution inputs target obsolete executable names, version sources, or product metadata +- **THEN** they SHALL be removed or replaced instead of remaining as runnable-looking packaging inputs +- **AND** the maintained distribution documentation SHALL identify the current packaging status and any intentionally retired paths + #### Scenario: FFprobe dependency is documented - **WHEN** release artifacts or packaging documentation are produced - **THEN** they SHALL document ffprobe/FFmpeg as the primary multimedia chapter reader dependency for MP4-family and other non-Matroska containers diff --git a/scripts/verify-publish-artifact.sh b/scripts/verify-publish-artifact.sh new file mode 100755 index 0000000..bcbe42c --- /dev/null +++ b/scripts/verify-publish-artifact.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Verifies the expected ChapterTool.Avalonia publish artifact layout. +set -euo pipefail + +usage() { + echo "Usage: $0 -Runtime -Path " >&2 +} + +Runtime="" +ArtifactPath="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -Runtime) + Runtime="${2-}"; shift 2 ;; + -Runtime=*) + Runtime="${1#*=}"; shift ;; + -Path) + ArtifactPath="${2-}"; shift 2 ;; + -Path=*) + ArtifactPath="${1#*=}"; shift ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$Runtime" || -z "$ArtifactPath" ]]; then + usage + exit 2 +fi + +if [[ ! -d "$ArtifactPath" ]]; then + echo "ERROR: artifact path '$ArtifactPath' does not exist or is not a directory" >&2 + exit 1 +fi + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "ERROR: expected file '$path' was not found" >&2 + exit 1 + fi +} + +require_executable() { + local path="$1" + require_file "$path" + if [[ "$Runtime" != win-* && ! -x "$path" ]]; then + echo "ERROR: expected executable '$path' is not executable" >&2 + exit 1 + fi +} + +require_runtimeconfig_for_file_layout() { + local path="$1" + local app_dll="$path/ChapterTool.Avalonia.dll" + local runtimeconfig="$path/ChapterTool.Avalonia.runtimeconfig.json" + + # Single-file publish embeds the runtimeconfig into the apphost bundle. + # Multi-file framework-dependent publish keeps the app dll and runtimeconfig side by side. + if [[ -f "$app_dll" ]]; then + require_file "$runtimeconfig" + fi +} + +case "$Runtime" in + win-*) + require_file "$ArtifactPath/ChapterTool.Avalonia.exe" + require_runtimeconfig_for_file_layout "$ArtifactPath" + ;; + linux-*) + require_executable "$ArtifactPath/ChapterTool.Avalonia" + require_runtimeconfig_for_file_layout "$ArtifactPath" + ;; + osx-*) + app_dir="$ArtifactPath/ChapterTool.app" + require_executable "$app_dir/Contents/MacOS/ChapterTool.Avalonia" + require_file "$app_dir/Contents/Info.plist" + require_file "$app_dir/Contents/Resources/app-icon.icns" + require_file "$ArtifactPath/ChapterTool-$Runtime.dmg" + ;; + *) + echo "ERROR: unsupported runtime '$Runtime'" >&2 + exit 2 + ;; +esac + +echo "Verified ChapterTool artifact for $Runtime at $ArtifactPath" diff --git a/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj b/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj index 6c822e7..5eb3571 100644 --- a/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj +++ b/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj @@ -21,6 +21,7 @@ + diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs index f8a4f62..9a49663 100644 --- a/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs @@ -16,7 +16,7 @@ public sealed class ChapterToolCliApplication( { private readonly ICliConsole console = console ?? new SystemCliConsole(); private readonly RuntimeChapterImporterRegistry importerRegistry = importerRegistry ?? CreateImporterRegistry(); - private readonly ChapterExportService exporter = exporter ?? new ChapterExportService(new Core.Transform.ChapterTimeFormatter(), new Core.Transform.ExpressionService()); + private readonly ChapterExportService exporter = exporter ?? new ChapterExportService(new Core.Transform.ChapterTimeFormatter()); public int ShowFormats() { @@ -98,7 +98,7 @@ public async Task ConvertAsync(CliConvertRequest request, CancellationToken return 1; } - var info = selection.Option!.ChapterInfo; + var info = selection.Entry!.ChapterSet; var export = exporter.Export( info with { @@ -124,7 +124,7 @@ private bool TryValidateRequest(CliConvertRequest request, out CliOutputFormatDe { if (request.Stdout && !string.IsNullOrWhiteSpace(request.OutputPath)) { - console.WriteErrorLine("Options --stdout and --output cannot be used together."); + console.WriteErrorLine("Entries --stdout and --output cannot be used together."); format = null!; errorCode = 1; return false; @@ -153,7 +153,7 @@ private bool TryValidateRequest(CliConvertRequest request, out CliOutputFormatDe private async Task WriteExportOutputAsync( CliConvertRequest request, CliOutputFormatDefinition format, - ChapterInfo info, + ChapterSet info, ChapterExportResult export, CancellationToken cancellationToken) { @@ -188,18 +188,18 @@ private async Task ImportAsync(string inputPath, Cancellatio { if (string.IsNullOrWhiteSpace(inputPath)) { - return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "MissingInput", "Input path is required.")); + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.MissingInput, "Input path is required.")); } if (!File.Exists(inputPath) && !Directory.Exists(inputPath)) { - return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "InputNotFound", $"Input path '{inputPath}' was not found.")); + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InputNotFound, $"Input path '{inputPath}' was not found.")); } var importer = importerRegistry.Resolve(inputPath); if (importer is null) { - return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "UnsupportedInput", $"No importer is available for '{inputPath}'.")); + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.UnsupportedInput, $"No importer is available for '{inputPath}'.")); } var result = await importer.ImportAsync(new ChapterImportRequest(inputPath), cancellationToken); @@ -214,7 +214,7 @@ private async Task ImportAsync(string inputPath, Cancellatio var diagnostics = result.Diagnostics.Concat([ new ChapterDiagnostic( DiagnosticSeverity.Info, - "FallbackImporterUsed", + ChapterDiagnosticCode.ImporterFallbackUsed, $"Primary importer '{importer.Id}' could not be invoked; fallback importer '{fallback.Id}' was used.") ]).ToList(); @@ -226,7 +226,7 @@ private async Task ImportAsync(string inputPath, Cancellatio return new CliImportExecution(result.Success, importer, result); } - private CliSelectionResult? SelectOption(IReadOnlyList groups, CliConvertRequest request) + private CliSelectionResult? SelectOption(IReadOnlyList groups, CliConvertRequest request) { if (groups.Count == 0) { @@ -238,18 +238,18 @@ private async Task ImportAsync(string inputPath, Cancellatio return failure; } - if (group is null || group.Options.Count == 0) + if (group is null || group.Entries.Count == 0) { - return CliSelectionResult.Failure($"Group {request.GroupIndex ?? 0} contains no selectable chapter options.", []); + return CliSelectionResult.Failure($"Group {request.GroupIndex ?? 0} contains no selectable chapter entries.", []); } - return ResolveOptionFromGroup(group, request); + return ResolveEntryFromGroup(group, request); } private bool TryResolveGroupIndex( - IReadOnlyList groups, + IReadOnlyList groups, CliConvertRequest request, - out ChapterInfoGroup? group, + out ChapterImportSource? group, out CliSelectionResult? failure) { var groupIndex = request.GroupIndex ?? (groups.Count == 1 ? 0 : null); @@ -267,57 +267,57 @@ private bool TryResolveGroupIndex( return true; } - private CliSelectionResult ResolveOptionFromGroup(ChapterInfoGroup group, CliConvertRequest request) + private CliSelectionResult ResolveEntryFromGroup(ChapterImportSource group, CliConvertRequest request) { var groupIndex = request.GroupIndex ?? 0; - if (!string.IsNullOrWhiteSpace(request.OptionId)) + if (!string.IsNullOrWhiteSpace(request.EntryId)) { - return ResolveOptionById(group, request.OptionId, groupIndex); + return ResolveEntryById(group, request.EntryId, groupIndex); } - if (request.OptionIndex is not null) + if (request.EntryIndex is not null) { - return ResolveOptionByIndex(group, request.OptionIndex.Value, groupIndex); + return ResolveEntryByIndex(group, request.EntryIndex.Value, groupIndex); } - if (group.Options.Count == 1) + if (group.Entries.Count == 1) { - return CliSelectionResult.Success(group.Options[0]); + return CliSelectionResult.Success(group.Entries[0]); } return CliSelectionResult.Failure( - $"Group {groupIndex} has multiple options. Specify --option-id or --option-index.", + $"Group {groupIndex} has multiple entries. Specify --entry-id or --entry-index.", AmbiguousSelectionDiagnostics([group], groupIndex)); } - private CliSelectionResult ResolveOptionById(ChapterInfoGroup group, string optionId, int groupIndex) + private CliSelectionResult ResolveEntryById(ChapterImportSource group, string entryId, int groupIndex) { - var option = group.Options.FirstOrDefault(candidate => - string.Equals(candidate.Id, optionId, StringComparison.OrdinalIgnoreCase)); - if (option is null) + var entry = group.Entries.FirstOrDefault(candidate => + string.Equals(candidate.Id, entryId, StringComparison.OrdinalIgnoreCase)); + if (entry is null) { return CliSelectionResult.Failure( - $"Option id '{optionId}' was not found in group {groupIndex}.", + $"Entry id '{entryId}' was not found in group {groupIndex}.", AmbiguousSelectionDiagnostics([group], groupIndex)); } - return CliSelectionResult.Success(option); + return CliSelectionResult.Success(entry); } - private CliSelectionResult ResolveOptionByIndex(ChapterInfoGroup group, int optionIndex, int groupIndex) + private CliSelectionResult ResolveEntryByIndex(ChapterImportSource group, int entryIndex, int groupIndex) { - if (optionIndex < 0 || optionIndex >= group.Options.Count) + if (entryIndex < 0 || entryIndex >= group.Entries.Count) { return CliSelectionResult.Failure( - $"Option index {optionIndex} is out of range for group {groupIndex}.", + $"Entry index {entryIndex} is out of range for group {groupIndex}.", AmbiguousSelectionDiagnostics([group], groupIndex)); } - return CliSelectionResult.Success(group.Options[optionIndex]); + return CliSelectionResult.Success(group.Entries[entryIndex]); } - private static string ResolveOutputPath(CliConvertRequest request, CliOutputFormatDefinition format, ChapterInfo info) + private static string ResolveOutputPath(CliConvertRequest request, CliOutputFormatDefinition format, ChapterSet info) { if (!string.IsNullOrWhiteSpace(request.OutputPath)) { @@ -330,15 +330,15 @@ private static string ResolveOutputPath(CliConvertRequest request, CliOutputForm return Path.Combine(directory, baseName + format.FileExtension); } - private static IEnumerable DescribeGroup(ChapterInfoGroup group) + private static IEnumerable DescribeGroup(ChapterImportSource group) { - for (var optionIndex = 0; optionIndex < group.Options.Count; optionIndex++) + for (var entryIndex = 0; entryIndex < group.Entries.Count; entryIndex++) { - var option = group.Options[optionIndex]; - var defaultMarker = optionIndex == group.DefaultOptionIndex ? " default" : string.Empty; + var entry = group.Entries[entryIndex]; + var defaultMarker = entryIndex == group.DefaultEntryIndex ? " default" : string.Empty; yield return string.Create( CultureInfo.InvariantCulture, - $" ({optionIndex}) id={option.Id} name=\"{option.DisplayName}\" chapters={option.ChapterInfo.Chapters.Count(static chapter => !chapter.IsSeparator)} fps={option.ChapterInfo.FramesPerSecond:0.###}{defaultMarker}"); + $" ({entryIndex}) id={entry.Id} name=\"{entry.DisplayName}\" chapters={entry.ChapterSet.Chapters.Count(static chapter => !chapter.IsSeparator)} fps={entry.ChapterSet.FramesPerSecond:0.###}{defaultMarker}"); } } @@ -372,7 +372,7 @@ private IEnumerable SupportedInputFormats() yield return "bdmv-directory BDMV/PLAYLIST directory"; } - private IReadOnlyList AmbiguousSelectionDiagnostics(IReadOnlyList groups, int groupOffset = 0) + private IReadOnlyList AmbiguousSelectionDiagnostics(IReadOnlyList groups, int groupOffset = 0) { var diagnostics = new List(); for (var localGroupIndex = 0; localGroupIndex < groups.Count; localGroupIndex++) @@ -381,15 +381,15 @@ private IReadOnlyList AmbiguousSelectionDiagnostics(IReadOnly var groupIndex = localGroupIndex + groupOffset; diagnostics.Add(new ChapterDiagnostic( DiagnosticSeverity.Info, - "AvailableGroup", - $"group={groupIndex} default-option-index={group.DefaultOptionIndex} source={group.SourcePath}")); - for (var optionIndex = 0; optionIndex < group.Options.Count; optionIndex++) + ChapterDiagnosticCode.SelectionGroupAvailable, + $"group={groupIndex} default-entry-index={group.DefaultEntryIndex} source={group.SourcePath}")); + for (var entryIndex = 0; entryIndex < group.Entries.Count; entryIndex++) { - var option = group.Options[optionIndex]; + var entry = group.Entries[entryIndex]; diagnostics.Add(new ChapterDiagnostic( DiagnosticSeverity.Info, - "AvailableOption", - $"group={groupIndex} option-index={optionIndex} option-id={option.Id} name={option.DisplayName}")); + ChapterDiagnosticCode.SelectionOptionAvailable, + $"group={groupIndex} entry-index={entryIndex} entry-id={entry.Id} name={entry.DisplayName}")); } } @@ -397,7 +397,7 @@ private IReadOnlyList AmbiguousSelectionDiagnostics(IReadOnly } private IEnumerable FormatDiagnostics(IEnumerable diagnostics) => - diagnostics.Select(static diagnostic => $"{diagnostic.Severity.ToString().ToUpperInvariant()} {diagnostic.Code}: {diagnostic.Message}"); + diagnostics.Select(static diagnostic => $"{diagnostic.Severity.ToString().ToUpperInvariant()} {diagnostic.DisplayCode}: {diagnostic.Message}"); private void RenderFailure(string message, IReadOnlyList diagnostics) { @@ -436,7 +436,7 @@ private sealed class NullImporter : IChapterImporter public IReadOnlySet SupportedExtensions { get; } = new HashSet(); public ValueTask ImportAsync(ChapterImportRequest request, CancellationToken cancellationToken) => - ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "Unavailable", "Importer is unavailable."))); + ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.Unavailable, "Importer is unavailable."))); } } @@ -448,15 +448,15 @@ public sealed record CliConvertRequest( string? OutputPath, bool Stdout, int? GroupIndex, - int? OptionIndex, - string? OptionId, + int? EntryIndex, + string? EntryId, string? XmlLanguage, string? SourceFileName, double? FrameRate); -public sealed record CliSelectionResult(bool IsSuccess, ChapterSourceOption? Option, string Message, IReadOnlyList Diagnostics) +public sealed record CliSelectionResult(bool IsSuccess, ChapterImportEntry? Entry, string Message, IReadOnlyList Diagnostics) { - public static CliSelectionResult Success(ChapterSourceOption option) => new(true, option, string.Empty, []); + public static CliSelectionResult Success(ChapterImportEntry entry) => new(true, entry, string.Empty, []); public static CliSelectionResult Failure(string message, IReadOnlyList diagnostics) => new(false, null, message, diagnostics); } diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs index 868d9be..1ab70bc 100644 --- a/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs @@ -7,9 +7,13 @@ namespace ChapterTool.Avalonia.Cli; Children = [typeof(LoadCliCommand), typeof(ConvertCliCommand), typeof(InspectCliCommand), typeof(FormatsCliCommand)])] public sealed class ChapterToolRootCliCommand { - public void Run(CliContext context) + [CliArgument(Description = "Input file or supported source path for GUI startup.", Required = false)] + public string Input { get; set; } = string.Empty; + + public int Run(CliContext context) { context.ShowHelp(); + return context.Result.HasTokens ? 1 : 0; } } @@ -40,7 +44,7 @@ public sealed class ConvertCliCommand [CliOption( Description = "Output format. Run `formats` to see the supported values.", Required = false, - AllowedValues = ["txt", "xml", "qpf", "timecodes", "tsmuxer", "cue", "json", "vtt", "celltimes", "chapter2qpf"])] + AllowedValues = ["txt", "xml", "qpf", "timecodes", "tsmuxer", "cue", "json", "vtt", "celltimes"])] public string Format { get; set; } = "txt"; [CliOption(Description = "Output file path. If omitted, ChapterTool writes next to the input file.", Required = false)] @@ -52,11 +56,11 @@ public sealed class ConvertCliCommand [CliOption(Description = "Imported group index to use when the source exposes multiple groups.", Required = false)] public int? GroupIndex { get; set; } - [CliOption(Description = "Imported option index to use inside the selected group.", Required = false)] - public int? OptionIndex { get; set; } + [CliOption(Description = "Imported entry index to use inside the selected group.", Required = false)] + public int? EntryIndex { get; set; } - [CliOption(Description = "Imported option id to use inside the selected group.", Required = false)] - public string? OptionId { get; set; } + [CliOption(Description = "Imported entry id to use inside the selected group.", Required = false)] + public string? EntryId { get; set; } [CliOption(Description = "Chapter language code for XML export.", Required = false)] public string? XmlLanguage { get; set; } @@ -77,8 +81,8 @@ public async Task RunAsync() Output, Stdout, GroupIndex, - OptionIndex, - OptionId, + EntryIndex, + EntryId, XmlLanguage, SourceFileName, FrameRate), @@ -86,7 +90,7 @@ public async Task RunAsync() } } -[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "Inspect available chapter groups, options, and diagnostics")] +[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "Inspect available chapter groups, entries, and diagnostics")] public sealed class InspectCliCommand { [CliArgument(Description = "Input file or supported source path", Required = false)] diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs index e27910a..e081127 100644 --- a/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs @@ -12,11 +12,6 @@ internal static class ChapterToolCliSupport public static CliLaunchPlan AnalyzeLaunch(IReadOnlyList args) { - if (args.Count == 0) - { - return CliLaunchPlan.None; - } - var parsed = DotMake.CommandLine.Cli.Parse([.. args], ParseSettings); if (parsed.IsCalled()) { @@ -26,6 +21,16 @@ public static CliLaunchPlan AnalyzeLaunch(IReadOnlyList args) return CliLaunchPlan.Gui(startupPath); } + if (!parsed.IsCalled() + && !parsed.IsCalled() + && !parsed.IsCalled() + && parsed.Bind() is ChapterToolRootCliCommand inputCommand + && inputCommand.Input.Length > 0 + && IsExistingPath(inputCommand.Input)) + { + return CliLaunchPlan.Gui(inputCommand.Input); + } + var shouldRunCli = parsed.IsCalled() || parsed.IsCalled() || parsed.IsCalled() @@ -33,19 +38,16 @@ public static CliLaunchPlan AnalyzeLaunch(IReadOnlyList args) return shouldRunCli ? CliLaunchPlan.Cli(parsed) : CliLaunchPlan.None; } + private static bool IsExistingPath(string value) => File.Exists(value) || Directory.Exists(value); + public static IReadOnlyList OutputFormats { get; } = - [ - new("txt", ChapterExportFormat.Txt, ".txt", "OGM chapter pairs"), - new("xml", ChapterExportFormat.Xml, ".xml", "Matroska chapter XML"), - new("qpf", ChapterExportFormat.Qpfile, ".qpf", "QPFile keyframe list"), - new("timecodes", ChapterExportFormat.TimeCodes, ".TimeCodes.txt", "Chapter start times only"), - new("tsmuxer", ChapterExportFormat.TsMuxerMeta, ".TsMuxeR_Meta.txt", "tsMuxeR meta chapter list"), - new("cue", ChapterExportFormat.Cue, ".cue", "CUE sheet"), - new("json", ChapterExportFormat.Json, ".json", "Structured JSON chapter payload"), - new("vtt", ChapterExportFormat.WebVtt, ".vtt", "WebVTT cue list"), - new("celltimes", ChapterExportFormat.Celltimes, ".txt", "Celltimes frame list"), - new("chapter2qpf", ChapterExportFormat.Chapter2Qpfile, ".qpf", "OGM-to-QPFile conversion") - ]; + ChapterExportFormats.All + .Select(static format => new CliOutputFormatDefinition( + ChapterExportFormats.Code(format), + format, + ChapterExportFormats.Extension(format), + ChapterExportFormats.Description(format))) + .ToArray(); public static bool TryParseFormat(string value, out CliOutputFormatDefinition definition) { diff --git a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs index f52a1cd..80f8249 100644 --- a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs +++ b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs @@ -8,6 +8,8 @@ using ChapterTool.Core.Importing.Media; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using ChapterTool.Core.Transform.Expressions.Lua; using ChapterTool.Infrastructure.Configuration; using ChapterTool.Infrastructure.Importing.Media; using ChapterTool.Infrastructure.Platform; @@ -24,7 +26,7 @@ public sealed class AppCompositionRoot : IDisposable { private readonly string? startupPath; private readonly ChapterTimeFormatter formatter = new(); - private readonly LuaExpressionScriptService expressionService = new(); + private readonly IChapterExpressionEngine expressionEngine = new LuaExpressionScriptService(); private readonly FrameRateService frameRateService = new(); private readonly ApplicationLogPanelProvider logService = new(capacity: 500, minimumLevel: LogLevel.Information); private readonly AppLocalizationManager localizationManager = new(); @@ -73,7 +75,8 @@ public MainWindowViewModel CreateMainWindowViewModel() => CreateShellService(), appSettingsStore, frameRateService, - localizationManager); + localizationManager, + expressionEngine); public IApplicationLogService CreateApplicationLogService() => logService; @@ -95,7 +98,7 @@ public FfprobeMediaChapterReader CreateMediaChapterReader() => new(CreateExternalToolLocator(), CreateProcessRunner()); public IChapterSaveService CreateChapterSaveService() => - new RuntimeChapterSaveService(new ChapterExportService(formatter, expressionService)); + new RuntimeChapterSaveService(new ChapterExportService(formatter, expressionEngine)); public IChapterEditingService CreateChapterEditingService() => new ChapterEditingService(formatter); @@ -141,6 +144,10 @@ private async Task ApplyThemeSettingsAsync() { themeApplicationService.Apply(ThemeColorSettings.Default); } + catch (CorruptSettingsFileException) + { + themeApplicationService.Apply(ThemeColorSettings.Default); + } } public void Dispose() diff --git a/src/ChapterTool.Avalonia/Diagnostics/SentryStartupConfiguration.cs b/src/ChapterTool.Avalonia/Diagnostics/SentryStartupConfiguration.cs new file mode 100644 index 0000000..c8aa108 --- /dev/null +++ b/src/ChapterTool.Avalonia/Diagnostics/SentryStartupConfiguration.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.Reflection; + +namespace ChapterTool.Avalonia.Diagnostics; + +internal sealed record SentryStartupOptions( + bool Enabled, + string? Dsn, + string Environment, + string? Release, + string? Distribution, + bool Debug, + SentryLevel DiagnosticLevel, + bool SendDefaultPii, + double? TracesSampleRate, + double? ProfilesSampleRate, + string CacheDirectoryPath); + +internal static class SentryStartupConfiguration +{ + internal const string DefaultDsn = "https://5f37a325bfae0275e1940fa0d2dfa5c0@o955448.ingest.us.sentry.io/4511698209931264"; + + internal static SentryStartupOptions FromEnvironment( + Func getEnvironmentVariable, + Assembly applicationAssembly, + string localApplicationDataPath, + bool debugBuild) + { + ArgumentNullException.ThrowIfNull(getEnvironmentVariable); + ArgumentNullException.ThrowIfNull(applicationAssembly); + + var dsn = FirstNonEmpty( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_DSN"), + getEnvironmentVariable("SENTRY_DSN"), + DefaultDsn); + var enabled = ParseBoolean( + FirstNonEmpty(getEnvironmentVariable("CHAPTERTOOL_SENTRY_ENABLED"), getEnvironmentVariable("SENTRY_ENABLED")), + defaultValue: !string.IsNullOrWhiteSpace(dsn)); + var environment = FirstNonEmpty( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_ENVIRONMENT"), + getEnvironmentVariable("SENTRY_ENVIRONMENT"), + debugBuild ? "debug" : "production")!; + var release = FirstNonEmpty( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_RELEASE"), + getEnvironmentVariable("SENTRY_RELEASE"), + ReleaseFromAssembly(applicationAssembly)); + var distribution = FirstNonEmpty( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_DIST"), + getEnvironmentVariable("SENTRY_DIST")); + var debug = ParseBoolean(getEnvironmentVariable("CHAPTERTOOL_SENTRY_DEBUG"), defaultValue: false); + var diagnosticLevel = ParseSentryLevel(getEnvironmentVariable("CHAPTERTOOL_SENTRY_DIAGNOSTIC_LEVEL"), defaultValue: SentryLevel.Warning); + var sendDefaultPii = ParseBoolean(getEnvironmentVariable("CHAPTERTOOL_SENTRY_SEND_DEFAULT_PII"), defaultValue: true); + var tracesSampleRate = ParseSampleRate( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_TRACES_SAMPLE_RATE"), + defaultValue: debugBuild ? null : 0.1d); + var profilesSampleRate = ParseSampleRate(getEnvironmentVariable("CHAPTERTOOL_SENTRY_PROFILES_SAMPLE_RATE"), defaultValue: null); + var cacheDirectoryPath = FirstNonEmpty( + getEnvironmentVariable("CHAPTERTOOL_SENTRY_CACHE_DIR"), + DefaultCacheDirectory(localApplicationDataPath))!; + + return new SentryStartupOptions( + enabled, + dsn, + environment, + release, + distribution, + debug, + diagnosticLevel, + sendDefaultPii, + tracesSampleRate, + profilesSampleRate, + cacheDirectoryPath); + } + + private static string? ReleaseFromAssembly(Assembly assembly) + { + var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion; + var version = FirstNonEmpty(informationalVersion, assembly.GetName().Version?.ToString(3)); + return string.IsNullOrWhiteSpace(version) ? null : $"ChapterTool@{version}"; + } + + private static string DefaultCacheDirectory(string localApplicationDataPath) => + string.IsNullOrWhiteSpace(localApplicationDataPath) + ? Path.Combine(AppContext.BaseDirectory, "sentry") + : Path.Combine(localApplicationDataPath, "ChapterTool", "sentry"); + + private static bool ParseBoolean(string? value, bool defaultValue) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return value.Trim().ToLowerInvariant() switch + { + "1" or "true" or "yes" or "on" => true, + "0" or "false" or "no" or "off" => false, + _ => defaultValue + }; + } + + private static double? ParseSampleRate(string? value, double? defaultValue) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + return defaultValue; + } + + return parsed is >= 0 and <= 1 ? parsed : defaultValue; + } + + private static SentryLevel ParseSentryLevel(string? value, SentryLevel defaultValue) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return Enum.TryParse(value, ignoreCase: true, out var level) ? level : defaultValue; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value))?.Trim(); +} diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx index 8687d30..bfc7441 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx @@ -15,7 +15,7 @@ - Combine segments: options={options}, sourceType={sourceType} + Combine segments: entries={entries}, sourceType={sourceType} Delete rows: indexes={indexes} @@ -33,7 +33,7 @@ Shift frames forward: frames={frames} - Split combined segments: options={options}, sourceType={sourceType} + Split combined segments: entries={entries}, sourceType={sourceType} [VCB-Studio] ChapterTool @@ -62,274 +62,274 @@ Use - + The operation was cancelled. - + The operation failed: {message} - + The operation timed out. - + The chapter export file was empty. - + The chapter export file was not created. - + The playlist output was not recognized. - + No embedded cuesheet was found. - + No chapters were parsed. - + CUE text is empty or no chapters were parsed. - + The XML document has no root element. - + ffprobe could not be started: {message} - + ffprobe did not return chapter JSON. - + ffprobe was not found. - + ffprobe output could not be parsed: {message} - + ffprobe was cancelled. - + ffprobe exited with a non-zero code. - + ffprobe timed out. - + No Vorbis cuesheet comment was found. - + The primary importer was unavailable; a fallback importer was used. - + Chapter index {index} is out of range. - + A chapter line could not be parsed: {line} - + Chapter text is empty or has no valid entries. - + A media chapter had no valid non-negative start timestamp. - + The file is not a supported container. - + Expected EditionEntry, got {name}. - + The expression is invalid: {message} - + The expression did not reduce to one value. - + Token '{token}' requires more operands. - + Invalid character '{character}'. - + Misplaced comma. - + Missing operand before ')'. - + Missing operator before '{token}'. - + Missing operator before function '{token}'. - + Missing operator before '('. - + Operator '{token}' requires a left operand. - + Operator '?' requires a condition. - + Operator ':' requires a true expression. - + Operator ':' requires a matching '?'. - + Operator '?' requires a matching ':'. - + Unbalanced parentheses. - + Unknown token '{token}'. - + Unsupported function '{function}'. - + Unsupported operator '{token}'. - + Unsupported token '{token}'. - + The expression time is invalid: {message} - + Frame rate must be greater than zero. - + Frame text did not contain a frame number or fps was invalid. - + The IFO file is invalid: {message} - + The MPLS file is invalid: {message} - + The Blu-ray BDMV/PLAYLIST directory was not found. - + Time text is empty or does not match the expected format. - + No valid timecode entries were found. - + The XML could not be parsed: {message} - + CUE index syntax is unsupported or malformed. - + mkvextract could not be started: {message} - + mkvextract was not found. - + mkvextract did not return chapter XML. - + mkvextract was cancelled: {message} - + mkvextract failed: {message} - + mkvextract timed out: {message} - + Media chapters could not be read: {message} - + eac3to was not found. - + The MP4 file could not be accessed: {message} - + The MP4 file was not found: {message} - + The MP4 path is empty. - + The MP4 chapter metadata is malformed. - + The MP4 file could not be read: {message} - + The MP4 chapter metadata is unsupported. - + A native dependency was not found: {message} - + No chapters are available for export. - + No chapters were found. - + Select one or more chapter rows first. - + No chapter segments are available. - + The first OGM chapter line is missing or invalid. - + Chapter number shift {shift} would produce non-positive chapter numbers and was normalized to 0. - + Parsing stopped before all input was consumed. - + Unable to parse the Adobe Premiere Pro chapter marker list. - + Saved: {path} - + Command output: {output} - + Only MPLS chapter groups can be appended. - + Only MPLS and DVD chapter groups can be combined. - + Unsupported export format. - + Unsupported source extension: {extension} - + The WebVTT header is missing. - + A WebVTT cue could not be parsed. - + The WebVTT timing settings are unsupported. - + Expected Chapters root, got {name}. - + No Matroska XML chapters were parsed. - + No HD-DVD chapters were parsed. - + The XPL playlist could not be parsed: {message} @@ -338,7 +338,7 @@ name - + time @@ -399,13 +399,13 @@ Frame info updated: option={option}, fps={fps}, round={round}, chapters={chapters} - {operation} group {groupIndex}: sourcePath='{sourcePath}', defaultOptionIndex={defaultOptionIndex}, options={options} + {operation} group {groupIndex}: sourcePath='{sourcePath}', defaultEntryIndex={defaultEntryIndex}, entries={entries} - - {operation} option {optionIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} + + {operation} entry {entryIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} - {operation} result: success={success}, partial={partial}, groups={groups}, options={options}, chapters={chapters}, diagnostics={diagnostics} + {operation} result: success={success}, partial={partial}, groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics} Language set to {language} @@ -626,6 +626,9 @@ Default XML language + + Write UTF-8 BOM + eac3to @@ -662,6 +665,9 @@ Settings loaded + + Settings could not be loaded; defaults are shown + Defaults restored @@ -821,28 +827,28 @@ Lua script loaded: {path} - + Lua expression failed: {message} - + Lua expression syntax error: {message} - + Lua expression runtime error: {message} - + Lua expression must return a finite number. - + Lua expression did not return a value. - + Unknown Lua token: {message} - + Lua expression was canceled. - + Check the Lua expression syntax. diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx index b1bab58..2700d3a 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx @@ -15,7 +15,7 @@ - セグメントを結合: オプション数={options}, ソース種別={sourceType} + セグメントを結合: オプション数={entries}, ソース種別={sourceType} 行を削除: インデックス={indexes} @@ -33,7 +33,7 @@ フレーム前方シフト: フレーム数={frames} - 結合済みセグメントを分割: オプション数={options}, ソース種別={sourceType} + 結合済みセグメントを分割: オプション数={entries}, ソース種別={sourceType} [VCB-Studio] ChapterTool @@ -62,274 +62,274 @@ 使用 - + 操作はキャンセルされました。 - + 操作が失敗しました: {message} - + 操作がタイムアウトしました。 - + 章節エクスポートファイルが空です。 - + 章節エクスポートファイルが作成されませんでした。 - + プレイリスト出力を認識できませんでした。 - + 埋め込み cuesheet が見つかりませんでした。 - + 章節が解析されませんでした。 - + CUE テキストが空であるか、章節が解析されませんでした。 - + XML ドキュメントにルート要素がありません。 - + ffprobe を起動できませんでした: {message} - + ffprobe が章節 JSON を返しませんでした。 - + ffprobe が見つかりませんでした。 - + ffprobe の出力を解析できませんでした: {message} - + ffprobe はキャンセルされました。 - + ffprobe は 0 以外の終了コードで終了しました。 - + ffprobe がタイムアウトしました。 - + Vorbis cuesheet コメントが見つかりませんでした。 - + プライマリ インポーターが利用できないため、フォールバック インポーターを使用しました。 - + 章節インデックス {index} が範囲外です。 - + 章節行を解析できませんでした: {line} - + 章節テキストが空であるか、有効なエントリがありません。 - + メディア章節に有効な負でない開始タイムスタンプがありません。 - + このファイルはサポート対象のコンテナではありません。 - + EditionEntry を期待しましたが、{name} でした。 - + 式が無効です: {message} - + 式が単一の値に還元されませんでした。 - + トークン '{token}' にはより多くのオペランドが必要です。 - + 無効な文字 '{character}' です。 - + カンマの位置が不正です。 - + ')' の前にオペランドがありません。 - + '{token}' の前に演算子がありません。 - + 関数 '{token}' の前に演算子がありません。 - + '(' の前に演算子がありません。 - + 演算子 '{token}' には左オペランドが必要です。 - + 演算子 '?' には条件が必要です。 - + 演算子 ':' には真の式が必要です。 - + 演算子 ':' に対応する '?' がありません。 - + 演算子 '?' に対応する ':' がありません。 - + 括弧が一致していません。 - + 不明なトークン '{token}' です。 - + サポート対象外の関数 '{function}' です。 - + サポート対象外の演算子 '{token}' です。 - + サポート対象外のトークン '{token}' です。 - + 式の時間が無効です: {message} - + フレームレートは 0 より大きい必要があります。 - + フレームテキストにフレーム番号が含まれないか、fps が無効です。 - + IFO ファイルが無効です: {message} - + MPLS ファイルが無効です: {message} - + Blu-ray の BDMV/PLAYLIST ディレクトリが見つかりませんでした。 - + 時間テキストが空であるか、想定形式と一致しません。 - + 有効なタイムコード エントリが見つかりませんでした。 - + XML を解析できませんでした: {message} - + CUE インデックス構文はサポート対象外または不正です。 - + mkvextract を起動できませんでした: {message} - + mkvextract が見つかりませんでした。 - + mkvextract が章節 XML を返しませんでした。 - + mkvextract はキャンセルされました: {message} - + mkvextract が失敗しました: {message} - + mkvextract がタイムアウトしました: {message} - + メディア章節を読み取れませんでした: {message} - + eac3to が見つかりませんでした。 - + MP4 ファイルにアクセスできませんでした: {message} - + MP4 ファイルが見つかりませんでした: {message} - + MP4 パスが空です。 - + MP4 章節メタデータが不正です。 - + MP4 ファイルを読み取れませんでした: {message} - + MP4 章節メタデータはサポート対象外です。 - + ネイティブ依存関係が見つかりませんでした: {message} - + エクスポート可能な章節がありません。 - + 章節が見つかりませんでした。 - + 1 つ以上の章節行を選択してください。 - + 使用可能な章節セグメントがありません。 - + 先頭の OGM 章節行が存在しないか無効です。 - + 章節番号シフト {shift} は負の章節番号になるため、0 に正規化されました。 - + すべての入力を消費する前に解析が停止しました。 - + Adobe Premiere Pro の章節マーカーリストを解析できませんでした。 - + 保存しました: {path} - + コマンド出力: {output} - + 追加できるのは MPLS 章節グループのみです。 - + 結合できるのは MPLS および DVD 章節グループのみです。 - + サポート対象外のエクスポート形式です。 - + サポート対象外のソース拡張子です: {extension} - + WebVTT ヘッダーがありません。 - + WebVTT キューを解析できませんでした。 - + サポート対象外の WebVTT タイミング設定です。 - + Chapters ルートを期待しましたが、{name} でした。 - + Matroska XML 章節が解析されませんでした。 - + HD-DVD 章節が解析されませんでした。 - + XPL プレイリストを解析できませんでした: {message} @@ -338,7 +338,7 @@ 名前 - + 時間 @@ -399,13 +399,13 @@ フレーム情報を更新: option={option}, fps={fps}, round={round}, chapters={chapters} - {operation} group {groupIndex}: sourcePath='{sourcePath}', defaultOptionIndex={defaultOptionIndex}, options={options} + {operation} group {groupIndex}: sourcePath='{sourcePath}', defaultEntryIndex={defaultEntryIndex}, entries={entries} - - {operation} option {optionIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} + + {operation} entry {entryIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} - {operation} result: success={success}, partial={partial}, groups={groups}, options={options}, chapters={chapters}, diagnostics={diagnostics} + {operation} result: success={success}, partial={partial}, groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics} 言語を {language} に設定しました @@ -626,6 +626,9 @@ 既定の XML 言語 + + UTF-8 BOM を書き込む + eac3to @@ -662,6 +665,9 @@ 設定を読み込みました + + 設定を読み込めなかったため、既定値を表示しています + 既定値を復元しました @@ -821,28 +827,28 @@ Lua スクリプトを読み込みました: {path} - + Lua 式に失敗しました: {message} - + Lua 式の構文エラー: {message} - + Lua 式の実行時エラー: {message} - + Lua 式は有限の数値を返す必要があります。 - + Lua 式が値を返しませんでした。 - + 不明な Lua トークン: {message} - + Lua 式はキャンセルされました。 - + Lua 式の構文を確認してください。 diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx index 6e6d52c..7e46e49 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx @@ -15,7 +15,7 @@ - 合并段落:选项数={options},源类型={sourceType} + 合并段落:选项数={entries},源类型={sourceType} 删除行:索引={indexes} @@ -33,7 +33,7 @@ 帧数前移:帧数={frames} - 拆分已合并段落:选项数={options},源类型={sourceType} + 拆分已合并段落:选项数={entries},源类型={sourceType} [VCB-Studio] ChapterTool @@ -62,274 +62,274 @@ 使用 - + 操作已取消。 - + 操作失败:{message} - + 操作超时。 - + 章节导出文件为空。 - + 未生成章节导出文件。 - + 无法识别播放列表输出。 - + 未找到内嵌的 cuesheet。 - + 未解析到任何章节。 - + CUE 文本为空,或未解析到章节。 - + XML 文档没有根元素。 - + 无法启动 ffprobe:{message} - + ffprobe 未返回章节 JSON。 - + 未找到 ffprobe。 - + 无法解析 ffprobe 输出:{message} - + ffprobe 已取消。 - + ffprobe 以非零代码退出。 - + ffprobe 超时。 - + 未找到 Vorbis cuesheet 注释。 - + 主导入器不可用,已使用备用导入器。 - + 章节索引 {index} 超出范围。 - + 无法解析章节行:{line} - + 章节文本为空,或没有有效条目。 - + 媒体章节没有有效的非负起始时间戳。 - + 该文件不是受支持的容器。 - + 应为 EditionEntry,实际为 {name}。 - + 表达式无效:{message} - + 表达式未能归约为单个值。 - + 标记 '{token}' 需要更多操作数。 - + 无效字符 '{character}'。 - + 逗号位置错误。 - + ')' 前缺少操作数。 - + '{token}' 前缺少运算符。 - + 函数 '{token}' 前缺少运算符。 - + '(' 前缺少运算符。 - + 运算符 '{token}' 需要左操作数。 - + 运算符 '?' 需要条件。 - + 运算符 ':' 需要真值表达式。 - + 运算符 ':' 需要匹配的 '?'。 - + 运算符 '?' 需要匹配的 ':'。 - + 括号不匹配。 - + 未知标记 '{token}'。 - + 不受支持的函数 '{function}'。 - + 不受支持的运算符 '{token}'。 - + 不受支持的标记 '{token}'。 - + 表达式时间无效:{message} - + 帧率必须大于零。 - + 帧文本未包含帧编号,或帧率无效。 - + IFO 文件无效:{message} - + MPLS 文件无效:{message} - + 未找到 Blu-ray BDMV/PLAYLIST 目录。 - + 时间文本为空,或不符合预期格式。 - + 未找到有效的时间码条目。 - + 无法解析 XML:{message} - + CUE 索引语法不受支持或格式错误。 - + 无法启动 mkvextract:{message} - + 未找到 mkvextract。 - + mkvextract 未返回章节 XML。 - + mkvextract 已取消:{message} - + mkvextract 执行失败:{message} - + mkvextract 超时:{message} - + 无法读取媒体章节:{message} - + 未找到 eac3to。 - + 无法访问 MP4 文件:{message} - + 未找到 MP4 文件:{message} - + MP4 路径为空。 - + MP4 章节元数据格式错误。 - + 无法读取 MP4 文件:{message} - + MP4 章节元数据不受支持。 - + 未找到本地依赖:{message} - + 没有可导出的章节。 - + 未找到章节。 - + 请先选择一个或多个章节行。 - + 没有可用的章节段落。 - + 缺少或无效的首行 OGM 章节行。 - + 章节编号位移 {shift} 会产生非正的章节编号,已归一化为 0。 - + 解析在消耗全部输入前停止。 - + 无法解析 Adobe Premiere Pro 章节标记列表。 - + 已保存:{path} - + 命令输出:{output} - + 仅支持追加 MPLS 章节组。 - + 仅支持合并 MPLS 与 DVD 章节组。 - + 不受支持的导出格式。 - + 不受支持的来源扩展名:{extension} - + 缺少 WebVTT 头。 - + 无法解析 WebVTT 提示。 - + 不受支持的 WebVTT 时间设置。 - + 应为 Chapters 根节点,实际为 {name}。 - + 未解析到 Matroska XML 章节。 - + 未解析到 HD-DVD 章节。 - + 无法解析 XPL 播放列表:{message} @@ -338,7 +338,7 @@ 名称 - + 时间 @@ -399,13 +399,13 @@ 帧信息已更新:option={option},fps={fps},round={round},chapters={chapters} - {operation} 分组 {groupIndex}:sourcePath='{sourcePath}',defaultOptionIndex={defaultOptionIndex},options={options} + {operation} 分组 {groupIndex}:sourcePath='{sourcePath}',defaultEntryIndex={defaultEntryIndex},entries={entries} - - {operation} 选项 {optionIndex}:id='{id}',label='{label}',source='{source}',sourceType={sourceType},chapters={chapters},duration={duration},fps={fps} + + {operation} 条目 {entryIndex}:id='{id}',label='{label}',source='{source}',sourceType={sourceType},chapters={chapters},duration={duration},fps={fps} - {operation} 结果:success={success},partial={partial},groups={groups},options={options},chapters={chapters},diagnostics={diagnostics} + {operation} 结果:success={success},partial={partial},groups={groups},entries={entries},chapters={chapters},diagnostics={diagnostics} 语言已设置为 {language} @@ -626,6 +626,9 @@ 默认 XML 语言 + + 写入 UTF-8 BOM + eac3to @@ -662,6 +665,9 @@ 设置已载入 + + 设置无法载入,当前显示默认值 + 已恢复默认值 @@ -821,28 +827,28 @@ 已加载 Lua 脚本:{path} - + Lua 表达式失败:{message} - + Lua 表达式语法错误:{message} - + Lua 表达式运行错误:{message} - + Lua 表达式必须返回有限数字。 - + Lua 表达式没有返回值。 - + 未知 Lua 标记:{message} - + Lua 表达式已取消。 - + 检查 Lua 表达式语法。 diff --git a/src/ChapterTool.Avalonia/Program.cs b/src/ChapterTool.Avalonia/Program.cs index 2d69e51..469bc47 100644 --- a/src/ChapterTool.Avalonia/Program.cs +++ b/src/ChapterTool.Avalonia/Program.cs @@ -1,5 +1,6 @@ using Avalonia; using ChapterTool.Avalonia.Cli; +using ChapterTool.Avalonia.Diagnostics; using Optris.Icons.Avalonia; using Optris.Icons.Avalonia.FontAwesome; @@ -12,6 +13,7 @@ internal static class Program [STAThread] public static void Main(string[] args) { + SetupSentry(); var launchPlan = ChapterToolCliSupport.AnalyzeLaunch(args); GuiStartupPath = launchPlan.GuiStartupPath; if (launchPlan.LaunchGui) @@ -54,4 +56,46 @@ private static void RegisterIconProviders() { IconProvider.Current.Register(); } + + private static void SetupSentry() + { + var localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var startupOptions = SentryStartupConfiguration.FromEnvironment( + Environment.GetEnvironmentVariable, + typeof(Program).Assembly, + localApplicationData, +#if DEBUG + debugBuild: true +#else + debugBuild: false +#endif + ); + + if (!startupOptions.Enabled || string.IsNullOrWhiteSpace(startupOptions.Dsn)) + { + return; + } + + SentrySdk.Init(options => ApplySentryOptions(options, startupOptions)); + } + + private static void ApplySentryOptions(SentryOptions options, SentryStartupOptions startupOptions) + { + options.Dsn = startupOptions.Dsn; + options.Environment = startupOptions.Environment; + options.Release = startupOptions.Release; + options.Distribution = startupOptions.Distribution; + options.Debug = startupOptions.Debug; + options.DiagnosticLevel = startupOptions.DiagnosticLevel; + options.SendDefaultPii = startupOptions.SendDefaultPii; + options.TracesSampleRate = startupOptions.TracesSampleRate; + options.ProfilesSampleRate = startupOptions.ProfilesSampleRate; + options.CacheDirectoryPath = startupOptions.CacheDirectoryPath; + options.AutoSessionTracking = true; + options.IsGlobalModeEnabled = true; + options.AttachStacktrace = true; + options.MaxBreadcrumbs = 100; + options.SendClientReports = true; + options.InitCacheFlushTimeout = TimeSpan.Zero; + } } diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs index 94e819e..f5c63e5 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs @@ -108,6 +108,7 @@ public ValueTask ShowAsync(string windowId, object? parameter, CancellationToken }; window.Closed += (_, _) => { + DisposeContentDataContext(window); windows.Remove(windowId); parameters.Remove(windowId); }; @@ -129,6 +130,7 @@ public ValueTask HideAsync(string windowId, CancellationToken cancellationToken) private void Refresh(Window window, string id, object? parameter) { + DisposeContentDataContext(window); window.Title = Title(id); parameters[id] = parameter; window.Content = parameter is MainWindowViewModel viewModel @@ -201,4 +203,11 @@ private static TextBlock Placeholder(string text) => private string PlaceholderText(string id) => Title(id); + private static void DisposeContentDataContext(Window window) + { + if (window.Content is Control { DataContext: IDisposable disposable }) + { + disposable.Dispose(); + } + } } diff --git a/src/ChapterTool.Avalonia/Services/IChapterLoadService.cs b/src/ChapterTool.Avalonia/Services/IChapterLoadService.cs index 581aca8..e63c193 100644 --- a/src/ChapterTool.Avalonia/Services/IChapterLoadService.cs +++ b/src/ChapterTool.Avalonia/Services/IChapterLoadService.cs @@ -6,6 +6,6 @@ public interface IChapterLoadService { ValueTask LoadAsync(string path, CancellationToken cancellationToken); - ValueTask LoadAsync(string path, IProgress? progress, CancellationToken cancellationToken) => + ValueTask LoadAsync(string path, IChapterImportProgressReporter? progress, CancellationToken cancellationToken) => LoadAsync(path, cancellationToken); } diff --git a/src/ChapterTool.Avalonia/Services/IChapterSaveService.cs b/src/ChapterTool.Avalonia/Services/IChapterSaveService.cs index c23927f..ee977dd 100644 --- a/src/ChapterTool.Avalonia/Services/IChapterSaveService.cs +++ b/src/ChapterTool.Avalonia/Services/IChapterSaveService.cs @@ -5,5 +5,5 @@ namespace ChapterTool.Avalonia.Services; public interface IChapterSaveService { - ValueTask SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken); + ValueTask SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken); } diff --git a/src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs b/src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs index fd9f9d3..10ca450 100644 --- a/src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs +++ b/src/ChapterTool.Avalonia/Services/RuntimeChapterImporterRegistry.cs @@ -3,6 +3,7 @@ using ChapterTool.Core.Importing.Disc; using ChapterTool.Core.Importing.Media; using ChapterTool.Core.Importing.Text; +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; using ChapterTool.Infrastructure.Importing.Bdmv; @@ -63,16 +64,16 @@ public sealed class RuntimeChapterImporterRegistry( var extension = Path.GetExtension(path).ToLowerInvariant(); return extension switch { - ".mp4" or ".m4a" or ".m4v" when ReferenceEquals(primaryImporter, mediaImporter) && HasDiagnostic(primaryResult, "FfprobeMissingDependency", "FfprobeCannotStart") + ".mp4" or ".m4a" or ".m4v" when ReferenceEquals(primaryImporter, mediaImporter) && HasDiagnostic(primaryResult, ChapterDiagnosticCode.FfprobeMissingDependency, ChapterDiagnosticCode.FfprobeCannotStart) => mp4FallbackImporter, - ".mkv" or ".mka" or ".mks" or ".webm" when primaryImporter is MatroskaChapterImporter && HasDiagnostic(primaryResult, "MatroskaMissingDependency", "MatroskaCannotStart") + ".mkv" or ".mka" or ".mks" or ".webm" when primaryImporter is MatroskaChapterImporter && HasDiagnostic(primaryResult, ChapterDiagnosticCode.MatroskaMissingDependency, ChapterDiagnosticCode.MatroskaCannotStart) => mediaImporter, - ".flac" when primaryImporter is FlacCueImporter && HasDiagnostic(primaryResult, "FlacEmbeddedCueNotFound") + ".flac" when primaryImporter is FlacCueImporter && HasDiagnostic(primaryResult, ChapterDiagnosticCode.FlacEmbeddedCueNotFound) => mediaImporter, _ => null }; } - private static bool HasDiagnostic(ChapterImportResult result, params string[] codes) => - result.Diagnostics.Any(diagnostic => codes.Contains(diagnostic.Code, StringComparer.Ordinal)); + private static bool HasDiagnostic(ChapterImportResult result, params ChapterDiagnosticCode[] codes) => + result.Diagnostics.Any(diagnostic => codes.Contains(diagnostic.Code)); } diff --git a/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs b/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs index e663be2..c91030b 100644 --- a/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs +++ b/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs @@ -10,18 +10,18 @@ public ValueTask LoadAsync(string path, CancellationToken c return LoadAsync(path, progress: null, cancellationToken); } - public ValueTask LoadAsync(string path, IProgress? progress, CancellationToken cancellationToken) + public ValueTask LoadAsync(string path, IChapterImportProgressReporter? progress, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(path) || (!File.Exists(path) && !Directory.Exists(path))) { - return ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidPath", "The source path does not exist."))); + return ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidPath, "The source path does not exist."))); } var extension = Path.GetExtension(path); var importer = importerRegistry.Resolve(path); return importer is null - ? ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "UnsupportedSource", $"Unsupported source extension: {extension}.", + ? ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.UnsupportedSource, $"Unsupported source extension: {extension}.", Arguments: new Dictionary(StringComparer.Ordinal) { ["extension"] = extension }))) : LoadWithFallbackAsync(path, importer, progress, cancellationToken); } @@ -29,10 +29,10 @@ public ValueTask LoadAsync(string path, IProgress LoadWithFallbackAsync( string path, IChapterImporter importer, - IProgress? progress, + IChapterImportProgressReporter? progress, CancellationToken cancellationToken) { - var primaryResult = await importer.ImportAsync(new ChapterImportRequest(path, Progress: progress), cancellationToken); + var primaryResult = await importer.ImportAsync(new ChapterImportRequest(path, ProgressReporter: progress), cancellationToken); if (primaryResult.Success) { return primaryResult; @@ -44,14 +44,14 @@ private async ValueTask LoadWithFallbackAsync( return primaryResult; } - var fallbackResult = await fallback.ImportAsync(new ChapterImportRequest(path, Progress: progress), cancellationToken); + var fallbackResult = await fallback.ImportAsync(new ChapterImportRequest(path, ProgressReporter: progress), cancellationToken); var fallbackReason = primaryResult.Diagnostics.FirstOrDefault(); var fallbackDiagnostic = new ChapterDiagnostic( DiagnosticSeverity.Info, - "ImporterFallbackUsed", + ChapterDiagnosticCode.ImporterFallbackUsed, $"Primary importer '{importer.Id}' could not be invoked; used fallback importer '{fallback.Id}'.", path, - $"primary={importer.Id}; fallback={fallback.Id}; reason={fallbackReason?.Code ?? "Unknown"}"); + $"primary={importer.Id}; fallback={fallback.Id}; reason={fallbackReason?.DisplayCode ?? "Unknown"}"); var diagnostics = primaryResult.Diagnostics.Concat([fallbackDiagnostic]).Concat(fallbackResult.Diagnostics).ToList(); return fallbackResult with { Diagnostics = diagnostics }; } diff --git a/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs b/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs index f948bb5..3f28c4d 100644 --- a/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs +++ b/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs @@ -1,12 +1,13 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Exporting; using ChapterTool.Core.Models; +using System.Text; namespace ChapterTool.Avalonia.Services; public sealed class RuntimeChapterSaveService(ChapterExportService exporter) : IChapterSaveService { - public async ValueTask SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) + public async ValueTask SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) { var result = exporter.Export(info, options); if (!result.Success) @@ -14,15 +15,42 @@ public async ValueTask SaveAsync(ChapterInfo info, ChapterE return result; } - var targetDirectory = string.IsNullOrWhiteSpace(directory) ? Environment.CurrentDirectory : directory; - Directory.CreateDirectory(targetDirectory); - var baseName = string.IsNullOrWhiteSpace(info.SourceName) ? "chapters" : Path.GetFileNameWithoutExtension(info.SourceName); - var path = Path.Combine(targetDirectory, baseName + result.FileExtension); - await File.WriteAllTextAsync(path, result.Content, cancellationToken); - return result with + try { - Diagnostics = [.. result.Diagnostics, new ChapterDiagnostic(DiagnosticSeverity.Info, "Saved", path, - Arguments: new Dictionary(StringComparer.Ordinal) { ["path"] = path })] - }; + var targetDirectory = string.IsNullOrWhiteSpace(directory) ? Environment.CurrentDirectory : directory; + Directory.CreateDirectory(targetDirectory); + var baseName = string.IsNullOrWhiteSpace(info.SourceName) ? "chapters" : Path.GetFileNameWithoutExtension(info.SourceName); + var path = Path.Combine(targetDirectory, baseName + result.FileExtension); + await File.WriteAllTextAsync( + path, + result.Content, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: options.EmitBom), + cancellationToken); + return result with + { + Diagnostics = [.. result.Diagnostics, new ChapterDiagnostic(DiagnosticSeverity.Info, ChapterDiagnosticCode.Saved, path, + Arguments: new Dictionary(StringComparer.Ordinal) { ["path"] = path })] + }; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return result with + { + Success = false, + Diagnostics = + [ + .. result.Diagnostics, + new ChapterDiagnostic( + DiagnosticSeverity.Error, + ChapterDiagnosticCode.SaveFailed, + $"Chapter file could not be saved: {exception.Message}", + Arguments: new Dictionary(StringComparer.Ordinal) + { + ["directory"] = directory, + ["message"] = exception.Message + }) + ] + }; + } } } diff --git a/src/ChapterTool.Avalonia/ViewModels/ChapterRowViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/ChapterRowViewModel.cs index e13f04c..5e53e9a 100644 --- a/src/ChapterTool.Avalonia/ViewModels/ChapterRowViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/ChapterRowViewModel.cs @@ -12,8 +12,8 @@ public ChapterRowViewModel( string? name = null) { Chapter = chapter; - Number = number ?? chapter.Number; - TimeText = chapter.IsSeparator ? string.Empty : formatter.Format(chapter.Time); + Number = number ?? chapter.DisplayNumber; + TimeText = chapter.IsSeparator ? string.Empty : formatter.Format(chapter.StartTime); Name = name ?? chapter.Name; FramesInfo = chapter.FramesInfo; IsFrameAccurate = chapter.FrameAccuracy == FrameAccuracy.Accurate; diff --git a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs index 2a17847..a4daafb 100644 --- a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs @@ -10,6 +10,8 @@ using ChapterTool.Core.Models; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using ChapterTool.Core.Transform.Expressions.Lua; using ChapterTool.Infrastructure.Configuration; using Microsoft.Extensions.Logging; @@ -24,19 +26,21 @@ public sealed partial class MainWindowViewModel : ObservableViewModel private readonly IWindowService windowService; private readonly IChapterTimeFormatter formatter; private readonly IFrameRateService frameRateService; + private readonly IChapterExpressionEngine expressionEngine; private readonly ChapterOutputProjectionService outputProjectionService; private readonly IApplicationLogService logService; private readonly ILogger logger; private readonly IShellService? shellService; private readonly ISettingsStore? appSettingsStore; - private ChapterInfoGroup? currentGroup; - private ChapterInfo? currentInfo; + private ChapterImportSource? currentGroup; + private ChapterSet? currentInfo; private FrameRateOption selectedFrameRateOption; private decimal? configuredFrameRate; private bool currentInfoBelongsToSelectedClip; - private ChapterInfoGroup? splitClipGroup; - private ChapterSourceOption? combinedClipOption; + private ChapterImportSource? splitClipGroup; + private ChapterImportEntry? combinedClipOption; + private int loadOperationVersion; private bool isRefreshingChapterNameModeOptions; private bool autoGenerateNames; private bool useTemplateNames; @@ -59,7 +63,8 @@ public MainWindowViewModel( IShellService? shellService = null, ISettingsStore? appSettingsStore = null, IFrameRateService? frameRateService = null, - IAppLocalizer? localizer = null) + IAppLocalizer? localizer = null, + IChapterExpressionEngine? expressionEngine = null) { this.loadService = loadService; this.saveService = saveService; @@ -68,7 +73,8 @@ public MainWindowViewModel( this.windowService = windowService; this.formatter = formatter; this.frameRateService = frameRateService ?? new FrameRateService(); - outputProjectionService = new ChapterOutputProjectionService(); + this.expressionEngine = expressionEngine ?? new LuaExpressionScriptService(); + outputProjectionService = new ChapterOutputProjectionService(this.expressionEngine); this.logService = logService; this.logger = logger; @@ -192,7 +198,7 @@ public string DisplayPath public bool IsChapterGridEmpty => Rows.Count == 0; - public ObservableCollection ClipOptions { get; } = []; + public ObservableCollection ClipOptions { get; } = []; public ObservableCollection ClipDisplayOptions { get; } = []; @@ -294,8 +300,12 @@ public ChapterExportFormat SaveFormat public int SaveFormatIndex { - get => (int)SaveFormat; - set => SaveFormat = (ChapterExportFormat)Math.Max(0, value); + get + { + var index = ChapterExportFormats.IndexOf(SaveFormat); + return Math.Max(0, index); + } + set => SaveFormat = ChapterExportFormats.AtIndex(value); } public IReadOnlyList XmlLanguageOptions { get; } = @@ -309,17 +319,17 @@ public SelectorDisplayOption? SelectedXmlLanguageDisplayOption { get { - var options = XmlLanguageDisplayOptions; - return XmlLanguageIndex < 0 || XmlLanguageIndex >= options.Count + var entries = XmlLanguageDisplayOptions; + return XmlLanguageIndex < 0 || XmlLanguageIndex >= entries.Count ? null - : options[XmlLanguageIndex]; + : entries[XmlLanguageIndex]; } set { var index = value is null ? -1 - : XmlLanguageDisplayOptions.ToList().FindIndex(option => - string.Equals(option.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); + : XmlLanguageDisplayOptions.ToList().FindIndex(entry => + string.Equals(entry.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); if (index >= 0) { XmlLanguageIndex = index; @@ -346,8 +356,8 @@ public int XmlLanguageIndex get { xmlLanguageIndexes ??= XmlLanguageOptions - .Select(static (option, index) => (option, index)) - .ToDictionary(static item => item.option, static item => item.index, StringComparer.OrdinalIgnoreCase); + .Select(static (entry, index) => (entry, index)) + .ToDictionary(static item => item.entry, static item => item.index, StringComparer.OrdinalIgnoreCase); return xmlLanguageIndexes.GetValueOrDefault(XmlLanguage, 0); } set @@ -369,6 +379,8 @@ public string UiLanguage public IAppLocalizer Localizer { get; } + public IReadOnlyList ExpressionPresets => expressionEngine.Presets; + public bool AutoGenerateNames { get => autoGenerateNames; @@ -497,13 +509,13 @@ public string Expression } } = "t"; - public string LuaExpressionPresetId + public string ExpressionPresetId { get; set => SetProperty(ref field, value); } = string.Empty; - public string LuaExpressionSourceName + public string ExpressionSourceName { get; set => SetProperty(ref field, value); @@ -527,18 +539,18 @@ public double Progress private set => SetProperty(ref field, value); } - public IReadOnlyList RelatedMediaReferences => - currentGroup is null || SelectedClipIndex < 0 || SelectedClipIndex >= currentGroup.Options.Count + public IReadOnlyList RelatedMediaReferences => + currentGroup is null || SelectedClipIndex < 0 || SelectedClipIndex >= currentGroup.Entries.Count ? [] - : currentGroup.Options[SelectedClipIndex].MediaReferences ?? []; + : currentGroup.Entries[SelectedClipIndex].ReferencedMediaFiles ?? []; - public bool CanAppendMpls => currentGroup?.Options.Any(static option => option.ChapterInfo.SourceType == "MPLS") == true; + public bool CanAppendMpls => currentGroup?.Entries.Any(static entry => entry.ChapterSet.ImportFormat == ChapterImportFormat.Mpls) == true; public bool CanCombine => IsClipCombineChecked || currentGroup is not null - && currentGroup.Options.Count > 1 - && currentGroup.Options[0].ChapterInfo.SourceType is "MPLS" or "DVD" - && currentGroup.Options.All(option => option.ChapterInfo.SourceType == currentGroup.Options[0].ChapterInfo.SourceType); + && currentGroup.Entries.Count > 1 + && currentGroup.Entries[0].ChapterSet.ImportFormat is ChapterImportFormat.Mpls or ChapterImportFormat.DvdIfo + && currentGroup.Entries.All(entry => entry.ChapterSet.ImportFormat == currentGroup.Entries[0].ChapterSet.ImportFormat); public bool CanSave => currentInfo is not null; @@ -548,6 +560,12 @@ public double Progress public bool CanOpenRelatedMedia => RelatedMediaReferences.Count > 0; + public bool EmitBom + { + get; + private set => SetProperty(ref field, value); + } = true; + public UiCommand LoadCommand { get; private set; } = null!; public UiCommand ReloadCommand { get; private set; } = null!; public UiCommand AppendMplsCommand { get; private set; } = null!; @@ -577,10 +595,10 @@ public double Progress public void SetFrameOptions(int frameRateIndex, bool roundFrames) { RoundFrames = roundFrames; - var option = FrameRateOptionForComboIndex(frameRateIndex); - if (option is not null) + var entry = FrameRateOptionForComboIndex(frameRateIndex); + if (entry is not null) { - selectedFrameRateOption = option; + selectedFrameRateOption = entry; SelectedFrameRateIndex = frameRateIndex; return; } @@ -620,6 +638,7 @@ public void ApplySettings(AppSettings settings) XmlLanguage = string.IsNullOrWhiteSpace(settings.DefaultXmlLanguage) ? "und" : settings.DefaultXmlLanguage; + EmitBom = settings.EmitBom; NotifyStateChanged(); } @@ -646,8 +665,8 @@ public string BuildPreview() } var projection = CurrentOutputProjection(); - var options = CurrentExportOptionsForProjectedInfo(); - var result = new ChapterExportService(formatter).Export(projection.Info, options); + var entries = CurrentExportOptionsForProjectedInfo(); + var result = new ChapterExportService(formatter).Export(projection.Info, entries); if (!result.Success) { return string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => diagnostic.Message)); @@ -708,11 +727,13 @@ private ChapterExportOptions CurrentExportOptions() => OrderShift: OrderShift, ApplyExpression: ApplyExpression, Expression: Expression, - LuaExpressionPresetId: LuaExpressionPresetId, - LuaExpressionSourceName: LuaExpressionSourceName); + ExpressionPresetId: ExpressionPresetId, + ExpressionSourceName: ExpressionSourceName, + EmitBom: EmitBom); private async ValueTask LoadPathAsync(string path, CancellationToken cancellationToken) { + var operationId = Interlocked.Increment(ref loadOperationVersion); if (string.IsNullOrWhiteSpace(path)) { SetStatus("Status.NoSourceSelected"); @@ -723,13 +744,23 @@ private async ValueTask LoadPathAsync(string path, CancellationToken cancellatio Log("Log.LoadingSource", ("path", path)); Progress = 0.05; - SetProgressStatus("Status.LoadingSource"); - var progress = new ChapterLoadProgressSink(update => + SetProgressStatus(ChapterImportProgressPhase.LoadingSource); + var progress = new ChapterImportProgressSink(update => { - Progress = Math.Clamp(update.Value, 0, 0.98); - SetProgressStatus(update.Message); + if (operationId != Volatile.Read(ref loadOperationVersion)) + { + return; + } + + Progress = Math.Clamp(update.Fraction ?? Progress, 0, 0.98); + SetProgressStatus(update.Phase); }); var result = await loadService.LoadAsync(path, progress, cancellationToken); + if (operationId != Volatile.Read(ref loadOperationVersion)) + { + return; + } + LogImportSummary("Load", result); if (!result.Success || result.Groups.Count == 0) { @@ -751,12 +782,12 @@ private async ValueTask LoadPathAsync(string path, CancellationToken cancellatio currentInfoBelongsToSelectedClip = false; SelectedClipIndex = -1; ClipOptions.Clear(); - foreach (var option in currentGroup.Options) + foreach (var entry in currentGroup.Entries) { - ClipOptions.Add(option); + ClipOptions.Add(entry); } - SelectClip(Math.Clamp(currentGroup.DefaultOptionIndex, 0, ClipOptions.Count - 1)); + SelectClip(Math.Clamp(currentGroup.DefaultEntryIndex, 0, ClipOptions.Count - 1)); SetStatus("Status.LoadedChapters", ("count", Rows.Count)); currentProgressMessage = null; Progress = 1; @@ -773,17 +804,17 @@ private async ValueTask SaveAsync(string? directory, CancellationToken cancellat } var projection = CurrentOutputProjection(); - var options = CurrentExportOptionsForProjectedInfo(); + var entries = CurrentExportOptionsForProjectedInfo(); Log("Log.SavingChapters", - ("format", options.Format), + ("format", entries.Format), ("directory", directory ?? string.Empty), ("source", currentInfo.SourceName ?? string.Empty), ("chapters", projection.Info.Chapters.Count), ("applyExpression", ApplyExpression), ("expression", Expression)); LogDiagnostics(Localizer.GetString("Operation.OutputProjection"), projection.Diagnostics); - var result = await saveService.SaveAsync(projection.Info, options, directory, cancellationToken); - if (!string.IsNullOrWhiteSpace(directory)) + var result = await saveService.SaveAsync(projection.Info, entries, directory, cancellationToken); + if (result.Success && !string.IsNullOrWhiteSpace(directory)) { SaveDirectory = directory; if (appSettingsStore is not null) @@ -807,14 +838,14 @@ private void SelectClip(int index) } SelectedClipIndex = index; - currentInfo = ClipOptions[index].ChapterInfo; + currentInfo = ClipOptions[index].ChapterSet; configuredFrameRate = (decimal)currentInfo.FramesPerSecond; currentInfoBelongsToSelectedClip = !IsClipCombineChecked; Log("Log.SelectedSourceOption", ("index", index), ("label", ClipOptions[index].DisplayName), ("source", currentInfo.SourceName ?? string.Empty), - ("sourceType", currentInfo.SourceType), + ("sourceType", ChapterImportFormats.DisplayName(currentInfo.ImportFormat)), ("chapters", currentInfo.Chapters.Count), ("fps", $"{currentInfo.FramesPerSecond:0.###}")); selectedFrameRateOption = frameRateService.FindByValue((decimal)currentInfo.FramesPerSecond); @@ -857,15 +888,15 @@ private void CombineSegments() var result = ChapterSegmentService.Combine(groupToCombine); if (result.Diagnostics.Count > 0) { - ApplyEdit(result, Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("options", groupToCombine.Options.Count), ("sourceType", groupToCombine.Options[0].ChapterInfo.SourceType)))); + ApplyEdit(result, Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("entries", groupToCombine.Entries.Count), ("sourceType", ChapterImportFormats.DisplayName(groupToCombine.Entries[0].ChapterSet.ImportFormat))))); return; } splitClipGroup = groupToCombine; - combinedClipOption = CreateCombinedClipOption(groupToCombine, result.ChapterInfo); - currentGroup = groupToCombine with { Options = [combinedClipOption], DefaultOptionIndex = 0 }; + combinedClipOption = CreateCombinedClipOption(groupToCombine, result.ChapterSet); + currentGroup = groupToCombine with { Entries = [combinedClipOption], DefaultEntryIndex = 0 }; IsClipCombineChecked = true; - currentInfo = result.ChapterInfo; + currentInfo = result.ChapterSet; currentInfoBelongsToSelectedClip = false; SelectedClipIndex = -1; ClipOptions.Clear(); @@ -873,8 +904,8 @@ private void CombineSegments() SelectClip(0); SetStatus("Status.Updated"); Log("Log.EditChapters", - ("action", Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("options", groupToCombine.Options.Count), ("sourceType", groupToCombine.Options[0].ChapterInfo.SourceType)))), - ("before", groupToCombine.Options.Sum(static option => option.ChapterInfo.Chapters.Count)), + ("action", Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("entries", groupToCombine.Entries.Count), ("sourceType", ChapterImportFormats.DisplayName(groupToCombine.Entries[0].ChapterSet.ImportFormat))))), + ("before", groupToCombine.Entries.Sum(static entry => entry.ChapterSet.Chapters.Count)), ("after", currentInfo?.Chapters.Count ?? 0)); LogStatus(); NotifyStateChanged(); @@ -882,7 +913,10 @@ private void CombineSegments() private async ValueTask AppendMplsAsync(string path, CancellationToken cancellationToken) { - if (currentGroup is null) + var operationId = Volatile.Read(ref loadOperationVersion); + var expectedGroup = currentGroup; + var expectedSplitGroup = splitClipGroup; + if (expectedGroup is null) { SetStatus("Status.NoCurrentMplsGroup"); LogStatus(); @@ -892,6 +926,13 @@ private async ValueTask AppendMplsAsync(string path, CancellationToken cancellat Log("Log.AppendingMpls", ("path", path)); var result = await loadService.LoadAsync(path, cancellationToken); + if (operationId != Volatile.Read(ref loadOperationVersion) + || !ReferenceEquals(expectedGroup, currentGroup) + || !ReferenceEquals(expectedSplitGroup, splitClipGroup)) + { + return; + } + LogImportSummary("Append load", result); if (!result.Success || result.Groups.Count == 0) { @@ -902,7 +943,7 @@ private async ValueTask AppendMplsAsync(string path, CancellationToken cancellat return; } - var baseGroup = splitClipGroup ?? currentGroup; + var baseGroup = expectedSplitGroup ?? expectedGroup; var edit = ChapterSegmentService.Append(baseGroup, result.Groups[0]); if (edit.Diagnostics.Count > 0) { @@ -913,26 +954,26 @@ private async ValueTask AppendMplsAsync(string path, CancellationToken cancellat return; } - var options = baseGroup.Options.ToList(); - options.AddRange(result.Groups[0].Options); - var appendedGroup = baseGroup with { Options = options }; - var combinedOption = CreateCombinedClipOption(appendedGroup, edit.ChapterInfo); + var entries = baseGroup.Entries.ToList(); + entries.AddRange(result.Groups[0].Entries); + var appendedGroup = baseGroup with { Entries = entries }; + var combinedOption = CreateCombinedClipOption(appendedGroup, edit.ChapterSet); splitClipGroup = appendedGroup; combinedClipOption = combinedOption; - currentGroup = appendedGroup with { Options = [combinedOption], DefaultOptionIndex = 0 }; + currentGroup = appendedGroup with { Entries = [combinedOption], DefaultEntryIndex = 0 }; IsClipCombineChecked = true; SelectedClipIndex = -1; ClipOptions.Clear(); - foreach (var option in currentGroup.Options) + foreach (var entry in currentGroup.Entries) { - ClipOptions.Add(option); + ClipOptions.Add(entry); } - currentInfo = edit.ChapterInfo; + currentInfo = edit.ChapterSet; currentInfoBelongsToSelectedClip = false; SelectClip(0); - SetStatus("Status.AppendedMplsSegments", ("count", result.Groups[0].Options.Count)); + SetStatus("Status.AppendedMplsSegments", ("count", result.Groups[0].Entries.Count)); LogStatus(); LogDiagnostics(Localizer.GetString("Operation.AppendLoad"), result.Diagnostics); NotifyStateChanged(); @@ -942,7 +983,7 @@ private void ApplyEdit(ChapterEditResult result, string? action = null) { var effectiveAction = action ?? Localizer.GetString("Action.EditChapters"); var before = currentInfo?.Chapters.Count ?? 0; - currentInfo = result.ChapterInfo; + currentInfo = result.ChapterSet; ApplyFrameInfo(); SetStatus(result.Diagnostics.Count == 0 ? "Status.Updated" : null, diagnostic: result.Diagnostics.FirstOrDefault()); Log("Log.EditChapters", ("action", effectiveAction), ("before", before), ("after", currentInfo.Chapters.Count)); @@ -983,7 +1024,7 @@ private void ApplyFrameInfo() selectedFrameRateOption = frameRateService.Options[0]; SetStatus("Status.DetectedFrameRate", ("displayName", detection.Option.DisplayName), ("confidence", detection.Confidence)); Log("Log.AutoFrameRateDetection", - ("option", detection.Option.DisplayName), + ("entry", detection.Option.DisplayName), ("confidence", detection.Confidence), ("accurate", detection.AccurateChapterCount), ("evaluated", detection.EvaluatedChapterCount), @@ -996,7 +1037,7 @@ private void ApplyFrameInfo() SelectedFrameRateIndex = ComboIndexFor(selectedFrameRateOption); Log("Log.FrameInfoUpdated", - ("option", appliedOption.DisplayName), + ("entry", appliedOption.DisplayName), ("fps", $"{result.FramesPerSecond:0.###}"), ("round", RoundFrames), ("chapters", currentInfo.Chapters.Count)); @@ -1045,7 +1086,7 @@ private void ChangeFpsToSelectedOption() NotifyStateChanged(); } - private void UpdateCurrentClipOption(ChapterInfo info) + private void UpdateCurrentClipOption(ChapterSet info) { if (currentGroup is null) { @@ -1058,35 +1099,35 @@ private void UpdateCurrentClipOption(ChapterInfo info) return; } - var options = currentGroup.Options.ToList(); - if (index >= options.Count) + var entries = currentGroup.Entries.ToList(); + if (index >= entries.Count) { return; } - var updatedOption = options[index] with { ChapterInfo = info }; - options[index] = updatedOption; + var updatedOption = entries[index] with { ChapterSet = info }; + entries[index] = updatedOption; ClipOptions[index] = updatedOption; - currentGroup = currentGroup with { Options = options }; + currentGroup = currentGroup with { Entries = entries }; OnPropertyChanged(nameof(RelatedMediaReferences)); } - private void UpdateCombinedClipOption(ChapterInfo info) + private void UpdateCombinedClipOption(ChapterSet info) { if (currentGroup is null || !IsClipCombineChecked) { return; } - var option = combinedClipOption ?? ClipOptions.FirstOrDefault(); - if (option is null) + var entry = combinedClipOption ?? ClipOptions.FirstOrDefault(); + if (entry is null) { return; } - combinedClipOption = option with { ChapterInfo = info }; - currentGroup = currentGroup with { Options = [combinedClipOption] }; + combinedClipOption = entry with { ChapterSet = info }; + currentGroup = currentGroup with { Entries = [combinedClipOption] }; if (ClipOptions.Count == 1) { ClipOptions[0] = combinedClipOption; @@ -1102,7 +1143,7 @@ private void RestoreSplitClips() return; } - var combinedChapterCount = combinedClipOption?.ChapterInfo.Chapters.Count ?? currentInfo?.Chapters.Count ?? 0; + var combinedChapterCount = combinedClipOption?.ChapterSet.Chapters.Count ?? currentInfo?.Chapters.Count ?? 0; currentGroup = splitClipGroup; splitClipGroup = null; combinedClipOption = null; @@ -1110,33 +1151,33 @@ private void RestoreSplitClips() currentInfoBelongsToSelectedClip = false; SelectedClipIndex = -1; ClipOptions.Clear(); - foreach (var option in currentGroup.Options) + foreach (var entry in currentGroup.Entries) { - ClipOptions.Add(option); + ClipOptions.Add(entry); } - SelectClip(Math.Clamp(currentGroup.DefaultOptionIndex, 0, ClipOptions.Count - 1)); + SelectClip(Math.Clamp(currentGroup.DefaultEntryIndex, 0, ClipOptions.Count - 1)); SetStatus("Status.Updated"); Log("Log.EditChapters", - ("action", Localizer.Format(LocalizedMessage.Create("Action.SplitCombinedSegments", ("options", currentGroup.Options.Count), ("sourceType", currentGroup.Options[0].ChapterInfo.SourceType)))), + ("action", Localizer.Format(LocalizedMessage.Create("Action.SplitCombinedSegments", ("entries", currentGroup.Entries.Count), ("sourceType", ChapterImportFormats.DisplayName(currentGroup.Entries[0].ChapterSet.ImportFormat))))), ("before", combinedChapterCount), ("after", currentInfo?.Chapters.Count ?? 0)); LogStatus(); NotifyStateChanged(); } - private static ChapterSourceOption CreateCombinedClipOption(ChapterInfoGroup sourceGroup, ChapterInfo combinedInfo) + private static ChapterImportEntry CreateCombinedClipOption(ChapterImportSource sourceGroup, ChapterSet combinedInfo) { - var mediaReferences = sourceGroup.Options - .SelectMany(static option => option.MediaReferences ?? []) + var mediaReferences = sourceGroup.Entries + .SelectMany(static entry => entry.ReferencedMediaFiles ?? []) .Distinct() .ToArray(); - return new ChapterSourceOption( + return new ChapterImportEntry( "combined", $"{combinedInfo.Title}__{combinedInfo.Chapters.Count}", combinedInfo, CanCombine: true, - MediaReferences: mediaReferences); + ReferencedMediaFiles: mediaReferences); } private void RefreshRows() @@ -1157,7 +1198,7 @@ private void RefreshRows() private ChapterOutputProjectionResult CurrentOutputProjection() => currentInfo is null ? new ChapterOutputProjectionResult( - new ChapterInfo(string.Empty, null, 0, string.Empty, 0, TimeSpan.Zero, []), + new ChapterSet(string.Empty, null, ChapterImportFormat.Unknown, 0, TimeSpan.Zero, []), []) : outputProjectionService.Project(currentInfo, CurrentExportOptions()); @@ -1190,9 +1231,9 @@ private void SyncClipDisplayOptions(NotifyCollectionChangedEventArgs args) if (args.NewItems is not null) { var index = args.NewStartingIndex; - foreach (ChapterSourceOption option in args.NewItems) + foreach (ChapterImportEntry entry in args.NewItems) { - ClipDisplayOptions.Insert(index++, ToClipDisplayOption(option)); + ClipDisplayOptions.Insert(index++, ToClipDisplayOption(entry)); } } @@ -1211,9 +1252,9 @@ private void SyncClipDisplayOptions(NotifyCollectionChangedEventArgs args) if (args.NewItems is not null) { var index = args.NewStartingIndex; - foreach (ChapterSourceOption option in args.NewItems) + foreach (ChapterImportEntry entry in args.NewItems) { - ClipDisplayOptions[index++] = ToClipDisplayOption(option); + ClipDisplayOptions[index++] = ToClipDisplayOption(entry); } } @@ -1234,25 +1275,25 @@ private void SyncClipDisplayOptions(NotifyCollectionChangedEventArgs args) private void RebuildClipDisplayOptions() { ClipDisplayOptions.Clear(); - foreach (var option in ClipOptions) + foreach (var entry in ClipOptions) { - ClipDisplayOptions.Add(ToClipDisplayOption(option)); + ClipDisplayOptions.Add(ToClipDisplayOption(entry)); } } - private static SelectorDisplayOption ToClipDisplayOption(ChapterSourceOption option) + private static SelectorDisplayOption ToClipDisplayOption(ChapterImportEntry entry) { - var mainText = option.DisplayName; + var mainText = entry.DisplayName; var remarkParts = new List(); - var markerIndex = option.DisplayName.LastIndexOf("__", StringComparison.Ordinal); - if (markerIndex > 0 && markerIndex + 2 < option.DisplayName.Length) + var markerIndex = entry.DisplayName.LastIndexOf("__", StringComparison.Ordinal); + if (markerIndex > 0 && markerIndex + 2 < entry.DisplayName.Length) { - mainText = option.DisplayName[..markerIndex]; - remarkParts.Add($"{option.DisplayName[(markerIndex + 2)..]} chapters"); + mainText = entry.DisplayName[..markerIndex]; + remarkParts.Add($"{entry.DisplayName[(markerIndex + 2)..]} chapters"); } - else if (option.ChapterInfo.Chapters.Count > 0) + else if (entry.ChapterSet.Chapters.Count > 0) { - remarkParts.Add($"{option.ChapterInfo.Chapters.Count} chapters"); + remarkParts.Add($"{entry.ChapterSet.Chapters.Count} chapters"); } var remarkText = string.Join(", ", remarkParts.Where(static part => !string.IsNullOrWhiteSpace(part)).Distinct(StringComparer.OrdinalIgnoreCase)); @@ -1303,14 +1344,14 @@ private void NotifyCommandStates() ForwardShiftCommand.RaiseCanExecuteChanged(); } - private static int ComboIndexFor(FrameRateOption option) + private static int ComboIndexFor(FrameRateOption entry) { - if (option.LegacyMplsCode == 0) + if (entry.LegacyMplsCode == 0) { return 0; } - return option.IsValid ? option.LegacyMplsCode : -1; + return entry.IsValid ? entry.LegacyMplsCode : -1; } private FrameRateOption? FrameRateOptionForComboIndex(int frameRateIndex) @@ -1331,7 +1372,7 @@ private static int ComboIndexFor(FrameRateOption option) return null; } - return frameRateService.Options.FirstOrDefault(option => option.LegacyMplsCode == legacyCode); + return frameRateService.Options.FirstOrDefault(entry => entry.LegacyMplsCode == legacyCode); } private UiCommand WindowCommand(string id, Func? canExecute = null) => @@ -1347,7 +1388,7 @@ private async ValueTask OpenRelatedMediaAsync(object? parameter, CancellationTok return; } - var reference = parameter as SourceMediaReference ?? RelatedMediaReferences.FirstOrDefault(); + var reference = parameter as ReferencedMediaFile ?? RelatedMediaReferences.FirstOrDefault(); var target = reference?.AbsolutePath; if (string.IsNullOrWhiteSpace(target) && reference is not null && !string.IsNullOrWhiteSpace(CurrentPath)) { @@ -1394,9 +1435,10 @@ private void SetStatus(string? key, ChapterDiagnostic? diagnostic, params (strin SetStatus(key, arguments); } - private void SetProgressStatus(string? messageKey, params (string Name, object? Value)[] arguments) + private void SetProgressStatus(ChapterImportProgressPhase? phase, params (string Name, object? Value)[] arguments) { currentStatusMessage = null; + var messageKey = phase is null ? null : ProgressStatusKey(phase.Value); currentProgressMessage = messageKey is null ? null : new LocalizedMessage( @@ -1405,9 +1447,19 @@ private void SetProgressStatus(string? messageKey, params (string Name, object? StatusText = currentProgressMessage is null ? string.Empty : Localizer.Format(currentProgressMessage); } + private static string ProgressStatusKey(ChapterImportProgressPhase phase) => phase switch + { + ChapterImportProgressPhase.LoadingSource => "Status.LoadingSource", + ChapterImportProgressPhase.ValidatingSource => "Status.LoadingSource.Validate", + ChapterImportProgressPhase.DiscoveringTitles => "Status.LoadingSource.Discover", + ChapterImportProgressPhase.ExportingChapters => "Status.LoadingSource.Export", + ChapterImportProgressPhase.ParsingChapters => "Status.LoadingSource.Parse", + _ => "Status.LoadingSource" + }; + private string LocalizeDiagnostic(ChapterDiagnostic diagnostic) { - var diagnosticKey = $"Diagnostic.{diagnostic.Code}"; + var diagnosticKey = $"Diagnostic.{diagnostic.DisplayCode}"; if (!Localizer.TryGetString(diagnosticKey, out var template)) { return diagnostic.Message; @@ -1497,20 +1549,20 @@ private void RefreshLocalizedState() private void RefreshXmlLanguageDisplayOptions(bool notify) { - var options = XmlLanguageDisplay.Options(Localizer); - if (xmlLanguageDisplayOptions.Count != options.Count) + var entries = XmlLanguageDisplay.Options(Localizer); + if (xmlLanguageDisplayOptions.Count != entries.Count) { xmlLanguageDisplayOptions.Clear(); - foreach (var option in options) + foreach (var entry in entries) { - xmlLanguageDisplayOptions.Add(option); + xmlLanguageDisplayOptions.Add(entry); } } else { - for (var index = 0; index < options.Count; index++) + for (var index = 0; index < entries.Count; index++) { - xmlLanguageDisplayOptions[index].UpdateFrom(options[index]); + xmlLanguageDisplayOptions[index].UpdateFrom(entries[index]); } } @@ -1523,7 +1575,7 @@ private void RefreshXmlLanguageDisplayOptions(bool notify) private void RefreshChapterNameModeOptions() { - var options = new[] + var entries = new[] { new SelectorDisplayOption("keep-original", string.Empty, Localizer.GetString("Main.KeepOriginalName")), new SelectorDisplayOption("standard-template", string.Empty, Localizer.GetString("Main.StandardTemplate")), @@ -1533,19 +1585,19 @@ private void RefreshChapterNameModeOptions() isRefreshingChapterNameModeOptions = true; try { - if (ChapterNameModeOptions.Count != options.Length) + if (ChapterNameModeOptions.Count != entries.Length) { ChapterNameModeOptions.Clear(); - foreach (var option in options) + foreach (var entry in entries) { - ChapterNameModeOptions.Add(option); + ChapterNameModeOptions.Add(entry); } } else { - for (var index = 0; index < options.Length; index++) + for (var index = 0; index < entries.Length; index++) { - ChapterNameModeOptions[index].UpdateFrom(options[index]); + ChapterNameModeOptions[index].UpdateFrom(entries[index]); } } } @@ -1559,16 +1611,16 @@ private void RefreshChapterNameModeOptions() private void LogImportSummary(string operation, ChapterImportResult result) { - var optionCount = result.Groups.Sum(static group => group.Options.Count); + var entryCount = result.Groups.Sum(static group => group.Entries.Count); var chapterCount = result.Groups - .SelectMany(static group => group.Options) - .Sum(static option => option.ChapterInfo.Chapters.Count); + .SelectMany(static group => group.Entries) + .Sum(static entry => entry.ChapterSet.Chapters.Count); Log(result.Success ? LogLevel.Information : LogLevel.Error, "Log.ImportSummary", ("operation", operation), ("success", result.Success), ("partial", result.IsPartial), ("groups", result.Groups.Count), - ("options", optionCount), + ("entries", entryCount), ("chapters", chapterCount), ("diagnostics", result.Diagnostics.Count)); for (var groupIndex = 0; groupIndex < result.Groups.Count; groupIndex++) @@ -1578,19 +1630,19 @@ private void LogImportSummary(string operation, ChapterImportResult result) ("operation", operation), ("groupIndex", groupIndex + 1), ("sourcePath", group.SourcePath), - ("defaultOptionIndex", group.DefaultOptionIndex), - ("options", group.Options.Count)); - for (var optionIndex = 0; optionIndex < group.Options.Count; optionIndex++) + ("defaultEntryIndex", group.DefaultEntryIndex), + ("entries", group.Entries.Count)); + for (var entryIndex = 0; entryIndex < group.Entries.Count; entryIndex++) { - var option = group.Options[optionIndex]; - var info = option.ChapterInfo; - Log("Log.ImportOption", + var entry = group.Entries[entryIndex]; + var info = entry.ChapterSet; + Log("Log.ImportEntry", ("operation", operation), - ("optionIndex", optionIndex + 1), - ("id", option.Id), - ("label", option.DisplayName), + ("entryIndex", entryIndex + 1), + ("id", entry.Id), + ("label", entry.DisplayName), ("source", info.SourceName ?? string.Empty), - ("sourceType", info.SourceType), + ("sourceType", ChapterImportFormats.DisplayName(info.ImportFormat)), ("chapters", info.Chapters.Count), ("duration", formatter.Format(info.Duration)), ("fps", $"{info.FramesPerSecond:0.###}")); @@ -1613,7 +1665,7 @@ private void LogDiagnostics(string operation, IReadOnlyList d diagnostic.Details, ("operation", operation), ("severity", diagnostic.Severity), - ("code", diagnostic.Code), + ("code", diagnostic.DisplayCode), ("location", location), ("message", LocalizeDiagnostic(diagnostic)), ("details", details)); @@ -1664,9 +1716,9 @@ private enum EditKind Frame } - private sealed class ChapterLoadProgressSink(Action handler) : IProgress + private sealed class ChapterImportProgressSink(Action handler) : IChapterImportProgressReporter { - public void Report(ChapterLoadProgress value) => handler(value); + public void Report(ChapterImportProgress progress) => handler(progress); } } @@ -1696,11 +1748,11 @@ public string DisplayText private set => SetProperty(ref displayText, value); } - public void UpdateFrom(SelectorDisplayOption option) + public void UpdateFrom(SelectorDisplayOption entry) { - MainText = option.MainText; - RemarkText = option.RemarkText; - DisplayText = option.DisplayText; + MainText = entry.MainText; + RemarkText = entry.RemarkText; + DisplayText = entry.DisplayText; } public override string ToString() => DisplayText; diff --git a/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs index b93c2b5..9a8b8a1 100644 --- a/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs @@ -12,21 +12,9 @@ namespace ChapterTool.Avalonia.ViewModels; -public sealed class SettingsToolViewModel : ObservableViewModel +public sealed class SettingsToolViewModel : ObservableViewModel, IDisposable { - private static readonly ChapterExportFormat[] SaveFormats = - [ - ChapterExportFormat.Txt, - ChapterExportFormat.Xml, - ChapterExportFormat.Qpfile, - ChapterExportFormat.TimeCodes, - ChapterExportFormat.TsMuxerMeta, - ChapterExportFormat.Cue, - ChapterExportFormat.Json, - ChapterExportFormat.WebVtt, - ChapterExportFormat.Celltimes, - ChapterExportFormat.Chapter2Qpfile - ]; + private static IReadOnlyList SaveFormats => ChapterExportFormats.All; private readonly MainWindowViewModel owner; private readonly ISettingsStore? appSettingsStore; @@ -37,6 +25,7 @@ public sealed class SettingsToolViewModel : ObservableViewModel private readonly IExternalToolLocator? externalToolLocator; private readonly IThemeApplicationService? themeApplicationService; private readonly IShellService? shellService; + private readonly EventHandler cultureChangedHandler; private AppSettings savedAppSettings = new(); private ThemeColorSettings savedThemeSettings = ThemeColorSettings.Default; private string selectedLanguage; @@ -69,7 +58,7 @@ public SettingsToolViewModel( this.themeApplicationService = themeApplicationService; this.shellService = shellService; selectedLanguage = AppLanguage.Normalize(owner.UiLanguage); - defaultSaveFormatIndex = Math.Clamp(owner.SaveFormatIndex, 0, SaveFormats.Length - 1); + defaultSaveFormatIndex = Math.Clamp(owner.SaveFormatIndex, 0, SaveFormats.Count - 1); defaultXmlLanguageIndex = XmlLanguageIndex(owner.XmlLanguage); frameAccuracyTolerance = MainWindowViewModel.NormalizeFrameAccuracyTolerance(owner.FrameAccuracyTolerance); frameAccuracyToleranceSliderValue = (double)frameAccuracyTolerance; @@ -107,25 +96,37 @@ public SettingsToolViewModel( ClearFfprobeCommand = ClearCommand(() => FfprobePath = null); ClearFfmpegCommand = ClearCommand(() => FfmpegPath = null); OpenRepositoryCommand = new UiCommand(async (_, token) => await OpenRepositoryAsync(token), _ => shellService is not null); - this.localizer.CultureChanged += (_, _) => + cultureChangedHandler = (_, _) => { RefreshLanguages(); RefreshXmlLanguageDisplayOptions(notify: true); RefreshToolStatuses(); if (!string.IsNullOrWhiteSpace(StatusText)) { - StatusText = this.localizer.GetString("Settings.Status.Ready"); + StatusText = StatusTextForCurrentLoadState(); } }; + this.localizer.CultureChanged += cultureChangedHandler; if (autoLoad) { - _ = InitializeAsync(); + InitializationTask = InitializeAsync(); } + else + { + InitializationTask = Task.CompletedTask; + } + } + + internal Task InitializationTask { get; } + + public void Dispose() + { + localizer.CultureChanged -= cultureChangedHandler; } public IReadOnlyList Languages => languages; - public IReadOnlyList SaveFormatOptions { get; } = SaveFormats.Select(ChapterExportFormatDisplay.LabelFor).ToArray(); + public IReadOnlyList SaveFormatOptions { get; } = SaveFormats.Select(ChapterExportFormats.DisplayName).ToArray(); public IReadOnlyList XmlLanguageOptions { get; } = XmlChapterLanguageCatalog.Languages.Select(static language => language.Code).ToList(); @@ -140,17 +141,17 @@ public SelectorDisplayOption? SelectedDefaultXmlLanguageDisplayOption { get { - var options = XmlLanguageDisplayOptions; - return DefaultXmlLanguageIndex < 0 || DefaultXmlLanguageIndex >= options.Count + var entries = XmlLanguageDisplayOptions; + return DefaultXmlLanguageIndex < 0 || DefaultXmlLanguageIndex >= entries.Count ? null - : options[DefaultXmlLanguageIndex]; + : entries[DefaultXmlLanguageIndex]; } set { var index = value is null ? -1 - : XmlLanguageDisplayOptions.ToList().FindIndex(option => - string.Equals(option.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); + : XmlLanguageDisplayOptions.ToList().FindIndex(entry => + string.Equals(entry.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); if (index >= 0) { DefaultXmlLanguageIndex = index; @@ -177,7 +178,7 @@ public int SelectedLanguageIndex { get { - var index = Languages.ToList().FindIndex(option => string.Equals(option.CultureName, SelectedLanguage, StringComparison.OrdinalIgnoreCase)); + var index = Languages.ToList().FindIndex(entry => string.Equals(entry.CultureName, SelectedLanguage, StringComparison.OrdinalIgnoreCase)); return index; } set @@ -263,7 +264,7 @@ public int DefaultSaveFormatIndex get => defaultSaveFormatIndex; set { - if (SetProperty(ref defaultSaveFormatIndex, Math.Clamp(value, 0, SaveFormats.Length - 1))) + if (SetProperty(ref defaultSaveFormatIndex, Math.Clamp(value, 0, SaveFormats.Count - 1))) { ApplyLiveSettings(); } @@ -283,6 +284,18 @@ public int DefaultXmlLanguageIndex } } + public bool EmitBom + { + get; + set + { + if (SetProperty(ref field, value)) + { + ApplyLiveSettings(); + } + } + } = true; + public decimal FrameAccuracyTolerance { get => frameAccuracyTolerance; @@ -307,6 +320,12 @@ public double FrameAccuracyToleranceSliderValue appSettingsStore is not null && CurrentAppSettings() != savedAppSettings || themeSettingsStore is not null && CurrentThemeSettings() != savedThemeSettings; + public bool SettingsLoadFailed + { + get; + private set => SetProperty(ref field, value); + } + public string StatusText { get; @@ -367,27 +386,80 @@ public string FfmpegStatus public async ValueTask LoadAsync(CancellationToken cancellationToken) { + SettingsLoadFailed = false; liveApplyEnabled = false; - if (appSettingsStore is not null) + try { - var settings = await appSettingsStore.LoadAsync(cancellationToken); - ApplyAppSettingsToFields(settings); - savedAppSettings = CurrentAppSettings(); - } + if (appSettingsStore is not null) + { + var settings = await LoadAppSettingsOrDefaultAsync(cancellationToken); + ApplyAppSettingsToFields(settings); + savedAppSettings = CurrentAppSettings(); + } - if (themeSettingsStore is not null) + if (themeSettingsStore is not null) + { + var theme = await LoadThemeSettingsOrDefaultAsync(cancellationToken); + savedThemeSettings = NormalizeThemeSettings(theme); + ApplyColors(theme); + themeApplicationService?.Apply(theme); + } + } + finally { - var theme = await themeSettingsStore.LoadAsync(cancellationToken); - savedThemeSettings = NormalizeThemeSettings(theme); - ApplyColors(theme); - themeApplicationService?.Apply(theme); + liveApplyEnabled = true; } - liveApplyEnabled = true; ApplyCurrentAppSettingsToOwner(); RefreshToolStatuses(); NotifyUnsavedChanges(); - StatusText = localizer.GetString("Settings.Status.Ready"); + StatusText = StatusTextForCurrentLoadState(); + } + + private async ValueTask LoadAppSettingsOrDefaultAsync(CancellationToken cancellationToken) + { + try + { + return await appSettingsStore!.LoadAsync(cancellationToken); + } + catch (IOException) + { + SettingsLoadFailed = true; + return new AppSettings(); + } + catch (UnauthorizedAccessException) + { + SettingsLoadFailed = true; + return new AppSettings(); + } + catch (CorruptSettingsFileException) + { + SettingsLoadFailed = true; + return new AppSettings(); + } + } + + private async ValueTask LoadThemeSettingsOrDefaultAsync(CancellationToken cancellationToken) + { + try + { + return await themeSettingsStore!.LoadAsync(cancellationToken); + } + catch (IOException) + { + SettingsLoadFailed = true; + return ThemeColorSettings.Default; + } + catch (UnauthorizedAccessException) + { + SettingsLoadFailed = true; + return ThemeColorSettings.Default; + } + catch (CorruptSettingsFileException) + { + SettingsLoadFailed = true; + return ThemeColorSettings.Default; + } } private async Task InitializeAsync() @@ -397,6 +469,12 @@ private async Task InitializeAsync() private async ValueTask SaveAsync(CancellationToken cancellationToken) { + if (SettingsLoadFailed && !HasUnsavedChanges) + { + StatusText = StatusTextForCurrentLoadState(); + return; + } + if (appSettingsStore is not null) { var settings = CurrentAppSettings(); @@ -415,6 +493,7 @@ private async ValueTask SaveAsync(CancellationToken cancellationToken) RefreshToolStatuses(); NotifyUnsavedChanges(); + SettingsLoadFailed = false; StatusText = localizer.GetString("Settings.Status.Saved"); } @@ -460,14 +539,19 @@ private void ApplyDefaults() FfmpegPath = defaults.FfmpegPath; DefaultSaveFormatIndex = SaveFormatIndex(defaults.DefaultSaveFormat); DefaultXmlLanguageIndex = XmlLanguageIndex(defaults.DefaultXmlLanguage); + EmitBom = defaults.EmitBom; FrameAccuracyTolerance = defaults.FrameAccuracyTolerance; ApplyColors(ThemeColorSettings.Default); themeApplicationService?.Apply(ThemeColorSettings.Default); ApplyLiveSettings(); RefreshToolStatuses(); + SettingsLoadFailed = false; StatusText = localizer.GetString("Settings.Status.Reset"); } + private string StatusTextForCurrentLoadState() => + localizer.GetString(SettingsLoadFailed ? "Settings.Status.LoadedDefaults" : "Settings.Status.Ready"); + private async ValueTask PickDirectoryAsync(Action apply, CancellationToken cancellationToken) { if (picker is null) @@ -529,20 +613,20 @@ private void RefreshLanguages() private void RefreshXmlLanguageDisplayOptions(bool notify) { - var options = XmlLanguageDisplay.Options(localizer); - if (xmlLanguageDisplayOptions.Count != options.Count) + var entries = XmlLanguageDisplay.Options(localizer); + if (xmlLanguageDisplayOptions.Count != entries.Count) { xmlLanguageDisplayOptions.Clear(); - foreach (var option in options) + foreach (var entry in entries) { - xmlLanguageDisplayOptions.Add(option); + xmlLanguageDisplayOptions.Add(entry); } } else { - for (var index = 0; index < options.Count; index++) + for (var index = 0; index < entries.Count; index++) { - xmlLanguageDisplayOptions[index].UpdateFrom(options[index]); + xmlLanguageDisplayOptions[index].UpdateFrom(entries[index]); } } @@ -560,12 +644,12 @@ private List BuildLanguageOptions() => localizer.GetString(language.DisplayNameKey))) .ToList(); - private void ReplaceLanguages(IReadOnlyList options) + private void ReplaceLanguages(IReadOnlyList entries) { languages.Clear(); - foreach (var option in options) + foreach (var entry in entries) { - languages.Add(option); + languages.Add(entry); } } @@ -690,6 +774,7 @@ savedAppSettings with FfmpegPath = FfmpegPath, DefaultSaveFormat = SaveFormats[DefaultSaveFormatIndex].ToString(), DefaultXmlLanguage = XmlLanguageOptions[DefaultXmlLanguageIndex], + EmitBom = EmitBom, FrameAccuracyTolerance = FrameAccuracyTolerance }; @@ -703,6 +788,7 @@ private void ApplyAppSettingsToFields(AppSettings settings) FfmpegPath = settings.FfmpegPath; DefaultSaveFormatIndex = SaveFormatIndex(settings.DefaultSaveFormat); DefaultXmlLanguageIndex = XmlLanguageIndex(settings.DefaultXmlLanguage); + EmitBom = settings.EmitBom; FrameAccuracyTolerance = settings.FrameAccuracyTolerance; } @@ -792,7 +878,7 @@ private static int SaveFormatIndex(string? value) { if (Enum.TryParse(value, ignoreCase: true, out var format)) { - var index = Array.IndexOf(SaveFormats, format); + var index = ChapterExportFormats.IndexOf(format); return Math.Max(0, index); } @@ -801,10 +887,9 @@ private static int SaveFormatIndex(string? value) private int XmlLanguageIndex(string? value) { - var index = XmlLanguageOptions.ToList().FindIndex(option => string.Equals(option, value, StringComparison.OrdinalIgnoreCase)); + var index = XmlLanguageOptions.ToList().FindIndex(entry => string.Equals(entry, value, StringComparison.OrdinalIgnoreCase)); return Math.Max(0, index); } - } public sealed record SettingsToolStatus( diff --git a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs index 7034dec..9798f46 100644 --- a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs +++ b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs @@ -322,29 +322,17 @@ public sealed class TextToolOptions public sealed class TextToolFormatSelector(MainWindowViewModel owner) { - private static readonly ChapterExportFormat[] Formats = - [ - ChapterExportFormat.Txt, - ChapterExportFormat.Xml, - ChapterExportFormat.Qpfile, - ChapterExportFormat.TimeCodes, - ChapterExportFormat.TsMuxerMeta, - ChapterExportFormat.Cue, - ChapterExportFormat.Json, - ChapterExportFormat.WebVtt, - ChapterExportFormat.Celltimes, - ChapterExportFormat.Chapter2Qpfile - ]; + private static IReadOnlyList Formats => ChapterExportFormats.All; private MainWindowViewModel Owner { get; } = owner; - public IReadOnlyList Labels { get; } = Formats.Select(ChapterExportFormatDisplay.LabelFor).ToArray(); + public IReadOnlyList Labels { get; } = Formats.Select(ChapterExportFormats.DisplayName).ToArray(); public int SelectedIndex { get; - set => field = Math.Clamp(value, 0, Formats.Length - 1); - } = Math.Clamp(owner.SaveFormatIndex, 0, Formats.Length - 1); + set => field = Math.Clamp(value, 0, Formats.Count - 1); + } = Math.Clamp(owner.SaveFormatIndex, 0, Formats.Count - 1); public TextToolKind Kind => KindFor(Formats[SelectedIndex]); @@ -363,25 +351,12 @@ private static TextToolKind KindFor(ChapterExportFormat format) => }; } -internal static class ChapterExportFormatDisplay -{ - public static string LabelFor(ChapterExportFormat format) => - format switch - { - ChapterExportFormat.Txt => "TXT", - ChapterExportFormat.Xml => "XML", - ChapterExportFormat.Qpfile => "QPFile", - ChapterExportFormat.TsMuxerMeta => "TsmuxerMeta", - ChapterExportFormat.Cue => "CUE", - ChapterExportFormat.Json => "JSON", - _ => format.ToString() - }; -} - public sealed class ColorSettingsViewModel : ObservableViewModel { private readonly ISettingsStore? store; private readonly IThemeApplicationService? themeApplicationService; + private bool isLoading; + private bool hasUserChanges; public ColorSettingsViewModel( ISettingsStore? store, @@ -397,19 +372,38 @@ public ColorSettingsViewModel( { if (args.PropertyName == nameof(ColorSlotViewModel.Value)) { + if (!isLoading) + { + hasUserChanges = true; + } + ApplyCurrentTheme(); } }; } SaveCommand = new UiCommand(async (_, token) => await SaveAsync(token), _ => this.store is not null); - _ = LoadAsync(); + InitializationTask = LoadAsync(); } public ObservableCollection Slots { get; } public UiCommand SaveCommand { get; } + public bool ThemeLoadFailed + { + get; + private set => SetProperty(ref field, value); + } + + public string LoadWarningText + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + + internal Task InitializationTask { get; } + private async Task LoadAsync() { if (store is null) @@ -417,19 +411,50 @@ private async Task LoadAsync() return; } - var settings = await store.LoadAsync(CancellationToken.None); - var values = settings.OrderedSlots.ToList(); - for (var index = 0; index < Slots.Count && index < values.Count; index++) + isLoading = true; + try + { + var settings = await LoadThemeOrDefaultAsync(); + var values = settings.OrderedSlots.ToList(); + for (var index = 0; index < Slots.Count && index < values.Count; index++) + { + Slots[index].Value = values[index].Value; + } + } + finally { - Slots[index].Value = values[index].Value; + isLoading = false; } ApplyCurrentTheme(); } + private async Task LoadThemeOrDefaultAsync() + { + try + { + return await store!.LoadAsync(CancellationToken.None); + } + catch (IOException) + { + MarkThemeLoadFailed(); + return ThemeColorSettings.Default; + } + catch (UnauthorizedAccessException) + { + MarkThemeLoadFailed(); + return ThemeColorSettings.Default; + } + catch (CorruptSettingsFileException) + { + MarkThemeLoadFailed(); + return ThemeColorSettings.Default; + } + } + private async ValueTask SaveAsync(CancellationToken cancellationToken) { - if (store is null || Slots.Count < 6) + if (store is null || Slots.Count < 6 || (ThemeLoadFailed && !hasUserChanges)) { return; } @@ -443,9 +468,18 @@ private async ValueTask SaveAsync(CancellationToken cancellationToken) NormalizeColor(Slots[4].Value, defaults[4].Value), NormalizeColor(Slots[5].Value, defaults[5].Value)); await store.SaveAsync(settings, cancellationToken); + ThemeLoadFailed = false; + LoadWarningText = string.Empty; + hasUserChanges = false; themeApplicationService?.Apply(settings); } + private void MarkThemeLoadFailed() + { + ThemeLoadFailed = true; + LoadWarningText = "Theme settings could not be loaded; defaults are shown."; + } + private void ApplyCurrentTheme() { if (Slots.Count < 6) @@ -546,9 +580,10 @@ private static string NormalizeColor(string? value, string fallback) => TryParseColor(value, out var color) ? ToHex(color) : fallback; } -public sealed class LanguageToolViewModel : ObservableViewModel +public sealed class LanguageToolViewModel : ObservableViewModel, IDisposable { private readonly MainWindowViewModel owner; + private readonly EventHandler cultureChangedHandler; private readonly ObservableCollection languages = []; private string selectedLanguage; private bool isRefreshingLanguages; @@ -558,10 +593,11 @@ public LanguageToolViewModel(MainWindowViewModel owner) this.owner = owner; selectedLanguage = AppLanguage.Normalize(owner.UiLanguage); ReplaceLanguages(BuildLanguages()); - owner.Localizer.CultureChanged += (_, _) => + cultureChangedHandler = (_, _) => { RefreshLanguages(); }; + owner.Localizer.CultureChanged += cultureChangedHandler; ApplyCommand = new UiCommand(async (parameter, token) => { var language = parameter is LanguageToolViewModel viewModel @@ -608,6 +644,11 @@ public int SelectedLanguageIndex public UiCommand ApplyCommand { get; } + public void Dispose() + { + owner.Localizer.CultureChanged -= cultureChangedHandler; + } + private void RefreshLanguages() { isRefreshingLanguages = true; @@ -654,11 +695,11 @@ public ExpressionToolViewModel(MainWindowViewModel owner, IFilePickerService? fi this.filePicker = filePicker; Expression = owner.Expression; ApplyExpression = owner.ApplyExpression; - LuaExpressionSourceName = owner.LuaExpressionSourceName; - Presets = new LuaExpressionScriptService().Presets - .Select(static preset => new LuaExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText)) + ExpressionSourceName = owner.ExpressionSourceName; + Presets = owner.ExpressionPresets + .Select(static preset => new ExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText)) .ToList(); - SelectedPresetIndex = Presets.ToList().FindIndex(preset => string.Equals(preset.Id, owner.LuaExpressionPresetId, StringComparison.Ordinal)); + SelectedPresetIndex = Presets.ToList().FindIndex(preset => string.Equals(preset.Id, owner.ExpressionPresetId, StringComparison.Ordinal)); BrowseScriptCommand = new UiCommand(async (_, token) => await BrowseScriptAsync(token), _ => this.filePicker is not null); ApplyCommand = new UiCommand((parameter, _) => { @@ -666,8 +707,8 @@ public ExpressionToolViewModel(MainWindowViewModel owner, IFilePickerService? fi { owner.Expression = string.IsNullOrWhiteSpace(viewModel.Expression) ? "t" : viewModel.Expression; owner.ApplyExpression = viewModel.ApplyExpression; - owner.LuaExpressionPresetId = viewModel.SelectedPreset?.Id ?? string.Empty; - owner.LuaExpressionSourceName = viewModel.LuaExpressionSourceName; + owner.ExpressionPresetId = viewModel.SelectedPreset?.Id ?? string.Empty; + owner.ExpressionSourceName = viewModel.ExpressionSourceName; } return ValueTask.CompletedTask; @@ -676,9 +717,9 @@ public ExpressionToolViewModel(MainWindowViewModel owner, IFilePickerService? fi public IAppLocalizer Localizer => owner.Localizer; - public IReadOnlyList Presets { get; } + public IReadOnlyList Presets { get; } - public LuaExpressionPresetViewModel? SelectedPreset => + public ExpressionPresetViewModel? SelectedPreset => SelectedPresetIndex >= 0 && SelectedPresetIndex < Presets.Count ? Presets[SelectedPresetIndex] : null; public int SelectedPresetIndex @@ -695,7 +736,7 @@ public int SelectedPresetIndex if (SelectedPreset is { } preset) { Expression = preset.ScriptText; - LuaExpressionSourceName = preset.DisplayName; + ExpressionSourceName = preset.DisplayName; StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionPresetSelected", ("preset", preset.DisplayName))); } } @@ -713,7 +754,7 @@ public bool ApplyExpression set => SetProperty(ref field, value); } - public string LuaExpressionSourceName + public string ExpressionSourceName { get; set => SetProperty(ref field, value); @@ -746,13 +787,13 @@ private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken) var text = await File.ReadAllTextAsync(path, cancellationToken); Expression = text; - LuaExpressionSourceName = Path.GetFileName(path); + ExpressionSourceName = Path.GetFileName(path); SelectedPresetIndex = -1; - StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionScriptLoaded", ("path", LuaExpressionSourceName))); + StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionScriptLoaded", ("path", ExpressionSourceName))); } } -public sealed record LuaExpressionPresetViewModel(string Id, string DisplayName, string Description, string ScriptText); +public sealed record ExpressionPresetViewModel(string Id, string DisplayName, string Description, string ScriptText); public sealed class TemplateNamesToolViewModel(MainWindowViewModel owner) : ObservableViewModel { diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs index ec31d09..09e5b1f 100644 --- a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs +++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs @@ -329,7 +329,7 @@ private void RenderDiagnostics(ExpressionAnalysisResult result) primaryDiagnostic = result.Diagnostics.FirstOrDefault(static diagnostic => diagnostic.Length > 0) ?? result.Diagnostics.FirstOrDefault(); var hasDiagnostic = primaryDiagnostic is not null; diagnosticTooltipText = hasDiagnostic - ? LocalizeDiagnostic(primaryDiagnostic!.Diagnostic.Code, primaryDiagnostic.Diagnostic.Message, primaryDiagnostic.Diagnostic.Arguments) + Environment.NewLine + LocalizeSuggestion(primaryDiagnostic.Suggestion) + ? LocalizeDiagnostic(primaryDiagnostic!.Diagnostic.DisplayCode, primaryDiagnostic.Diagnostic.Message, primaryDiagnostic.Diagnostic.Arguments) + Environment.NewLine + LocalizeSuggestion(primaryDiagnostic.Suggestion) : string.Empty; DiagnosticTextBlock.Text = diagnosticTooltipText; if (!hasDiagnostic) diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml b/src/ChapterTool.Avalonia/Views/MainWindow.axaml index 1c25026..b92acd8 100644 --- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml +++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml @@ -476,7 +476,6 @@ - diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs b/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs index b89b6cf..bda06c8 100644 --- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs +++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs @@ -143,7 +143,7 @@ private async Task BrowseAndLoadAsync() private async Task SaveAsync(string? directory) { ReadAdvancedOptions(); - viewModel.SaveFormat = (ChapterExportFormat)Math.Max(0, FormatBox.SelectedIndex); + viewModel.SaveFormatIndex = Math.Max(0, FormatBox.SelectedIndex); directory ??= string.IsNullOrWhiteSpace(viewModel.CurrentPath) ? null : Path.GetDirectoryName(viewModel.CurrentPath); await viewModel.SaveDirectoryCommand.ExecuteAsync(directory); } @@ -203,8 +203,8 @@ private async Task LoadLuaExpressionScriptAsync() var text = await File.ReadAllTextAsync(path, CancellationToken.None); viewModel.Expression = string.IsNullOrWhiteSpace(text) ? "t" : text; - viewModel.LuaExpressionSourceName = Path.GetFileName(path); - viewModel.LuaExpressionPresetId = string.Empty; + viewModel.ExpressionSourceName = Path.GetFileName(path); + viewModel.ExpressionPresetId = string.Empty; viewModel.ApplyExpression = true; } @@ -342,7 +342,7 @@ private async void OnKeyDown(object? sender, KeyEventArgs args) if (mapped >= 0 && mapped < FormatBox.ItemCount) { FormatBox.SelectedIndex = mapped; - viewModel.SaveFormat = (ChapterExportFormat)mapped; + viewModel.SaveFormatIndex = mapped; } return; @@ -660,5 +660,4 @@ private static string SelectedComboText(ComboBox comboBox, string fallback) _ => fallback }; } - } diff --git a/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml b/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml index fa9bafa..b0d552a 100644 --- a/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml +++ b/src/ChapterTool.Avalonia/Views/Tools/ExpressionToolView.axaml @@ -16,7 +16,7 @@ ItemsSource="{Binding Presets}" SelectedIndex="{Binding SelectedPresetIndex}"> - + @@ -47,7 +47,7 @@ - + diff --git a/src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml b/src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml index 6cb69f7..b911e51 100644 --- a/src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml +++ b/src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml @@ -191,7 +191,7 @@ - + - - + + + + /// Represents a diagnostic message produced while importing, editing, transforming, or exporting chapters. /// -/// The Severity value. -/// The Code value. -/// The Message value. -/// The Location value. -/// The Details value. -/// The Arguments value. +/// The diagnostic severity. +/// The stable diagnostic code. +/// The user-facing diagnostic message. +/// The source location associated with the diagnostic, when available. +/// Additional diagnostic details for troubleshooting, when available. +/// Structured diagnostic arguments for localization or formatting, when available. public sealed record ChapterDiagnostic( DiagnosticSeverity Severity, - string Code, + ChapterDiagnosticCode Code, string Message, string? Location = null, string? Details = null, - IReadOnlyDictionary? Arguments = null); + IReadOnlyDictionary? Arguments = null) +{ + /// + /// Gets the stable string code used in localization resources, logs, and CLI output. + /// + public string DisplayCode => Code.ToDisplayCode(); +} diff --git a/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCode.cs b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCode.cs new file mode 100644 index 0000000..e62b9dd --- /dev/null +++ b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCode.cs @@ -0,0 +1,126 @@ +namespace ChapterTool.Core.Diagnostics; + +#pragma warning disable CS1591 + +/// +/// Identifies a diagnostic by the component that produced it and the failure or status reason. +/// +/// The component or data source associated with the diagnostic. +/// The failure or status reason. +public readonly record struct ChapterDiagnosticCode(ChapterDiagnosticSource Source, ChapterDiagnosticReason Reason) +{ + public override string ToString() => this.ToDisplayCode(); + + public static readonly ChapterDiagnosticCode DependencyCannotStart = new(ChapterDiagnosticSource.Dependency, ChapterDiagnosticReason.CannotStart); + public static readonly ChapterDiagnosticCode DependencyExecutionCancelled = new(ChapterDiagnosticSource.DependencyExecution, ChapterDiagnosticReason.Cancelled); + public static readonly ChapterDiagnosticCode DependencyExecutionFailed = new(ChapterDiagnosticSource.DependencyExecution, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode DependencyExecutionTimedOut = new(ChapterDiagnosticSource.DependencyExecution, ChapterDiagnosticReason.TimedOut); + public static readonly ChapterDiagnosticCode DependencyOutputEmpty = new(ChapterDiagnosticSource.DependencyOutput, ChapterDiagnosticReason.Empty); + public static readonly ChapterDiagnosticCode DependencyOutputMissing = new(ChapterDiagnosticSource.DependencyOutput, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode DependencyOutputTruncated = new(ChapterDiagnosticSource.DependencyOutput, ChapterDiagnosticReason.Truncated); + public static readonly ChapterDiagnosticCode DependencyOutputUnrecognized = new(ChapterDiagnosticSource.DependencyOutput, ChapterDiagnosticReason.Unrecognized); + public static readonly ChapterDiagnosticCode EmbeddedCueNotFound = new(ChapterDiagnosticSource.EmbeddedCue, ChapterDiagnosticReason.NotFound); + public static readonly ChapterDiagnosticCode EmptyChapters = new(ChapterDiagnosticSource.Chapters, ChapterDiagnosticReason.Empty); + public static readonly ChapterDiagnosticCode EmptyCueFile = new(ChapterDiagnosticSource.CueFile, ChapterDiagnosticReason.Empty); + public static readonly ChapterDiagnosticCode EmptyXml = new(ChapterDiagnosticSource.Xml, ChapterDiagnosticReason.Empty); + public static readonly ChapterDiagnosticCode FfprobeCannotStart = new(ChapterDiagnosticSource.Ffprobe, ChapterDiagnosticReason.CannotStart); + public static readonly ChapterDiagnosticCode FfprobeEmptyOutput = new(ChapterDiagnosticSource.FfprobeOutput, ChapterDiagnosticReason.Empty); + public static readonly ChapterDiagnosticCode FfprobeMissingDependency = new(ChapterDiagnosticSource.Ffprobe, ChapterDiagnosticReason.MissingDependency); + public static readonly ChapterDiagnosticCode FfprobeOutputTruncated = new(ChapterDiagnosticSource.FfprobeOutput, ChapterDiagnosticReason.Truncated); + public static readonly ChapterDiagnosticCode FfprobeParseFailed = new(ChapterDiagnosticSource.Ffprobe, ChapterDiagnosticReason.ParseFailed); + public static readonly ChapterDiagnosticCode FfprobeProcessCancelled = new(ChapterDiagnosticSource.FfprobeProcess, ChapterDiagnosticReason.Cancelled); + public static readonly ChapterDiagnosticCode FfprobeProcessFailed = new(ChapterDiagnosticSource.FfprobeProcess, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode FfprobeProcessTimedOut = new(ChapterDiagnosticSource.FfprobeProcess, ChapterDiagnosticReason.TimedOut); + public static readonly ChapterDiagnosticCode FlacEmbeddedCueNotFound = new(ChapterDiagnosticSource.FlacEmbeddedCue, ChapterDiagnosticReason.NotFound); + public static readonly ChapterDiagnosticCode ImporterFallbackUsed = new(ChapterDiagnosticSource.ImporterFallback, ChapterDiagnosticReason.Used); + public static readonly ChapterDiagnosticCode InputNotFound = new(ChapterDiagnosticSource.Input, ChapterDiagnosticReason.NotFound); + public static readonly ChapterDiagnosticCode InvalidChapterIndex = new(ChapterDiagnosticSource.ChapterIndex, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidChapterPair = new(ChapterDiagnosticSource.ChapterPair, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidChapterText = new(ChapterDiagnosticSource.ChapterText, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidChapterTimestamp = new(ChapterDiagnosticSource.ChapterTimestamp, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidContainerHeader = new(ChapterDiagnosticSource.ContainerHeader, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidEntryElement = new(ChapterDiagnosticSource.EntryElement, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpression = new(ChapterDiagnosticSource.Expression, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpressionIncomplete = new(ChapterDiagnosticSource.Expression, ChapterDiagnosticReason.Incomplete); + public static readonly ChapterDiagnosticCode InvalidExpressionInsufficientOperands = new(ChapterDiagnosticSource.Expression, ChapterDiagnosticReason.InsufficientOperands); + public static readonly ChapterDiagnosticCode InvalidExpressionInvalidCharacter = new(ChapterDiagnosticSource.ExpressionCharacter, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpressionLua = new(ChapterDiagnosticSource.LuaExpression, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaCanceled = new(ChapterDiagnosticSource.LuaExpression, ChapterDiagnosticReason.Cancelled); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaCompile = new(ChapterDiagnosticSource.LuaExpression, ChapterDiagnosticReason.CompileFailed); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaInvalidReturn = new(ChapterDiagnosticSource.LuaExpressionReturn, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaMissingReturn = new(ChapterDiagnosticSource.LuaExpressionReturn, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaRuntime = new(ChapterDiagnosticSource.LuaExpression, ChapterDiagnosticReason.RuntimeFailed); + public static readonly ChapterDiagnosticCode InvalidExpressionLuaUnknownToken = new(ChapterDiagnosticSource.LuaExpressionToken, ChapterDiagnosticReason.Unknown); + public static readonly ChapterDiagnosticCode InvalidExpressionMisplacedComma = new(ChapterDiagnosticSource.ExpressionComma, ChapterDiagnosticReason.Misplaced); + public static readonly ChapterDiagnosticCode InvalidExpressionMissingOperandBeforeParen = new(ChapterDiagnosticSource.ExpressionOperandBeforeParen, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionMissingOperator = new(ChapterDiagnosticSource.ExpressionOperator, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionMissingOperatorBeforeFunction = new(ChapterDiagnosticSource.ExpressionOperatorBeforeFunction, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionMissingOperatorBeforeParen = new(ChapterDiagnosticSource.ExpressionOperatorBeforeParen, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionOperatorRequiresLeftOperand = new(ChapterDiagnosticSource.ExpressionOperator, ChapterDiagnosticReason.RequiresLeftOperand); + public static readonly ChapterDiagnosticCode InvalidExpressionTernaryMissingCondition = new(ChapterDiagnosticSource.ExpressionTernaryCondition, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionTernaryMissingTrueExpression = new(ChapterDiagnosticSource.ExpressionTernaryTrueExpression, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode InvalidExpressionTernaryUnmatchedColon = new(ChapterDiagnosticSource.ExpressionTernaryColon, ChapterDiagnosticReason.Unmatched); + public static readonly ChapterDiagnosticCode InvalidExpressionTernaryUnmatchedQuestion = new(ChapterDiagnosticSource.ExpressionTernaryQuestion, ChapterDiagnosticReason.Unmatched); + public static readonly ChapterDiagnosticCode InvalidExpressionTime = new(ChapterDiagnosticSource.ExpressionTime, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidExpressionUnbalancedParentheses = new(ChapterDiagnosticSource.ExpressionParentheses, ChapterDiagnosticReason.Unbalanced); + public static readonly ChapterDiagnosticCode InvalidExpressionUnknownToken = new(ChapterDiagnosticSource.ExpressionToken, ChapterDiagnosticReason.Unknown); + public static readonly ChapterDiagnosticCode InvalidExpressionUnsupportedFunction = new(ChapterDiagnosticSource.ExpressionFunction, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode InvalidExpressionUnsupportedOperator = new(ChapterDiagnosticSource.ExpressionOperator, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode InvalidExpressionUnsupportedToken = new(ChapterDiagnosticSource.ExpressionToken, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode InvalidFrameRate = new(ChapterDiagnosticSource.FrameRate, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidFrameText = new(ChapterDiagnosticSource.FrameText, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidIfo = new(ChapterDiagnosticSource.Ifo, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidMpls = new(ChapterDiagnosticSource.Mpls, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidPath = new(ChapterDiagnosticSource.Path, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidStructure = new(ChapterDiagnosticSource.Structure, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidTimecodeText = new(ChapterDiagnosticSource.TimecodeText, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidTimeText = new(ChapterDiagnosticSource.TimeText, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidWebVttCueText = new(ChapterDiagnosticSource.WebVttCueText, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode InvalidXml = new(ChapterDiagnosticSource.Xml, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode MalformedCueSyntax = new(ChapterDiagnosticSource.CueSyntax, ChapterDiagnosticReason.Malformed); + public static readonly ChapterDiagnosticCode MatroskaCannotStart = new(ChapterDiagnosticSource.Matroska, ChapterDiagnosticReason.CannotStart); + public static readonly ChapterDiagnosticCode MatroskaMissingDependency = new(ChapterDiagnosticSource.Matroska, ChapterDiagnosticReason.MissingDependency); + public static readonly ChapterDiagnosticCode MatroskaNoChapters = new(ChapterDiagnosticSource.MatroskaChapters, ChapterDiagnosticReason.None); + public static readonly ChapterDiagnosticCode MatroskaOutputTruncated = new(ChapterDiagnosticSource.MatroskaOutput, ChapterDiagnosticReason.Truncated); + public static readonly ChapterDiagnosticCode MatroskaProcessCancelled = new(ChapterDiagnosticSource.MatroskaProcess, ChapterDiagnosticReason.Cancelled); + public static readonly ChapterDiagnosticCode MatroskaProcessFailed = new(ChapterDiagnosticSource.MatroskaProcess, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode MatroskaProcessTimedOut = new(ChapterDiagnosticSource.MatroskaProcess, ChapterDiagnosticReason.TimedOut); + public static readonly ChapterDiagnosticCode MediaReadFailed = new(ChapterDiagnosticSource.MediaRead, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode MissingDependency = new(ChapterDiagnosticSource.Dependency, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode MissingInput = new(ChapterDiagnosticSource.Input, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode Mp4FileInaccessible = new(ChapterDiagnosticSource.Mp4File, ChapterDiagnosticReason.Inaccessible); + public static readonly ChapterDiagnosticCode Mp4FileNotFound = new(ChapterDiagnosticSource.Mp4File, ChapterDiagnosticReason.NotFound); + public static readonly ChapterDiagnosticCode Mp4InvalidPath = new(ChapterDiagnosticSource.Mp4Path, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode Mp4MalformedMetadata = new(ChapterDiagnosticSource.Mp4Metadata, ChapterDiagnosticReason.Malformed); + public static readonly ChapterDiagnosticCode Mp4ReadFailed = new(ChapterDiagnosticSource.Mp4Read, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode Mp4UnsupportedMetadata = new(ChapterDiagnosticSource.Mp4Metadata, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode NativeLibraryMissing = new(ChapterDiagnosticSource.NativeLibrary, ChapterDiagnosticReason.Missing); + public static readonly ChapterDiagnosticCode NoChapters = new(ChapterDiagnosticSource.Chapters, ChapterDiagnosticReason.None); + public static readonly ChapterDiagnosticCode NoChaptersFound = new(ChapterDiagnosticSource.Chapters, ChapterDiagnosticReason.NotFound); + public static readonly ChapterDiagnosticCode NoRowsSelected = new(ChapterDiagnosticSource.Rows, ChapterDiagnosticReason.NoneSelected); + public static readonly ChapterDiagnosticCode NoSegments = new(ChapterDiagnosticSource.Segments, ChapterDiagnosticReason.None); + public static readonly ChapterDiagnosticCode OgmInvalidFirstLine = new(ChapterDiagnosticSource.OgmFirstLine, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode OrderShiftNormalized = new(ChapterDiagnosticSource.OrderShift, ChapterDiagnosticReason.Normalized); + public static readonly ChapterDiagnosticCode PartialParse = new(ChapterDiagnosticSource.Parse, ChapterDiagnosticReason.Partial); + public static readonly ChapterDiagnosticCode PremiereMarkerListInvalid = new(ChapterDiagnosticSource.PremiereMarkerList, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode SaveFailed = new(ChapterDiagnosticSource.Save, ChapterDiagnosticReason.Failed); + public static readonly ChapterDiagnosticCode Saved = new(ChapterDiagnosticSource.Save, ChapterDiagnosticReason.Succeeded); + public static readonly ChapterDiagnosticCode SelectionGroupAvailable = new(ChapterDiagnosticSource.SelectionGroup, ChapterDiagnosticReason.Available); + public static readonly ChapterDiagnosticCode SelectionOptionAvailable = new(ChapterDiagnosticSource.SelectionOption, ChapterDiagnosticReason.Available); + public static readonly ChapterDiagnosticCode Stdout = new(ChapterDiagnosticSource.StandardOutput, ChapterDiagnosticReason.Captured); + public static readonly ChapterDiagnosticCode Unavailable = new(ChapterDiagnosticSource.Importer, ChapterDiagnosticReason.Unavailable); + public static readonly ChapterDiagnosticCode UnsupportedAppendSource = new(ChapterDiagnosticSource.AppendSource, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode UnsupportedCombineSource = new(ChapterDiagnosticSource.CombineSource, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode UnsupportedExportFormat = new(ChapterDiagnosticSource.ExportFormat, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode UnsupportedInput = new(ChapterDiagnosticSource.Input, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode UnsupportedSource = new(ChapterDiagnosticSource.Source, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode WebVttInvalidHeader = new(ChapterDiagnosticSource.WebVttHeader, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode WebVttMalformedCue = new(ChapterDiagnosticSource.WebVttCue, ChapterDiagnosticReason.Malformed); + public static readonly ChapterDiagnosticCode WebVttUnsupportedTimingSettings = new(ChapterDiagnosticSource.WebVttTimingSettings, ChapterDiagnosticReason.Unsupported); + public static readonly ChapterDiagnosticCode XmlInvalidRoot = new(ChapterDiagnosticSource.XmlRoot, ChapterDiagnosticReason.Invalid); + public static readonly ChapterDiagnosticCode XmlNoChapters = new(ChapterDiagnosticSource.XmlChapters, ChapterDiagnosticReason.None); + public static readonly ChapterDiagnosticCode XplNoChapters = new(ChapterDiagnosticSource.XplChapters, ChapterDiagnosticReason.None); + public static readonly ChapterDiagnosticCode XplParseFailed = new(ChapterDiagnosticSource.XplParse, ChapterDiagnosticReason.Failed); +} + +#pragma warning restore CS1591 diff --git a/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCodeExtensions.cs b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCodeExtensions.cs new file mode 100644 index 0000000..a69448f --- /dev/null +++ b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticCodeExtensions.cs @@ -0,0 +1,14 @@ +namespace ChapterTool.Core.Diagnostics; + +/// +/// Provides formatting helpers for diagnostic codes. +/// +public static class ChapterDiagnosticCodeExtensions +{ + /// + /// Returns the stable string form used in localization resources, logs, and CLI output. + /// + /// The diagnostic code. + /// The stable display code. + public static string ToDisplayCode(this ChapterDiagnosticCode code) => $"{code.Source}.{code.Reason}"; +} diff --git a/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticReason.cs b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticReason.cs new file mode 100644 index 0000000..eeecf7d --- /dev/null +++ b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticReason.cs @@ -0,0 +1,45 @@ +namespace ChapterTool.Core.Diagnostics; + +#pragma warning disable CS1591 + +/// +/// Failure or status reasons for chapter diagnostics. +/// +public enum ChapterDiagnosticReason +{ + Available, + Cancelled, + CannotStart, + Captured, + CompileFailed, + Empty, + Failed, + Inaccessible, + Incomplete, + InsufficientOperands, + Invalid, + Malformed, + Misplaced, + Missing, + MissingDependency, + None, + NoneSelected, + Normalized, + NotFound, + ParseFailed, + Partial, + RequiresLeftOperand, + RuntimeFailed, + Succeeded, + TimedOut, + Truncated, + Unavailable, + Unbalanced, + Unknown, + Unmatched, + Unrecognized, + Unsupported, + Used +} + +#pragma warning restore CS1591 diff --git a/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticSource.cs b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticSource.cs new file mode 100644 index 0000000..f5beea0 --- /dev/null +++ b/src/ChapterTool.Core/Diagnostics/ChapterDiagnosticSource.cs @@ -0,0 +1,92 @@ +namespace ChapterTool.Core.Diagnostics; + +#pragma warning disable CS1591 + +/// +/// Components, formats, or data fields that can produce diagnostics. +/// +public enum ChapterDiagnosticSource +{ + AppendSource, + ChapterIndex, + ChapterPair, + Chapters, + ChapterText, + ChapterTimestamp, + CombineSource, + ContainerHeader, + CueFile, + CueSyntax, + Dependency, + DependencyExecution, + DependencyOutput, + EmbeddedCue, + EntryElement, + ExportFormat, + Expression, + ExpressionCharacter, + ExpressionComma, + ExpressionFunction, + ExpressionOperandBeforeParen, + ExpressionOperator, + ExpressionOperatorBeforeFunction, + ExpressionOperatorBeforeParen, + ExpressionParentheses, + ExpressionTernaryColon, + ExpressionTernaryCondition, + ExpressionTernaryQuestion, + ExpressionTernaryTrueExpression, + ExpressionTime, + ExpressionToken, + Ffprobe, + FfprobeOutput, + FfprobeProcess, + FlacEmbeddedCue, + FrameRate, + FrameText, + Ifo, + Importer, + ImporterFallback, + Input, + LuaExpression, + LuaExpressionReturn, + LuaExpressionToken, + Matroska, + MatroskaChapters, + MatroskaOutput, + MatroskaProcess, + MediaRead, + Mp4File, + Mp4Metadata, + Mp4Path, + Mp4Read, + Mpls, + NativeLibrary, + OgmFirstLine, + OrderShift, + Parse, + Path, + PremiereMarkerList, + Rows, + Save, + Segments, + SelectionGroup, + SelectionOption, + Source, + StandardOutput, + Structure, + TimecodeText, + TimeText, + WebVtt, + WebVttCue, + WebVttCueText, + WebVttHeader, + WebVttTimingSettings, + Xml, + XmlChapters, + XmlRoot, + XplChapters, + XplParse +} + +#pragma warning restore CS1591 diff --git a/src/ChapterTool.Core/Editing/ChapterEditResult.cs b/src/ChapterTool.Core/Editing/ChapterEditResult.cs index e23069a..56f05fc 100644 --- a/src/ChapterTool.Core/Editing/ChapterEditResult.cs +++ b/src/ChapterTool.Core/Editing/ChapterEditResult.cs @@ -6,8 +6,8 @@ namespace ChapterTool.Core.Editing; /// /// Represents the result of a chapter edit operation. /// -/// The ChapterInfo value. -/// The Diagnostics value. +/// The chapter data after the edit operation. +/// Diagnostics produced while editing chapters. public sealed record ChapterEditResult( - ChapterInfo ChapterInfo, + ChapterSet ChapterSet, IReadOnlyList Diagnostics); diff --git a/src/ChapterTool.Core/Editing/ChapterEditingService.cs b/src/ChapterTool.Core/Editing/ChapterEditingService.cs index bc56896..0bf8ed6 100644 --- a/src/ChapterTool.Core/Editing/ChapterEditingService.cs +++ b/src/ChapterTool.Core/Editing/ChapterEditingService.cs @@ -19,7 +19,7 @@ public sealed partial class ChapterEditingService(IChapterTimeFormatter timeForm /// The zero-based chapter index. /// The text to parse. /// The operation result. - public ChapterEditResult EditTime(ChapterInfo info, int index, string text) + public ChapterEditResult EditTime(ChapterSet info, int index, string text) { var chapters = info.Chapters.ToList(); if (!TryGetChapter(chapters, index, out var chapter)) @@ -29,7 +29,7 @@ public ChapterEditResult EditTime(ChapterInfo info, int index, string text) var parsed = timeFormatter.Parse(text); var value = parsed.Value >= TimeSpan.FromDays(1) ? TimeSpan.Zero : parsed.Value; - chapters[index] = chapter with { Time = value }; + chapters[index] = chapter with { StartTime = value }; return new ChapterEditResult(info with { Chapters = Renumber(chapters) }, parsed.Diagnostics); } @@ -41,7 +41,7 @@ public ChapterEditResult EditTime(ChapterInfo info, int index, string text) /// The text to parse. /// The frame rate in frames per second. /// The operation result. - public ChapterEditResult EditFrame(ChapterInfo info, int index, string text, decimal framesPerSecond) + public ChapterEditResult EditFrame(ChapterSet info, int index, string text, decimal framesPerSecond) { var chapters = info.Chapters.ToList(); if (!TryGetChapter(chapters, index, out var chapter)) @@ -50,24 +50,33 @@ public ChapterEditResult EditFrame(ChapterInfo info, int index, string text, dec } var match = FirstIntegerRegex().Match(text); - if (!match.Success || framesPerSecond <= 0) + if (!match.Success + || framesPerSecond <= 0 + || !decimal.TryParse(match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var frame)) { - return new ChapterEditResult( - info, - [new ChapterDiagnostic(DiagnosticSeverity.Warning, "InvalidFrameText", "Frame text did not contain a frame number or fps was invalid.")]); + return InvalidFrameText(info); } - var frame = decimal.Parse(match.Value, CultureInfo.InvariantCulture); var seconds = frame / framesPerSecond; + if (seconds < 0 || seconds > (decimal)TimeSpan.MaxValue.TotalSeconds) + { + return InvalidFrameText(info); + } + chapters[index] = chapter with { - Time = TimeSpan.FromSeconds((double)seconds), + StartTime = TimeSpan.FromSeconds((double)seconds), FramesInfo = frame.ToString("0", CultureInfo.InvariantCulture), FrameAccuracy = FrameAccuracy.Accurate }; return new ChapterEditResult(info with { Chapters = Renumber(chapters) }, []); } + private static ChapterEditResult InvalidFrameText(ChapterSet info) => + new( + info, + [new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.InvalidFrameText, "Frame text did not contain a frame number or fps was invalid.")]); + /// /// Executes the Rename operation. /// @@ -75,7 +84,7 @@ public ChapterEditResult EditFrame(ChapterInfo info, int index, string text, dec /// The zero-based chapter index. /// The chapter name. /// The operation result. - public ChapterEditResult Rename(ChapterInfo info, int index, string name) + public ChapterEditResult Rename(ChapterSet info, int index, string name) { var chapters = info.Chapters.ToList(); if (!TryGetChapter(chapters, index, out var chapter)) @@ -93,13 +102,13 @@ public ChapterEditResult Rename(ChapterInfo info, int index, string name) /// The chapter data to process. /// The zero-based chapter indexes. /// The operation result. - public ChapterEditResult Delete(ChapterInfo info, IReadOnlySet indexes) + public ChapterEditResult Delete(ChapterSet info, IReadOnlySet indexes) { var chapters = info.Chapters.Where((_, index) => !indexes.Contains(index)).ToList(); if (chapters.Count > 0 && indexes.Contains(0)) { - var shift = chapters[0].Time; - chapters = chapters.Select(chapter => chapter.IsSeparator ? chapter : chapter with { Time = chapter.Time - shift }).ToList(); + var shift = chapters[0].StartTime; + chapters = chapters.Select(chapter => chapter.IsSeparator ? chapter : chapter with { StartTime = chapter.StartTime - shift }).ToList(); } return new ChapterEditResult(info with { Chapters = Renumber(chapters) }, []); @@ -111,7 +120,7 @@ public ChapterEditResult Delete(ChapterInfo info, IReadOnlySet indexes) /// The chapter data to process. /// The zero-based chapter index. /// The operation result. - public ChapterEditResult InsertBefore(ChapterInfo info, int index) + public ChapterEditResult InsertBefore(ChapterSet info, int index) { if (index < 0 || index > info.Chapters.Count) { @@ -129,12 +138,12 @@ public ChapterEditResult InsertBefore(ChapterInfo info, int index) /// The chapter data to process. /// The order shift. /// The operation result. - public ChapterEditResult ApplyOrderShift(ChapterInfo info, int shift) + public ChapterEditResult ApplyOrderShift(ChapterSet info, int shift) { var effectiveShift = Math.Max(0, shift); var number = 0; var chapters = info.Chapters - .Select(chapter => chapter.IsSeparator ? chapter with { Number = 0 } : chapter with { Number = ++number + effectiveShift }) + .Select(chapter => chapter.IsSeparator ? chapter with { DisplayNumber = 0 } : chapter with { DisplayNumber = ++number + effectiveShift }) .ToList(); var diagnostics = effectiveShift == shift ? Array.Empty() @@ -142,7 +151,7 @@ public ChapterEditResult ApplyOrderShift(ChapterInfo info, int shift) [ new ChapterDiagnostic( DiagnosticSeverity.Warning, - "OrderShiftNormalized", + ChapterDiagnosticCode.OrderShiftNormalized, $"Chapter number shift {shift} would produce non-positive chapter numbers and was normalized to 0.", Arguments: new Dictionary(StringComparer.Ordinal) { ["shift"] = shift }) ]; @@ -155,7 +164,7 @@ public ChapterEditResult ApplyOrderShift(ChapterInfo info, int shift) /// The chapter data to process. /// The template text. /// The operation result. - public ChapterEditResult ApplyTemplate(ChapterInfo info, string templateText) + public ChapterEditResult ApplyTemplate(ChapterSet info, string templateText) { var names = templateText .Trim(' ', '\r', '\n') @@ -175,19 +184,19 @@ public ChapterEditResult ApplyTemplate(ChapterInfo info, string templateText) /// The frame count. /// The frame rate in frames per second. /// The operation result. - public ChapterEditResult ShiftFramesForward(ChapterInfo info, int frames, decimal framesPerSecond) + public ChapterEditResult ShiftFramesForward(ChapterSet info, int frames, decimal framesPerSecond) { if (framesPerSecond <= 0) { return new ChapterEditResult( info, - [new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidFrameRate", "Frame rate must be greater than zero.")]); + [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidFrameRate, "Frame rate must be greater than zero.")]); } var shift = ChapterRounding.SecondsToTimeSpan(frames / framesPerSecond); var chapters = info.Chapters - .Select(chapter => chapter.IsSeparator ? chapter : chapter with { Time = chapter.Time - shift }) - .Where(static chapter => chapter.IsSeparator || chapter.Time >= TimeSpan.Zero); + .Select(chapter => chapter.IsSeparator ? chapter : chapter with { StartTime = chapter.StartTime - shift }) + .Where(static chapter => chapter.IsSeparator || chapter.StartTime >= TimeSpan.Zero); return new ChapterEditResult(info with { Chapters = Renumber(chapters) }, []); } @@ -199,20 +208,20 @@ public ChapterEditResult ShiftFramesForward(ChapterInfo info, int frames, decima /// The zero-based chapter indexes. /// The frame rate in frames per second. /// The operation result. - public ChapterZonesResult CreateZones(ChapterInfo info, IReadOnlySet indexes, decimal framesPerSecond) + public ChapterZonesResult CreateZones(ChapterSet info, IReadOnlySet indexes, decimal framesPerSecond) { if (framesPerSecond <= 0) { return new ChapterZonesResult( string.Empty, - [new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidFrameRate", "Frame rate must be greater than zero.")]); + [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidFrameRate, "Frame rate must be greater than zero.")]); } if (indexes.Count == 0) { return new ChapterZonesResult( string.Empty, - [new ChapterDiagnostic(DiagnosticSeverity.Warning, "NoRowsSelected", "Select one or more chapter rows first.")]); + [new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.NoRowsSelected, "Select one or more chapter rows first.")]); } var ranges = new List<(long Begin, long End)>(); @@ -235,7 +244,7 @@ public ChapterZonesResult CreateZones(ChapterInfo info, IReadOnlySet indexe { return new ChapterZonesResult( string.Empty, - [new ChapterDiagnostic(DiagnosticSeverity.Warning, "NoRowsSelected", "No valid chapter rows were selected.")]); + [new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.NoRowsSelected, "No valid chapter rows were selected.")]); } var zones = "--zones " + string.Join("/", ranges.OrderBy(static range => range.Begin).Select(static range => $"{range.Begin},{range.End},")); @@ -246,7 +255,7 @@ private static List Renumber(IEnumerable chapters) { var number = 0; return chapters - .Select(chapter => chapter.IsSeparator ? chapter : chapter with { Number = ++number }) + .Select(chapter => chapter.IsSeparator ? chapter : chapter with { DisplayNumber = ++number }) .ToList(); } @@ -262,10 +271,10 @@ private static bool TryGetChapter(IReadOnlyList chapters, int index, ou return false; } - private static ChapterEditResult InvalidIndex(ChapterInfo info, int index) => + private static ChapterEditResult InvalidIndex(ChapterSet info, int index) => new( info, - [new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidChapterIndex", $"Chapter index {index} is out of range.", + [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidChapterIndex, $"Chapter index {index} is out of range.", Arguments: new Dictionary(StringComparer.Ordinal) { ["index"] = index })]); [GeneratedRegex(@"\d+")] @@ -273,6 +282,6 @@ [new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidChapterIndex", $"Chapte private static long ChapterFrame(Chapter chapter, decimal framesPerSecond) { - return ChapterRounding.RoundToInt64((decimal)chapter.Time.TotalSeconds * framesPerSecond); + return ChapterRounding.RoundToInt64((decimal)chapter.StartTime.TotalSeconds * framesPerSecond); } } diff --git a/src/ChapterTool.Core/Editing/ChapterSegmentService.cs b/src/ChapterTool.Core/Editing/ChapterSegmentService.cs index bd5a60b..56740e2 100644 --- a/src/ChapterTool.Core/Editing/ChapterSegmentService.cs +++ b/src/ChapterTool.Core/Editing/ChapterSegmentService.cs @@ -11,39 +11,37 @@ public sealed class ChapterSegmentService /// /// Executes the Combine operation. /// - /// The group value. + /// The imported chapter source whose entries should be combined. /// The operation result. - public static ChapterEditResult Combine(ChapterInfoGroup group) + public static ChapterEditResult Combine(ChapterImportSource group) { - if (group.Options.Count == 0) + if (group.Entries.Count == 0) { - return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, "NoSegments", "No chapter segments are available.")]); + return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.NoSegments, "No chapter segments are available.")]); } - var sourceType = group.Options[0].ChapterInfo.SourceType; - if (sourceType is not ("MPLS" or "DVD") || group.Options.Any(option => option.ChapterInfo.SourceType != sourceType)) + var sourceType = group.Entries[0].ChapterSet.ImportFormat; + if (sourceType is not (ChapterImportFormat.Mpls or ChapterImportFormat.DvdIfo) || group.Entries.Any(entry => entry.ChapterSet.ImportFormat != sourceType)) { - return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, "UnsupportedCombineSource", "Only MPLS and DVD chapter groups can be combined.")]); + return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.UnsupportedCombineSource, "Only MPLS and DVD chapter groups can be combined.")]); } var offset = TimeSpan.Zero; var chapters = new List(); - foreach (var option in group.Options) + foreach (var entry in group.Entries) { - foreach (var chapter in option.ChapterInfo.Chapters.Where(static chapter => !chapter.IsSeparator)) + foreach (var chapter in entry.ChapterSet.Chapters.Where(static chapter => !chapter.IsSeparator)) { - chapters.Add(new Chapter(chapters.Count + 1, offset + chapter.Time, $"Chapter {chapters.Count + 1:D2}")); + chapters.Add(new Chapter(chapters.Count + 1, offset + chapter.StartTime, $"Chapter {chapters.Count + 1:D2}")); } - offset += option.ChapterInfo.Duration; + offset += entry.ChapterSet.Duration; } - var info = new ChapterInfo( + var info = new ChapterSet( "FULL Chapter", - null, - 0, - sourceType, - group.Options[0].ChapterInfo.FramesPerSecond, + null, sourceType, + group.Entries[0].ChapterSet.FramesPerSecond, offset, chapters); return new ChapterEditResult(info, []); @@ -52,25 +50,25 @@ public static ChapterEditResult Combine(ChapterInfoGroup group) /// /// Executes the Append operation. /// - /// The existing value. - /// The appended value. + /// The imported chapter source that should remain first in the combined result. + /// The imported chapter source to append after . /// The operation result. - public static ChapterEditResult Append(ChapterInfoGroup existing, ChapterInfoGroup appended) + public static ChapterEditResult Append(ChapterImportSource existing, ChapterImportSource appended) { - if (existing.Options.Count == 0 || appended.Options.Count == 0) + if (existing.Entries.Count == 0 || appended.Entries.Count == 0) { - return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, "NoSegments", "No chapter segments are available.")]); + return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.NoSegments, "No chapter segments are available.")]); } - if (existing.Options.Concat(appended.Options).Any(static option => option.ChapterInfo.SourceType != "MPLS")) + if (existing.Entries.Concat(appended.Entries).Any(static entry => entry.ChapterSet.ImportFormat != ChapterImportFormat.Mpls)) { - return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, "UnsupportedAppendSource", "Only MPLS chapter groups can be appended.")]); + return new ChapterEditResult(Empty(), [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.UnsupportedAppendSource, "Only MPLS chapter groups can be appended.")]); } - var combined = existing with { Options = existing.Options.Concat(appended.Options).ToList() }; + var combined = existing with { Entries = existing.Entries.Concat(appended.Entries).ToList() }; return Combine(combined); } - private static ChapterInfo Empty() => - new(string.Empty, null, 0, string.Empty, 0, TimeSpan.Zero, []); + private static ChapterSet Empty() => + new(string.Empty, null, ChapterImportFormat.Unknown, 0, TimeSpan.Zero, []); } diff --git a/src/ChapterTool.Core/Editing/IChapterEditingService.cs b/src/ChapterTool.Core/Editing/IChapterEditingService.cs index c3f55f2..42d2f2b 100644 --- a/src/ChapterTool.Core/Editing/IChapterEditingService.cs +++ b/src/ChapterTool.Core/Editing/IChapterEditingService.cs @@ -14,7 +14,7 @@ public interface IChapterEditingService /// The zero-based chapter index. /// The formatted time text. /// The edit result. - ChapterEditResult EditTime(ChapterInfo info, int index, string text); + ChapterEditResult EditTime(ChapterSet info, int index, string text); /// /// Edits a chapter frame number from text. @@ -24,7 +24,7 @@ public interface IChapterEditingService /// The frame text. /// The frame rate in frames per second. /// The edit result. - ChapterEditResult EditFrame(ChapterInfo info, int index, string text, decimal framesPerSecond); + ChapterEditResult EditFrame(ChapterSet info, int index, string text, decimal framesPerSecond); /// /// Renames a chapter. @@ -33,7 +33,7 @@ public interface IChapterEditingService /// The zero-based chapter index. /// The new chapter name. /// The edit result. - ChapterEditResult Rename(ChapterInfo info, int index, string name); + ChapterEditResult Rename(ChapterSet info, int index, string name); /// /// Deletes chapters by index. @@ -41,7 +41,7 @@ public interface IChapterEditingService /// The chapter data to edit. /// The zero-based chapter indexes to delete. /// The edit result. - ChapterEditResult Delete(ChapterInfo info, IReadOnlySet indexes); + ChapterEditResult Delete(ChapterSet info, IReadOnlySet indexes); /// /// Inserts a chapter before the specified index. @@ -49,7 +49,7 @@ public interface IChapterEditingService /// The chapter data to edit. /// The zero-based insertion index. /// The edit result. - ChapterEditResult InsertBefore(ChapterInfo info, int index); + ChapterEditResult InsertBefore(ChapterSet info, int index); /// /// Shifts chapter order numbers by the specified amount. @@ -57,7 +57,7 @@ public interface IChapterEditingService /// The chapter data to edit. /// The order shift. /// The edit result. - ChapterEditResult ApplyOrderShift(ChapterInfo info, int shift); + ChapterEditResult ApplyOrderShift(ChapterSet info, int shift); /// /// Applies chapter names from newline-delimited template text. @@ -65,7 +65,7 @@ public interface IChapterEditingService /// The chapter data to edit. /// The template text. /// The edit result. - ChapterEditResult ApplyTemplate(ChapterInfo info, string templateText); + ChapterEditResult ApplyTemplate(ChapterSet info, string templateText); /// /// Shifts all chapter times forward by a number of frames. @@ -74,7 +74,7 @@ public interface IChapterEditingService /// The frame count. /// The frame rate in frames per second. /// The edit result. - ChapterEditResult ShiftFramesForward(ChapterInfo info, int frames, decimal framesPerSecond); + ChapterEditResult ShiftFramesForward(ChapterSet info, int frames, decimal framesPerSecond); /// /// Creates zone text from selected chapters. @@ -83,14 +83,14 @@ public interface IChapterEditingService /// The selected zero-based chapter indexes. /// The frame rate in frames per second. /// The generated zone text and diagnostics. - ChapterZonesResult CreateZones(ChapterInfo info, IReadOnlySet indexes, decimal framesPerSecond); + ChapterZonesResult CreateZones(ChapterSet info, IReadOnlySet indexes, decimal framesPerSecond); } /// /// Represents zone text generated from selected chapters. /// -/// The Zones value. -/// The Diagnostics value. +/// The generated zone text for the selected chapters. +/// Diagnostics produced while creating zone text. public sealed record ChapterZonesResult( string Zones, IReadOnlyList Diagnostics); diff --git a/src/ChapterTool.Core/Exporting/ChapterConversionResult.cs b/src/ChapterTool.Core/Exporting/ChapterConversionResult.cs index f33ebe7..04146ed 100644 --- a/src/ChapterTool.Core/Exporting/ChapterConversionResult.cs +++ b/src/ChapterTool.Core/Exporting/ChapterConversionResult.cs @@ -5,10 +5,10 @@ namespace ChapterTool.Core.Exporting; /// /// Represents the result of a chapter conversion operation. /// -/// The Success value. -/// The Content value. -/// The Extension value. -/// The Diagnostics value. +/// Whether the conversion completed successfully. +/// The converted chapter text. +/// The recommended output file extension. +/// Diagnostics produced during conversion. public sealed record ChapterConversionResult( bool Success, string Content, diff --git a/src/ChapterTool.Core/Exporting/ChapterConversionService.cs b/src/ChapterTool.Core/Exporting/ChapterConversionService.cs index a656afd..e99a6de 100644 --- a/src/ChapterTool.Core/Exporting/ChapterConversionService.cs +++ b/src/ChapterTool.Core/Exporting/ChapterConversionService.cs @@ -17,19 +17,19 @@ public sealed class ChapterConversionService(IChapterTimeFormatter timeFormatter /// The chapter data to process. /// The frame rate in frames per second. /// The operation result. - public static ChapterConversionResult ToCelltimes(ChapterInfo info, decimal framesPerSecond) + public static ChapterConversionResult ToCelltimes(ChapterSet info, decimal framesPerSecond) { ArgumentNullException.ThrowIfNull(info); if (framesPerSecond <= 0) { - return Failure("InvalidFrameRate", "Frame rate must be greater than zero."); + return Failure(ChapterDiagnosticCode.InvalidFrameRate, "Frame rate must be greater than zero."); } var lines = info.Chapters .Where(static chapter => !chapter.IsSeparator) .Select(chapter => ChapterRounding - .RoundToInt64((decimal)chapter.Time.TotalSeconds * framesPerSecond) + .RoundToInt64((decimal)chapter.StartTime.TotalSeconds * framesPerSecond) .ToString(CultureInfo.InvariantCulture)); return Success(string.Join(Environment.NewLine, lines), ".celltimes.txt"); @@ -38,26 +38,26 @@ public static ChapterConversionResult ToCelltimes(ChapterInfo info, decimal fram /// /// Executes the ChapterTextToQpfile operation. /// - /// The chapterText value. + /// The OGM-style chapter text to convert. /// The frame rate in frames per second. - /// The timecodeText value. + /// Optional v2 timecode text used to map chapter times to frames. /// The operation result. public ChapterConversionResult ChapterTextToQpfile(string chapterText, decimal framesPerSecond, string? timecodeText = null) { if (string.IsNullOrWhiteSpace(chapterText)) { - return Failure("InvalidChapterText", "Chapter text is empty."); + return Failure(ChapterDiagnosticCode.InvalidChapterText, "Chapter text is empty."); } if (framesPerSecond <= 0 && string.IsNullOrWhiteSpace(timecodeText)) { - return Failure("InvalidFrameRate", "Frame rate must be greater than zero when no timecode file is provided."); + return Failure(ChapterDiagnosticCode.InvalidFrameRate, "Frame rate must be greater than zero when no timecode file is provided."); } var times = ParseChapterTimes(chapterText); if (times.Count == 0) { - return Failure("InvalidChapterText", "No valid OGM chapter time entries were found."); + return Failure(ChapterDiagnosticCode.InvalidChapterText, "No valid OGM chapter time entries were found."); } TimecodeMap? timecodeMap = null; @@ -66,7 +66,7 @@ public ChapterConversionResult ChapterTextToQpfile(string chapterText, decimal f var parsed = TimecodeMap.Parse(timecodeText); if (parsed is null) { - return Failure("InvalidTimecodeText", "No valid timecode entries were found."); + return Failure(ChapterDiagnosticCode.InvalidTimecodeText, "No valid timecode entries were found."); } timecodeMap = parsed; @@ -119,7 +119,7 @@ private List ParseChapterTimes(string chapterText) private static ChapterConversionResult Success(string content, string extension) => new(true, content, extension, []); - private static ChapterConversionResult Failure(string code, string message) => + private static ChapterConversionResult Failure(ChapterDiagnosticCode code, string message) => new(false, string.Empty, string.Empty, [new ChapterDiagnostic(DiagnosticSeverity.Error, code, message)]); private sealed class TimecodeMap diff --git a/src/ChapterTool.Core/Exporting/ChapterExportFormat.cs b/src/ChapterTool.Core/Exporting/ChapterExportFormat.cs index 9b67a08..8e79b23 100644 --- a/src/ChapterTool.Core/Exporting/ChapterExportFormat.cs +++ b/src/ChapterTool.Core/Exporting/ChapterExportFormat.cs @@ -8,41 +8,37 @@ public enum ChapterExportFormat /// /// Exports OGM-style chapter text. /// - Txt, + Txt = 10, /// /// Exports Matroska XML chapters. /// - Xml, + Xml = 20, /// /// Exports QPFile frame markers. /// - Qpfile, + Qpfile = 30, /// /// Exports timestamp lines. /// - TimeCodes, + TimeCodes = 40, /// /// Exports tsMuxeR metadata. /// - TsMuxerMeta, + TsMuxerMeta = 50, /// /// Exports a CUE sheet. /// - Cue, + Cue = 60, /// /// Exports JSON chapter data. /// - Json, + Json = 70, /// /// Exports WebVTT cues. /// - WebVtt, + WebVtt = 80, /// /// Exports celltimes frame numbers. /// - Celltimes, - /// - /// Converts chapter text to QPFile output. - /// - Chapter2Qpfile + Celltimes = 90 } diff --git a/src/ChapterTool.Core/Exporting/ChapterExportFormats.cs b/src/ChapterTool.Core/Exporting/ChapterExportFormats.cs new file mode 100644 index 0000000..b915863 --- /dev/null +++ b/src/ChapterTool.Core/Exporting/ChapterExportFormats.cs @@ -0,0 +1,124 @@ +namespace ChapterTool.Core.Exporting; + +/// +/// Provides stable metadata for supported chapter export formats. +/// +public static class ChapterExportFormats +{ + /// + /// Supported export formats in UI and CLI presentation order. + /// + public static IReadOnlyList All { get; } = + [ + ChapterExportFormat.Txt, + ChapterExportFormat.Xml, + ChapterExportFormat.Qpfile, + ChapterExportFormat.TimeCodes, + ChapterExportFormat.TsMuxerMeta, + ChapterExportFormat.Cue, + ChapterExportFormat.Json, + ChapterExportFormat.WebVtt, + ChapterExportFormat.Celltimes + ]; + + /// + /// Returns the presentation index for an export format. + /// + /// The export format. + /// The zero-based index, or -1 when the value is unsupported. + public static int IndexOf(ChapterExportFormat format) + { + for (var index = 0; index < All.Count; index++) + { + if (All[index] == format) + { + return index; + } + } + + return -1; + } + + /// + /// Returns the export format at a clamped presentation index. + /// + /// The requested index. + /// The matching export format. + public static ChapterExportFormat AtIndex(int index) => All[Math.Clamp(index, 0, All.Count - 1)]; + + /// + /// Returns the stable machine code for an export format. + /// + /// The export format. + /// The stable code. + public static string Code(ChapterExportFormat format) => format switch + { + ChapterExportFormat.Txt => "txt", + ChapterExportFormat.Xml => "xml", + ChapterExportFormat.Qpfile => "qpf", + ChapterExportFormat.TimeCodes => "timecodes", + ChapterExportFormat.TsMuxerMeta => "tsmuxer", + ChapterExportFormat.Cue => "cue", + ChapterExportFormat.Json => "json", + ChapterExportFormat.WebVtt => "vtt", + ChapterExportFormat.Celltimes => "celltimes", + _ => string.Empty + }; + + /// + /// Returns the default file extension for an export format. + /// + /// The export format. + /// The default file extension, including the leading dot. + public static string Extension(ChapterExportFormat format) => format switch + { + ChapterExportFormat.Txt => ".txt", + ChapterExportFormat.Xml => ".xml", + ChapterExportFormat.Qpfile => ".qpf", + ChapterExportFormat.TimeCodes => ".TimeCodes.txt", + ChapterExportFormat.TsMuxerMeta => ".TsMuxeR_Meta.txt", + ChapterExportFormat.Cue => ".cue", + ChapterExportFormat.Json => ".json", + ChapterExportFormat.WebVtt => ".vtt", + ChapterExportFormat.Celltimes => ".txt", + _ => string.Empty + }; + + /// + /// Returns the short user-facing label for an export format. + /// + /// The export format. + /// The display label. + public static string DisplayName(ChapterExportFormat format) => format switch + { + ChapterExportFormat.Txt => "TXT", + ChapterExportFormat.Xml => "XML", + ChapterExportFormat.Qpfile => "QPFile", + ChapterExportFormat.TimeCodes => "TimeCodes", + ChapterExportFormat.TsMuxerMeta => "TsmuxerMeta", + ChapterExportFormat.Cue => "CUE", + ChapterExportFormat.Json => "JSON", + ChapterExportFormat.WebVtt => "WebVTT", + ChapterExportFormat.Celltimes => "Celltimes", + _ => string.Empty + }; + + /// + /// Returns the CLI description for an export format. + /// + /// The export format. + /// The description. + public static string Description(ChapterExportFormat format) => format switch + { + ChapterExportFormat.Txt => "OGM chapter pairs", + ChapterExportFormat.Xml => "Matroska chapter XML", + ChapterExportFormat.Qpfile => "QPFile keyframe list", + ChapterExportFormat.TimeCodes => "Chapter start times only", + ChapterExportFormat.TsMuxerMeta => "tsMuxeR meta chapter list", + ChapterExportFormat.Cue => "CUE sheet", + ChapterExportFormat.Json => "Structured JSON chapter payload", + ChapterExportFormat.WebVtt => "WebVTT cue list", + ChapterExportFormat.Celltimes => "Celltimes frame list", + _ => string.Empty + }; +} diff --git a/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs b/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs index 7a4d642..333eb97 100644 --- a/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs +++ b/src/ChapterTool.Core/Exporting/ChapterExportOptions.cs @@ -3,19 +3,19 @@ namespace ChapterTool.Core.Exporting; /// /// Describes options for chapter export and output projection. /// -/// The Format value. -/// The XmlLanguage value. -/// The SourceFileName value. -/// The AutoGenerateNames value. -/// The UseTemplateNames value. -/// The ChapterNameTemplateText value. -/// The OrderShift value. -/// The ApplyExpression value. -/// The Expression value. -/// The LuaExpressionPresetId value. -/// The LuaExpressionSourceName value. -/// The EmitBom value. -/// The ProjectOutput value. +/// The target chapter export format. +/// The Matroska XML chapter language code, when exporting XML. +/// The source file name used by formats that include source metadata. +/// Whether to generate sequential chapter names during export. +/// Whether to apply names from . +/// Newline-delimited chapter names used as a naming template. +/// The amount added to exported chapter display numbers. +/// Whether to apply before exporting. +/// The expression source text used to project chapter times. +/// The selected expression preset identifier, when one is used. +/// The display name of the expression source, when available. +/// Whether exported text should include a UTF-8 byte order mark. +/// Whether export should apply output projection before formatting content. public sealed record ChapterExportOptions( ChapterExportFormat Format, string? XmlLanguage = null, @@ -26,7 +26,7 @@ public sealed record ChapterExportOptions( int OrderShift = 0, bool ApplyExpression = false, string Expression = "t", - string LuaExpressionPresetId = "", - string LuaExpressionSourceName = "", + string ExpressionPresetId = "", + string ExpressionSourceName = "", bool EmitBom = true, bool ProjectOutput = true); diff --git a/src/ChapterTool.Core/Exporting/ChapterExportResult.cs b/src/ChapterTool.Core/Exporting/ChapterExportResult.cs index ac62241..33079ff 100644 --- a/src/ChapterTool.Core/Exporting/ChapterExportResult.cs +++ b/src/ChapterTool.Core/Exporting/ChapterExportResult.cs @@ -5,10 +5,10 @@ namespace ChapterTool.Core.Exporting; /// /// Represents the result of a chapter export operation. /// -/// The Success value. -/// The Content value. -/// The FileExtension value. -/// The Diagnostics value. +/// Whether the export completed successfully. +/// The exported chapter file content. +/// The recommended output file extension. +/// Diagnostics produced during export. public sealed record ChapterExportResult( bool Success, string Content, diff --git a/src/ChapterTool.Core/Exporting/ChapterExportService.cs b/src/ChapterTool.Core/Exporting/ChapterExportService.cs index 8ebfe30..37a7714 100644 --- a/src/ChapterTool.Core/Exporting/ChapterExportService.cs +++ b/src/ChapterTool.Core/Exporting/ChapterExportService.cs @@ -8,6 +8,8 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using LuaExpressionScriptService = ChapterTool.Core.Transform.Expressions.Lua.LuaExpressionScriptService; namespace ChapterTool.Core.Exporting; @@ -17,29 +19,17 @@ namespace ChapterTool.Core.Exporting; public sealed partial class ChapterExportService { private readonly IChapterTimeFormatter timeFormatter; - private readonly ILuaExpressionScriptService luaExpressionService; - private readonly ChapterConversionService chapterConversionService; + private readonly IChapterExpressionEngine expressionEngine; /// /// Exports ChapterTool chapter data to supported chapter formats. /// /// The chapter time formatter. - /// The Lua expression service. - public ChapterExportService(IChapterTimeFormatter timeFormatter, ILuaExpressionScriptService? luaExpressionService = null) + /// The chapter expression engine. + public ChapterExportService(IChapterTimeFormatter timeFormatter, IChapterExpressionEngine? expressionEngine = null) { this.timeFormatter = timeFormatter; - this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService(); - chapterConversionService = new ChapterConversionService(timeFormatter); - } - - /// - /// Exports ChapterTool chapter data to supported chapter formats. - /// - /// The chapter time formatter. - /// The _ value. - public ChapterExportService(IChapterTimeFormatter timeFormatter, IExpressionService _) - : this(timeFormatter, new LuaExpressionScriptService()) - { + this.expressionEngine = expressionEngine ?? new LuaExpressionScriptService(); } /// @@ -48,10 +38,10 @@ public ChapterExportService(IChapterTimeFormatter timeFormatter, IExpressionServ /// The chapter data to process. /// The export options. /// The operation result. - public ChapterExportResult Export(ChapterInfo info, ChapterExportOptions options) + public ChapterExportResult Export(ChapterSet info, ChapterExportOptions options) { var projection = options.ProjectOutput - ? new ChapterOutputProjectionService(luaExpressionService).Project(info, options) + ? new ChapterOutputProjectionService(expressionEngine).Project(info, options) : new ChapterOutputProjectionResult(info, []); info = projection.Info; var outputInfo = info with { Chapters = projection.OutputChapters }; @@ -66,8 +56,7 @@ public ChapterExportResult Export(ChapterInfo info, ChapterExportOptions options ChapterExportFormat.Json => Json(info, options), ChapterExportFormat.WebVtt => WebVtt(outputInfo, options), ChapterExportFormat.Celltimes => Celltimes(outputInfo), - ChapterExportFormat.Chapter2Qpfile => Chapter2Qpfile(outputInfo, options), - _ => Failure("UnsupportedExportFormat", "Unsupported export format.") + _ => Failure(ChapterDiagnosticCode.UnsupportedExportFormat, "Unsupported export format.") }; return result with @@ -76,22 +65,22 @@ public ChapterExportResult Export(ChapterInfo info, ChapterExportOptions options }; } - private ChapterExportResult Text(ChapterInfo info, ChapterExportOptions options) + private ChapterExportResult Text(ChapterSet info, ChapterExportOptions options) { var builder = new StringBuilder(); foreach (var chapter in info.Chapters.Where(NotSeparator)) { - builder.AppendLine(CultureInfo.InvariantCulture, $"CHAPTER{chapter.Number:D2}={FormatTime(chapter)}"); - builder.AppendLine(CultureInfo.InvariantCulture, $"CHAPTER{chapter.Number:D2}NAME={chapter.Name}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"CHAPTER{chapter.DisplayNumber:D2}={FormatTime(chapter)}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"CHAPTER{chapter.DisplayNumber:D2}NAME={chapter.Name}"); } return Success(builder.ToString(), ".txt"); } - private ChapterExportResult Xml(ChapterInfo info, ChapterExportOptions options) + private ChapterExportResult Xml(ChapterSet info, ChapterExportOptions options) { var language = XmlChapterLanguageCatalog.NormalizeOrDefault(options.XmlLanguage); - var uidSeed = StableHashCode(info.Title, info.SourceName, info.SourceType, info.Chapters.Count.ToString(CultureInfo.InvariantCulture)); + var uidSeed = StableHashCode(info.Title, info.SourceName, ChapterImportFormats.DisplayName(info.ImportFormat), info.Chapters.Count.ToString(CultureInfo.InvariantCulture)); var random = new Random(uidSeed); var atoms = info.Chapters.Where(NotSeparator).Select(chapter => new XElement( @@ -115,23 +104,22 @@ private ChapterExportResult Xml(ChapterInfo info, ChapterExportOptions options) return Success(document.Declaration + Environment.NewLine + document.ToString(SaveOptions.None), ".xml"); } - private ChapterExportResult TsMuxer(ChapterInfo info, ChapterExportOptions options) + private ChapterExportResult TsMuxer(ChapterSet info, ChapterExportOptions options) { var chapters = info.Chapters.Where(NotSeparator).Select(FormatTime).ToList(); if (chapters.Count == 0) { - return Failure("NoChapters", "No chapters are available for tsMuxeR meta export."); + return Failure(ChapterDiagnosticCode.NoChapters, "No chapters are available for tsMuxeR meta export."); } return Success($"--custom-{Environment.NewLine}chapters={string.Join(';', chapters)}", ".TsMuxeR_Meta.txt"); } - private static ChapterExportResult Qpfile(ChapterInfo info) + private static ChapterExportResult Qpfile(ChapterSet info) { - var framesPerSecond = (decimal)info.FramesPerSecond; - if (framesPerSecond <= 0) + if (!FrameRateValidation.TryNormalize(info.FramesPerSecond, out var framesPerSecond, out var diagnostic)) { - return Failure("InvalidFrameRate", "Frame rate must be greater than zero for QPFile export."); + return Failure(diagnostic!); } return Lines( @@ -139,24 +127,22 @@ private static ChapterExportResult Qpfile(ChapterInfo info) info.Chapters .Where(NotSeparator) .Select(chapter => ChapterRounding - .RoundToInt64((decimal)chapter.Time.TotalSeconds * framesPerSecond) + .RoundToInt64((decimal)chapter.StartTime.TotalSeconds * framesPerSecond) .ToString(CultureInfo.InvariantCulture) + " I")); } - private static ChapterExportResult Celltimes(ChapterInfo info) + private static ChapterExportResult Celltimes(ChapterSet info) { - var conversion = ChapterConversionService.ToCelltimes(info, (decimal)info.FramesPerSecond); - return new ChapterExportResult(conversion.Success, conversion.Content, conversion.Extension, conversion.Diagnostics); - } + if (!FrameRateValidation.TryNormalize(info.FramesPerSecond, out var framesPerSecond, out var diagnostic)) + { + return Failure(diagnostic!); + } - private ChapterExportResult Chapter2Qpfile(ChapterInfo info, ChapterExportOptions options) - { - var text = Text(info, options); - var conversion = chapterConversionService.ChapterTextToQpfile(text.Content, (decimal)info.FramesPerSecond); + var conversion = ChapterConversionService.ToCelltimes(info, framesPerSecond); return new ChapterExportResult(conversion.Success, conversion.Content, conversion.Extension, conversion.Diagnostics); } - private ChapterExportResult Cue(ChapterInfo info, ChapterExportOptions options) + private ChapterExportResult Cue(ChapterSet info, ChapterExportOptions options) { var builder = new StringBuilder(); builder.AppendLine("REM Generate By ChapterTool"); @@ -167,13 +153,13 @@ private ChapterExportResult Cue(ChapterInfo info, ChapterExportOptions options) { builder.AppendLine(CultureInfo.InvariantCulture, $" TRACK {++track:D2} AUDIO"); builder.AppendLine(CultureInfo.InvariantCulture, $" TITLE \"{Escape(chapter.Name)}\""); - builder.AppendLine(CultureInfo.InvariantCulture, $" INDEX 01 {timeFormatter.FormatCue(chapter.Time)}"); + builder.AppendLine(CultureInfo.InvariantCulture, $" INDEX 01 {timeFormatter.FormatCue(chapter.StartTime)}"); } return Success(builder.ToString(), ".cue"); } - private static ChapterExportResult WebVtt(ChapterInfo info, ChapterExportOptions options) + private static ChapterExportResult WebVtt(ChapterSet info, ChapterExportOptions options) { var builder = new StringBuilder(); builder.AppendLine("WEBVTT"); @@ -183,9 +169,16 @@ private static ChapterExportResult WebVtt(ChapterInfo info, ChapterExportOptions for (var i = 0; i < chapters.Count; i++) { var chapter = chapters[i]; - var endTime = i + 1 < chapters.Count ? chapters[i + 1].Time : info.Duration; + if (!IsSupportedWebVttCueText(chapter.Name)) + { + return Failure( + ChapterDiagnosticCode.InvalidWebVttCueText, + "WebVTT cue text cannot contain line breaks or the cue timing separator."); + } + + var endTime = chapter.EndTime ?? (i + 1 < chapters.Count ? chapters[i + 1].StartTime : info.Duration); - builder.AppendLine(CultureInfo.InvariantCulture, $"{FormatWebVttTime(chapter.Time)} --> {FormatWebVttTime(endTime)}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"{FormatWebVttTime(chapter.StartTime)} --> {FormatWebVttTime(endTime)}"); builder.AppendLine(chapter.Name); if (i < chapters.Count - 1) { @@ -205,7 +198,12 @@ private static string FormatWebVttTime(TimeSpan time) return $"{hours:D2}:{minutes:D2}:{seconds:D2}.{milliseconds:D3}"; } - private static ChapterExportResult Json(ChapterInfo info, ChapterExportOptions options) + private static bool IsSupportedWebVttCueText(string value) => + !value.Contains('\r') + && !value.Contains('\n') + && !value.Contains("-->", StringComparison.Ordinal); + + private static ChapterExportResult Json(ChapterSet info, ChapterExportOptions options) { var entries = new List(); var baseTime = TimeSpan.Zero; @@ -216,18 +214,18 @@ private static ChapterExportResult Json(ChapterInfo info, ChapterExportOptions o { if (previous is not null) { - baseTime = previous.Time; + baseTime = previous.StartTime; entries.Add(new JsonChapter(previous.Name, 0)); } continue; } - entries.Add(new JsonChapter(chapter.Name, (chapter.Time - baseTime).TotalSeconds)); + entries.Add(new JsonChapter(chapter.Name, (chapter.StartTime - baseTime).TotalSeconds)); previous = chapter; } - var payload = new JsonPayload(info.SourceType == "MPLS" ? $"{info.SourceName}.m2ts" : null, entries); + var payload = new JsonPayload(info.ImportFormat == ChapterImportFormat.Mpls ? $"{info.SourceName}.m2ts" : null, entries); return Success(JsonSerializer.Serialize(payload, ExportJsonSerializerContext.Default.JsonPayload), ".json"); } @@ -238,7 +236,7 @@ private static ChapterExportResult Lines(string extension, IEnumerable l private static string Escape(string value) => value.Replace("\"", "\\\"", StringComparison.Ordinal); - private string FormatTime(Chapter chapter) => timeFormatter.Format(chapter.Time); + private string FormatTime(Chapter chapter) => timeFormatter.Format(chapter.StartTime); private static int NextUid(Random random) => random.Next(1, int.MaxValue); @@ -252,9 +250,12 @@ private static int StableHashCode(params string?[] values) private static ChapterExportResult Success(string content, string extension) => new(true, content, extension, []); - private static ChapterExportResult Failure(string code, string message) => + private static ChapterExportResult Failure(ChapterDiagnosticCode code, string message) => new(false, string.Empty, string.Empty, [new ChapterDiagnostic(DiagnosticSeverity.Error, code, message)]); + private static ChapterExportResult Failure(ChapterDiagnostic diagnostic) => + new(false, string.Empty, string.Empty, [diagnostic]); + private sealed record JsonPayload(string? SourceName, IReadOnlyList Chapter); private sealed record JsonChapter(string Name, double Time); diff --git a/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs b/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs index f646c3d..26bb93a 100644 --- a/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs +++ b/src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs @@ -1,32 +1,25 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using LuaExpressionScriptService = ChapterTool.Core.Transform.Expressions.Lua.LuaExpressionScriptService; namespace ChapterTool.Core.Exporting; /// -/// Projects chapter output data before export by applying expressions, ordering, and naming options. +/// Projects chapter output data before export by applying expressions, ordering, and naming entries. /// public sealed class ChapterOutputProjectionService { - private readonly ILuaExpressionScriptService luaExpressionService; + private readonly IChapterExpressionEngine expressionEngine; /// - /// Projects chapter output data before export by applying expressions, ordering, and naming options. + /// Projects chapter output data before export by applying expressions, ordering, and naming entries. /// - /// The Lua expression service. - public ChapterOutputProjectionService(ILuaExpressionScriptService? luaExpressionService = null) - { - this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService(); - } - - /// - /// Projects chapter output data before export by applying expressions, ordering, and naming options. - /// - /// The _ value. - public ChapterOutputProjectionService(IExpressionService _) - : this(new LuaExpressionScriptService()) + /// The chapter expression engine. + public ChapterOutputProjectionService(IChapterExpressionEngine? expressionEngine = null) { + this.expressionEngine = expressionEngine ?? new LuaExpressionScriptService(); } /// @@ -35,10 +28,10 @@ public ChapterOutputProjectionService(IExpressionService _) /// The chapter data to process. /// The export options. /// The operation result. - public ChapterOutputProjectionResult Project(ChapterInfo info, ChapterExportOptions options) + public ChapterOutputProjectionResult Project(ChapterSet info, ChapterExportOptions options) { var diagnostics = new List(); - var expressionResult = new ChapterExpressionService(luaExpressionService).Apply(info, options.ApplyExpression, options.Expression); + var expressionResult = new ChapterExpressionService(expressionEngine).Apply(info, options.ApplyExpression, options.Expression); diagnostics.AddRange(expressionResult.Diagnostics); var effectiveShift = NormalizeOrderShift(options.OrderShift, diagnostics); @@ -50,13 +43,13 @@ public ChapterOutputProjectionResult Project(ChapterInfo info, ChapterExportOpti { if (chapter.IsSeparator) { - return chapter with { Number = 0 }; + return chapter with { DisplayNumber = 0 }; } outputIndex++; return chapter with { - Number = outputIndex + effectiveShift, + DisplayNumber = outputIndex + effectiveShift, Name = OutputName(chapter.Name, outputIndex, useGeneratedNames, templateNames) }; }).ToList(); @@ -76,7 +69,7 @@ private static int NormalizeOrderShift(int orderShift, List d diagnostics.Add(new ChapterDiagnostic( DiagnosticSeverity.Warning, - "OrderShiftNormalized", + ChapterDiagnosticCode.OrderShiftNormalized, $"Chapter number shift {orderShift} would produce non-positive chapter numbers and was normalized to 0.", Arguments: new Dictionary(StringComparer.Ordinal) { ["shift"] = orderShift })); return 0; @@ -112,11 +105,11 @@ private static string OutputName( /// /// Represents projected chapter output and diagnostics. /// -/// The Info value. -/// The OutputChapters value. -/// The Diagnostics value. +/// The projected chapter set used for export. +/// The non-separator chapters included in the exported output. +/// Diagnostics produced while projecting export output. public sealed record ChapterOutputProjectionResult( - ChapterInfo Info, + ChapterSet Info, IReadOnlyList OutputChapters, IReadOnlyList Diagnostics) { @@ -125,7 +118,7 @@ public sealed record ChapterOutputProjectionResult( /// /// The chapter data to process. /// The diagnostics for the operation. - public ChapterOutputProjectionResult(ChapterInfo info, IReadOnlyList diagnostics) + public ChapterOutputProjectionResult(ChapterSet info, IReadOnlyList diagnostics) : this(info, info.Chapters.Where(static chapter => !chapter.IsSeparator).ToList(), diagnostics) { } diff --git a/src/ChapterTool.Core/Exporting/IChapterExporter.cs b/src/ChapterTool.Core/Exporting/IChapterExporter.cs index 91f28bc..634248e 100644 --- a/src/ChapterTool.Core/Exporting/IChapterExporter.cs +++ b/src/ChapterTool.Core/Exporting/IChapterExporter.cs @@ -15,8 +15,8 @@ public interface IChapterExporter /// /// Exports chapter data with the supplied options. /// - /// The chapter data to export. + /// The chapter data to export. /// The export options. /// The export result. - ChapterExportResult Export(ChapterInfo chapterInfo, ChapterExportOptions options); + ChapterExportResult Export(ChapterSet chapterSet, ChapterExportOptions options); } diff --git a/src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs b/src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs index 71d6f92..98c85aa 100644 --- a/src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs +++ b/src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs @@ -5,8 +5,8 @@ namespace ChapterTool.Core.Exporting; /// /// Represents a Matroska XML chapter language option. /// -/// The Code value. -/// The DisplayName value. +/// The two-letter or three-letter language code. +/// The language label shown to users. public sealed record XmlChapterLanguage(string Code, string DisplayName); /// diff --git a/src/ChapterTool.Core/Importing/ChapterImportProgress.cs b/src/ChapterTool.Core/Importing/ChapterImportProgress.cs new file mode 100644 index 0000000..b81d252 --- /dev/null +++ b/src/ChapterTool.Core/Importing/ChapterImportProgress.cs @@ -0,0 +1,59 @@ +namespace ChapterTool.Core.Importing; + +/// +/// Receives semantic chapter import progress updates. +/// +public interface IChapterImportProgressReporter +{ + /// + /// Reports import progress. + /// + /// The import progress update. + void Report(ChapterImportProgress progress); +} + +/// +/// Identifies the current phase of a chapter import operation. +/// +public enum ChapterImportProgressPhase +{ + /// + /// The source is being opened or prepared. + /// + LoadingSource, + + /// + /// The source structure or required dependencies are being validated. + /// + ValidatingSource, + + /// + /// Candidate chapter-bearing titles or streams are being discovered. + /// + DiscoveringTitles, + + /// + /// Chapter text is being exported from an external source. + /// + ExportingChapters, + + /// + /// Exported or embedded chapter text is being parsed. + /// + ParsingChapters +} + +/// +/// Reports semantic chapter import progress. +/// +/// The import phase currently being performed. +/// The optional progress fraction, typically from 0 to 1. +/// The source item currently being processed, when available. +/// The one-based item index currently being processed, when available. +/// The total item count for the current phase, when available. +public sealed record ChapterImportProgress( + ChapterImportProgressPhase Phase, + double? Fraction = null, + string? SourceName = null, + int? Current = null, + int? Total = null); diff --git a/src/ChapterTool.Core/Importing/ChapterImportRequest.cs b/src/ChapterTool.Core/Importing/ChapterImportRequest.cs index 779d986..39ade51 100644 --- a/src/ChapterTool.Core/Importing/ChapterImportRequest.cs +++ b/src/ChapterTool.Core/Importing/ChapterImportRequest.cs @@ -3,12 +3,10 @@ namespace ChapterTool.Core.Importing; /// /// Describes a chapter import request. /// -/// The Path value. -/// The Content value. -/// The Options value. -/// The Progress value. +/// The path of the source file to import. +/// An optional stream containing source content in place of opening . +/// An optional progress reporter for long-running import operations. public sealed record ChapterImportRequest( string Path, Stream? Content = null, - IReadOnlyDictionary? Options = null, - IProgress? Progress = null); + IChapterImportProgressReporter? ProgressReporter = null); diff --git a/src/ChapterTool.Core/Importing/ChapterImportResult.cs b/src/ChapterTool.Core/Importing/ChapterImportResult.cs index 3610362..5c43692 100644 --- a/src/ChapterTool.Core/Importing/ChapterImportResult.cs +++ b/src/ChapterTool.Core/Importing/ChapterImportResult.cs @@ -6,13 +6,13 @@ namespace ChapterTool.Core.Importing; /// /// Represents the result of a chapter import operation. /// -/// The Success value. -/// The Groups value. -/// The Diagnostics value. -/// The IsPartial value. +/// Whether the import completed successfully. +/// The imported sources and chapter entries. +/// Diagnostics produced during import. +/// Whether the result contains usable data despite import diagnostics. public sealed record ChapterImportResult( bool Success, - IReadOnlyList Groups, + IReadOnlyList Groups, IReadOnlyList Diagnostics, bool IsPartial = false) { @@ -21,7 +21,7 @@ public sealed record ChapterImportResult( /// /// The imported groups. /// The operation result. - public static ChapterImportResult Succeeded(params ChapterInfoGroup[] groups) => + public static ChapterImportResult Succeeded(params ChapterImportSource[] groups) => new(true, groups, []); /// diff --git a/src/ChapterTool.Core/Importing/ChapterLoadProgress.cs b/src/ChapterTool.Core/Importing/ChapterLoadProgress.cs deleted file mode 100644 index 9e9f22a..0000000 --- a/src/ChapterTool.Core/Importing/ChapterLoadProgress.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ChapterTool.Core.Importing; - -/// -/// Reports chapter loading progress. -/// -/// The Value value. -/// The Message value. -public sealed record ChapterLoadProgress(double Value, string? Message = null); diff --git a/src/ChapterTool.Core/Importing/Cue/CueSheetParser.cs b/src/ChapterTool.Core/Importing/Cue/CueSheetParser.cs index f6390dd..b422ec2 100644 --- a/src/ChapterTool.Core/Importing/Cue/CueSheetParser.cs +++ b/src/ChapterTool.Core/Importing/Cue/CueSheetParser.cs @@ -20,7 +20,7 @@ public static ChapterImportResult Parse(string text, string path = "") { if (string.IsNullOrWhiteSpace(text)) { - return ChapterImportResult.Failed(Error("EmptyCueFile", "CUE text is empty.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.EmptyCueFile, "CUE text is empty.")); } var title = string.Empty; @@ -103,27 +103,24 @@ public static ChapterImportResult Parse(string text, string path = "") if (malformed) { - return ChapterImportResult.Failed(Error("MalformedCueSyntax", "CUE index syntax is unsupported or malformed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.MalformedCueSyntax, "CUE index syntax is unsupported or malformed.")); } if (chapters.Count == 0) { - return ChapterImportResult.Failed(Error("EmptyCueFile", "No CUE chapters were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.EmptyCueFile, "No CUE chapters were parsed.")); } - var ordered = chapters.OrderBy(static chapter => chapter.Number).ToList(); - var info = new ChapterInfo( + var ordered = chapters.OrderBy(static chapter => chapter.DisplayNumber).ToList(); + var info = new ChapterSet( title, sourceName.Length == 0 ? Path.GetFileName(path) : sourceName, + ChapterImportFormat.Cue, 0, - "CUE", - 0, - ordered[^1].Time, - ordered, - Tag: text, - TagType: typeof(string).FullName); - var option = new ChapterSourceOption("default", "CUE Chapters", info); - return new ChapterImportResult(true, [new ChapterInfoGroup(path, [option])], []); + ordered[^1].StartTime, + ordered); + var entry = new ChapterImportEntry("default", "CUE Chapters", info); + return new ChapterImportResult(true, [new ChapterImportSource(path, [entry])], []); } private static TimeSpan ParseCueTime(Match match) @@ -135,7 +132,7 @@ private static TimeSpan ParseCueTime(Match match) return new TimeSpan(0, 0, minute, second, millisecond); } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); [GeneratedRegex(@"^TITLE\s+""(?.+)""$", RegexOptions.IgnoreCase)] diff --git a/src/ChapterTool.Core/Importing/Cue/CueTextDecoder.cs b/src/ChapterTool.Core/Importing/Cue/CueTextDecoder.cs index fc5770a..c49629b 100644 --- a/src/ChapterTool.Core/Importing/Cue/CueTextDecoder.cs +++ b/src/ChapterTool.Core/Importing/Cue/CueTextDecoder.cs @@ -7,7 +7,7 @@ internal static class CueTextDecoder /// <summary> /// Executes the Decode operation. /// </summary> - /// <param name="bytes">The bytes value.</param> + /// <param name="bytes">The encoded CUE text bytes to decode.</param> /// <returns>The operation result.</returns> public static string Decode(byte[] bytes) { diff --git a/src/ChapterTool.Core/Importing/Cue/FlacCueImporter.cs b/src/ChapterTool.Core/Importing/Cue/FlacCueImporter.cs index 664b987..38560e9 100644 --- a/src/ChapterTool.Core/Importing/Cue/FlacCueImporter.cs +++ b/src/ChapterTool.Core/Importing/Cue/FlacCueImporter.cs @@ -39,14 +39,14 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req { cue = ReadCue(stream); } - catch (InvalidDataException exception) when (exception.Message == "InvalidContainerHeader") + catch (InvalidDataException exception) when (exception.Message == ChapterDiagnosticCode.InvalidContainerHeader.ToDisplayCode()) { - return ChapterImportResult.Failed(Error("InvalidContainerHeader", "The file is not a FLAC container.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidContainerHeader, "The file is not a FLAC container.")); } if (cue is null) { - return ChapterImportResult.Failed(Error("FlacEmbeddedCueNotFound", "No Vorbis cuesheet comment was found.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.FlacEmbeddedCueNotFound, "No Vorbis cuesheet comment was found.")); } return CueSheetParser.Parse(cue, request.Path); @@ -57,7 +57,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req Span<byte> header = stackalloc byte[4]; if (stream.Read(header) != 4 || Encoding.ASCII.GetString(header) != "fLaC") { - throw new InvalidDataException("InvalidContainerHeader"); + throw new InvalidDataException(ChapterDiagnosticCode.InvalidContainerHeader.ToDisplayCode()); } Span<byte> lengthBytes = stackalloc byte[3]; @@ -104,7 +104,9 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req private static string? ReadVorbisComment(byte[] block) { var offset = 0; - if (!TryReadInt32(block, ref offset, out var vendorLength) || offset + vendorLength > block.Length) + if (!TryReadInt32(block, ref offset, out var vendorLength) + || vendorLength < 0 + || vendorLength > block.Length - offset) { return null; } @@ -117,7 +119,9 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req for (var i = 0; i < count; i++) { - if (!TryReadInt32(block, ref offset, out var commentLength) || offset + commentLength > block.Length) + if (!TryReadInt32(block, ref offset, out var commentLength) + || commentLength < 0 + || commentLength > block.Length - offset) { return null; } @@ -147,6 +151,6 @@ private static bool TryReadInt32(byte[] block, ref int offset, out int value) return true; } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); } diff --git a/src/ChapterTool.Core/Importing/Cue/TakCueImporter.cs b/src/ChapterTool.Core/Importing/Cue/TakCueImporter.cs index 898ba60..9db4e80 100644 --- a/src/ChapterTool.Core/Importing/Cue/TakCueImporter.cs +++ b/src/ChapterTool.Core/Importing/Cue/TakCueImporter.cs @@ -46,13 +46,13 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req if (bytes.Length < 4 || Encoding.ASCII.GetString(bytes, 0, 4) != "tBaK") { - return ChapterImportResult.Failed(Error("InvalidContainerHeader", "The file is not a TAK container.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidContainerHeader, "The file is not a TAK container.")); } var cue = ExtractCue(bytes.AsSpan(Math.Min(4, bytes.Length))); if (cue is null) { - return ChapterImportResult.Failed(Error("EmbeddedCueNotFound", "No TAK cuesheet marker was found.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.EmbeddedCueNotFound, "No TAK cuesheet marker was found.")); } return CueSheetParser.Parse(cue, request.Path); @@ -61,7 +61,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req /// <summary> /// Executes the ExtractCue operation. /// </summary> - /// <param name="data">The data value.</param> + /// <param name="data">The TAK file bytes to scan for an embedded CUE sheet.</param> /// <returns>The operation result.</returns> public static string? ExtractCue(ReadOnlySpan<byte> data) { @@ -140,6 +140,6 @@ private static int IndexOfAsciiIgnoreCase(ReadOnlySpan<byte> data, ReadOnlySpan< return -1; } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); } diff --git a/src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs b/src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs index da08076..afb624b 100644 --- a/src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs @@ -35,24 +35,24 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req { var stream = await OpenImportStreamAsync(request, cancellationToken); ownedStream = ReferenceEquals(stream, request.Content) ? null : stream; - var options = GetStreams(request.Path, stream) - .Select((info, index) => new ChapterSourceOption( + var entries = GetStreams(request.Path, stream) + .Select((info, index) => new ChapterImportEntry( $"pgc-{index}", $"{info.SourceName}__{info.Chapters.Count}", info, CanCombine: true, - MediaReferences: [new SourceMediaReference($"{info.SourceName}.VOB", $"{info.SourceName}.VOB")])) + ReferencedMediaFiles: [new ReferencedMediaFile($"{info.SourceName}.VOB", $"{info.SourceName}.VOB")])) .ToList(); - if (options.Count == 0) + if (entries.Count == 0) { - return ChapterImportResult.Failed(Error("NoChaptersFound", "No DVD chapters were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.NoChaptersFound, "No DVD chapters were parsed.")); } - return new ChapterImportResult(true, [new ChapterInfoGroup(request.Path, options)], []); + return new ChapterImportResult(true, [new ChapterImportSource(request.Path, entries)], []); } catch (Exception exception) when (exception is IOException or InvalidDataException or EndOfStreamException) { - return ChapterImportResult.Failed(Error("InvalidIfo", exception.Message)); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidIfo, exception.Message)); } finally { @@ -65,19 +65,19 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req /// </summary> /// <param name="path">The source path.</param> /// <returns>The operation result.</returns> - public static IReadOnlyList<ChapterInfo> GetStreams(string path) + public static IReadOnlyList<ChapterSet> GetStreams(string path) { using var stream = File.OpenRead(path); return GetStreams(path, stream); } - private static IReadOnlyList<ChapterInfo> GetStreams(string path, Stream stream) + private static IReadOnlyList<ChapterSet> GetStreams(string path, Stream stream) { var count = GetPgcCount(stream); - var streams = new List<ChapterInfo>(); + var streams = new List<ChapterSet>(); for (var i = 1; i <= count; i++) { - var info = GetChapterInfo(path, stream, i); + var info = GetChapterSet(path, stream, i); if (info is not null) { streams.Add(info); @@ -97,11 +97,11 @@ private static IReadOnlyList<ChapterInfo> GetStreams(string path, Stream stream) /// <summary> /// Executes the ConvertDvdPlaybackTime operation. /// </summary> - /// <param name="hour">The hour value.</param> - /// <param name="minute">The minute value.</param> - /// <param name="second">The second value.</param> - /// <param name="frameByte">The frameByte value.</param> - /// <param name="isNtsc">The isNtsc value.</param> + /// <param name="hour">The BCD-encoded DVD playback hour.</param> + /// <param name="minute">The BCD-encoded DVD playback minute.</param> + /// <param name="second">The BCD-encoded DVD playback second.</param> + /// <param name="frameByte">The DVD frame byte containing frame count and frame-rate flags.</param> + /// <param name="isNtsc">Returns whether the playback time uses the NTSC frame-rate flag.</param> /// <returns>The operation result.</returns> public static TimeSpan ConvertDvdPlaybackTime(byte hour, byte minute, byte second, byte frameByte, out bool isNtsc) { @@ -114,7 +114,7 @@ public static TimeSpan ConvertDvdPlaybackTime(byte hour, byte minute, byte secon return TimeSpan.FromSeconds(totalFrames / rate); } - private static ChapterInfo? GetChapterInfo(string path, Stream stream, int programChain) + private static ChapterSet? GetChapterSet(string path, Stream stream, int programChain) { var chapters = GetChapters(stream, programChain, out var duration, out var isNtsc); if (duration.TotalSeconds < 10) @@ -129,11 +129,10 @@ public static TimeSpan ConvertDvdPlaybackTime(byte hour, byte minute, byte secon sourceName = $"{sourceName[..last]}_{programChain}"; } - return new ChapterInfo( + return new ChapterSet( sourceName, sourceName, - programChain, - "DVD", + ChapterImportFormat.DvdIfo, isNtsc ? 30000d / 1001d : 25d, duration, chapters); @@ -231,7 +230,7 @@ private static byte[] ReadBlock(Stream stream, long position, int count) private static uint ToInt32(byte[] bytes) => (uint)((bytes[0] << 24) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3]); - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); [GeneratedRegex(@"^VTS_(?<Title>\d+)_0\.IFO$", RegexOptions.IgnoreCase)] diff --git a/src/ChapterTool.Core/Importing/Disc/MplsChapterImporter.cs b/src/ChapterTool.Core/Importing/Disc/MplsChapterImporter.cs index fbb01a5..8d14095 100644 --- a/src/ChapterTool.Core/Importing/Disc/MplsChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Disc/MplsChapterImporter.cs @@ -45,19 +45,19 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req try { var parsed = MplsPlaylistFile.Read(stream); - var options = parsed.PlayList.PlayItems.Select((playItem, index) => ToOption(request.Path, playItem, parsed.PlayListMark.Marks, index)).ToList(); - return new ChapterImportResult(true, [new ChapterInfoGroup(request.Path, options)], []); + var entries = parsed.PlayList.PlayItems.Select((playItem, index) => ToOption(request.Path, playItem, parsed.PlayListMark.Marks, index)).ToList(); + return new ChapterImportResult(true, [new ChapterImportSource(request.Path, entries)], []); } catch (Exception exception) when (exception is InvalidDataException or EndOfStreamException or IOException) { - return ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidMpls", exception.Message)); + return ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidMpls, exception.Message)); } } /// <summary> /// Executes the PtsToTime operation. /// </summary> - /// <param name="pts">The pts value.</param> + /// <param name="pts">The Blu-ray presentation timestamp in 45 kHz PTS units.</param> /// <returns>The operation result.</returns> public static TimeSpan PtsToTime(uint pts) { @@ -73,16 +73,14 @@ public static TimeSpan PtsToTime(uint pts) /// <param name="path">The source path.</param> /// <param name="title">The display title.</param> /// <param name="sourceName">The source display name.</param> - /// <param name="sourceIndex">The source index.</param> /// <param name="sourceType">The source type.</param> /// <param name="duration">The duration.</param> /// <returns>The operation result.</returns> - public static ChapterInfo ReadPlaylistInfo( + public static ChapterSet ReadPlaylistInfo( string path, string title = "", string? sourceName = null, - int sourceIndex = 0, - string sourceType = "MPLS", + ChapterImportFormat sourceType = ChapterImportFormat.Mpls, TimeSpan? duration = null) { using var stream = File.OpenRead(path); @@ -95,19 +93,16 @@ public static ChapterInfo ReadPlaylistInfo( .FirstOrDefault(); var totalPts = playItems.Aggregate(0UL, static (sum, item) => sum + item.OUTTime - item.INTime); - return new ChapterInfo( + return new ChapterSet( title, sourceName ?? string.Join("+", playItems.Select(static item => item.FullName)), - sourceIndex, sourceType, frameRateCode < FrameRates.Length ? FrameRates[frameRateCode] : 0, duration ?? PtsToTime(checked((uint)Math.Min(totalPts, uint.MaxValue))), - chapters, - Tag: path, - TagType: sourceType); + chapters); } - private static ChapterSourceOption ToOption(string path, MplsPlayItem playItem, IReadOnlyList<MplsMark> marks, int playItemIndex) + private static ChapterImportEntry ToOption(string path, MplsPlayItem playItem, IReadOnlyList<MplsMark> marks, int playItemIndex) { var matchingMarks = marks .Where(mark => mark.MarkType == 0x01 && mark.RefToPlayItemID == playItemIndex) @@ -124,21 +119,18 @@ private static ChapterSourceOption ToOption(string path, MplsPlayItem playItem, .Select((mark, index) => new Chapter(index + 1, PtsToTime(mark.MarkTimeStamp - offset), $"Chapter {index + 1:D2}")) .ToList(); var frameRateCode = playItem.STNTable.PrimaryVideoStreamEntries.FirstOrDefault()?.StreamAttributes.FrameRate ?? 0; - var info = new ChapterInfo( + var info = new ChapterSet( string.Empty, playItem.FullName, - playItemIndex, - "MPLS", + ChapterImportFormat.Mpls, frameRateCode < FrameRates.Length ? FrameRates[frameRateCode] : 0, PtsToTime(playItem.OUTTime - playItem.INTime), - chapters, - Tag: path, - TagType: "MPLS"); + chapters); var refs = playItem.FullName .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(clip => new SourceMediaReference($"{clip}.m2ts", Path.Combine("..", "STREAM", $"{clip}.m2ts"))) + .Select(clip => new ReferencedMediaFile($"{clip}.m2ts", Path.Combine("..", "STREAM", $"{clip}.m2ts"))) .ToList(); - return new ChapterSourceOption($"clip-{playItemIndex}", $"{playItem.FullName}__{chapters.Count}", info, CanCombine: true, MediaReferences: refs); + return new ChapterImportEntry($"clip-{playItemIndex}", $"{playItem.FullName}__{chapters.Count}", info, CanCombine: true, ReferencedMediaFiles: refs); } private static List<Chapter> PlaylistChapters(IReadOnlyList<MplsPlayItem> playItems, IReadOnlyList<MplsMark> marks) diff --git a/src/ChapterTool.Core/Importing/Disc/MplsPlaylistFile.cs b/src/ChapterTool.Core/Importing/Disc/MplsPlaylistFile.cs index 3fa299e..540766e 100644 --- a/src/ChapterTool.Core/Importing/Disc/MplsPlaylistFile.cs +++ b/src/ChapterTool.Core/Importing/Disc/MplsPlaylistFile.cs @@ -853,9 +853,19 @@ public static MplsExtensionData Read(Stream stream) entries.Add(MplsExtDataEntry.Read(stream)); } + if (dataBlockStartAddress > length) + { + throw new InvalidDataException("MPLS extension data block start address exceeds extension length."); + } + var dataBlockLength = length - dataBlockStartAddress; + if (dataBlockLength > int.MaxValue) + { + throw new InvalidDataException("MPLS extension data block is too large."); + } + stream.Position = basePosition + dataBlockStartAddress; - var dataBlock = stream.ReadExactBytes(checked((int)dataBlockLength)); + var dataBlock = stream.ReadExactBytes((int)dataBlockLength); return new MplsExtensionData(length, dataBlockStartAddress, numberOfExtDataEntries, entries, dataBlock); } } diff --git a/src/ChapterTool.Core/Importing/Disc/XplChapterImporter.cs b/src/ChapterTool.Core/Importing/Disc/XplChapterImporter.cs index 8b7c031..46d52a0 100644 --- a/src/ChapterTool.Core/Importing/Disc/XplChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Disc/XplChapterImporter.cs @@ -46,21 +46,21 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req document = await XDocument.LoadAsync(request.Content, LoadOptions.None, cancellationToken); } - var options = Parse(document, request.Path).ToList(); - if (options.Count == 0) + var entries = Parse(document, request.Path).ToList(); + if (entries.Count == 0) { - return ChapterImportResult.Failed(Error("XplNoChapters", "No HD-DVD chapters were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.XplNoChapters, "No HD-DVD chapters were parsed.")); } - return new ChapterImportResult(true, [new ChapterInfoGroup(request.Path, options)], []); + return new ChapterImportResult(true, [new ChapterImportSource(request.Path, entries)], []); } catch (Exception exception) when (exception is FormatException or InvalidDataException or InvalidOperationException or System.Xml.XmlException) { - return ChapterImportResult.Failed(Error("XplParseFailed", exception.Message)); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.XplParseFailed, exception.Message)); } } - private static IEnumerable<ChapterSourceOption> Parse(XDocument document, string path) + private static IEnumerable<ChapterImportEntry> Parse(XDocument document, string path) { var playlist = document.Element(Namespace + "Playlist") ?? throw new InvalidDataException("Missing XPL Playlist root."); var optionIndex = 0; @@ -90,18 +90,17 @@ private static IEnumerable<ChapterSourceOption> Parse(XDocument document, string } var sourceName = (string?)title.Element(Namespace + "PrimaryAudioVideoClip")?.Attribute("src") ?? string.Empty; - var info = new ChapterInfo( + var info = new ChapterSet( titleName, sourceName, - optionIndex, - "HD-DVD", + ChapterImportFormat.HdDvdXpl, 24, ParseTime(durationText, timeBase, tickBase, tickBaseDivisor), chapters); - IReadOnlyList<SourceMediaReference> mediaReferences = string.IsNullOrWhiteSpace(sourceName) + IReadOnlyList<ReferencedMediaFile> mediaReferences = string.IsNullOrWhiteSpace(sourceName) ? [] - : [new SourceMediaReference(Path.GetFileName(sourceName), Path.Combine("..", "HVDVD_TS", Path.GetFileName(sourceName)))]; - yield return new ChapterSourceOption($"title-{optionIndex}", $"{info.Title}__{chapters.Count}", info, MediaReferences: mediaReferences); + : [new ReferencedMediaFile(Path.GetFileName(sourceName), Path.Combine("..", "HVDVD_TS", Path.GetFileName(sourceName)))]; + yield return new ChapterImportEntry($"title-{optionIndex}", $"{info.Title}__{chapters.Count}", info, ReferencedMediaFiles: mediaReferences); optionIndex++; } } @@ -132,6 +131,6 @@ private static TimeSpan ParseTime(string value, double timeBase, double tickBase return main.Add(TimeSpan.FromTicks((long)ticks)); } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); } diff --git a/src/ChapterTool.Core/Importing/Media/MediaChapterEntry.cs b/src/ChapterTool.Core/Importing/Media/MediaChapterEntry.cs index 7680068..92493ad 100644 --- a/src/ChapterTool.Core/Importing/Media/MediaChapterEntry.cs +++ b/src/ChapterTool.Core/Importing/Media/MediaChapterEntry.cs @@ -3,14 +3,14 @@ namespace ChapterTool.Core.Importing.Media; /// <summary> /// Represents raw chapter metadata read from a media container. /// </summary> -/// <param name="Id">The Id value.</param> -/// <param name="TimeBase">The TimeBase value.</param> -/// <param name="Start">The Start value.</param> -/// <param name="End">The End value.</param> -/// <param name="StartTime">The StartTime value.</param> -/// <param name="EndTime">The EndTime value.</param> -/// <param name="Tags">The Tags value.</param> -/// <param name="SourceOrder">The SourceOrder value.</param> +/// <param name="Id">The chapter identifier reported by the media container, when available.</param> +/// <param name="TimeBase">The media time base used by <paramref name="Start"/> and <paramref name="End"/>.</param> +/// <param name="Start">The raw chapter start timestamp in media time-base units, when available.</param> +/// <param name="End">The raw chapter end timestamp in media time-base units, when available.</param> +/// <param name="StartTime">The formatted chapter start time reported by the media reader, when available.</param> +/// <param name="EndTime">The formatted chapter end time reported by the media reader, when available.</param> +/// <param name="Tags">Container tags associated with the chapter.</param> +/// <param name="SourceOrder">The zero-based order in which the media reader returned the chapter.</param> public sealed record MediaChapterEntry( int? Id, string? TimeBase, diff --git a/src/ChapterTool.Core/Importing/Media/MediaChapterImporter.cs b/src/ChapterTool.Core/Importing/Media/MediaChapterImporter.cs index 0faf400..a3ca9e9 100644 --- a/src/ChapterTool.Core/Importing/Media/MediaChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Media/MediaChapterImporter.cs @@ -8,11 +8,13 @@ namespace ChapterTool.Core.Importing.Media; /// Imports media chapter metadata through an injected media chapter reader. /// </summary> /// <param name="reader">The media chapter reader.</param> -/// <param name="supportedExtensions">The supportedExtensions value.</param> +/// <param name="supportedExtensions">The file extensions this importer should accept, or the default media extensions when omitted.</param> public sealed class MediaChapterImporter( IMediaChapterReader reader, IEnumerable<string>? supportedExtensions = null) : IChapterImporter { + private static readonly decimal MaxTimeSpanSeconds = (decimal)TimeSpan.MaxValue.Ticks / TimeSpan.TicksPerSecond; + private static readonly IReadOnlySet<string> DefaultSupportedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4", @@ -65,32 +67,32 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req { return ChapterImportResult.Failed(new ChapterDiagnostic( DiagnosticSeverity.Error, - read.DiagnosticCode ?? "MediaReadFailed", + read.DiagnosticCode ?? ChapterDiagnosticCode.MediaReadFailed, read.Message ?? "Media chapter reader failed.", Details: read.Details)); } if (read.Chapters.Count == 0) { - return ChapterImportResult.Failed(Error("NoChaptersFound", "No media chapters were read.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.NoChaptersFound, "No media chapters were read.")); } var diagnostics = new List<ChapterDiagnostic>(); var normalized = NormalizeEntries(read.Chapters, diagnostics); if (normalized.Count == 0) { - diagnostics.Add(Error("InvalidChapterTimestamp", "No media chapter had a valid non-negative start timestamp.")); + diagnostics.Add(Error(ChapterDiagnosticCode.InvalidChapterTimestamp, "No media chapter had a valid non-negative start timestamp.")); return new ChapterImportResult(false, [], diagnostics); } - var options = CreateOptions(request.Path, normalized); + var entries = CreateOptions(request.Path, normalized); return new ChapterImportResult( true, - [new ChapterInfoGroup(request.Path, options)], + [new ChapterImportSource(request.Path, entries)], diagnostics); } - private static IReadOnlyList<ChapterSourceOption> CreateOptions(string path, IReadOnlyList<NormalizedMediaChapter> chapters) + private static IReadOnlyList<ChapterImportEntry> CreateOptions(string path, IReadOnlyList<NormalizedMediaChapter> chapters) { if (chapters.Any(static chapter => !string.IsNullOrWhiteSpace(EditionUid(chapter.Entry)))) { @@ -100,13 +102,12 @@ private static IReadOnlyList<ChapterSourceOption> CreateOptions(string path, IRe var info = CreateInfo( Path.GetFileNameWithoutExtension(path), Path.GetFileName(path), - 0, chapters, renumberFallbacks: true); - return [new ChapterSourceOption("default", "FFprobe Chapters", info, MediaReferences: [CreateReference(path)])]; + return [new ChapterImportEntry("default", "FFprobe Chapters", info, ReferencedMediaFiles: [CreateReference(path)])]; } - private static List<ChapterSourceOption> CreateEditionOptions(string path, IReadOnlyList<NormalizedMediaChapter> chapters) + private static List<ChapterImportEntry> CreateEditionOptions(string path, IReadOnlyList<NormalizedMediaChapter> chapters) { var editionKeys = new List<string>(); foreach (var chapter in chapters) @@ -119,32 +120,31 @@ private static List<ChapterSourceOption> CreateEditionOptions(string path, IRead } var hasUntagged = chapters.Any(static chapter => string.IsNullOrWhiteSpace(EditionUid(chapter.Entry))); - var options = new List<ChapterSourceOption>(editionKeys.Count + (hasUntagged ? 1 : 0)); + var entries = new List<ChapterImportEntry>(editionKeys.Count + (hasUntagged ? 1 : 0)); var editionIndex = 0; foreach (var key in editionKeys) { - options.Add(CreateEditionOption(path, editionIndex++, chapters.Where(chapter => EditionUid(chapter.Entry) == key).ToList())); + entries.Add(CreateEditionOption(path, editionIndex++, chapters.Where(chapter => EditionUid(chapter.Entry) == key).ToList())); } if (hasUntagged) { - options.Add(CreateEditionOption(path, editionIndex, chapters.Where(static chapter => string.IsNullOrWhiteSpace(EditionUid(chapter.Entry))).ToList())); + entries.Add(CreateEditionOption(path, editionIndex, chapters.Where(static chapter => string.IsNullOrWhiteSpace(EditionUid(chapter.Entry))).ToList())); } - return options; + return entries; } - private static ChapterSourceOption CreateEditionOption(string path, int editionIndex, IReadOnlyList<NormalizedMediaChapter> chapters) + private static ChapterImportEntry CreateEditionOption(string path, int editionIndex, IReadOnlyList<NormalizedMediaChapter> chapters) { var title = $"Edition {editionIndex + 1:D2}"; - var info = CreateInfo(title, Path.GetFileName(path), editionIndex, chapters, renumberFallbacks: true); - return new ChapterSourceOption($"edition-{editionIndex}", title, info, CanCombine: false, MediaReferences: [CreateReference(path)]); + var info = CreateInfo(title, Path.GetFileName(path), chapters, renumberFallbacks: true); + return new ChapterImportEntry($"edition-{editionIndex}", title, info, CanCombine: false, ReferencedMediaFiles: [CreateReference(path)]); } - private static ChapterInfo CreateInfo( + private static ChapterSet CreateInfo( string title, string? sourceName, - int sourceIndex, IReadOnlyList<NormalizedMediaChapter> chapters, bool renumberFallbacks) { @@ -158,7 +158,7 @@ private static ChapterInfo CreateInfo( index + 1, chapter.Start, ChapterName(chapter.Entry, renumberFallbacks ? index + 1 : chapter.Entry.SourceOrder + 1), - End: chapter.End)) + EndTime: chapter.End)) .ToList(); var duration = ordered .Select(static chapter => chapter.End) @@ -167,7 +167,7 @@ private static ChapterInfo CreateInfo( .DefaultIfEmpty(TimeSpan.Zero) .Max(); - return new ChapterInfo(title, sourceName, sourceIndex, "MEDIA", 0, duration, modelChapters); + return new ChapterSet(title, sourceName, ChapterImportFormat.Media, 0, duration, modelChapters); } private static List<NormalizedMediaChapter> NormalizeEntries( @@ -182,7 +182,7 @@ private static List<NormalizedMediaChapter> NormalizeEntries( { diagnostics.Add(new ChapterDiagnostic( DiagnosticSeverity.Warning, - "InvalidChapterTimestamp", + ChapterDiagnosticCode.InvalidChapterTimestamp, $"Skipped media chapter at source index {entry.SourceOrder} because it has no valid non-negative start timestamp.")); continue; } @@ -208,7 +208,14 @@ private static List<NormalizedMediaChapter> NormalizeEntries( if (integerValue.HasValue && TryParseTimeBase(timeBase, out var numerator, out var denominator)) { - return SecondsToTimeSpan(integerValue.Value * numerator / denominator); + try + { + return SecondsToTimeSpan(integerValue.Value * numerator / denominator); + } + catch (OverflowException) + { + return null; + } } return null; @@ -235,8 +242,22 @@ private static bool TryParseTimeBase(string? value, out decimal numerator, out d return true; } - private static TimeSpan SecondsToTimeSpan(decimal seconds) => - TimeSpan.FromTicks((long)decimal.Round(seconds * TimeSpan.TicksPerSecond, 0, MidpointRounding.AwayFromZero)); + private static TimeSpan? SecondsToTimeSpan(decimal seconds) + { + if (seconds < 0 || seconds > MaxTimeSpanSeconds) + { + return null; + } + + try + { + return TimeSpan.FromTicks((long)decimal.Round(seconds * TimeSpan.TicksPerSecond, 0, MidpointRounding.AwayFromZero)); + } + catch (OverflowException) + { + return null; + } + } private static string ChapterName(MediaChapterEntry entry, int number) => TagValue(entry, "title") is { Length: > 0 } title @@ -250,10 +271,10 @@ private static string ChapterName(MediaChapterEntry entry, int number) => private static string? TagValue(MediaChapterEntry entry, string key) => entry.Tags.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null; - private static SourceMediaReference CreateReference(string path) => + private static ReferencedMediaFile CreateReference(string path) => new(Path.GetFileName(path), Path.GetFileName(path), path); - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); private sealed record NormalizedMediaChapter(MediaChapterEntry Entry, TimeSpan Start, TimeSpan? End); diff --git a/src/ChapterTool.Core/Importing/Media/MediaChapterReadResult.cs b/src/ChapterTool.Core/Importing/Media/MediaChapterReadResult.cs index 07560df..d89f1bf 100644 --- a/src/ChapterTool.Core/Importing/Media/MediaChapterReadResult.cs +++ b/src/ChapterTool.Core/Importing/Media/MediaChapterReadResult.cs @@ -3,15 +3,15 @@ namespace ChapterTool.Core.Importing.Media; /// <summary> /// Represents the result returned by a media chapter reader. /// </summary> -/// <param name="Success">The Success value.</param> -/// <param name="Chapters">The Chapters value.</param> -/// <param name="DiagnosticCode">The DiagnosticCode value.</param> -/// <param name="Message">The Message value.</param> -/// <param name="Details">The Details value.</param> +/// <param name="Success">Whether the media reader completed successfully.</param> +/// <param name="Chapters">The raw chapter entries returned by the media reader.</param> +/// <param name="DiagnosticCode">The diagnostic code to report when reading fails.</param> +/// <param name="Message">The diagnostic message to report when reading fails.</param> +/// <param name="Details">Additional diagnostic details from the media reader.</param> public sealed record MediaChapterReadResult( bool Success, IReadOnlyList<MediaChapterEntry> Chapters, - string? DiagnosticCode = null, + Diagnostics.ChapterDiagnosticCode? DiagnosticCode = null, string? Message = null, string? Details = null) { @@ -29,6 +29,6 @@ public sealed record MediaChapterReadResult( /// <param name="message">The diagnostic message.</param> /// <param name="details">Additional diagnostic details.</param> /// <returns>The operation result.</returns> - public static MediaChapterReadResult Failed(string code, string message, string? details = null) => + public static MediaChapterReadResult Failed(Diagnostics.ChapterDiagnosticCode code, string message, string? details = null) => new(false, [], code, message, details); } diff --git a/src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs b/src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs index 8f603b3..d15941c 100644 --- a/src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs @@ -48,7 +48,7 @@ public ChapterImportResult ImportText(string text, string path = "") var lines = text.Trim(' ', '\t', '\r', '\n').Split('\n'); if (lines.Length == 0 || !TimeLineRegex().IsMatch(lines[0])) { - return ChapterImportResult.Failed(Error("OgmInvalidFirstLine", "The first OGM chapter line is missing or invalid.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.OgmInvalidFirstLine, "The first OGM chapter line is missing or invalid.")); } var firstTimeText = TimeValueRegex().Match(lines[0]).Value; @@ -90,12 +90,12 @@ public ChapterImportResult ImportText(string text, string path = "") if (chapters.Count == 0) { - return ChapterImportResult.Failed(Error("EmptyChapters", "No OGM chapters were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.EmptyChapters, "No OGM chapters were parsed.")); } if (state == State.Name) { - diagnostics.Add(new ChapterDiagnostic(DiagnosticSeverity.Warning, "PartialParse", "Parsing stopped after a chapter time without a matching name.")); + diagnostics.Add(new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.PartialParse, "Parsing stopped after a chapter time without a matching name.")); return Success(path, chapters, diagnostics, isPartial: true); } @@ -110,11 +110,11 @@ private static ChapterImportResult PartialOrFailure( { if (chapters.Count == 0) { - return ChapterImportResult.Failed(Error("InvalidChapterPair", $"Unable to parse OGM chapter line: {line}", + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidChapterPair, $"Unable to parse OGM chapter line: {line}", new Dictionary<string, object?>(StringComparer.Ordinal) { ["line"] = line })); } - diagnostics.Add(new ChapterDiagnostic(DiagnosticSeverity.Warning, "PartialParse", $"Parsing stopped at line: {line}")); + diagnostics.Add(new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.PartialParse, $"Parsing stopped at line: {line}")); return Success(path, chapters, diagnostics, isPartial: true); } @@ -124,23 +124,22 @@ private static ChapterImportResult Success( IReadOnlyList<ChapterDiagnostic> diagnostics, bool isPartial) { - var info = new ChapterInfo( + var info = new ChapterSet( "OGM Chapters", Path.GetFileName(path), + ChapterImportFormat.Ogm, 0, - "OGM", - 0, - chapters[^1].Time, + chapters[^1].StartTime, chapters); - var option = new ChapterSourceOption("default", "OGM Chapters", info); - var group = new ChapterInfoGroup(path, [option]); + var entry = new ChapterImportEntry("default", "OGM Chapters", info); + var group = new ChapterImportSource(path, [entry]); return new ChapterImportResult(true, [group], diagnostics, isPartial); } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); - private static ChapterDiagnostic Error(string code, string message, IReadOnlyDictionary<string, object?> arguments) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message, IReadOnlyDictionary<string, object?> arguments) => new(DiagnosticSeverity.Error, code, message, Arguments: arguments); private enum State diff --git a/src/ChapterTool.Core/Importing/Text/PremiereMarkerListImporter.cs b/src/ChapterTool.Core/Importing/Text/PremiereMarkerListImporter.cs index e911a5c..772f0ed 100644 --- a/src/ChapterTool.Core/Importing/Text/PremiereMarkerListImporter.cs +++ b/src/ChapterTool.Core/Importing/Text/PremiereMarkerListImporter.cs @@ -61,7 +61,7 @@ public ChapterImportResult ImportText(string text, string path = "") return result; } - return ChapterImportResult.Failed(Error("PremiereMarkerListInvalid", "Unable to parse Adobe Premiere Pro chapter marker list.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.PremiereMarkerListInvalid, "Unable to parse Adobe Premiere Pro chapter marker list.")); } private bool TryParse(string text, string path, out ChapterImportResult result) @@ -137,16 +137,13 @@ private bool TryParse(string text, string path, out ChapterImportResult result) return false; } - var info = new ChapterInfo( + var info = new ChapterSet( "Adobe Premiere Pro Markers", Path.GetFileName(path), + ChapterImportFormat.PremiereMarkers, 0, - "Adobe Premiere Pro", - 0, - chapters[^1].Time, - chapters, - Tag: text, - TagType: typeof(string).FullName); + chapters[^1].StartTime, + chapters); result = TextImportUtilities.SingleGroup(path, info); return true; } @@ -292,7 +289,7 @@ private static decimal GuessFrameRate(int frame) return CommonFrameRates[^1]; } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); [GeneratedRegex(@"^\s*(?<Hour>\d+)\s*[:;]\s*(?<Minute>\d+)\s*[:;]\s*(?<Second>\d+)\s*[:;]\s*(?<Frame>\d+)\s*$")] diff --git a/src/ChapterTool.Core/Importing/Text/TextImportUtilities.cs b/src/ChapterTool.Core/Importing/Text/TextImportUtilities.cs index d838b8c..eb19711 100644 --- a/src/ChapterTool.Core/Importing/Text/TextImportUtilities.cs +++ b/src/ChapterTool.Core/Importing/Text/TextImportUtilities.cs @@ -28,10 +28,10 @@ public static async ValueTask<string> ReadTextAsync(ChapterImportRequest request /// <param name="path">The source path.</param> /// <param name="info">The chapter data to process.</param> /// <returns>The operation result.</returns> - public static ChapterImportResult SingleGroup(string path, ChapterInfo info) + public static ChapterImportResult SingleGroup(string path, ChapterSet info) { - var option = new ChapterSourceOption("default", info.Title, info); - var group = new ChapterInfoGroup(path, [option]); + var entry = new ChapterImportEntry("default", info.Title, info); + var group = new ChapterImportSource(path, [entry]); return ChapterImportResult.Succeeded(group); } } diff --git a/src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs b/src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs index 4d24bf1..fd5d56d 100644 --- a/src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs @@ -45,7 +45,7 @@ public static ChapterImportResult ImportText(string text, string path = "") var blocks = text.Split("\n\n"); if (blocks.Length == 0 || !blocks[0].TrimStart().StartsWith("WEBVTT", StringComparison.Ordinal)) { - return ChapterImportResult.Failed(Error("WebVttInvalidHeader", "WebVTT header is missing.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.WebVttInvalidHeader, "WebVTT header is missing.")); } var chapters = new List<Chapter>(); @@ -54,37 +54,36 @@ public static ChapterImportResult ImportText(string text, string path = "") var lines = block.Split('\n').SkipWhile(static line => !line.Contains("-->", StringComparison.Ordinal)).ToArray(); if (lines.Length < 2) { - return ChapterImportResult.Failed(Error("WebVttMalformedCue", $"Unable to parse WebVTT cue: {block}")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.WebVttMalformedCue, $"Unable to parse WebVTT cue: {block}")); } var parts = lines[0].Split("-->", StringSplitOptions.TrimEntries); if (parts.Length != 2 || !TimeSpan.TryParse(parts[0], out var start) || !TimeSpan.TryParse(parts[1], out var end)) { var code = parts.Length == 2 && parts[1].Contains(' ', StringComparison.Ordinal) - ? "WebVttUnsupportedTimingSettings" - : "WebVttMalformedCue"; + ? ChapterDiagnosticCode.WebVttUnsupportedTimingSettings + : ChapterDiagnosticCode.WebVttMalformedCue; return ChapterImportResult.Failed(Error(code, $"Unable to parse WebVTT timing line: {lines[0]}")); } - chapters.Add(new Chapter(chapters.Count + 1, start, lines[1], End: end)); + chapters.Add(new Chapter(chapters.Count + 1, start, lines[1], EndTime: end)); } if (chapters.Count == 0) { - return ChapterImportResult.Failed(Error("WebVttMalformedCue", "No WebVTT cues were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.WebVttMalformedCue, "No WebVTT cues were parsed.")); } - var info = new ChapterInfo( + var info = new ChapterSet( "WebVTT Chapters", Path.GetFileName(path), + ChapterImportFormat.WebVtt, 0, - "WebVTT", - 0, - chapters[^1].End ?? chapters[^1].Time, + chapters[^1].EndTime ?? chapters[^1].StartTime, chapters); return TextImportUtilities.SingleGroup(path, info); } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); } diff --git a/src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs b/src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs index 9325cd9..47fddb1 100644 --- a/src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs +++ b/src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs @@ -43,7 +43,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req } catch (XmlException exception) { - return ChapterImportResult.Failed(Error("InvalidXml", exception.Message)); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidXml, exception.Message)); } } @@ -56,7 +56,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req } catch (XmlException exception) { - return ChapterImportResult.Failed(Error("InvalidXml", exception.Message)); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidXml, exception.Message)); } } @@ -75,7 +75,7 @@ public ChapterImportResult ImportText(string text, string path = "") } catch (XmlException exception) { - return ChapterImportResult.Failed(Error("InvalidXml", exception.Message)); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidXml, exception.Message)); } return ParseDocument(document, path); @@ -86,18 +86,19 @@ private ChapterImportResult ParseDocument(XmlDocument document, string path) var root = document.DocumentElement; if (root is null) { - return ChapterImportResult.Failed(Error("EmptyXml", "XML document has no root element.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.EmptyXml, "XML document has no root element.")); } if (root.Name != "Chapters") { - return ChapterImportResult.Failed(Error("XmlInvalidRoot", $"Expected Chapters root, got {root.Name}.", + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.XmlInvalidRoot, $"Expected Chapters root, got {root.Name}.", new Dictionary<string, object?>(StringComparer.Ordinal) { ["name"] = root.Name })); } - var groups = new List<ChapterInfoGroup>(); - var options = new List<ChapterSourceOption>(); - var defaultOptionIndex = 0; + var groups = new List<ChapterImportSource>(); + var entries = new List<ChapterImportEntry>(); + var defaultEntryIndex = 0; + var hasDefaultEdition = false; var editionIndex = 0; foreach (XmlNode child in root.ChildNodes) { @@ -108,7 +109,7 @@ private ChapterImportResult ParseDocument(XmlDocument document, string path) if (child.Name != "EditionEntry") { - return ChapterImportResult.Failed(Error("InvalidEntryElement", $"Expected EditionEntry, got {child.Name}.", + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidEntryElement, $"Expected EditionEntry, got {child.Name}.", new Dictionary<string, object?>(StringComparer.Ordinal) { ["name"] = child.Name })); } @@ -129,36 +130,36 @@ private ChapterImportResult ParseDocument(XmlDocument document, string path) for (var i = 0; i < chapters.Count - 1; i++) { - if (chapters[i].Time == chapters[i + 1].Time) + if (chapters[i].StartTime == chapters[i + 1].StartTime) { chapters.RemoveAt(i--); } } - var info = new ChapterInfo( + var info = new ChapterSet( $"Edition {editionIndex + 1:D2}", Path.GetFileName(path), - editionIndex, - "XML", + ChapterImportFormat.MatroskaXml, 0, - chapters.Count == 0 ? TimeSpan.Zero : chapters[^1].Time, + chapters.Count == 0 ? TimeSpan.Zero : chapters[^1].StartTime, Renumber(chapters)); - options.Add(new ChapterSourceOption($"edition-{editionIndex}", info.Title, info)); + entries.Add(new ChapterImportEntry($"edition-{editionIndex}", info.Title, info)); - if (isDefaultEdition && defaultOptionIndex == 0) + if (isDefaultEdition && !hasDefaultEdition) { - defaultOptionIndex = editionIndex; + defaultEntryIndex = editionIndex; + hasDefaultEdition = true; } editionIndex++; } - if (options.Count == 0 || options.All(static option => option.ChapterInfo.Chapters.Count == 0)) + if (entries.Count == 0 || entries.All(static entry => entry.ChapterSet.Chapters.Count == 0)) { - return ChapterImportResult.Failed(Error("XmlNoChapters", "No Matroska XML chapters were parsed.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.XmlNoChapters, "No Matroska XML chapters were parsed.")); } - groups.Add(new ChapterInfoGroup(path, options, defaultOptionIndex)); + groups.Add(new ChapterImportSource(path, entries, defaultEntryIndex)); return new ChapterImportResult(true, groups, []); } @@ -192,7 +193,7 @@ private IEnumerable<Chapter> ParseAtom(XmlNode atom, int index) if (hasStart) { - yield return new Chapter(index, start, name, End: end); + yield return new Chapter(index, start, name, EndTime: end); } foreach (var chapter in inner) @@ -203,11 +204,11 @@ private IEnumerable<Chapter> ParseAtom(XmlNode atom, int index) } private static IReadOnlyList<Chapter> Renumber(IReadOnlyList<Chapter> chapters) => - chapters.Select((chapter, index) => chapter with { Number = index + 1 }).ToList(); + chapters.Select((chapter, index) => chapter with { DisplayNumber = index + 1 }).ToList(); - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); - private static ChapterDiagnostic Error(string code, string message, IReadOnlyDictionary<string, object?> arguments) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message, IReadOnlyDictionary<string, object?> arguments) => new(DiagnosticSeverity.Error, code, message, Arguments: arguments); } diff --git a/src/ChapterTool.Core/Models/Chapter.cs b/src/ChapterTool.Core/Models/Chapter.cs index fc4a348..aa4129e 100644 --- a/src/ChapterTool.Core/Models/Chapter.cs +++ b/src/ChapterTool.Core/Models/Chapter.cs @@ -1,31 +1,52 @@ namespace ChapterTool.Core.Models; /// <summary> -/// Represents a single chapter marker with timing, naming, and frame metadata. +/// Represents a single chapter row with timing, naming, frame metadata, and structural role. /// </summary> -/// <param name="Number">The Number value.</param> -/// <param name="Time">The Time value.</param> -/// <param name="Name">The Name value.</param> -/// <param name="FramesInfo">The FramesInfo value.</param> -/// <param name="End">The End value.</param> -/// <param name="FrameAccuracy">The FrameAccuracy value.</param> +/// <param name="DisplayNumber">The output-facing chapter number.</param> +/// <param name="StartTime">The chapter start time.</param> +/// <param name="Name">The chapter display name.</param> +/// <param name="FramesInfo">The frame display/edit text associated with the chapter.</param> +/// <param name="EndTime">The optional chapter end time.</param> +/// <param name="FrameAccuracy">Whether the start time lands exactly on a frame boundary.</param> +/// <param name="Kind">The chapter row kind.</param> public sealed record Chapter( - int Number, - TimeSpan Time, + int DisplayNumber, + TimeSpan StartTime, string Name, string FramesInfo = "", - TimeSpan? End = null, - FrameAccuracy FrameAccuracy = FrameAccuracy.Neutral) + TimeSpan? EndTime = null, + FrameAccuracy FrameAccuracy = FrameAccuracy.Neutral, + ChapterKind Kind = ChapterKind.Marker) { /// <summary> - /// Gets the SeparatorTime value. + /// Gets whether this row is a structural separator rather than a real chapter marker. /// </summary> - public static readonly TimeSpan SeparatorTime = TimeSpan.MinValue; + public bool IsSeparator => Kind == ChapterKind.Separator; /// <summary> - /// Gets the IsSeparator value. + /// Creates a structural separator row. /// </summary> - public bool IsSeparator => Time == SeparatorTime; + /// <param name="name">The optional separator label.</param> + /// <returns>A separator chapter row.</returns> + public static Chapter Separator(string name = "") => + new(0, TimeSpan.Zero, name, Kind: ChapterKind.Separator); +} + +/// <summary> +/// Identifies whether a chapter row is a real marker or a structural separator. +/// </summary> +public enum ChapterKind +{ + /// <summary> + /// A real chapter marker with a meaningful start time. + /// </summary> + Marker, + + /// <summary> + /// A structural separator row used to split chapter sections. + /// </summary> + Separator } /// <summary> diff --git a/src/ChapterTool.Core/Models/ChapterImportEntry.cs b/src/ChapterTool.Core/Models/ChapterImportEntry.cs new file mode 100644 index 0000000..e6627a2 --- /dev/null +++ b/src/ChapterTool.Core/Models/ChapterImportEntry.cs @@ -0,0 +1,20 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Represents one chapter set entry discovered for an imported source. +/// </summary> +/// <param name="Id">The stable entry identifier within the imported source.</param> +/// <param name="DisplayName">The label shown when the user selects among imported entries.</param> +/// <param name="ChapterSet">The chapter data represented by this import entry.</param> +/// <param name="CanCombine">Whether this entry can be combined with sibling entries from the same source.</param> +/// <param name="ReferencedMediaFiles">The media files referenced by this chapter entry, when known.</param> +public sealed record ChapterImportEntry( + string Id, + string DisplayName, + ChapterSet ChapterSet, + bool CanCombine = false, + IReadOnlyList<ReferencedMediaFile>? ReferencedMediaFiles = null) +{ + /// <inheritdoc /> + public override string ToString() => DisplayName; +} diff --git a/src/ChapterTool.Core/Models/ChapterImportFormat.cs b/src/ChapterTool.Core/Models/ChapterImportFormat.cs new file mode 100644 index 0000000..fdd17fe --- /dev/null +++ b/src/ChapterTool.Core/Models/ChapterImportFormat.cs @@ -0,0 +1,62 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Identifies the origin format or importer family for a chapter set. +/// </summary> +public enum ChapterImportFormat +{ + /// <summary> + /// Import format is unknown or intentionally empty. + /// </summary> + Unknown = 0, + + /// <summary> + /// OGM-style text chapters. + /// </summary> + Ogm = 10, + + /// <summary> + /// Matroska XML chapters. + /// </summary> + MatroskaXml = 20, + + /// <summary> + /// WebVTT chapter cues. + /// </summary> + WebVtt = 30, + + /// <summary> + /// CUE sheet chapters. + /// </summary> + Cue = 40, + + /// <summary> + /// Adobe Premiere Pro marker list chapters. + /// </summary> + PremiereMarkers = 50, + + /// <summary> + /// Blu-ray MPLS playlist chapters. + /// </summary> + Mpls = 60, + + /// <summary> + /// DVD IFO chapters. + /// </summary> + DvdIfo = 70, + + /// <summary> + /// HD-DVD XPL chapters. + /// </summary> + HdDvdXpl = 80, + + /// <summary> + /// Generic media-container chapters read by media metadata readers. + /// </summary> + Media = 90, + + /// <summary> + /// Blu-ray disc chapters discovered through BDMV/eac3to integration. + /// </summary> + Bdmv = 100 +} diff --git a/src/ChapterTool.Core/Models/ChapterImportFormats.cs b/src/ChapterTool.Core/Models/ChapterImportFormats.cs new file mode 100644 index 0000000..7c55f46 --- /dev/null +++ b/src/ChapterTool.Core/Models/ChapterImportFormats.cs @@ -0,0 +1,47 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Provides stable metadata for chapter source types. +/// </summary> +public static class ChapterImportFormats +{ + /// <summary> + /// Returns the stable machine code for a chapter source type. + /// </summary> + /// <param name="sourceType">The source type.</param> + /// <returns>The stable code.</returns> + public static string Code(ChapterImportFormat sourceType) => sourceType switch + { + ChapterImportFormat.Ogm => "ogm", + ChapterImportFormat.MatroskaXml => "matroska-xml", + ChapterImportFormat.WebVtt => "webvtt", + ChapterImportFormat.Cue => "cue", + ChapterImportFormat.PremiereMarkers => "premiere-markers", + ChapterImportFormat.Mpls => "mpls", + ChapterImportFormat.DvdIfo => "dvd-ifo", + ChapterImportFormat.HdDvdXpl => "hddvd-xpl", + ChapterImportFormat.Media => "media", + ChapterImportFormat.Bdmv => "bdmv", + _ => "unknown" + }; + + /// <summary> + /// Returns the user-facing display name for a chapter source type. + /// </summary> + /// <param name="sourceType">The source type.</param> + /// <returns>The display name.</returns> + public static string DisplayName(ChapterImportFormat sourceType) => sourceType switch + { + ChapterImportFormat.Ogm => "OGM", + ChapterImportFormat.MatroskaXml => "Matroska XML", + ChapterImportFormat.WebVtt => "WebVTT", + ChapterImportFormat.Cue => "CUE", + ChapterImportFormat.PremiereMarkers => "Adobe Premiere Pro markers", + ChapterImportFormat.Mpls => "Blu-ray MPLS", + ChapterImportFormat.DvdIfo => "DVD IFO", + ChapterImportFormat.HdDvdXpl => "HD-DVD XPL", + ChapterImportFormat.Media => "Media metadata", + ChapterImportFormat.Bdmv => "BDMV", + _ => string.Empty + }; +} diff --git a/src/ChapterTool.Core/Models/ChapterImportSource.cs b/src/ChapterTool.Core/Models/ChapterImportSource.cs new file mode 100644 index 0000000..4bd13c8 --- /dev/null +++ b/src/ChapterTool.Core/Models/ChapterImportSource.cs @@ -0,0 +1,12 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Represents one source path and the chapter entries discovered for that source. +/// </summary> +/// <param name="SourcePath">The path of the imported source file.</param> +/// <param name="Entries">The chapter entries discovered for the source.</param> +/// <param name="DefaultEntryIndex">The zero-based entry index selected by default.</param> +public sealed record ChapterImportSource( + string SourcePath, + IReadOnlyList<ChapterImportEntry> Entries, + int DefaultEntryIndex = 0); diff --git a/src/ChapterTool.Core/Models/ChapterInfo.cs b/src/ChapterTool.Core/Models/ChapterInfo.cs deleted file mode 100644 index e563ff0..0000000 --- a/src/ChapterTool.Core/Models/ChapterInfo.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace ChapterTool.Core.Models; - -/// <summary> -/// Represents a chapter collection and source metadata loaded from or written to a media source. -/// </summary> -/// <param name="Title">The Title value.</param> -/// <param name="SourceName">The SourceName value.</param> -/// <param name="SourceIndex">The SourceIndex value.</param> -/// <param name="SourceType">The SourceType value.</param> -/// <param name="FramesPerSecond">The FramesPerSecond value.</param> -/// <param name="Duration">The Duration value.</param> -/// <param name="Chapters">The Chapters value.</param> -/// <param name="Expression">The Expression value.</param> -/// <param name="Tag">The Tag value.</param> -/// <param name="TagType">The TagType value.</param> -public sealed record ChapterInfo( - string Title, - string? SourceName, - int SourceIndex, - string SourceType, - double FramesPerSecond, - TimeSpan Duration, - IReadOnlyList<Chapter> Chapters, - string Expression = "t", - object? Tag = null, - string? TagType = null); diff --git a/src/ChapterTool.Core/Models/ChapterInfoGroup.cs b/src/ChapterTool.Core/Models/ChapterInfoGroup.cs deleted file mode 100644 index b05defa..0000000 --- a/src/ChapterTool.Core/Models/ChapterInfoGroup.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ChapterTool.Core.Models; - -/// <summary> -/// Represents one source path and the chapter options discovered for that source. -/// </summary> -/// <param name="SourcePath">The SourcePath value.</param> -/// <param name="Options">The Options value.</param> -/// <param name="DefaultOptionIndex">The DefaultOptionIndex value.</param> -public sealed record ChapterInfoGroup( - string SourcePath, - IReadOnlyList<ChapterSourceOption> Options, - int DefaultOptionIndex = 0); diff --git a/src/ChapterTool.Core/Models/ChapterSet.cs b/src/ChapterTool.Core/Models/ChapterSet.cs new file mode 100644 index 0000000..7d5ccac --- /dev/null +++ b/src/ChapterTool.Core/Models/ChapterSet.cs @@ -0,0 +1,18 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Represents a chapter collection and source metadata loaded from or written to a media source. +/// </summary> +/// <param name="Title">The display title for the chapter collection.</param> +/// <param name="SourceName">The source file, playlist, or stream name associated with the chapters.</param> +/// <param name="ImportFormat">The format that produced or should represent the chapter data.</param> +/// <param name="FramesPerSecond">The frame rate associated with frame-based chapter calculations.</param> +/// <param name="Duration">The total duration covered by the chapter collection.</param> +/// <param name="Chapters">The ordered chapter entries in the collection.</param> +public sealed record ChapterSet( + string Title, + string? SourceName, + ChapterImportFormat ImportFormat, + double FramesPerSecond, + TimeSpan Duration, + IReadOnlyList<Chapter> Chapters); diff --git a/src/ChapterTool.Core/Models/ChapterSourceOption.cs b/src/ChapterTool.Core/Models/ChapterSourceOption.cs deleted file mode 100644 index bd3fff1..0000000 --- a/src/ChapterTool.Core/Models/ChapterSourceOption.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ChapterTool.Core.Models; - -/// <summary> -/// Represents one selectable chapter option within an imported source group. -/// </summary> -/// <param name="Id">The Id value.</param> -/// <param name="DisplayName">The DisplayName value.</param> -/// <param name="ChapterInfo">The ChapterInfo value.</param> -/// <param name="CanCombine">The CanCombine value.</param> -/// <param name="MediaReferences">The MediaReferences value.</param> -public sealed record ChapterSourceOption( - string Id, - string DisplayName, - ChapterInfo ChapterInfo, - bool CanCombine = false, - IReadOnlyList<SourceMediaReference>? MediaReferences = null) -{ - /// <inheritdoc /> - public override string ToString() => DisplayName; -} diff --git a/src/ChapterTool.Core/Models/ReferencedMediaFile.cs b/src/ChapterTool.Core/Models/ReferencedMediaFile.cs new file mode 100644 index 0000000..02775c3 --- /dev/null +++ b/src/ChapterTool.Core/Models/ReferencedMediaFile.cs @@ -0,0 +1,12 @@ +namespace ChapterTool.Core.Models; + +/// <summary> +/// Describes a source media file referenced by an imported chapter set. +/// </summary> +/// <param name="DisplayName">The file name or label shown for the referenced media file.</param> +/// <param name="RelativePath">The media path relative to the importing source, when available.</param> +/// <param name="AbsolutePath">The resolved absolute media path, when available.</param> +public sealed record ReferencedMediaFile( + string DisplayName, + string RelativePath, + string? AbsolutePath = null); diff --git a/src/ChapterTool.Core/Models/SourceMediaReference.cs b/src/ChapterTool.Core/Models/SourceMediaReference.cs deleted file mode 100644 index 82b7020..0000000 --- a/src/ChapterTool.Core/Models/SourceMediaReference.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ChapterTool.Core.Models; - -/// <summary> -/// Describes a source media file referenced by an imported chapter set. -/// </summary> -/// <param name="DisplayName">The DisplayName value.</param> -/// <param name="RelativePath">The RelativePath value.</param> -/// <param name="AbsolutePath">The AbsolutePath value.</param> -public sealed record SourceMediaReference( - string DisplayName, - string RelativePath, - string? AbsolutePath = null); diff --git a/src/ChapterTool.Core/README.md b/src/ChapterTool.Core/README.md index 3a04199..690ffca 100644 --- a/src/ChapterTool.Core/README.md +++ b/src/ChapterTool.Core/README.md @@ -11,10 +11,11 @@ dotnet add package ChapterTool.Core ## Features - **Import** chapters from common chapter formats and media-container adapters: CUE, FLAC, TAK, IFO, MPLS, XPL, MP4/media containers via `IMediaChapterReader`, OGM, Matroska XML, WebVTT, plain text, Premiere markers -- **Export** chapters to multiple chapter formats: OGM Text, Matroska XML, QPFile, TimeCodes, tsMuxeR Meta, CUE, JSON, WebVTT, Celltimes, Chapter→QPFile +- **Export** chapters to multiple chapter formats: OGM Text, Matroska XML, QPFile, TimeCodes, tsMuxeR Meta, CUE, JSON, WebVTT, Celltimes +- **Convert** OGM chapter text to QPFile output, optionally using timecode mappings - **Edit** chapter data: time edit, frame edit, rename, delete, insert, reorder, shift, apply name templates -- **Transform** chapter times: infix math expression engine, Lua scripting engine, frame rate detection and conversion -- **Combine & append** chapter segments from multi-part sources (MPLS/DVD) +- **Transform** chapter times: pluggable expression engines, frame rate detection and conversion +- **Combine & append** chapter segments from multipart sources (MPLS/DVD) ## Quick Start @@ -22,15 +23,11 @@ dotnet add package ChapterTool.Core ```csharp using ChapterTool.Core.Importing; -using ChapterTool.Core.Importing.Cue; -using ChapterTool.Core.Models; -using ChapterTool.Core.Transform; - -var formatter = new ChapterTimeFormatter(); +using ChapterTool.Core.Importing.Disc; -// CUE files don't require extra dependencies -IChapterImporter importer = new CueChapterImporter(); -var request = new ChapterImportRequest("/path/to/chapters.cue"); +// MPLS playlists can expose one entry per play item/angle. +IChapterImporter importer = new MplsChapterImporter(); +var request = new ChapterImportRequest("/path/to/BDMV/PLAYLIST/00001.mpls"); var result = await importer.ImportAsync(request, CancellationToken.None); if (result.Success) @@ -38,9 +35,9 @@ if (result.Success) foreach (var group in result.Groups) { Console.WriteLine($"Source: {group.SourcePath}"); - foreach (var option in group.Options) + foreach (var entry in group.Entries) { - Console.WriteLine($" {option.DisplayName}: {option.ChapterInfo.Chapters.Count} chapters"); + Console.WriteLine($" {entry.DisplayName}: {entry.ChapterSet.Chapters.Count} chapters"); } } } @@ -55,12 +52,12 @@ using ChapterTool.Core.Transform; var formatter = new ChapterTimeFormatter(); var exportService = new ChapterExportService(formatter); -var options = new ChapterExportOptions( +var entries = new ChapterExportOptions( Format: ChapterExportFormat.Xml, XmlLanguage: "eng" ); -var result = exportService.Export(chapterInfo, options); +var result = exportService.Export(chapterInfo, entries); if (result.Success) { File.WriteAllText($"output{result.FileExtension}", result.Content); @@ -71,6 +68,7 @@ if (result.Success) ```csharp using ChapterTool.Core.Editing; +using ChapterTool.Core.Transform; var editor = new ChapterEditingService(new ChapterTimeFormatter()); @@ -91,6 +89,8 @@ result = editor.ApplyTemplate(info, "Opening\nMain Feature\nCredits"); ```csharp using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using ChapterTool.Core.Transform.Expressions.Lua; // Frame rate detection var fpsService = new FrameRateService(); @@ -99,34 +99,29 @@ var detected = fpsService.Detect(info, tolerance: 0.001m); // Change frame rate var fpsResult = ChapterFpsTransformService.ChangeFps(info, sourceFps: 23.976m, targetFps: 25m); -// Evaluate a mathematical expression against chapter times -var exprService = new ExpressionService(); -var eval = exprService.EvaluateInfix("t + 1.0", timeSeconds: 60m, framesPerSecond: 23.976m); - -// Use Lua scripting for advanced transforms -var luaService = new LuaExpressionScriptService(); -var context = new LuaExpressionContext(chapter, index: 1, count: 10, timeSeconds: 60m, framesPerSecond: 23.976m); -var luaResult = luaService.Evaluate("return t + 1", context); +IChapterExpressionEngine expressionEngine = new LuaExpressionScriptService(); +var context = new ChapterExpressionContext(chapter, Index: 1, Count: 10, TimeSeconds: 60m, FramesPerSecond: 23.976m); +var expressionResult = expressionEngine.Evaluate("t + 1.0", context); ``` ## Supported Formats ### Import -| Format | Importer | Extensions | -|--------|----------|------------| -| CUE Sheet | `CueChapterImporter` | `.cue` | -| FLAC CUE | `FlacCueImporter` | `.flac` | -| TAK CUE | `TakCueImporter` | `.tak` | -| DVD IFO | `IfoChapterImporter` | `.ifo` | -| Blu-ray MPLS | `MplsChapterImporter` | `.mpls` | -| Blu-ray XPL | `XplChapterImporter` | `.xpl` | +| Format | Importer | Extensions | +|----------------------------------|-------------------------------------------------------------------|--------------| +| CUE Sheet | `CueChapterImporter` | `.cue` | +| FLAC CUE | `FlacCueImporter` | `.flac` | +| TAK CUE | `TakCueImporter` | `.tak` | +| DVD IFO | `IfoChapterImporter` | `.ifo` | +| Blu-ray MPLS | `MplsChapterImporter` | `.mpls` | +| Blu-ray XPL | `XplChapterImporter` | `.xpl` | | Media files, including MP4 / M4V | `MediaChapterImporter` with caller-supplied `IMediaChapterReader` | configurable | -| OGM Text | `OgmChapterImporter` | `.txt` | -| Matroska XML | `XmlChapterImporter` | `.xml` | -| WebVTT | `WebVttChapterImporter` | `.vtt` | -| Plain Text | `TextChapterImporter` | `.txt` | -| Premiere Markers | `PremiereMarkerListImporter` | `.csv` | +| OGM Text | `OgmChapterImporter` | `.txt` | +| Matroska XML | `XmlChapterImporter` | `.xml` | +| WebVTT | `WebVttChapterImporter` | `.vtt` | +| Plain Text | `TextChapterImporter` | `.txt` | +| Premiere Markers | `PremiereMarkerListImporter` | `.csv` | ### Media Reader Adapters @@ -163,29 +158,27 @@ ValueTask<MediaChapterReadResult> ReadAsync(string path, CancellationToken cance ### Export -| Format | `ChapterExportFormat` | -|--------|----------------------| -| OGM Text | `Txt` | -| Matroska XML | `Xml` | -| QPFile | `Qpfile` | -| TimeCodes | `TimeCodes` | -| tsMuxeR Meta | `TsMuxerMeta` | -| CUE Sheet | `Cue` | -| JSON | `Json` | -| WebVTT | `WebVtt` | -| Celltimes | `Celltimes` | -| Chapter → QPFile | `Chapter2Qpfile` | +| Format | `ChapterExportFormat` | +|------------------|-----------------------| +| OGM Text | `Txt` | +| Matroska XML | `Xml` | +| QPFile | `Qpfile` | +| TimeCodes | `TimeCodes` | +| tsMuxeR Meta | `TsMuxerMeta` | +| CUE Sheet | `Cue` | +| JSON | `Json` | +| WebVTT | `WebVtt` | +| Celltimes | `Celltimes` | ## Expression Engine -`ExpressionService` supports infix mathematical expressions with: - -- **Variables**: `t` (time in seconds), `fps` (frames per second) -- **Operators**: `+`, `-`, `*`, `/`, `%`, `^`, `>`, `<`, `>=`, `<=`, ternary `?:` -- **Functions**: `abs`, `acos`, `asin`, `atan`, `atan2`, `cos`, `sin`, `tan`, `cosh`, `sinh`, `tanh`, `exp`, `log`, `log10`, `sqrt`, `ceil`, `floor`, `round`, `int`, `sign`, `pow`, `max`, `min` -- **Constants**: `M_E`, `M_PI`, `M_LN2`, `M_LN10`, `M_SQRT2`, etc. +`IChapterExpressionEngine` is the standard interface for chapter time expression evaluation. +`LuaExpressionScriptService` is the built-in Lua implementation under `ChapterTool.Core.Transform.Expressions.Lua`. Simple arithmetic can be entered without `return`, while full scripts may use `return` or define `transform(chapter)`. -For advanced transforms, `LuaExpressionScriptService` provides full Lua scripting with access to chapter properties (`t`, `fps`, `index`, `count`) and the `math` standard library. Built-in presets include identity, time offset, frame rounding, and half-frame shift. +- **Globals**: `t` (time in seconds), `fps` (frames per second), `index`, `count`, and `chapter` +- **Libraries**: safe Lua `math`, `string`, and `table` libraries +- **Aliases**: common math helpers such as `floor`, `ceil`, `round`, `sin`, `sqrt`, and `sign` +- **Presets**: identity, time offset, frame rounding, and half-frame shift ## Diagnostic System @@ -214,7 +207,7 @@ public interface IChapterImporter } ``` -Implement `IChapterExporter` to add custom export formats, or `IMediaChapterReader` to support reading chapters from additional media container formats. +Implement `IChapterExporter` to add custom export formats, `IMediaChapterReader` to support reading chapters from additional media container formats, or `IChapterExpressionEngine` to evaluate chapter time expressions with another language or execution framework. ## License diff --git a/src/ChapterTool.Core/Transform/ChangeFpsResult.cs b/src/ChapterTool.Core/Transform/ChangeFpsResult.cs index cc6ffc4..3a9c0b3 100644 --- a/src/ChapterTool.Core/Transform/ChangeFpsResult.cs +++ b/src/ChapterTool.Core/Transform/ChangeFpsResult.cs @@ -6,10 +6,10 @@ namespace ChapterTool.Core.Transform; /// <summary> /// Represents the result of changing chapter timing between frame rates. /// </summary> -/// <param name="Success">The Success value.</param> -/// <param name="Info">The Info value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> +/// <param name="Success">Whether the frame rate change completed successfully.</param> +/// <param name="Info">The chapter set after frame rate conversion.</param> +/// <param name="Diagnostics">Diagnostics produced while changing frame rates.</param> public sealed record ChangeFpsResult( bool Success, - ChapterInfo Info, + ChapterSet Info, IReadOnlyList<ChapterDiagnostic> Diagnostics); diff --git a/src/ChapterTool.Core/Transform/ChapterExpressionService.cs b/src/ChapterTool.Core/Transform/ChapterExpressionService.cs index 9f39a71..14f6768 100644 --- a/src/ChapterTool.Core/Transform/ChapterExpressionService.cs +++ b/src/ChapterTool.Core/Transform/ChapterExpressionService.cs @@ -1,6 +1,7 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; using System.Globalization; +using ChapterTool.Core.Transform.Expressions; namespace ChapterTool.Core.Transform; @@ -9,24 +10,15 @@ namespace ChapterTool.Core.Transform; /// </summary> public sealed class ChapterExpressionService { - private readonly ILuaExpressionScriptService luaExpressionService; + private readonly IChapterExpressionEngine expressionEngine; /// <summary> /// Applies expression-based time transforms to chapter data. /// </summary> - /// <param name="luaExpressionService">The Lua expression service.</param> - public ChapterExpressionService(ILuaExpressionScriptService? luaExpressionService = null) - { - this.luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService(); - } - - /// <summary> - /// Applies expression-based time transforms to chapter data. - /// </summary> - /// <param name="_">The _ value.</param> - public ChapterExpressionService(IExpressionService _) - : this(new LuaExpressionScriptService()) + /// <param name="expressionEngine">The chapter expression engine.</param> + public ChapterExpressionService(IChapterExpressionEngine? expressionEngine = null) { + this.expressionEngine = expressionEngine ?? new Expressions.Lua.LuaExpressionScriptService(); } /// <summary> @@ -36,7 +28,7 @@ public ChapterExpressionService(IExpressionService _) /// <param name="applyExpression">Whether to apply the expression.</param> /// <param name="expression">The expression text.</param> /// <returns>The operation result.</returns> - public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, string expression) + public ChapterExpressionResult Apply(ChapterSet info, bool applyExpression, string expression) { if (!applyExpression) { @@ -46,7 +38,11 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str var diagnostics = new List<ChapterDiagnostic>(); var nonSeparatorCount = info.Chapters.Count(static chapter => !chapter.IsSeparator); var nonSeparatorIndex = 0; - var framesPerSecond = (decimal)info.FramesPerSecond; + if (!FrameRateValidation.TryNormalize(info.FramesPerSecond, out var framesPerSecond, out var frameRateDiagnostic)) + { + return new ChapterExpressionResult(info, [frameRateDiagnostic!]); + } + var chapters = info.Chapters.Select(chapter => { if (chapter.IsSeparator) @@ -55,10 +51,10 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str } nonSeparatorIndex++; - var originalSeconds = (decimal)chapter.Time.TotalSeconds; - var evaluated = luaExpressionService.Evaluate( + var originalSeconds = (decimal)chapter.StartTime.TotalSeconds; + var evaluated = expressionEngine.Evaluate( expression, - new LuaExpressionContext(chapter, nonSeparatorIndex, nonSeparatorCount, originalSeconds, framesPerSecond)); + new ChapterExpressionContext(chapter, nonSeparatorIndex, nonSeparatorCount, originalSeconds, framesPerSecond)); diagnostics.AddRange(evaluated.Diagnostics); if (!evaluated.Success) { @@ -74,7 +70,7 @@ public ChapterExpressionResult Apply(ChapterInfo info, bool applyExpression, str var frameDisplay = FormatFrames(normalized, framesPerSecond); return chapter with { - Time = TimeSpan.FromSeconds((double)normalized), + StartTime = TimeSpan.FromSeconds((double)normalized), FramesInfo = frameDisplay.Text, FrameAccuracy = frameDisplay.Accuracy }; @@ -110,7 +106,7 @@ private static FrameDisplay FormatFrames(decimal seconds, decimal framesPerSecon } private static ChapterDiagnostic InvalidTime(string message) => - new(DiagnosticSeverity.Warning, "InvalidExpressionTime", message); + new(DiagnosticSeverity.Warning, ChapterDiagnosticCode.InvalidExpressionTime, message); private sealed record FrameDisplay(string Text, FrameAccuracy Accuracy); } @@ -118,8 +114,8 @@ private sealed record FrameDisplay(string Text, FrameAccuracy Accuracy); /// <summary> /// Represents the result of applying an expression transform. /// </summary> -/// <param name="Info">The Info value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> +/// <param name="Info">The chapter set after applying the expression transform.</param> +/// <param name="Diagnostics">Diagnostics produced while applying the expression.</param> public sealed record ChapterExpressionResult( - ChapterInfo Info, + ChapterSet Info, IReadOnlyList<ChapterDiagnostic> Diagnostics); diff --git a/src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs b/src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs index 927b137..7a04a3b 100644 --- a/src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs +++ b/src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs @@ -15,14 +15,14 @@ public sealed class ChapterFpsTransformService /// <param name="sourceFps">The source frame rate.</param> /// <param name="targetFps">The target frame rate.</param> /// <returns>The operation result.</returns> - public static ChangeFpsResult ChangeFps(ChapterInfo info, decimal sourceFps, decimal targetFps) + public static ChangeFpsResult ChangeFps(ChapterSet info, decimal sourceFps, decimal targetFps) { if (sourceFps <= 0 || targetFps <= 0) { return new ChangeFpsResult( false, info, - [new ChapterDiagnostic(DiagnosticSeverity.Error, "InvalidFrameRate", "Source and target frame rates must be greater than zero.")]); + [new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidFrameRate, "Source and target frame rates must be greater than zero.")]); } var chapters = info.Chapters.Select(chapter => TransformChapter(chapter, sourceFps, targetFps)).ToList(); @@ -44,12 +44,12 @@ private static Chapter TransformChapter(Chapter chapter, decimal sourceFps, deci return chapter; } - var frame = ChapterRounding.RoundToInt64((decimal)chapter.Time.TotalSeconds * sourceFps); + var frame = ChapterRounding.RoundToInt64((decimal)chapter.StartTime.TotalSeconds * sourceFps); var time = ChapterRounding.SecondsToTimeSpan(frame / targetFps); - TimeSpan? end = chapter.End is null + TimeSpan? end = chapter.EndTime is null ? null - : TransformEnd(chapter, chapter.End.Value, sourceFps, targetFps, frame); - return chapter with { Time = time, End = end }; + : TransformEnd(chapter, chapter.EndTime.Value, sourceFps, targetFps, frame); + return chapter with { StartTime = time, EndTime = end }; } private static TimeSpan TransformEnd(Chapter chapter, TimeSpan end, decimal sourceFps, decimal targetFps, long startFrame) diff --git a/src/ChapterTool.Core/Transform/ChapterRounding.cs b/src/ChapterTool.Core/Transform/ChapterRounding.cs index 63425cd..8ee21d3 100644 --- a/src/ChapterTool.Core/Transform/ChapterRounding.cs +++ b/src/ChapterTool.Core/Transform/ChapterRounding.cs @@ -24,7 +24,7 @@ public static int RoundToInt32(decimal value) => /// <summary> /// Executes the SecondsToTimeSpan operation. /// </summary> - /// <param name="seconds">The seconds value.</param> + /// <param name="seconds">The duration in seconds to convert.</param> /// <returns>The operation result.</returns> public static TimeSpan SecondsToTimeSpan(decimal seconds) => TimeSpan.FromTicks(RoundToInt64(seconds * TimeSpan.TicksPerSecond)); diff --git a/src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs b/src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs index 17d34f0..5691bb0 100644 --- a/src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs +++ b/src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs @@ -54,7 +54,7 @@ public TimeParseResult Parse(string text) [ new ChapterDiagnostic( DiagnosticSeverity.Warning, - "InvalidTimeText", + ChapterDiagnosticCode.InvalidTimeText, "Time text is empty or does not match the legacy HH:mm:ss.sss format.") ]); } @@ -89,12 +89,29 @@ private static bool TryParse(string text, out TimeSpan value) return false; } - var hour = int.Parse(match.Groups["Hour"].Value, CultureInfo.InvariantCulture); - var minute = int.Parse(match.Groups["Minute"].Value, CultureInfo.InvariantCulture); - var second = int.Parse(match.Groups["Second"].Value, CultureInfo.InvariantCulture); - var millisecond = int.Parse(match.Groups["Millisecond"].Value, CultureInfo.InvariantCulture); - value = new TimeSpan(0, hour, minute, second, millisecond); - return true; + if (!int.TryParse(match.Groups["Hour"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var hour) + || !int.TryParse(match.Groups["Minute"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var minute) + || !int.TryParse(match.Groups["Second"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var second) + || !int.TryParse(match.Groups["Millisecond"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var millisecond)) + { + return false; + } + + try + { + value = new TimeSpan(0, hour, minute, second, millisecond); + return true; + } + catch (ArgumentOutOfRangeException) + { + value = TimeSpan.Zero; + return false; + } + catch (OverflowException) + { + value = TimeSpan.Zero; + return false; + } } [GeneratedRegex(@"(?<Hour>\d+)\s*:\s*(?<Minute>\d+)\s*:\s*(?<Second>\d+)\s*[\.,]\s*(?<Millisecond>\d{3})")] diff --git a/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs b/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs index eb21a6d..689ed59 100644 --- a/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs +++ b/src/ChapterTool.Core/Transform/ExpressionAuthoringModels.cs @@ -56,10 +56,10 @@ public enum ExpressionTokenKind /// <summary> /// Describes one token span in an expression. /// </summary> -/// <param name="Start">The Start value.</param> -/// <param name="Length">The Length value.</param> -/// <param name="Text">The Text value.</param> -/// <param name="Kind">The Kind value.</param> +/// <param name="Start">The zero-based start position of the token in the expression text.</param> +/// <param name="Length">The token length in characters.</param> +/// <param name="Text">The token text.</param> +/// <param name="Kind">The token classification.</param> public sealed record ExpressionTokenSpan( int Start, int Length, @@ -69,11 +69,11 @@ public sealed record ExpressionTokenSpan( /// <summary> /// Describes an expression symbol available to authoring tools. /// </summary> -/// <param name="Text">The Text value.</param> -/// <param name="Kind">The Kind value.</param> -/// <param name="Description">The Description value.</param> -/// <param name="Arity">The Arity value.</param> -/// <param name="InsertText">The InsertText value.</param> +/// <param name="Text">The symbol text shown in authoring tools.</param> +/// <param name="Kind">The symbol classification.</param> +/// <param name="Description">The symbol description shown to users.</param> +/// <param name="Arity">The number of arguments accepted by a function symbol, when applicable.</param> +/// <param name="InsertText">The text inserted when the symbol is selected.</param> public sealed record ExpressionSymbol( string Text, ExpressionTokenKind Kind, @@ -84,12 +84,12 @@ public sealed record ExpressionSymbol( /// <summary> /// Describes a completion item for expression authoring. /// </summary> -/// <param name="Text">The Text value.</param> -/// <param name="Kind">The Kind value.</param> -/// <param name="Description">The Description value.</param> -/// <param name="ReplacementStart">The ReplacementStart value.</param> -/// <param name="ReplacementLength">The ReplacementLength value.</param> -/// <param name="InsertText">The InsertText value.</param> +/// <param name="Text">The completion text shown in the completion list.</param> +/// <param name="Kind">The completion classification.</param> +/// <param name="Description">The completion description shown to users.</param> +/// <param name="ReplacementStart">The zero-based start position of the text replaced by the completion.</param> +/// <param name="ReplacementLength">The number of characters replaced by the completion.</param> +/// <param name="InsertText">The text inserted when the completion is accepted.</param> public sealed record ExpressionCompletion( string Text, ExpressionTokenKind Kind, @@ -118,8 +118,8 @@ public sealed record ExpressionCompletion( /// <summary> /// Describes a suggested fix for an expression diagnostic. /// </summary> -/// <param name="Code">The Code value.</param> -/// <param name="Message">The Message value.</param> +/// <param name="Code">The stable suggestion code.</param> +/// <param name="Message">The suggestion message shown to users.</param> public sealed record ExpressionDiagnosticSuggestion( string Code, string Message); @@ -127,10 +127,10 @@ public sealed record ExpressionDiagnosticSuggestion( /// <summary> /// Describes an expression diagnostic and its source span. /// </summary> -/// <param name="Diagnostic">The Diagnostic value.</param> -/// <param name="Suggestion">The Suggestion value.</param> -/// <param name="Start">The Start value.</param> -/// <param name="Length">The Length value.</param> +/// <param name="Diagnostic">The diagnostic raised for the expression.</param> +/// <param name="Suggestion">The suggested fix associated with the diagnostic.</param> +/// <param name="Start">The zero-based start position of the diagnostic span.</param> +/// <param name="Length">The diagnostic span length in characters.</param> public sealed record ExpressionAuthoringDiagnostic( ChapterDiagnostic Diagnostic, ExpressionDiagnosticSuggestion Suggestion, @@ -140,9 +140,9 @@ public sealed record ExpressionAuthoringDiagnostic( /// <summary> /// Represents token, completion, and diagnostic analysis for an expression. /// </summary> -/// <param name="Spans">The Spans value.</param> -/// <param name="Completions">The Completions value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> +/// <param name="Spans">The token spans recognized in the expression.</param> +/// <param name="Completions">The completion items available at the caret position.</param> +/// <param name="Diagnostics">Expression diagnostics and suggested fixes.</param> public sealed record ExpressionAnalysisResult( IReadOnlyList<ExpressionTokenSpan> Spans, IReadOnlyList<ExpressionCompletion> Completions, diff --git a/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs b/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs index 6ffcfc2..ef33c7b 100644 --- a/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs +++ b/src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs @@ -1,13 +1,14 @@ using System.Globalization; using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Transform.Expressions; namespace ChapterTool.Core.Transform; /// <summary> /// Provides tokenization, diagnostics, and completions for expression authoring. /// </summary> -/// <param name="luaExpressionService">The Lua expression service.</param> -public sealed class ExpressionAuthoringService(ILuaExpressionScriptService? luaExpressionService = null) : IExpressionAuthoringService +/// <param name="expressionEngine">The chapter expression engine.</param> +public sealed class ExpressionAuthoringService(IChapterExpressionEngine? expressionEngine = null) : IExpressionAuthoringService { private static readonly HashSet<string> Keywords = new(StringComparer.Ordinal) { @@ -19,12 +20,12 @@ public sealed class ExpressionAuthoringService(ILuaExpressionScriptService? luaE "+", "-", "*", "/", "%", "^", "#", "==", "~=", "<=", ">=", "<", ">", "=", ".." }; - private readonly ILuaExpressionScriptService luaExpressionService = luaExpressionService ?? new LuaExpressionScriptService(); + private readonly IChapterExpressionEngine expressionEngine = expressionEngine ?? new Expressions.Lua.LuaExpressionScriptService(); /// <summary> /// Gets symbols available to expression authoring. /// </summary> - public IReadOnlyList<ExpressionSymbol> Symbols { get; } = BuildSymbols(luaExpressionService ?? new LuaExpressionScriptService()); + public IReadOnlyList<ExpressionSymbol> Symbols { get; } = BuildSymbols(expressionEngine ?? new Expressions.Lua.LuaExpressionScriptService()); /// <summary> /// Executes the Analyze operation. @@ -46,7 +47,7 @@ public ExpressionAnalysisResult Analyze(string expression, int caretIndex, decim return new ExpressionAnalysisResult(spans, completions, diagnostics); } - private static IReadOnlyList<ExpressionSymbol> BuildSymbols(ILuaExpressionScriptService luaExpressionService) + private static IReadOnlyList<ExpressionSymbol> BuildSymbols(IChapterExpressionEngine expressionEngine) { var symbols = new List<ExpressionSymbol> { @@ -78,7 +79,7 @@ private static IReadOnlyList<ExpressionSymbol> BuildSymbols(ILuaExpressionScript new("function transform(chapter)\n return t\nend", ExpressionTokenKind.Snippet, "Reusable transform function snippet.", null, "function transform(chapter)\n return t\nend") }; - symbols.AddRange(luaExpressionService.Presets.Select(static preset => new ExpressionSymbol( + symbols.AddRange(expressionEngine.Presets.Select(static preset => new ExpressionSymbol( $"preset.{preset.Id}", ExpressionTokenKind.Snippet, preset.Description, @@ -141,12 +142,12 @@ private List<ExpressionAuthoringDiagnostic> Diagnostics(string expression, IRead if (spans.Any(static span => span.Kind == ExpressionTokenKind.Unknown)) { var unknown = spans.First(static span => span.Kind == ExpressionTokenKind.Unknown); - return [Diagnostic("InvalidExpression.LuaUnknownToken", $"Unsupported Lua token '{unknown.Text}'.", "Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax.", unknown.Start, unknown.Length)]; + return [Diagnostic(ChapterDiagnosticCode.InvalidExpressionLuaUnknownToken, $"Unsupported Lua token '{unknown.Text}'.", "Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax.", unknown.Start, unknown.Length)]; } - var result = luaExpressionService.Evaluate( + var result = expressionEngine.Evaluate( expression, - new LuaExpressionContext(new Models.Chapter(1, TimeSpan.FromSeconds((double)timeSeconds), "Chapter"), 1, 1, timeSeconds, framesPerSecond)); + new ChapterExpressionContext(new Models.Chapter(1, TimeSpan.FromSeconds((double)timeSeconds), "Chapter"), 1, 1, timeSeconds, framesPerSecond)); if (result.Success) { return []; @@ -163,15 +164,27 @@ private List<ExpressionAuthoringDiagnostic> Diagnostics(string expression, IRead return [new ExpressionAuthoringDiagnostic(diagnostic, suggestion, target.Start, Math.Max(1, target.Length))]; } - private static ExpressionDiagnosticSuggestion SuggestionFor(string diagnosticCode, string expression) => - diagnosticCode switch + private static ExpressionDiagnosticSuggestion SuggestionFor(ChapterDiagnosticCode diagnosticCode, string expression) + { + if (diagnosticCode == ChapterDiagnosticCode.InvalidExpressionLuaCompile && EndsWithOperator(expression)) { - "InvalidExpression.LuaCompile" when EndsWithOperator(expression) => new("Expression.Suggestion.AddOperand", "Add the missing operand before applying this token."), - "InvalidExpression.LuaCompile" => new("Expression.Suggestion.CheckLuaSyntax", "Check the Lua syntax or complete the expression."), - "InvalidExpression.LuaRuntime" => new("Expression.Suggestion.CheckLuaRuntime", "Check that referenced Lua variables and functions exist."), - "InvalidExpression.LuaInvalidReturn" => new("Expression.Suggestion.ReturnNumber", "Return a finite number of seconds."), - _ => new("Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax.") - }; + return new("Expression.Suggestion.AddOperand", "Add the missing operand before applying this token."); + } + + if (diagnosticCode == ChapterDiagnosticCode.InvalidExpressionLuaCompile) + { + return new("Expression.Suggestion.CheckLuaSyntax", "Check the Lua syntax or complete the expression."); + } + + if (diagnosticCode == ChapterDiagnosticCode.InvalidExpressionLuaRuntime) + { + return new("Expression.Suggestion.CheckLuaRuntime", "Check that referenced Lua variables and functions exist."); + } + + return diagnosticCode == ChapterDiagnosticCode.InvalidExpressionLuaInvalidReturn + ? new ExpressionDiagnosticSuggestion("Expression.Suggestion.ReturnNumber", "Return a finite number of seconds.") + : new ExpressionDiagnosticSuggestion("Expression.Suggestion.CheckLuaSyntax", "Check the Lua expression syntax."); + } private static bool EndsWithOperator(string expression) { @@ -179,7 +192,7 @@ private static bool EndsWithOperator(string expression) return trimmed.EndsWith('+') || trimmed.EndsWith('-') || trimmed.EndsWith('*') || trimmed.EndsWith('/') || trimmed.EndsWith('%') || trimmed.EndsWith('^') || trimmed.EndsWith('.'); } - private static ExpressionAuthoringDiagnostic Diagnostic(string code, string message, string suggestionCode, string suggestion, int start, int length) => + private static ExpressionAuthoringDiagnostic Diagnostic(ChapterDiagnosticCode code, string message, string suggestionCode, string suggestion, int start, int length) => new(new ChapterDiagnostic(DiagnosticSeverity.Warning, code, message, Arguments: new Dictionary<string, object?>(StringComparer.Ordinal) { ["message"] = message }), new ExpressionDiagnosticSuggestion(suggestionCode, suggestion), start, length); private static ExpressionTokenSpan? LastMeaningfulSpan(IReadOnlyList<ExpressionTokenSpan> spans) => diff --git a/src/ChapterTool.Core/Transform/ExpressionEvaluationResult.cs b/src/ChapterTool.Core/Transform/ExpressionEvaluationResult.cs deleted file mode 100644 index 414bd85..0000000 --- a/src/ChapterTool.Core/Transform/ExpressionEvaluationResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ChapterTool.Core.Diagnostics; - -namespace ChapterTool.Core.Transform; - -/// <summary> -/// Represents the result of expression evaluation. -/// </summary> -/// <param name="Success">The Success value.</param> -/// <param name="Value">The Value value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> -public sealed record ExpressionEvaluationResult( - bool Success, - decimal Value, - IReadOnlyList<ChapterDiagnostic> Diagnostics); diff --git a/src/ChapterTool.Core/Transform/ExpressionService.cs b/src/ChapterTool.Core/Transform/ExpressionService.cs deleted file mode 100644 index be972bb..0000000 --- a/src/ChapterTool.Core/Transform/ExpressionService.cs +++ /dev/null @@ -1,582 +0,0 @@ -using System.Globalization; -using ChapterTool.Core.Diagnostics; - -namespace ChapterTool.Core.Transform; - -/// <summary> -/// Evaluates infix and postfix mathematical expressions for chapter time transforms. -/// </summary> -public sealed class ExpressionService : IExpressionService -{ - private static readonly Dictionary<string, decimal> Constants = new(StringComparer.Ordinal) - { - ["M_E"] = 2.71828182845904523536m, - ["M_LOG2E"] = 1.44269504088896340736m, - ["M_LOG10E"] = 0.43429448190325182765m, - ["M_PI"] = 3.14159265358979323846m, - ["M_PI_2"] = 1.57079632679489661923m, - ["M_PI_4"] = 0.78539816339744830962m, - ["M_1_PI"] = 0.31830988618379067154m, - ["M_2_PI"] = 0.63661977236758134308m, - ["M_2_SQRTPI"] = 1.12837916709551257390m, - ["M_LN2"] = 0.69314718055994530942m, - ["M_LN10"] = 2.30258509299404568402m, - ["M_SQRT2"] = 1.41421356237309504880m, - ["M_SQRT1_2"] = 0.70710678118654752440m - }; - - private static readonly Dictionary<string, int> Functions = new(StringComparer.Ordinal) - { - ["abs"] = 1, - ["acos"] = 1, - ["asin"] = 1, - ["atan"] = 1, - ["atan2"] = 2, - ["cos"] = 1, - ["sin"] = 1, - ["tan"] = 1, - ["cosh"] = 1, - ["sinh"] = 1, - ["tanh"] = 1, - ["exp"] = 1, - ["log"] = 1, - ["log10"] = 1, - ["sqrt"] = 1, - ["ceil"] = 1, - ["floor"] = 1, - ["round"] = 1, - ["int"] = 1, - ["sign"] = 1, - ["pow"] = 2, - ["max"] = 2, - ["min"] = 2 - }; - - private static readonly Dictionary<string, int> Precedence = new(StringComparer.Ordinal) - { - [">"] = -1, - ["<"] = -1, - [">="] = -1, - ["<="] = -1, - ["+"] = 0, - ["-"] = 0, - ["*"] = 1, - ["/"] = 1, - ["%"] = 1, - ["^"] = 2, - ["u+"] = 3, - ["u-"] = 3, - ["?:"] = -2 - }; - - private static readonly HashSet<string> RightAssociativeOperators = new(StringComparer.Ordinal) - { - "^", - "u+", - "u-", - "?:" - }; - - /// <summary> - /// Executes the EvaluateInfix operation. - /// </summary> - /// <param name="expression">The expression text.</param> - /// <param name="timeSeconds">The chapter time in seconds.</param> - /// <param name="framesPerSecond">The frame rate in frames per second.</param> - /// <returns>The operation result.</returns> - public ExpressionEvaluationResult EvaluateInfix(string expression, decimal timeSeconds, decimal framesPerSecond) - { - try - { - if (string.IsNullOrWhiteSpace(expression)) - { - expression = "t"; - } - - var postfix = ToPostfix(Tokenize(expression)); - return EvaluatePostfix(postfix, timeSeconds, framesPerSecond); - } - catch (Exception exception) when (exception is InvalidOperationException or FormatException or KeyNotFoundException or OverflowException) - { - return exception is ExpressionException ee - ? Failure(timeSeconds, ee.Code, ee.Message, ee.Arguments) - : Failure(timeSeconds, "InvalidExpression", exception.Message); - } - } - - /// <summary> - /// Executes the EvaluatePostfix operation. - /// </summary> - /// <param name="tokens">The tokens value.</param> - /// <param name="timeSeconds">The chapter time in seconds.</param> - /// <param name="framesPerSecond">The frame rate in frames per second.</param> - /// <returns>The operation result.</returns> - public ExpressionEvaluationResult EvaluatePostfix(IEnumerable<string> tokens, decimal timeSeconds, decimal framesPerSecond) - { - try - { - var values = new Dictionary<string, decimal>(StringComparer.Ordinal) - { - ["t"] = timeSeconds, - ["fps"] = framesPerSecond - }; - var stack = new Stack<decimal>(); - - foreach (var token in tokens.TakeWhile(token => !token.StartsWith("//", StringComparison.Ordinal)).Where(token => token.Length > 0)) - { - if (decimal.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) - { - stack.Push(number); - } - else if (values.TryGetValue(token, out var variable)) - { - stack.Push(variable); - } - else if (Constants.TryGetValue(token, out var constant)) - { - stack.Push(constant); - } - else if (Functions.ContainsKey(token)) - { - ApplyFunction(token, stack); - } - else if (Precedence.ContainsKey(token)) - { - ApplyOperator(token, stack); - } - else if (token is "and" or "or" or "xor") - { - throw new ExpressionException("InvalidExpression.UnsupportedOperator", $"Unsupported operator '{token}'.", Args(("token", token))); - } - else - { - throw new ExpressionException("InvalidExpression.UnknownToken", $"Unknown token '{token}'.", Args(("token", token))); - } - } - - if (stack.Count != 1) - { - throw new ExpressionException("InvalidExpression.Incomplete", "Expression did not reduce to one value."); - } - - return new ExpressionEvaluationResult(true, stack.Pop(), []); - } - catch (Exception exception) when (exception is InvalidOperationException or DivideByZeroException or OverflowException) - { - return exception is ExpressionException ee - ? Failure(timeSeconds, ee.Code, ee.Message, ee.Arguments) - : Failure(timeSeconds, "InvalidExpression", exception.Message); - } - } - - private static ExpressionEvaluationResult Failure(decimal fallback, string code, string message, IReadOnlyDictionary<string, object?>? arguments = null) => - new( - false, - fallback, - [ - new ChapterDiagnostic(DiagnosticSeverity.Warning, code, message, Arguments: arguments) - ]); - - private static IReadOnlyDictionary<string, object?>? Args(params (string Name, object? Value)[] pairs) => - pairs.Length == 0 ? null : pairs.ToDictionary(static pair => pair.Name, static pair => pair.Value, StringComparer.Ordinal); - - private sealed class ExpressionException : InvalidOperationException - { - /// <summary> - /// Executes the ExpressionException operation. - /// </summary> - /// <param name="code">The diagnostic code.</param> - /// <param name="message">The diagnostic message.</param> - /// <param name="arguments">The arguments value.</param> - /// <param name="innerException">The innerException value.</param> - public ExpressionException(string code, string message, IReadOnlyDictionary<string, object?>? arguments = null, Exception? innerException = null) - : base(message, innerException) - { - Code = code; - Arguments = arguments; - } - - /// <summary> - /// Gets the Code value. - /// </summary> - public string Code { get; } - /// <summary> - /// Provides the object value. - /// </summary> - public IReadOnlyDictionary<string, object?>? Arguments { get; } - } - - private static List<string> Tokenize(string expression) - { - var tokens = new List<string>(); - for (var i = 0; i < expression.Length;) - { - var c = expression[i]; - if (char.IsWhiteSpace(c)) - { - i++; - continue; - } - - if (c == '/' && i + 1 < expression.Length && expression[i + 1] == '/') - { - break; - } - - if (char.IsDigit(c) || c == '.') - { - var start = i++; - while (i < expression.Length && (char.IsDigit(expression[i]) || expression[i] == '.')) - { - i++; - } - - tokens.Add(expression[start..i]); - continue; - } - - if (char.IsLetter(c) || c == '_') - { - var start = i++; - while (i < expression.Length && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_')) - { - i++; - } - - tokens.Add(expression[start..i]); - continue; - } - - if (c is '>' or '<' && i + 1 < expression.Length && expression[i + 1] == '=') - { - tokens.Add(expression.Substring(i, 2)); - i += 2; - continue; - } - - if ("()+-*/%^,<>\u003f:".Contains(c, StringComparison.Ordinal)) - { - tokens.Add(c.ToString(CultureInfo.InvariantCulture)); - i++; - continue; - } - - throw new ExpressionException("InvalidExpression.InvalidCharacter", $"Invalid character '{c}'.", Args(("character", c))); - } - - return tokens; - } - - private static List<string> ToPostfix(IReadOnlyList<string> tokens) - { - var output = new List<string>(); - var operators = new Stack<string>(); - var previous = string.Empty; - - foreach (var token in tokens) - { - if (decimal.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out _) || - token is "t" or "fps" || - Constants.ContainsKey(token)) - { - if (previous.Length > 0 && - previous != "(" && - previous != "," && - previous != "?" && - previous != ":" && - !Precedence.ContainsKey(previous)) - { - throw new ExpressionException("InvalidExpression.MissingOperator", $"Missing operator before '{token}'.", Args(("token", token))); - } - - output.Add(token); - } - else if (Functions.ContainsKey(token)) - { - if (previous is not "" and not "(" and not "," and not "?" and not ":" && !Precedence.ContainsKey(previous)) - { - throw new ExpressionException("InvalidExpression.MissingOperatorBeforeFunction", $"Missing operator before function '{token}'.", Args(("token", token))); - } - - operators.Push(token); - } - else switch (token) - { - case "," when previous.Length == 0 || - previous == "(" || - previous == "," || - Precedence.ContainsKey(previous) || - Functions.ContainsKey(previous) || - previous == "?": - throw new ExpressionException("InvalidExpression.MisplacedComma", "Misplaced comma."); - case ",": - { - while (operators.Count > 0 && operators.Peek() != "(") - { - output.Add(operators.Pop()); - } - - if (operators.Count == 0) - { - throw new ExpressionException("InvalidExpression.MisplacedComma", "Misplaced comma."); - } - - break; - } - case "(" when previous.Length > 0 && - previous != "(" && - previous != "," && - previous != "?" && - previous != ":" && - !Precedence.ContainsKey(previous) && - !Functions.ContainsKey(previous): - throw new ExpressionException("InvalidExpression.MissingOperatorBeforeParen", "Missing operator before '('."); - case "(": - operators.Push(token); - break; - case ")" when previous.Length == 0 || - previous == "(" || - previous == "," || - Precedence.ContainsKey(previous) || - Functions.ContainsKey(previous) || - previous == "?": - throw new ExpressionException("InvalidExpression.MissingOperandBeforeParen", "Missing operand before ')'."); - case ")": - { - while (operators.Count > 0 && operators.Peek() != "(") - { - output.Add(operators.Pop()); - } - - if (operators.Count == 0) - { - throw new ExpressionException("InvalidExpression.UnbalancedParentheses", "Unbalanced parentheses."); - } - - operators.Pop(); - if (operators.Count > 0 && Functions.ContainsKey(operators.Peek())) - { - output.Add(operators.Pop()); - } - - break; - } - case "?" when previous.Length == 0 || - previous == "(" || - previous == "," || - previous == "?" || - Precedence.ContainsKey(previous) || - Functions.ContainsKey(previous): - throw new ExpressionException("InvalidExpression.TernaryMissingCondition", "Operator '?' requires a condition."); - case "?": - { - while (operators.Count > 0 && Precedence.TryGetValue(operators.Peek(), out var lastPrecedence) && - ShouldPopOperator(lastPrecedence, "?:")) - { - output.Add(operators.Pop()); - } - - operators.Push(token); - break; - } - case ":" when previous.Length == 0 || - previous == "(" || - previous == "," || - previous == "?" || - Precedence.ContainsKey(previous) || - Functions.ContainsKey(previous): - throw new ExpressionException("InvalidExpression.TernaryMissingTrueExpression", "Operator ':' requires a true expression."); - case ":": - { - while (operators.Count > 0 && operators.Peek() != "?" && operators.Peek() != "(") - { - output.Add(operators.Pop()); - } - - if (operators.Count == 0 || operators.Peek() != "?") - { - throw new ExpressionException("InvalidExpression.TernaryUnmatchedColon", "Operator ':' requires a matching '?'."); - } - - operators.Pop(); - - while (operators.Count > 0 && Precedence.TryGetValue(operators.Peek(), out var lastPrecedence) && - ShouldPopOperator(lastPrecedence, "?:")) - { - output.Add(operators.Pop()); - } - - operators.Push("?:"); - break; - } - default: - { - if (Precedence.ContainsKey(token)) - { - var isUnarySign = token is "-" or "+" && - (previous.Length == 0 || previous is "(" or "," or "?" or ":" || Precedence.ContainsKey(previous)); - var operatorToken = isUnarySign ? $"u{token}" : token; - - if (!isUnarySign && - (previous.Length == 0 || previous == "(" || previous == "," || previous == "?" || Precedence.ContainsKey(previous))) - { - throw new ExpressionException("InvalidExpression.OperatorRequiresLeftOperand", $"Operator '{token}' requires a left operand.", Args(("token", token))); - } - - while (operators.Count > 0 && Precedence.TryGetValue(operators.Peek(), out var lastPrecedence) && - ShouldPopOperator(lastPrecedence, operatorToken)) - { - output.Add(operators.Pop()); - } - - operators.Push(operatorToken); - } - else - { - throw new ExpressionException("InvalidExpression.UnsupportedToken", $"Unsupported token '{token}'.", Args(("token", token))); - } - - break; - } - } - - previous = token; - } - - if (previous == ",") - { - throw new ExpressionException("InvalidExpression.MisplacedComma", "Misplaced comma."); - } - - if (previous == ":") - { - throw new ExpressionException("InvalidExpression.InsufficientOperands", "Operator ':' requires a false expression.", Args(("token", previous))); - } - - if (Precedence.ContainsKey(previous)) - { - throw new ExpressionException("InvalidExpression.InsufficientOperands", $"Token '{previous}' requires more operands.", Args(("token", previous))); - } - - while (operators.Count > 0) - { - var token = operators.Pop(); - if (token is "(" or "?") - { - throw token == "(" - ? new ExpressionException("InvalidExpression.UnbalancedParentheses", "Unbalanced parentheses.") - : new ExpressionException("InvalidExpression.TernaryUnmatchedQuestion", "Operator '?' requires a matching ':'."); - } - - output.Add(token); - } - - return output; - } - - private static bool ShouldPopOperator(int previousPrecedence, string currentOperator) - { - return previousPrecedence > Precedence[currentOperator] || - (previousPrecedence == Precedence[currentOperator] && !RightAssociativeOperators.Contains(currentOperator)); - } - - private static void ApplyOperator(string op, Stack<decimal> stack) - { - switch (op) - { - case "u+" or "u-": - { - var value = Pop(stack, op); - stack.Push(op == "u-" ? -value : value); - return; - } - case "?:": - { - var falseValue = Pop(stack, op); - var trueValue = Pop(stack, op); - var condition = Pop(stack, op); - stack.Push(condition == 0 ? falseValue : trueValue); - return; - } - } - - var rhs = Pop(stack, op); - var lhs = Pop(stack, op); - stack.Push(op switch - { - "+" => lhs + rhs, - "-" => lhs - rhs, - "*" => lhs * rhs, - "/" => lhs / rhs, - "%" => lhs % rhs, - "^" => (decimal)Math.Pow((double)lhs, (double)rhs), - ">" => lhs > rhs ? 1 : 0, - "<" => lhs < rhs ? 1 : 0, - ">=" => lhs >= rhs ? 1 : 0, - "<=" => lhs <= rhs ? 1 : 0, - _ => throw new ExpressionException("InvalidExpression.UnsupportedOperator", $"Unsupported operator '{op}'.", Args(("token", op))) - }); - } - - private static void ApplyFunction(string function, Stack<decimal> stack) - { - stack.Push(function switch - { - "abs" => Math.Abs(Pop(stack, function)), - "acos" => Unary(Math.Acos), - "asin" => Unary(Math.Asin), - "atan" => Unary(Math.Atan), - "atan2" => Binary(Math.Atan2), - "cos" => Unary(Math.Cos), - "sin" => Unary(Math.Sin), - "tan" => Unary(Math.Tan), - "cosh" => Unary(Math.Cosh), - "sinh" => Unary(Math.Sinh), - "tanh" => Unary(Math.Tanh), - "exp" => Unary(Math.Exp), - "log" => Unary(Math.Log), - "log10" => Unary(Math.Log10), - "sqrt" => Unary(Math.Sqrt), - "ceil" => Math.Ceiling(Pop(stack, function)), - "floor" => Math.Floor(Pop(stack, function)), - "round" => Math.Round(Pop(stack, function)), - "int" => Math.Truncate(Pop(stack, function)), - "sign" => Math.Sign(Pop(stack, function)), - "pow" => Binary(Math.Pow), - "max" => Max(PopPair(stack, function)), - "min" => Min(PopPair(stack, function)), - _ => throw new ExpressionException("InvalidExpression.UnsupportedFunction", $"Unsupported function '{function}'.", Args(("function", function))) - }); - return; - - decimal Binary(Func<double, double, double> func) - { - var rhs = Pop(stack, function); - var lhs = Pop(stack, function); - return (decimal)func((double)lhs, (double)rhs); - } - - decimal Unary(Func<double, double> func) => (decimal)func((double)Pop(stack, function)); - } - - private static decimal Max((decimal Left, decimal Right) pair) => Math.Max(pair.Left, pair.Right); - - private static decimal Min((decimal Left, decimal Right) pair) => Math.Min(pair.Left, pair.Right); - - private static (decimal Left, decimal Right) PopPair(Stack<decimal> stack, string token) - { - var rhs = Pop(stack, token); - var lhs = Pop(stack, token); - return (lhs, rhs); - } - - private static decimal Pop(Stack<decimal> stack, string token) - { - if (!stack.TryPop(out var value)) - { - throw new ExpressionException("InvalidExpression.InsufficientOperands", $"Token '{token}' requires more operands.", Args(("token", token))); - } - - return value; - } -} diff --git a/src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs b/src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs new file mode 100644 index 0000000..f382314 --- /dev/null +++ b/src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs @@ -0,0 +1,67 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; + +namespace ChapterTool.Core.Transform.Expressions; + +/// <summary> +/// Provides chapter context values to expression engines. +/// </summary> +/// <param name="Chapter">The chapter being evaluated.</param> +/// <param name="Index">The one-based index among non-separator chapters.</param> +/// <param name="Count">The total number of non-separator chapters.</param> +/// <param name="TimeSeconds">The chapter start time in seconds.</param> +/// <param name="FramesPerSecond">The frame rate available to the expression.</param> +public sealed record ChapterExpressionContext( + Chapter Chapter, + int Index, + int Count, + decimal TimeSeconds, + decimal FramesPerSecond); + +/// <summary> +/// Represents the result of expression evaluation. +/// </summary> +/// <param name="Success">Whether the expression evaluated successfully.</param> +/// <param name="Value">The numeric result returned by the expression.</param> +/// <param name="Diagnostics">Diagnostics produced during expression evaluation.</param> +public sealed record ChapterExpressionEvaluationResult( + bool Success, + decimal Value, + IReadOnlyList<ChapterDiagnostic> Diagnostics); + +/// <summary> +/// Describes a built-in expression preset. +/// </summary> +/// <param name="Id">The stable preset identifier.</param> +/// <param name="DisplayName">The preset label shown to users.</param> +/// <param name="Description">The preset description shown to users.</param> +/// <param name="ScriptText">The expression source text supplied by the preset.</param> +public sealed record ChapterExpressionPreset( + string Id, + string DisplayName, + string Description, + string ScriptText); + +/// <summary> +/// Evaluates chapter time expressions through a pluggable language or execution framework. +/// </summary> +public interface IChapterExpressionEngine +{ + /// <summary> + /// Gets the stable language or engine identifier. + /// </summary> + string EngineId { get; } + + /// <summary> + /// Gets built-in expression presets supported by the engine. + /// </summary> + IReadOnlyList<ChapterExpressionPreset> Presets { get; } + + /// <summary> + /// Evaluates expression source text against a chapter expression context. + /// </summary> + /// <param name="sourceText">The expression source text.</param> + /// <param name="context">The expression context.</param> + /// <returns>The expression evaluation result.</returns> + ChapterExpressionEvaluationResult Evaluate(string sourceText, ChapterExpressionContext context); +} diff --git a/src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs b/src/ChapterTool.Core/Transform/Expressions/Lua/LuaExpressionScriptService.cs similarity index 60% rename from src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs rename to src/ChapterTool.Core/Transform/Expressions/Lua/LuaExpressionScriptService.cs index 0065e83..1e5c8e2 100644 --- a/src/ChapterTool.Core/Transform/LuaExpressionScriptService.cs +++ b/src/ChapterTool.Core/Transform/Expressions/Lua/LuaExpressionScriptService.cs @@ -1,22 +1,47 @@ -using System.Globalization; using System.Text.RegularExpressions; using ChapterTool.Core.Diagnostics; using Lua; using Lua.Standard; -namespace ChapterTool.Core.Transform; +namespace ChapterTool.Core.Transform.Expressions.Lua; /// <summary> /// Evaluates Lua scripts against chapter expression contexts. /// </summary> -public sealed partial class LuaExpressionScriptService : ILuaExpressionScriptService +public sealed partial class LuaExpressionScriptService : IChapterExpressionEngine { private static readonly Regex ReturnOrTransformPattern = ReturnOrTransformRegex(); + private static readonly TimeSpan DefaultExecutionTimeout = TimeSpan.FromMilliseconds(500); + private readonly TimeSpan executionTimeout; + + /// <summary> + /// Initializes a new instance of the <see cref="LuaExpressionScriptService"/> class. + /// </summary> + public LuaExpressionScriptService() + : this(DefaultExecutionTimeout) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="LuaExpressionScriptService"/> class. + /// </summary> + /// <param name="executionTimeout">The maximum time allowed for one Lua evaluation.</param> + public LuaExpressionScriptService(TimeSpan executionTimeout) + { + this.executionTimeout = executionTimeout <= TimeSpan.Zero + ? DefaultExecutionTimeout + : executionTimeout; + } + + /// <summary> + /// Gets the stable language or engine identifier. + /// </summary> + public string EngineId => "lua"; /// <summary> /// Gets the built-in Lua expression presets. /// </summary> - public IReadOnlyList<LuaExpressionScriptPreset> Presets { get; } = + public IReadOnlyList<ChapterExpressionPreset> Presets { get; } = [ new( "identity", @@ -41,21 +66,22 @@ public sealed partial class LuaExpressionScriptService : ILuaExpressionScriptSer ]; /// <summary> - /// Executes the Evaluate operation. + /// Evaluates Lua script text against a chapter expression context. /// </summary> - /// <param name="scriptText">The Lua script text.</param> + /// <param name="sourceText">The Lua script text.</param> /// <param name="context">The expression context.</param> - /// <returns>The operation result.</returns> - public LuaExpressionEvaluationResult Evaluate(string scriptText, LuaExpressionContext context) + /// <returns>The expression evaluation result.</returns> + public ChapterExpressionEvaluationResult Evaluate(string sourceText, ChapterExpressionContext context) { var fallback = context.TimeSeconds; try { - var source = NormalizeSource(scriptText); + var source = NormalizeSource(sourceText); using var state = LuaState.Create(); + using var timeout = new CancellationTokenSource(executionTimeout); ConfigureState(state, context); - var results = state.DoStringAsync(source, "chapter-expression.lua", CancellationToken.None) + var results = state.DoStringAsync(source, "chapter-expression.lua", timeout.Token) .GetAwaiter() .GetResult(); @@ -67,41 +93,41 @@ public LuaExpressionEvaluationResult Evaluate(string scriptText, LuaExpressionCo var transform = state.Environment["transform"]; if (transform.TryRead<LuaFunction>(out _)) { - var callResults = state.CallAsync(transform, [state.Environment["chapter"]], CancellationToken.None) + var callResults = state.CallAsync(transform, [state.Environment["chapter"]], timeout.Token) .GetAwaiter() .GetResult(); return callResults.Length == 0 - ? Failure(fallback, "InvalidExpression.LuaMissingReturn", "Lua transform did not return a value.") + ? Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaMissingReturn, "Lua transform did not return a value.") : NumericResult(callResults[0], fallback); } - return Failure(fallback, "InvalidExpression.LuaMissingReturn", "Lua expression did not return a value."); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaMissingReturn, "Lua expression did not return a value."); } catch (LuaCompileException exception) { - return Failure(fallback, "InvalidExpression.LuaCompile", exception.Message); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaCompile, exception.Message); } catch (LuaRuntimeException exception) { - return Failure(fallback, "InvalidExpression.LuaRuntime", exception.Message); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaRuntime, exception.Message); } catch (OperationCanceledException exception) { - return Failure(fallback, "InvalidExpression.LuaCanceled", exception.Message); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaCanceled, exception.Message); } catch (Exception exception) when (exception is InvalidOperationException or ArgumentException or OverflowException) { - return Failure(fallback, "InvalidExpression.Lua", exception.Message); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLua, exception.Message); } } - private static string NormalizeSource(string scriptText) + private static string NormalizeSource(string sourceText) { - var source = string.IsNullOrWhiteSpace(scriptText) ? "t" : scriptText.Trim(); + var source = string.IsNullOrWhiteSpace(sourceText) ? "t" : sourceText.Trim(); return ReturnOrTransformPattern.IsMatch(source) ? source : $"return ({source})"; } - private static void ConfigureState(LuaState state, LuaExpressionContext context) + private static void ConfigureState(LuaState state, ChapterExpressionContext context) { state.OpenMathLibrary(); state.OpenStringLibrary(); @@ -114,7 +140,7 @@ private static void ConfigureState(LuaState state, LuaExpressionContext context) var chapter = new LuaTable { - ["number"] = context.Chapter.Number, + ["number"] = context.Chapter.DisplayNumber, ["time"] = (double)context.TimeSeconds, ["name"] = context.Chapter.Name, ["frames"] = context.Chapter.FramesInfo, @@ -147,24 +173,24 @@ private static void ConfigureState(LuaState state, LuaExpressionContext context) }); } - private static LuaExpressionEvaluationResult NumericResult(LuaValue value, decimal fallback) + private static ChapterExpressionEvaluationResult NumericResult(LuaValue value, decimal fallback) { if (!value.TryRead<double>(out var number) || double.IsNaN(number) || double.IsInfinity(number)) { - return Failure(fallback, "InvalidExpression.LuaInvalidReturn", $"Lua expression returned {value.TypeToString()} instead of a finite number."); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaInvalidReturn, $"Lua expression returned {value.TypeToString()} instead of a finite number."); } try { - return new LuaExpressionEvaluationResult(true, (decimal)number, []); + return new ChapterExpressionEvaluationResult(true, (decimal)number, []); } catch (OverflowException exception) { - return Failure(fallback, "InvalidExpression.LuaInvalidReturn", exception.Message); + return Failure(fallback, ChapterDiagnosticCode.InvalidExpressionLuaInvalidReturn, exception.Message); } } - private static LuaExpressionEvaluationResult Failure(decimal fallback, string code, string message) => + private static ChapterExpressionEvaluationResult Failure(decimal fallback, ChapterDiagnosticCode code, string message) => new( false, fallback, diff --git a/src/ChapterTool.Core/Transform/FrameInfoResult.cs b/src/ChapterTool.Core/Transform/FrameInfoResult.cs index e4bd759..1f64afd 100644 --- a/src/ChapterTool.Core/Transform/FrameInfoResult.cs +++ b/src/ChapterTool.Core/Transform/FrameInfoResult.cs @@ -5,13 +5,13 @@ namespace ChapterTool.Core.Transform; /// <summary> /// Represents calculated frame numbers and accuracy for chapters. /// </summary> -/// <param name="Info">The Info value.</param> -/// <param name="Chapters">The Chapters value.</param> -/// <param name="SelectedOption">The SelectedOption value.</param> -/// <param name="FramesPerSecond">The FramesPerSecond value.</param> -/// <param name="Accuracy">The Accuracy value.</param> +/// <param name="Info">The chapter set updated with frame metadata.</param> +/// <param name="Chapters">The chapters updated with frame display values.</param> +/// <param name="SelectedOption">The frame rate option used for the calculation.</param> +/// <param name="FramesPerSecond">The effective frame rate in frames per second.</param> +/// <param name="Accuracy">Per-chapter frame accuracy classifications.</param> public sealed record FrameInfoResult( - ChapterInfo Info, + ChapterSet Info, IReadOnlyList<Chapter> Chapters, FrameRateOption SelectedOption, decimal FramesPerSecond, diff --git a/src/ChapterTool.Core/Transform/FrameRateDetectionResult.cs b/src/ChapterTool.Core/Transform/FrameRateDetectionResult.cs index c68ac84..3fb12ea 100644 --- a/src/ChapterTool.Core/Transform/FrameRateDetectionResult.cs +++ b/src/ChapterTool.Core/Transform/FrameRateDetectionResult.cs @@ -3,11 +3,11 @@ namespace ChapterTool.Core.Transform; /// <summary> /// Represents detailed frame rate detection output. /// </summary> -/// <param name="Option">The Option value.</param> -/// <param name="AccurateChapterCount">The AccurateChapterCount value.</param> -/// <param name="EvaluatedChapterCount">The EvaluatedChapterCount value.</param> -/// <param name="CumulativeDeviation">The CumulativeDeviation value.</param> -/// <param name="Confidence">The Confidence value.</param> +/// <param name="Option">The detected frame rate option.</param> +/// <param name="AccurateChapterCount">The number of chapters that landed within the detection tolerance.</param> +/// <param name="EvaluatedChapterCount">The number of non-separator chapters evaluated.</param> +/// <param name="CumulativeDeviation">The accumulated frame deviation across evaluated chapters.</param> +/// <param name="Confidence">The confidence level assigned to the detected option.</param> public sealed record FrameRateDetectionResult( FrameRateOption Option, int AccurateChapterCount, diff --git a/src/ChapterTool.Core/Transform/FrameRateOption.cs b/src/ChapterTool.Core/Transform/FrameRateOption.cs index 439e8ad..3f3219d 100644 --- a/src/ChapterTool.Core/Transform/FrameRateOption.cs +++ b/src/ChapterTool.Core/Transform/FrameRateOption.cs @@ -3,11 +3,11 @@ namespace ChapterTool.Core.Transform; /// <summary> /// Describes a supported frame rate option. /// </summary> -/// <param name="Code">The Code value.</param> -/// <param name="DisplayName">The DisplayName value.</param> -/// <param name="Value">The Value value.</param> -/// <param name="IsValid">The IsValid value.</param> -/// <param name="LegacyMplsCode">The LegacyMplsCode value.</param> +/// <param name="Code">The stable frame rate option code.</param> +/// <param name="DisplayName">The frame rate label shown to users.</param> +/// <param name="Value">The frame rate value in frames per second.</param> +/// <param name="IsValid">Whether the option can be used for frame calculations.</param> +/// <param name="LegacyMplsCode">The Blu-ray MPLS frame rate code associated with the option.</param> public sealed record FrameRateOption( string Code, string DisplayName, diff --git a/src/ChapterTool.Core/Transform/FrameRateService.cs b/src/ChapterTool.Core/Transform/FrameRateService.cs index ea98d57..193ed12 100644 --- a/src/ChapterTool.Core/Transform/FrameRateService.cs +++ b/src/ChapterTool.Core/Transform/FrameRateService.cs @@ -32,8 +32,8 @@ public sealed class FrameRateService : IFrameRateService /// <returns>The operation result.</returns> public FrameRateOption FindByValue(decimal framesPerSecond) { - return FrameRateOptions.FirstOrDefault(option => - option.IsValid && Math.Abs(option.Value - framesPerSecond) < 0.00001m) + return FrameRateOptions.FirstOrDefault(entry => + entry.IsValid && Math.Abs(entry.Value - framesPerSecond) < 0.00001m) ?? FrameRateOptions[0]; } @@ -41,18 +41,18 @@ public FrameRateOption FindByValue(decimal framesPerSecond) /// Executes the Detect operation. /// </summary> /// <param name="info">The chapter data to process.</param> - /// <param name="tolerance">The tolerance value.</param> + /// <param name="tolerance">The maximum frame deviation treated as accurate.</param> /// <returns>The operation result.</returns> - public FrameRateOption Detect(ChapterInfo info, decimal tolerance) => + public FrameRateOption Detect(ChapterSet info, decimal tolerance) => DetectDetailed(info, tolerance).Option; /// <summary> /// Executes the DetectDetailed operation. /// </summary> /// <param name="info">The chapter data to process.</param> - /// <param name="tolerance">The tolerance value.</param> + /// <param name="tolerance">The maximum frame deviation treated as accurate.</param> /// <returns>The operation result.</returns> - public FrameRateDetectionResult DetectDetailed(ChapterInfo info, decimal tolerance) + public FrameRateDetectionResult DetectDetailed(ChapterSet info, decimal tolerance) { var defaultOption = FrameRateOptions[1]; var evaluated = info.Chapters.Count(static chapter => !chapter.IsSeparator); @@ -65,13 +65,13 @@ public FrameRateDetectionResult DetectDetailed(ChapterInfo info, decimal toleran var bestDeviation = decimal.MaxValue; var bestAccurateCount = -1; - foreach (var option in FrameRateOptions.Where(static option => option.IsValid)) + foreach (var entry in FrameRateOptions.Where(static entry => entry.IsValid)) { var deviation = 0m; var accurateCount = 0; foreach (var chapter in info.Chapters.Where(static chapter => !chapter.IsSeparator)) { - var frames = CalculateFrames(chapter, option.Value); + var frames = CalculateFrames(chapter, entry.Value); var rounded = ChapterRounding.RoundToInt64(frames); var delta = Math.Abs(frames - rounded); deviation += Math.Min(delta, tolerance); @@ -86,7 +86,7 @@ public FrameRateDetectionResult DetectDetailed(ChapterInfo info, decimal toleran { bestDeviation = deviation; bestAccurateCount = accurateCount; - bestOption = option; + bestOption = entry; } } @@ -118,12 +118,12 @@ private static FrameRateConfidence ClassifyConfidence( /// Executes the UpdateFrames operation. /// </summary> /// <param name="info">The chapter data to process.</param> - /// <param name="option">The option value.</param> - /// <param name="round">The round value.</param> - /// <param name="tolerance">The tolerance value.</param> + /// <param name="option">The requested frame rate option.</param> + /// <param name="round">Whether frame values should be rounded for display.</param> + /// <param name="tolerance">The maximum frame deviation treated as accurate.</param> /// <returns>The operation result.</returns> public FrameInfoResult UpdateFrames( - ChapterInfo info, + ChapterSet info, FrameRateOption option, bool round, decimal tolerance) @@ -172,7 +172,7 @@ private static FrameDisplay FormatFrames(Chapter chapter, decimal framesPerSecon private static decimal CalculateFrames(Chapter chapter, decimal framesPerSecond) { - return (decimal)chapter.Time.TotalSeconds * framesPerSecond; + return (decimal)chapter.StartTime.TotalSeconds * framesPerSecond; } private sealed record FrameDisplay(string Text, FrameAccuracy Accuracy); diff --git a/src/ChapterTool.Core/Transform/FrameRateValidation.cs b/src/ChapterTool.Core/Transform/FrameRateValidation.cs new file mode 100644 index 0000000..c1045be --- /dev/null +++ b/src/ChapterTool.Core/Transform/FrameRateValidation.cs @@ -0,0 +1,29 @@ +using ChapterTool.Core.Diagnostics; + +namespace ChapterTool.Core.Transform; + +internal static class FrameRateValidation +{ + public static bool TryNormalize(double framesPerSecond, out decimal value, out ChapterDiagnostic? diagnostic) + { + value = 0; + if (!double.IsFinite(framesPerSecond) || framesPerSecond <= 0 || framesPerSecond > (double)decimal.MaxValue) + { + diagnostic = InvalidFrameRate(); + return false; + } + + value = (decimal)framesPerSecond; + if (value <= 0) + { + diagnostic = InvalidFrameRate(); + return false; + } + + diagnostic = null; + return true; + } + + public static ChapterDiagnostic InvalidFrameRate() => + new(DiagnosticSeverity.Error, ChapterDiagnosticCode.InvalidFrameRate, "Frame rate must be a finite value greater than zero."); +} diff --git a/src/ChapterTool.Core/Transform/IChapterTimeFormatter.cs b/src/ChapterTool.Core/Transform/IChapterTimeFormatter.cs index a9fbaf0..10720ed 100644 --- a/src/ChapterTool.Core/Transform/IChapterTimeFormatter.cs +++ b/src/ChapterTool.Core/Transform/IChapterTimeFormatter.cs @@ -10,7 +10,7 @@ public interface IChapterTimeFormatter /// <summary> /// Formats a time value as a ChapterTool timestamp. /// </summary> - /// <param name="time">The time value.</param> + /// <param name="time">The time value to format.</param> /// <returns>The formatted timestamp.</returns> string Format(TimeSpan time); @@ -31,7 +31,7 @@ public interface IChapterTimeFormatter /// <summary> /// Formats a time value as a CUE sheet timestamp. /// </summary> - /// <param name="time">The time value.</param> + /// <param name="time">The time value to format.</param> /// <returns>The formatted CUE timestamp.</returns> string FormatCue(TimeSpan time); } @@ -39,8 +39,8 @@ public interface IChapterTimeFormatter /// <summary> /// Represents the result of parsing a chapter time string. /// </summary> -/// <param name="Value">The Value value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> +/// <param name="Value">Parsed timestamp value.</param> +/// <param name="Diagnostics">Diagnostics produced while parsing the timestamp.</param> public sealed record TimeParseResult( TimeSpan Value, IReadOnlyList<ChapterDiagnostic> Diagnostics); diff --git a/src/ChapterTool.Core/Transform/IExpressionService.cs b/src/ChapterTool.Core/Transform/IExpressionService.cs deleted file mode 100644 index 35362ca..0000000 --- a/src/ChapterTool.Core/Transform/IExpressionService.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ChapterTool.Core.Transform; - -/// <summary> -/// Evaluates mathematical chapter time expressions. -/// </summary> -public interface IExpressionService -{ - /// <summary> - /// Evaluates an infix expression against a chapter time context. - /// </summary> - /// <param name="expression">The expression text.</param> - /// <param name="timeSeconds">The chapter time in seconds.</param> - /// <param name="framesPerSecond">The frame rate in frames per second.</param> - /// <returns>The expression evaluation result.</returns> - ExpressionEvaluationResult EvaluateInfix(string expression, decimal timeSeconds, decimal framesPerSecond); - - /// <summary> - /// Evaluates postfix expression tokens against a chapter time context. - /// </summary> - /// <param name="tokens">The postfix expression tokens.</param> - /// <param name="timeSeconds">The chapter time in seconds.</param> - /// <param name="framesPerSecond">The frame rate in frames per second.</param> - /// <returns>The expression evaluation result.</returns> - ExpressionEvaluationResult EvaluatePostfix(IEnumerable<string> tokens, decimal timeSeconds, decimal framesPerSecond); -} diff --git a/src/ChapterTool.Core/Transform/IFrameRateService.cs b/src/ChapterTool.Core/Transform/IFrameRateService.cs index 654cc5f..b8462a2 100644 --- a/src/ChapterTool.Core/Transform/IFrameRateService.cs +++ b/src/ChapterTool.Core/Transform/IFrameRateService.cs @@ -25,7 +25,7 @@ public interface IFrameRateService /// <param name="info">The chapter data to inspect.</param> /// <param name="tolerance">The acceptable frame deviation tolerance.</param> /// <returns>The detected frame rate option.</returns> - FrameRateOption Detect(ChapterInfo info, decimal tolerance); + FrameRateOption Detect(ChapterSet info, decimal tolerance); /// <summary> /// Detects the most likely frame rate and returns confidence details. @@ -33,7 +33,7 @@ public interface IFrameRateService /// <param name="info">The chapter data to inspect.</param> /// <param name="tolerance">The acceptable frame deviation tolerance.</param> /// <returns>The detailed frame rate detection result.</returns> - FrameRateDetectionResult DetectDetailed(ChapterInfo info, decimal tolerance); + FrameRateDetectionResult DetectDetailed(ChapterSet info, decimal tolerance); /// <summary> /// Calculates frame numbers and accuracy for chapter data. @@ -44,7 +44,7 @@ public interface IFrameRateService /// <param name="tolerance">The acceptable frame deviation tolerance.</param> /// <returns>The calculated frame information.</returns> FrameInfoResult UpdateFrames( - ChapterInfo info, + ChapterSet info, FrameRateOption option, bool round, decimal tolerance); diff --git a/src/ChapterTool.Core/Transform/LuaExpressionModels.cs b/src/ChapterTool.Core/Transform/LuaExpressionModels.cs deleted file mode 100644 index ed1973a..0000000 --- a/src/ChapterTool.Core/Transform/LuaExpressionModels.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ChapterTool.Core.Diagnostics; -using ChapterTool.Core.Models; - -namespace ChapterTool.Core.Transform; - -/// <summary> -/// Provides chapter context values to Lua expression scripts. -/// </summary> -/// <param name="Chapter">The Chapter value.</param> -/// <param name="Index">The Index value.</param> -/// <param name="Count">The Count value.</param> -/// <param name="TimeSeconds">The TimeSeconds value.</param> -/// <param name="FramesPerSecond">The FramesPerSecond value.</param> -public sealed record LuaExpressionContext( - Chapter Chapter, - int Index, - int Count, - decimal TimeSeconds, - decimal FramesPerSecond); - -/// <summary> -/// Represents the result of Lua expression evaluation. -/// </summary> -/// <param name="Success">The Success value.</param> -/// <param name="Value">The Value value.</param> -/// <param name="Diagnostics">The Diagnostics value.</param> -public sealed record LuaExpressionEvaluationResult( - bool Success, - decimal Value, - IReadOnlyList<ChapterDiagnostic> Diagnostics); - -/// <summary> -/// Describes a built-in Lua expression preset. -/// </summary> -/// <param name="Id">The Id value.</param> -/// <param name="DisplayName">The DisplayName value.</param> -/// <param name="Description">The Description value.</param> -/// <param name="ScriptText">The ScriptText value.</param> -public sealed record LuaExpressionScriptPreset( - string Id, - string DisplayName, - string Description, - string ScriptText); - -/// <summary> -/// Evaluates Lua scripts for chapter time transforms. -/// </summary> -public interface ILuaExpressionScriptService -{ - /// <summary> - /// Gets built-in Lua expression presets. - /// </summary> - IReadOnlyList<LuaExpressionScriptPreset> Presets { get; } - - /// <summary> - /// Evaluates Lua script text against a chapter expression context. - /// </summary> - /// <param name="scriptText">The Lua script text.</param> - /// <param name="context">The expression context.</param> - /// <returns>The Lua expression evaluation result.</returns> - LuaExpressionEvaluationResult Evaluate(string scriptText, LuaExpressionContext context); -} diff --git a/src/ChapterTool.Infrastructure/Configuration/AppSettings.cs b/src/ChapterTool.Infrastructure/Configuration/AppSettings.cs index 894f254..601b495 100644 --- a/src/ChapterTool.Infrastructure/Configuration/AppSettings.cs +++ b/src/ChapterTool.Infrastructure/Configuration/AppSettings.cs @@ -10,6 +10,7 @@ public sealed record AppSettings( string? FfmpegPath = null, string DefaultSaveFormat = "Txt", string DefaultXmlLanguage = "und", + bool EmitBom = true, decimal FrameAccuracyTolerance = 0.15m); public sealed record WindowLocation(int X, int Y); diff --git a/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs b/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs index 94e122b..4aa8e2e 100644 --- a/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs +++ b/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs @@ -8,12 +8,14 @@ public sealed partial class AppSettingsStore(string settingsDirectory) : ISettin private const string CurrentFileName = "appsettings.json"; private readonly Lock syncRoot = new(); + private readonly SemaphoreSlim saveLock = new(1, 1); private AppSettings? cachedSettings; private FileStamp cachedFileStamp; public async ValueTask<AppSettings> LoadAsync(CancellationToken cancellationToken) { var currentPath = Path.Combine(settingsDirectory, CurrentFileName); + using var corruptLoadScope = CorruptSettingsFile.EnterLoad(currentPath); var stamp = GetFileStamp(currentPath); lock (syncRoot) @@ -39,6 +41,18 @@ public async ValueTask<AppSettings> LoadAsync(CancellationToken cancellationToke { throw CorruptSettingsFile.Preserve(currentPath, exception); } + catch (FileNotFoundException exception) when (CorruptSettingsFile.TryGetConcurrentPreservation(currentPath, exception, out var corruptException)) + { + throw corruptException; + } + } + + if (CorruptSettingsFile.TryGetConcurrentPreservation( + currentPath, + new FileNotFoundException("The settings file was preserved by another concurrent load.", currentPath), + out var concurrentCorruptException)) + { + throw concurrentCorruptException; } settings = new AppSettings(); @@ -48,12 +62,15 @@ public async ValueTask<AppSettings> LoadAsync(CancellationToken cancellationToke public async ValueTask SaveAsync(AppSettings settings, CancellationToken cancellationToken) { - Directory.CreateDirectory(settingsDirectory); - var currentPath = Path.Combine(settingsDirectory, CurrentFileName); - var tempPath = currentPath + ".tmp"; + await saveLock.WaitAsync(cancellationToken); + string? tempPath = null; try { + Directory.CreateDirectory(settingsDirectory); + var currentPath = Path.Combine(settingsDirectory, CurrentFileName); + tempPath = $"{currentPath}.{Guid.NewGuid():N}.tmp"; + await using (var stream = File.Create(tempPath)) { await JsonSerializer.SerializeAsync(stream, settings, AppJsonSerializerContext.Default.AppSettings, cancellationToken); @@ -64,13 +81,26 @@ public async ValueTask SaveAsync(AppSettings settings, CancellationToken cancell } catch { - if (File.Exists(tempPath)) + if (!string.IsNullOrWhiteSpace(tempPath) && File.Exists(tempPath)) { - File.Delete(tempPath); + try + { + File.Delete(tempPath); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } } throw; } + finally + { + saveLock.Release(); + } } private void Cache(AppSettings settings, FileStamp stamp) diff --git a/src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs b/src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs index fd9c355..0372eed 100644 --- a/src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs +++ b/src/ChapterTool.Infrastructure/Configuration/CorruptSettingsFile.cs @@ -1,12 +1,86 @@ +using System.Runtime.InteropServices; + namespace ChapterTool.Infrastructure.Configuration; internal static class CorruptSettingsFile { + private static readonly Lock SyncRoot = new(); + private static readonly Dictionary<string, LoadState> LoadStates = new(StringComparer.Ordinal); + + public static IDisposable EnterLoad(string path) + { + var key = NormalizePath(path); + + lock (SyncRoot) + { + ref var state = ref CollectionsMarshal.GetValueRefOrAddDefault(LoadStates, key, out _); + state.ActiveLoads++; + } + + return new LoadScope(key); + } + public static CorruptSettingsFileException Preserve(string path, Exception exception) { - var backupPath = NextBackupPath(path); - File.Move(path, backupPath); - return new CorruptSettingsFileException(path, backupPath, exception); + var key = NormalizePath(path); + + lock (SyncRoot) + { + if (!File.Exists(path)) + { + var existingBackupPath = ExistingBackupPath(path); + MarkConcurrentPreservation(key, existingBackupPath); + return new CorruptSettingsFileException(path, existingBackupPath, exception); + } + + var backupPath = NextBackupPath(path); + File.Move(path, backupPath); + MarkConcurrentPreservation(key, backupPath); + return new CorruptSettingsFileException(path, backupPath, exception); + } + } + + public static bool TryGetConcurrentPreservation(string path, Exception exception, out CorruptSettingsFileException corruptException) + { + var key = NormalizePath(path); + + lock (SyncRoot) + { + if (LoadStates.TryGetValue(key, out var state) + && state.PendingConcurrentFailures > 0 + && !string.IsNullOrEmpty(state.BackupPath)) + { + state.PendingConcurrentFailures--; + LoadStates[key] = state; + corruptException = new CorruptSettingsFileException(path, state.BackupPath, exception); + return true; + } + } + + corruptException = null!; + return false; + } + + private static void MarkConcurrentPreservation(string key, string backupPath) + { + ref var state = ref CollectionsMarshal.GetValueRefOrAddDefault(LoadStates, key, out _); + state.BackupPath = backupPath; + state.PendingConcurrentFailures = Math.Max(state.PendingConcurrentFailures, state.ActiveLoads - 1); + } + + private static string ExistingBackupPath(string path) + { + var backupPath = path + ".corrupt"; + if (File.Exists(backupPath)) + { + return backupPath; + } + + var latest = Directory + .EnumerateFiles(Path.GetDirectoryName(path) ?? ".", Path.GetFileName(backupPath) + "*") + .Order(StringComparer.Ordinal) + .LastOrDefault(); + return latest ?? backupPath; } private static string NextBackupPath(string path) @@ -28,4 +102,39 @@ private static string NextBackupPath(string path) throw new IOException($"Unable to allocate a corrupt settings backup path for '{path}'."); } + + private static string NormalizePath(string path) + { + return Path.GetFullPath(path); + } + + private sealed class LoadScope(string key) : IDisposable + { + public void Dispose() + { + lock (SyncRoot) + { + if (!LoadStates.TryGetValue(key, out var state)) + { + return; + } + + state.ActiveLoads--; + if (state.ActiveLoads <= 0) + { + LoadStates.Remove(key); + return; + } + + LoadStates[key] = state; + } + } + } + + private struct LoadState + { + public int ActiveLoads; + public int PendingConcurrentFailures; + public string? BackupPath; + } } diff --git a/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs b/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs index 005fa65..8e8f9d0 100644 --- a/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs +++ b/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs @@ -6,10 +6,12 @@ namespace ChapterTool.Infrastructure.Configuration; public sealed partial class ThemeSettingsStore(string settingsDirectory) : ISettingsStore<ThemeColorSettings> { private const string CurrentFileName = "theme-colors.json"; + private readonly SemaphoreSlim saveLock = new(1, 1); public async ValueTask<ThemeColorSettings> LoadAsync(CancellationToken cancellationToken) { var currentPath = Path.Combine(settingsDirectory, CurrentFileName); + using var corruptLoadScope = CorruptSettingsFile.EnterLoad(currentPath); if (File.Exists(currentPath)) { try @@ -22,6 +24,18 @@ public async ValueTask<ThemeColorSettings> LoadAsync(CancellationToken cancellat { throw CorruptSettingsFile.Preserve(currentPath, exception); } + catch (FileNotFoundException exception) when (CorruptSettingsFile.TryGetConcurrentPreservation(currentPath, exception, out var corruptException)) + { + throw corruptException; + } + } + + if (CorruptSettingsFile.TryGetConcurrentPreservation( + currentPath, + new FileNotFoundException("The settings file was preserved by another concurrent load.", currentPath), + out var concurrentCorruptException)) + { + throw concurrentCorruptException; } return ThemeColorSettings.Default; @@ -29,12 +43,15 @@ public async ValueTask<ThemeColorSettings> LoadAsync(CancellationToken cancellat public async ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken cancellationToken) { - Directory.CreateDirectory(settingsDirectory); - var currentPath = Path.Combine(settingsDirectory, CurrentFileName); - var tempPath = currentPath + ".tmp"; + await saveLock.WaitAsync(cancellationToken); + string? tempPath = null; try { + Directory.CreateDirectory(settingsDirectory); + var currentPath = Path.Combine(settingsDirectory, CurrentFileName); + tempPath = $"{currentPath}.{Guid.NewGuid():N}.tmp"; + await using (var stream = File.Create(tempPath)) { await JsonSerializer.SerializeAsync(stream, settings, AppJsonSerializerContext.Default.ThemeColorSettings, cancellationToken); @@ -44,12 +61,25 @@ public async ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken } catch { - if (File.Exists(tempPath)) + if (!string.IsNullOrWhiteSpace(tempPath) && File.Exists(tempPath)) { - File.Delete(tempPath); + try + { + File.Delete(tempPath); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } } throw; } + finally + { + saveLock.Release(); + } } } diff --git a/src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs b/src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs index b6aa49d..7634bc1 100644 --- a/src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs +++ b/src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs @@ -32,20 +32,20 @@ public BdmvChapterImporter(IExternalToolLocator toolLocator, IProcessRunner proc public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest request, CancellationToken cancellationToken) { var playlistDirectory = Path.Combine(request.Path, "BDMV", "PLAYLIST"); - Report(request.Progress, 0.05, "Status.LoadingSource"); + Report(request.ProgressReporter, ChapterImportProgressPhase.LoadingSource, 0.05); if (!Directory.Exists(playlistDirectory)) { - return ChapterImportResult.Failed(Error("InvalidStructure", "Blu-ray BDMV/PLAYLIST directory was not found.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.InvalidStructure, "Blu-ray BDMV/PLAYLIST directory was not found.")); } - Report(request.Progress, 0.10, "Status.LoadingSource.Validate"); + Report(request.ProgressReporter, ChapterImportProgressPhase.ValidatingSource, 0.10); var location = await toolLocator.LocateAsync("eac3to", cancellationToken); if (!location.Found || string.IsNullOrWhiteSpace(location.Path)) { - return ChapterImportResult.Failed(Error("MissingDependency", location.Message ?? "eac3to was not found.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.MissingDependency, location.Message ?? "eac3to was not found.")); } - Report(request.Progress, 0.15, "Status.LoadingSource.Discover"); + Report(request.ProgressReporter, ChapterImportProgressPhase.DiscoveringTitles, 0.15); var listResult = await RunAsync(location.Path, ToolWorkingDirectory(location.Path), [request.Path, "-showall"], cancellationToken); if (!listResult.Success) { @@ -55,10 +55,10 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req var candidates = ParsePlaylistList(listResult.Text ?? string.Empty); if (candidates.Count == 0) { - return ChapterImportResult.Failed(Error("DependencyOutputUnrecognized", "eac3to playlist output was not recognized.")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.DependencyOutputUnrecognized, "eac3to playlist output was not recognized.")); } - var options = new List<ChapterSourceOption>(); + var entries = new List<ChapterImportEntry>(); var diagnostics = new List<ChapterDiagnostic>(); var discTitle = ReadDiscTitle(request.Path); var chapterCandidates = candidates.Where(static candidate => candidate.HasChapters).ToArray(); @@ -66,7 +66,13 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req { var candidate = chapterCandidates[candidateIndex]; var baseProgress = 0.20 + candidateIndex * 0.75 / Math.Max(chapterCandidates.Length, 1); - Report(request.Progress, baseProgress, "Status.LoadingSource.Export"); + Report( + request.ProgressReporter, + ChapterImportProgressPhase.ExportingChapters, + baseProgress, + candidate.SourceName, + candidateIndex + 1, + chapterCandidates.Length); var playlistPath = Path.Combine(playlistDirectory, candidate.MplsName); if (!File.Exists(playlistPath)) @@ -74,15 +80,14 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req continue; } - ChapterInfo info; + ChapterSet info; try { info = MplsChapterImporter.ReadPlaylistInfo( playlistPath, discTitle, candidate.SourceName, - candidate.Index, - "BDMV", + ChapterImportFormat.Bdmv, candidate.Duration); } catch (Exception exception) when (exception is InvalidDataException or EndOfStreamException or IOException) @@ -97,7 +102,13 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req continue; } - Report(request.Progress, Math.Min(baseProgress + 0.05, 0.95), "Status.LoadingSource.Parse"); + Report( + request.ProgressReporter, + ChapterImportProgressPhase.ParsingChapters, + Math.Min(baseProgress + 0.05, 0.95), + candidate.SourceName, + candidateIndex + 1, + chapterCandidates.Length); var parsed = ogmChapterImporter.ImportText(export.Text, playlistPath); diagnostics.AddRange(parsed.Diagnostics); if (!parsed.Success) @@ -106,12 +117,12 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req } var chapterInfo = parsed.Groups - .SelectMany(static group => group.Options) - .Select(static option => option.ChapterInfo) + .SelectMany(static group => group.Entries) + .Select(static entry => entry.ChapterSet) .FirstOrDefault(); if (chapterInfo is null || chapterInfo.Chapters.Count == 0) { - diagnostics.Add(Error("NoChaptersFound", $"eac3to exported no parseable chapters for {candidate.MplsName}.")); + diagnostics.Add(Error(ChapterDiagnosticCode.NoChaptersFound, $"eac3to exported no parseable chapters for {candidate.MplsName}.")); continue; } @@ -121,40 +132,60 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req Duration = candidate.Duration == TimeSpan.Zero ? info.Duration : candidate.Duration }; - options.Add(new ChapterSourceOption( + entries.Add(new ChapterImportEntry( $"playlist-{candidate.Index}", $"{candidate.SourceName}__{bdmvInfo.Chapters.Count}", bdmvInfo, - MediaReferences: MediaReferences(candidate.SourceName))); + ReferencedMediaFiles: MediaReferences(candidate.SourceName))); } - if (options.Count == 0) + if (entries.Count == 0) { return ChapterImportResult.Failed(FailureDiagnostics(diagnostics)); } - return new ChapterImportResult(true, [new ChapterInfoGroup(request.Path, options)], diagnostics); + return new ChapterImportResult(true, [new ChapterImportSource(request.Path, entries)], diagnostics); } - private static void Report(IProgress<ChapterLoadProgress>? progress, double value, string message) => - progress?.Report(new ChapterLoadProgress(value, message)); + private static void Report( + IChapterImportProgressReporter? progress, + ChapterImportProgressPhase phase, + double fraction, + string? sourceName = null, + int? current = null, + int? total = null) => + progress?.Report(new ChapterImportProgress(phase, fraction, sourceName, current, total)); private async ValueTask<ProcessTextResult> RunAsync(string executable, string? workingDirectory, IReadOnlyList<string> arguments, CancellationToken cancellationToken) { - var result = await processRunner.RunAsync(new ProcessRunRequest(executable, arguments, workingDirectory, TimeSpan.FromSeconds(60)), cancellationToken); + ProcessRunResult result; + try + { + result = await processRunner.RunAsync(new ProcessRunRequest(executable, arguments, workingDirectory, TimeSpan.FromSeconds(60)), cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or InvalidOperationException) + { + return ProcessTextResult.Failed(Error(ChapterDiagnosticCode.DependencyCannotStart, $"eac3to could not be started: {exception.Message}")); + } + if (result.Cancelled) { - return ProcessTextResult.Failed(Error("DependencyExecutionCancelled", "eac3to was cancelled.")); + return ProcessTextResult.Failed(Error(ChapterDiagnosticCode.DependencyExecutionCancelled, "eac3to was cancelled.")); } if (result.TimedOut) { - return ProcessTextResult.Failed(Error("DependencyExecutionTimedOut", "eac3to timed out.")); + return ProcessTextResult.Failed(Error(ChapterDiagnosticCode.DependencyExecutionTimedOut, "eac3to timed out.")); } if (result.ExitCode is not 0 || (string.IsNullOrWhiteSpace(result.StandardOutput) && !string.IsNullOrWhiteSpace(result.StandardError))) { - return ProcessTextResult.Failed(Error("DependencyExecutionFailed", result.StandardError.Length == 0 ? "eac3to failed." : result.StandardError)); + return ProcessTextResult.Failed(Error(ChapterDiagnosticCode.DependencyExecutionFailed, result.StandardError.Length == 0 ? "eac3to failed." : result.StandardError)); + } + + if (result.OutputTruncated) + { + return ProcessTextResult.Failed(Error(ChapterDiagnosticCode.DependencyOutputTruncated, "eac3to output exceeded the capture limit and cannot be parsed safely.")); } return ProcessTextResult.Succeeded(result.StandardOutput); @@ -166,23 +197,38 @@ private async ValueTask<ChapterExportResult> ExportChaptersAsync(string executab try { var arguments = new[] { bdmvRoot, $"{titleIndex})", $"1:{tempPath}", "-showall" }; - var result = await processRunner.RunAsync( - new ProcessRunRequest(executable, arguments, Path.GetTempPath(), TimeSpan.FromSeconds(60), RedirectOutput: false, CreateNoWindow: true), - cancellationToken); + ProcessRunResult result; + try + { + result = await processRunner.RunAsync( + new ProcessRunRequest(executable, arguments, Path.GetTempPath(), TimeSpan.FromSeconds(60), RedirectOutput: false, CreateNoWindow: true), + cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or InvalidOperationException) + { + return new ChapterExportResult( + false, + null, + [Error(ChapterDiagnosticCode.DependencyCannotStart, $"eac3to chapter export could not be started: {exception.Message}")]); + } + + if (ToExecutionDiagnostic(result, "eac3to chapter export") is { } execution) + { + return new ChapterExportResult(false, null, [execution]); + } if (!File.Exists(tempPath)) { - var execution = ToExecutionDiagnostic(result, "eac3to chapter export"); return new ChapterExportResult( false, null, - [execution ?? Error("DependencyOutputMissing", "eac3to did not create a chapter export file.")]); + [Error(ChapterDiagnosticCode.DependencyOutputMissing, "eac3to did not create a chapter export file.")]); } var text = await File.ReadAllTextAsync(tempPath, cancellationToken); if (string.IsNullOrWhiteSpace(text)) { - return new ChapterExportResult(false, null, [Error("DependencyOutputEmpty", "eac3to chapter export was empty.")]); + return new ChapterExportResult(false, null, [Error(ChapterDiagnosticCode.DependencyOutputEmpty, "eac3to chapter export was empty.")]); } return new ChapterExportResult(true, text, []); @@ -209,17 +255,22 @@ private async ValueTask<ChapterExportResult> ExportChaptersAsync(string executab { if (result.Cancelled) { - return Error("DependencyExecutionCancelled", $"{operation} was cancelled."); + return Error(ChapterDiagnosticCode.DependencyExecutionCancelled, $"{operation} was cancelled."); } if (result.TimedOut) { - return Error("DependencyExecutionTimedOut", $"{operation} timed out."); + return Error(ChapterDiagnosticCode.DependencyExecutionTimedOut, $"{operation} timed out."); } if (result.ExitCode is not 0) { - return Error("DependencyExecutionFailed", result.StandardError.Length == 0 ? $"{operation} failed." : result.StandardError); + return Error(ChapterDiagnosticCode.DependencyExecutionFailed, result.StandardError.Length == 0 ? $"{operation} failed." : result.StandardError); + } + + if (result.OutputTruncated) + { + return Error(ChapterDiagnosticCode.DependencyOutputTruncated, $"{operation} output exceeded the capture limit and cannot be parsed safely."); } return null; @@ -293,22 +344,29 @@ private static string ReadDiscTitle(string root) return string.Empty; } - var file = Directory.EnumerateFiles(meta, "*.xml").FirstOrDefault(); - if (file is null) + try + { + var file = Directory.EnumerateFiles(meta, "*.xml").FirstOrDefault(); + if (file is null) + { + return string.Empty; + } + + var text = File.ReadAllText(file); + var match = DiscTitleRegex().Match(text); + return match.Success ? match.Groups["Title"].Value : string.Empty; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { return string.Empty; } - - var text = File.ReadAllText(file); - var match = DiscTitleRegex().Match(text); - return match.Success ? match.Groups["Title"].Value : string.Empty; } - private static IReadOnlyList<SourceMediaReference> MediaReferences(string sourceName) => + private static IReadOnlyList<ReferencedMediaFile> MediaReferences(string sourceName) => sourceName .Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(static part => part.EndsWith(".m2ts", StringComparison.OrdinalIgnoreCase)) - .Select(static part => new SourceMediaReference(part, Path.Combine("..", "STREAM", part))) + .Select(static part => new ReferencedMediaFile(part, Path.Combine("..", "STREAM", part))) .ToList(); private static string? ToolWorkingDirectory(string executable) @@ -326,11 +384,11 @@ private static ChapterDiagnostic[] FailureDiagnostics(IReadOnlyList<ChapterDiagn .ToArray(); return errors.Length == 0 - ? [Error("NoChaptersFound", "No BDMV playlists with chapters were parsed.")] + ? [Error(ChapterDiagnosticCode.NoChaptersFound, "No BDMV playlists with chapters were parsed.")] : errors; } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); private sealed record PlaylistCandidate(int Index, string MplsName, string SourceName, TimeSpan Duration, bool HasChapters); diff --git a/src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs b/src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs index 25adce4..9c1ce40 100644 --- a/src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs +++ b/src/ChapterTool.Infrastructure/Importing/Matroska/MatroskaChapterImporter.cs @@ -36,7 +36,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req if (!location.Found || string.IsNullOrWhiteSpace(location.Path)) { return ChapterImportResult.Failed(Error( - "MatroskaMissingDependency", + ChapterDiagnosticCode.MatroskaMissingDependency, location.Message ?? "mkvextract was not found.")); } @@ -52,27 +52,32 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or InvalidOperationException) { - return ChapterImportResult.Failed(Error("MatroskaCannotStart", $"mkvextract could not be started: {exception.Message}")); + return ChapterImportResult.Failed(Error(ChapterDiagnosticCode.MatroskaCannotStart, $"mkvextract could not be started: {exception.Message}")); } if (result.Cancelled) { - return ChapterImportResult.Failed(ProcessError("MatroskaProcessCancelled", "mkvextract was cancelled.", result)); + return ChapterImportResult.Failed(ProcessError(ChapterDiagnosticCode.MatroskaProcessCancelled, "mkvextract was cancelled.", result)); } if (result.TimedOut) { - return ChapterImportResult.Failed(ProcessError("MatroskaProcessTimedOut", "mkvextract timed out.", result)); + return ChapterImportResult.Failed(ProcessError(ChapterDiagnosticCode.MatroskaProcessTimedOut, "mkvextract timed out.", result)); } if (result.ExitCode is not 0) { - return ChapterImportResult.Failed(ProcessError("MatroskaProcessFailed", "mkvextract exited with a non-zero code.", result)); + return ChapterImportResult.Failed(ProcessError(ChapterDiagnosticCode.MatroskaProcessFailed, "mkvextract exited with a non-zero code.", result)); + } + + if (result.OutputTruncated) + { + return ChapterImportResult.Failed(ProcessError(ChapterDiagnosticCode.MatroskaOutputTruncated, "mkvextract output exceeded the capture limit and cannot be parsed safely.", result)); } if (string.IsNullOrWhiteSpace(result.StandardOutput)) { - var code = string.IsNullOrWhiteSpace(result.StandardError) ? "MatroskaNoChapters" : "MatroskaProcessFailed"; + var code = string.IsNullOrWhiteSpace(result.StandardError) ? ChapterDiagnosticCode.MatroskaNoChapters : ChapterDiagnosticCode.MatroskaProcessFailed; var message = string.IsNullOrWhiteSpace(result.StandardError) ? "mkvextract did not return chapter XML." : $"mkvextract wrote stderr without chapter XML: {result.StandardError.Trim()}"; @@ -82,7 +87,7 @@ public async ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest req return xmlImporter.ImportText(result.StandardOutput, request.Path); } - private static ChapterDiagnostic ProcessError(string code, string message, ProcessRunResult result) + private static ChapterDiagnostic ProcessError(ChapterDiagnosticCode code, string message, ProcessRunResult result) { var stderr = string.IsNullOrWhiteSpace(result.StandardError) ? string.Empty @@ -90,6 +95,6 @@ private static ChapterDiagnostic ProcessError(string code, string message, Proce return Error(code, $"{message}{stderr} Command: {result.FileName} {string.Join(" ", result.Arguments)} ExitCode: {result.ExitCode?.ToString() ?? "<none>"}"); } - private static ChapterDiagnostic Error(string code, string message) => + private static ChapterDiagnostic Error(ChapterDiagnosticCode code, string message) => new(DiagnosticSeverity.Error, code, message); } diff --git a/src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs b/src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs index 7ecd086..ca96ff7 100644 --- a/src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs +++ b/src/ChapterTool.Infrastructure/Importing/Media/AtlMp4ChapterReader.cs @@ -1,4 +1,5 @@ using ATL; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing.Media; namespace ChapterTool.Infrastructure.Importing.Media; @@ -19,7 +20,7 @@ public ValueTask<MediaChapterReadResult> ReadAsync(string path, CancellationToke if (string.IsNullOrWhiteSpace(path)) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4InvalidPath", "MP4 path is empty.")); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4InvalidPath, "MP4 path is empty.")); } try @@ -29,27 +30,27 @@ public ValueTask<MediaChapterReadResult> ReadAsync(string path, CancellationToke } catch (FileNotFoundException ex) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4FileNotFound", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4FileNotFound, ex.Message)); } catch (DirectoryNotFoundException ex) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4FileNotFound", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4FileNotFound, ex.Message)); } catch (UnauthorizedAccessException ex) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4FileInaccessible", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4FileInaccessible, ex.Message)); } catch (IOException ex) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4ReadFailed", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4ReadFailed, ex.Message)); } catch (InvalidDataException ex) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4MalformedMetadata", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4MalformedMetadata, ex.Message)); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException) { - return ValueTask.FromResult(MediaChapterReadResult.Failed("Mp4UnsupportedMetadata", ex.Message)); + return ValueTask.FromResult(MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4UnsupportedMetadata, ex.Message)); } } @@ -65,12 +66,12 @@ private static MediaChapterReadResult Normalize(IReadOnlyList<AtlChapterEntry> c { if (chapter.UseOffset) { - return MediaChapterReadResult.Failed("Mp4UnsupportedMetadata", "Offset-based MP4 chapters are not supported."); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4UnsupportedMetadata, "Offset-based MP4 chapters are not supported."); } if (chapter.EndTime <= chapter.StartTime) { - return MediaChapterReadResult.Failed("Mp4MalformedMetadata", "MP4 chapter end time must be greater than start time."); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4MalformedMetadata, "MP4 chapter end time must be greater than start time."); } var title = string.IsNullOrWhiteSpace(chapter.Title) diff --git a/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs b/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs index 47959ee..051ee21 100644 --- a/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs +++ b/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing.Media; using ChapterTool.Infrastructure.Services; using ChapterTool.Infrastructure.Configuration; @@ -18,7 +19,7 @@ public async ValueTask<MediaChapterReadResult> ReadAsync(string path, Cancellati if (!location.Found || string.IsNullOrWhiteSpace(location.Path)) { return MediaChapterReadResult.Failed( - "FfprobeMissingDependency", + ChapterDiagnosticCode.FfprobeMissingDependency, location.Message ?? "ffprobe was not found."); } @@ -36,28 +37,33 @@ public async ValueTask<MediaChapterReadResult> ReadAsync(string path, Cancellati catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or InvalidOperationException) { return MediaChapterReadResult.Failed( - "FfprobeCannotStart", + ChapterDiagnosticCode.FfprobeCannotStart, $"ffprobe could not be started: {exception.Message}"); } if (result.Cancelled) { - return MediaChapterReadResult.Failed("FfprobeProcessCancelled", "ffprobe was cancelled.", ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeProcessCancelled, "ffprobe was cancelled.", ProcessDetails(result)); } if (result.TimedOut) { - return MediaChapterReadResult.Failed("FfprobeProcessTimedOut", "ffprobe timed out.", ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeProcessTimedOut, "ffprobe timed out.", ProcessDetails(result)); } if (result.ExitCode is not 0) { - return MediaChapterReadResult.Failed("FfprobeProcessFailed", "ffprobe exited with a non-zero code.", ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeProcessFailed, "ffprobe exited with a non-zero code.", ProcessDetails(result)); + } + + if (result.OutputTruncated) + { + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeOutputTruncated, "ffprobe output exceeded the capture limit and cannot be parsed safely.", ProcessDetails(result)); } if (string.IsNullOrWhiteSpace(result.StandardOutput)) { - return MediaChapterReadResult.Failed("FfprobeEmptyOutput", "ffprobe did not return chapter JSON.", ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeEmptyOutput, "ffprobe did not return chapter JSON.", ProcessDetails(result)); } return Parse(result.StandardOutput, result); @@ -71,7 +77,7 @@ private static MediaChapterReadResult Parse(string json, ProcessRunResult result var rawChapters = output?.Chapters; if (rawChapters is null || rawChapters.Length == 0) { - return MediaChapterReadResult.Failed("FfprobeParseFailed", "ffprobe JSON did not contain a chapters array.", ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeParseFailed, "ffprobe JSON did not contain a chapters array.", ProcessDetails(result)); } var chapters = new List<MediaChapterEntry>(); @@ -94,7 +100,7 @@ private static MediaChapterReadResult Parse(string json, ProcessRunResult result } catch (JsonException exception) { - return MediaChapterReadResult.Failed("FfprobeParseFailed", exception.Message, ProcessDetails(result)); + return MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeParseFailed, exception.Message, ProcessDetails(result)); } } diff --git a/src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs b/src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs index f9a8827..5745369 100644 --- a/src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs +++ b/src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; + namespace ChapterTool.Infrastructure.Platform; public sealed class FileSystemNativeDependencyService(IReadOnlyList<string> searchDirectories) : INativeDependencyService @@ -17,8 +19,7 @@ public ValueTask<NativeDependencyLocation> ResolveAsync(string dependencyId, Can return ValueTask.FromResult(new NativeDependencyLocation( false, null, - "NativeLibraryMissing", + ChapterDiagnosticCode.NativeLibraryMissing, $"Native dependency '{dependencyId}' was not found.")); } - } diff --git a/src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs b/src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs index 00eaff2..3216eed 100644 --- a/src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs +++ b/src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs @@ -3,5 +3,5 @@ namespace ChapterTool.Infrastructure.Platform; public sealed record NativeDependencyLocation( bool Found, string? Path, - string? DiagnosticCode = null, + Core.Diagnostics.ChapterDiagnosticCode? DiagnosticCode = null, string? Message = null); diff --git a/src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs b/src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs index 26fdbca..8fefb8a 100644 --- a/src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs +++ b/src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs @@ -6,6 +6,8 @@ namespace ChapterTool.Infrastructure.Processes; public sealed class ProcessRunner : IProcessRunner { + private static readonly TimeSpan KillWaitTimeout = TimeSpan.FromSeconds(2); + public async ValueTask<ProcessRunResult> RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) { using var process = CreateProcess(request); @@ -13,16 +15,18 @@ public async ValueTask<ProcessRunResult> RunAsync(ProcessRunRequest request, Can using var linkedCts = timeoutCts is null ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var stdoutTask = Task.FromResult(new CapturedOutput(string.Empty, false)); + var stderrTask = Task.FromResult(new CapturedOutput(string.Empty, false)); try { process.Start(); - var stdoutTask = request.RedirectOutput - ? process.StandardOutput.ReadToEndAsync(linkedCts.Token) - : Task.FromResult(string.Empty); - var stderrTask = request.RedirectOutput - ? process.StandardError.ReadToEndAsync(linkedCts.Token) - : Task.FromResult(string.Empty); + stdoutTask = request.RedirectOutput + ? ReadBoundedAsync(process.StandardOutput, request.MaxOutputCharacters) + : stdoutTask; + stderrTask = request.RedirectOutput + ? ReadBoundedAsync(process.StandardError, request.MaxOutputCharacters) + : stderrTask; await process.WaitForExitAsync(linkedCts.Token); var stdout = await stdoutTask; @@ -30,27 +34,32 @@ public async ValueTask<ProcessRunResult> RunAsync(ProcessRunRequest request, Can return new ProcessRunResult( process.ExitCode, - stdout, - stderr, + stdout.Text, + stderr.Text, TimedOut: false, Cancelled: false, request.FileName, request.Arguments, - request.WorkingDirectory); + request.WorkingDirectory, + stdout.Truncated || stderr.Truncated); } catch (OperationCanceledException) { KillProcess(process); + await WaitForKilledProcessAsync(process); + var stdout = await CaptureCompletedOutputAsync(stdoutTask); + var stderr = await CaptureCompletedOutputAsync(stderrTask); var timedOut = timeoutCts?.IsCancellationRequested == true && !cancellationToken.IsCancellationRequested; return new ProcessRunResult( ExitCode: null, - StandardOutput: string.Empty, - StandardError: string.Empty, + StandardOutput: stdout.Text, + StandardError: stderr.Text, TimedOut: timedOut, Cancelled: !timedOut, request.FileName, request.Arguments, - request.WorkingDirectory); + request.WorkingDirectory, + stdout.Truncated || stderr.Truncated); } } @@ -92,4 +101,59 @@ private static void KillProcess(Process process) { } } + + private static async Task<CapturedOutput> CaptureCompletedOutputAsync(Task<CapturedOutput> outputTask) + { + try + { + return await outputTask.WaitAsync(KillWaitTimeout); + } + catch (Exception exception) when (exception is TimeoutException or IOException or ObjectDisposedException or InvalidOperationException) + { + return new CapturedOutput(string.Empty, false); + } + } + + private static async Task WaitForKilledProcessAsync(Process process) + { + try + { + await process.WaitForExitAsync(CancellationToken.None).WaitAsync(KillWaitTimeout); + } + catch (Exception exception) when (exception is TimeoutException or InvalidOperationException) + { + } + } + + private static async Task<CapturedOutput> ReadBoundedAsync(TextReader reader, int maxCharacters) + { + maxCharacters = Math.Max(0, maxCharacters); + var builder = new StringBuilder(capacity: Math.Min(maxCharacters, 4096)); + var buffer = new char[4096]; + var truncated = false; + + while (true) + { + var read = await reader.ReadAsync(buffer); + if (read == 0) + { + break; + } + + var remaining = maxCharacters - builder.Length; + if (remaining > 0) + { + builder.Append(buffer, 0, Math.Min(read, remaining)); + } + + if (read > remaining) + { + truncated = true; + } + } + + return new CapturedOutput(builder.ToString(), truncated); + } + + private sealed record CapturedOutput(string Text, bool Truncated); } diff --git a/src/ChapterTool.Infrastructure/Services/ExternalToolLocation.cs b/src/ChapterTool.Infrastructure/Services/ExternalToolLocation.cs index 0db9a53..2bfa65b 100644 --- a/src/ChapterTool.Infrastructure/Services/ExternalToolLocation.cs +++ b/src/ChapterTool.Infrastructure/Services/ExternalToolLocation.cs @@ -3,5 +3,5 @@ namespace ChapterTool.Infrastructure.Services; public sealed record ExternalToolLocation( bool Found, string? Path, - string? DiagnosticCode = null, + Core.Diagnostics.ChapterDiagnosticCode? DiagnosticCode = null, string? Message = null); diff --git a/src/ChapterTool.Infrastructure/Services/ProcessRunRequest.cs b/src/ChapterTool.Infrastructure/Services/ProcessRunRequest.cs index cdcc93f..568cb30 100644 --- a/src/ChapterTool.Infrastructure/Services/ProcessRunRequest.cs +++ b/src/ChapterTool.Infrastructure/Services/ProcessRunRequest.cs @@ -6,4 +6,5 @@ public sealed record ProcessRunRequest( string? WorkingDirectory = null, TimeSpan? Timeout = null, bool RedirectOutput = true, - bool CreateNoWindow = true); + bool CreateNoWindow = true, + int MaxOutputCharacters = 1_000_000); diff --git a/src/ChapterTool.Infrastructure/Services/ProcessRunResult.cs b/src/ChapterTool.Infrastructure/Services/ProcessRunResult.cs index bdc4052..e492974 100644 --- a/src/ChapterTool.Infrastructure/Services/ProcessRunResult.cs +++ b/src/ChapterTool.Infrastructure/Services/ProcessRunResult.cs @@ -8,4 +8,5 @@ public sealed record ProcessRunResult( bool Cancelled, string FileName, IReadOnlyList<string> Arguments, - string? WorkingDirectory); + string? WorkingDirectory, + bool OutputTruncated = false); diff --git a/src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs b/src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs index 678c159..831ea5f 100644 --- a/src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs +++ b/src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Services; using ChapterTool.Infrastructure.Configuration; @@ -73,7 +74,7 @@ public async ValueTask<ExternalToolLocation> LocateAsync(string toolId, Cancella return Cache(cacheKey, new ExternalToolLocation( false, null, - "MissingDependency", + ChapterDiagnosticCode.MissingDependency, $"External tool '{toolId}' was not found.")); } diff --git a/tests/ChapterTool.Avalonia.Tests/Cli/ChapterToolCliApplicationTests.cs b/tests/ChapterTool.Avalonia.Tests/Cli/ChapterToolCliApplicationTests.cs index 3c96b06..6636583 100644 --- a/tests/ChapterTool.Avalonia.Tests/Cli/ChapterToolCliApplicationTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Cli/ChapterToolCliApplicationTests.cs @@ -15,7 +15,88 @@ public void AnalyzeLaunchRecognizesCliTokens() } [Fact] - public void AnalyzeLaunchReturnsOnlyExplicitLoadInputsForGui() + public void AnalyzeLaunchDoesNotBindInvalidCliOptionsBeforeRun() + { + var plan = ChapterToolCliSupport.AnalyzeLaunch(["convert", "missing.xml", "--format", "expr"]); + + Assert.False(plan.LaunchGui); + Assert.NotNull(plan.CliResult); + } + + [Fact] + public void UnknownRootTokenReturnsFailureExitCode() + { + var plan = ChapterToolCliSupport.AnalyzeLaunch(["nosuchcommand"]); + + Assert.False(plan.LaunchGui); + Assert.NotNull(plan.CliResult); + Assert.NotEqual(0, plan.CliResult.Run()); + } + + [Fact] + public void CommandLevelFormatsReturnsSuccess() + { + var plan = ChapterToolCliSupport.AnalyzeLaunch(["formats"]); + + Assert.Equal(0, plan.CliResult!.Run()); + } + + [Fact] + public void CommandLevelConvertWritesOutputFile() + { + var outputPath = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N"), "chapters.txt"); + try + { + var plan = ChapterToolCliSupport.AnalyzeLaunch([ + "convert", + XmlFixture(), + "--format", + "txt", + "--output", + outputPath, + "--group-index", + "0", + "--entry-index", + "0" + ]); + + Assert.Equal(0, plan.CliResult!.Run()); + Assert.Contains("CHAPTER01=", File.ReadAllText(outputPath), StringComparison.Ordinal); + } + finally + { + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void CommandLevelConvertRejectsConflictingOutputOptions() + { + var outputPath = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N"), "chapters.txt"); + var plan = ChapterToolCliSupport.AnalyzeLaunch([ + "convert", + XmlFixture(), + "--format", + "txt", + "--stdout", + "--output", + outputPath, + "--group-index", + "0", + "--entry-index", + "0" + ]); + + Assert.Equal(1, plan.CliResult!.Run()); + Assert.False(File.Exists(outputPath)); + } + + [Fact] + public void AnalyzeLaunchReturnsLoadInputsForGui() { var path = Path.GetTempFileName(); try @@ -23,6 +104,7 @@ public void AnalyzeLaunchReturnsOnlyExplicitLoadInputsForGui() var loadByArgument = ChapterToolCliSupport.AnalyzeLaunch(["load", path]); var loadByOption = ChapterToolCliSupport.AnalyzeLaunch(["load", "--source", path]); var plainPath = ChapterToolCliSupport.AnalyzeLaunch([path]); + var missingPlainPath = ChapterToolCliSupport.AnalyzeLaunch([Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.xml")]); var convert = ChapterToolCliSupport.AnalyzeLaunch(["convert", path]); var empty = ChapterToolCliSupport.AnalyzeLaunch([]); @@ -30,8 +112,10 @@ public void AnalyzeLaunchReturnsOnlyExplicitLoadInputsForGui() Assert.Equal(path, loadByArgument.GuiStartupPath); Assert.True(loadByOption.LaunchGui); Assert.Equal(path, loadByOption.GuiStartupPath); - Assert.False(plainPath.LaunchGui); - Assert.Null(plainPath.GuiStartupPath); + Assert.True(plainPath.LaunchGui); + Assert.Equal(path, plainPath.GuiStartupPath); + Assert.False(missingPlainPath.LaunchGui); + Assert.NotNull(missingPlainPath.CliResult); Assert.False(convert.LaunchGui); Assert.Null(convert.GuiStartupPath); Assert.False(empty.LaunchGui); @@ -87,8 +171,8 @@ public async Task ConvertWritesStdoutForBasicTxtExport() OutputPath: null, Stdout: true, GroupIndex: 0, - OptionIndex: 0, - OptionId: null, + EntryIndex: 0, + EntryId: null, XmlLanguage: null, SourceFileName: null, FrameRate: null), @@ -116,8 +200,8 @@ public async Task ConvertWritesOutputFileWhenRequested() outputPath, Stdout: false, GroupIndex: 0, - OptionIndex: 0, - OptionId: null, + EntryIndex: 0, + EntryId: null, XmlLanguage: "eng", SourceFileName: null, FrameRate: null), @@ -153,16 +237,16 @@ public async Task ConvertFailsWhenSelectionIsAmbiguous() OutputPath: null, Stdout: true, GroupIndex: null, - OptionIndex: null, - OptionId: null, + EntryIndex: null, + EntryId: null, XmlLanguage: null, SourceFileName: null, FrameRate: null), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); - Assert.Contains("Group 0 has multiple options", console.Stderr, StringComparison.Ordinal); - Assert.Contains("AvailableGroup", console.Stderr, StringComparison.Ordinal); + Assert.Contains("Group 0 has multiple entries", console.Stderr, StringComparison.Ordinal); + Assert.Contains("SelectionGroup.Available", console.Stderr, StringComparison.Ordinal); } [Fact] @@ -178,8 +262,8 @@ public async Task ConvertFailsForUnsupportedFormat() OutputPath: null, Stdout: true, GroupIndex: 0, - OptionIndex: 0, - OptionId: null, + EntryIndex: 0, + EntryId: null, XmlLanguage: null, SourceFileName: null, FrameRate: null), diff --git a/tests/ChapterTool.Avalonia.Tests/Diagnostics/SentryStartupConfigurationTests.cs b/tests/ChapterTool.Avalonia.Tests/Diagnostics/SentryStartupConfigurationTests.cs new file mode 100644 index 0000000..5b2fccd --- /dev/null +++ b/tests/ChapterTool.Avalonia.Tests/Diagnostics/SentryStartupConfigurationTests.cs @@ -0,0 +1,83 @@ +using ChapterTool.Avalonia.Diagnostics; + +namespace ChapterTool.Avalonia.Tests.Diagnostics; + +public sealed class SentryStartupConfigurationTests +{ + [Fact] + public void FromEnvironmentUsesSafeDefaults() + { + var options = SentryStartupConfiguration.FromEnvironment( + _ => null, + typeof(SentryStartupConfiguration).Assembly, + "/tmp/local", + debugBuild: false); + + Assert.True(options.Enabled); + Assert.Equal(SentryStartupConfiguration.DefaultDsn, options.Dsn); + Assert.Equal("production", options.Environment); + Assert.StartsWith("ChapterTool@23.0.0", options.Release, StringComparison.Ordinal); + Assert.False(options.Debug); + Assert.Equal(SentryLevel.Warning, options.DiagnosticLevel); + Assert.True(options.SendDefaultPii); + Assert.Equal(0.1d, options.TracesSampleRate); + Assert.Null(options.ProfilesSampleRate); + Assert.Equal(Path.Combine("/tmp/local", "ChapterTool", "sentry"), options.CacheDirectoryPath); + } + + [Fact] + public void FromEnvironmentAllowsChapterToolOverrides() + { + var values = new Dictionary<string, string?>(StringComparer.Ordinal) + { + ["CHAPTERTOOL_SENTRY_ENABLED"] = "off", + ["CHAPTERTOOL_SENTRY_DSN"] = "https://example.test/1", + ["CHAPTERTOOL_SENTRY_ENVIRONMENT"] = "staging", + ["CHAPTERTOOL_SENTRY_RELEASE"] = "ChapterTool@test", + ["CHAPTERTOOL_SENTRY_DIST"] = "macos-arm64", + ["CHAPTERTOOL_SENTRY_DEBUG"] = "true", + ["CHAPTERTOOL_SENTRY_DIAGNOSTIC_LEVEL"] = "Error", + ["CHAPTERTOOL_SENTRY_SEND_DEFAULT_PII"] = "yes", + ["CHAPTERTOOL_SENTRY_TRACES_SAMPLE_RATE"] = "0.25", + ["CHAPTERTOOL_SENTRY_PROFILES_SAMPLE_RATE"] = "0.5", + ["CHAPTERTOOL_SENTRY_CACHE_DIR"] = "/tmp/sentry" + }; + + var options = SentryStartupConfiguration.FromEnvironment( + name => values.GetValueOrDefault(name), + typeof(SentryStartupConfiguration).Assembly, + "/tmp/local", + debugBuild: false); + + Assert.False(options.Enabled); + Assert.Equal("https://example.test/1", options.Dsn); + Assert.Equal("staging", options.Environment); + Assert.Equal("ChapterTool@test", options.Release); + Assert.Equal("macos-arm64", options.Distribution); + Assert.True(options.Debug); + Assert.Equal(SentryLevel.Error, options.DiagnosticLevel); + Assert.True(options.SendDefaultPii); + Assert.Equal(0.25d, options.TracesSampleRate); + Assert.Equal(0.5d, options.ProfilesSampleRate); + Assert.Equal("/tmp/sentry", options.CacheDirectoryPath); + } + + [Fact] + public void FromEnvironmentFallsBackWhenSampleRatesAreInvalid() + { + var values = new Dictionary<string, string?>(StringComparer.Ordinal) + { + ["CHAPTERTOOL_SENTRY_TRACES_SAMPLE_RATE"] = "2", + ["CHAPTERTOOL_SENTRY_PROFILES_SAMPLE_RATE"] = "abc" + }; + + var options = SentryStartupConfiguration.FromEnvironment( + name => values.GetValueOrDefault(name), + typeof(SentryStartupConfiguration).Assembly, + "/tmp/local", + debugBuild: false); + + Assert.Equal(0.1d, options.TracesSampleRate); + Assert.Null(options.ProfilesSampleRate); + } +} diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs index 9aa1002..eaf76e4 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs @@ -88,6 +88,31 @@ public async Task Settings_close_without_changes_does_not_prompt() Assert.False(window.IsVisible); } + [AvaloniaFact] + public async Task Settings_close_disposes_localization_subscription() + { + using var host = new MainWindowHeadlessTestHost(appSettings: new AppSettings(Language: "en-US", SavingPath: "saved")); + var service = CreateService(host, new FakeSettingsCloseConfirmationService(SettingsCloseAction.Cancel)); + await service.ShowAsync("settings", host.ViewModel, TestContext.Current.CancellationToken); + var window = SettingsWindow(service); + var settings = SettingsViewModel(window); + var notifications = 0; + settings.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(SettingsToolViewModel.XmlLanguageDisplayOptions)) + { + notifications++; + } + }; + + window.Close(); + await DrainUiAsync(); + host.Localizer.SetCulture("zh-CN"); + await DrainUiAsync(); + + Assert.Equal(0, notifications); + } + [AvaloniaFact] public async Task Settings_language_change_keeps_live_selection_and_refreshes_option_text() { diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs index 2e276d1..afe36d6 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs @@ -3,7 +3,6 @@ using Avalonia.VisualTree; using ChapterTool.Avalonia.Localization; using ChapterTool.Avalonia.ViewModels; -using ChapterTool.Avalonia.Views.Tools; using ChapterTool.Infrastructure.Configuration; using System.Text.RegularExpressions; @@ -47,16 +46,6 @@ public async Task Runtime_language_switch_refreshes_main_window_and_tool_text() Assert.False(string.IsNullOrWhiteSpace(xmlLanguageBox.SelectionBoxItem?.ToString())); Assert.StartsWith("jpn(", xmlLanguageBox.SelectionBoxItem?.ToString(), StringComparison.Ordinal); - var languageWindow = await MainWindowHeadlessTestHost.RenderToolAsync(new LanguageToolView(), new LanguageToolViewModel(host.ViewModel)); - try - { - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(languageWindow, "语言")); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(languageWindow, "应用")); - } - finally - { - languageWindow.Close(); - } } private static string ChapterNameModeSelectionText(MainWindowHeadlessTestHost host) @@ -79,108 +68,6 @@ public async Task Unsupported_or_blank_language_falls_back_without_keys_or_mojib Assert.DoesNotContain(rendered, ContainsEncodingArtifact); } - [AvaloniaFact] - public async Task English_main_window_option_labels_have_room_at_default_and_narrow_widths() - { - using var host = new MainWindowHeadlessTestHost(localizer: new AppLocalizationManager("en-US")); - await host.LoadAsync("movie.txt"); - - foreach (var size in new[] { UiTestSize.Default, UiTestSize.Narrow }) - { - await host.LayoutAtAsync(size); - - foreach (var groupName in new[] - { - "FormatOptionsGroup", - "ChapterNameOptionsGroup", - "OrderShiftOptionsGroup", - "XmlLanguageOptionsGroup", - "ExpressionOptionsGroup" - }) - { - var group = host.RequiredControl<Grid>(groupName); - var label = MainWindowHeadlessTestHost.RequiredDescendant<TextBlock>( - group, - block => block.Classes.Contains("optionLabel"), - $"{groupName} label"); - - Assert.True(label.Bounds.Width > 0, $"{groupName} label width was {label.Bounds.Width}."); - Assert.True(label.Bounds.Height >= 14, $"{groupName} label height was {label.Bounds.Height}."); - } - - var artifact = await host.CaptureArtifactAsync($"main-window-en-{size.ToString().ToLowerInvariant()}.png"); - Assert.True(File.Exists(artifact)); - Assert.True(new FileInfo(artifact).Length > 0); - } - } - - [AvaloniaFact] - public async Task Main_window_layout_captures_default_wide_and_narrow_artifacts() - { - using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( - "movie.txt", - MainWindowHeadlessTestHost.Option("OGM", "movie.txt", "Intro", "Middle", "Ending"))); - await host.LoadAsync("movie.txt"); - - foreach (var size in new[] { UiTestSize.Default, UiTestSize.Wide, UiTestSize.Narrow }) - { - await host.LayoutAtAsync(size); - var artifact = await host.CaptureArtifactAsync($"main-window-{size.ToString().ToLowerInvariant()}.png"); - - Assert.True(File.Exists(artifact)); - Assert.True(new FileInfo(artifact).Length > 0); - Assert.True(host.RequiredControl<Button>("LoadButton").Bounds.Width > 0); - Assert.True(host.RequiredControl<Button>("SaveButton").Bounds.Width > 0); - Assert.True(host.RequiredControl<NumericUpDown>("OrderShiftBox").Bounds.Width >= 92); - Assert.True(host.RequiredControl<DataGrid>("ChapterGrid").Columns.All(column => column.MinWidth > 0)); - Assert.True(host.RequiredControl<Grid>("AdvancedOptionsGrid").Bounds.Width > 0); - } - } - - [AvaloniaFact] - public async Task Settings_tool_layout_captures_default_wide_and_narrow_artifacts() - { - using var host = new MainWindowHeadlessTestHost(); - var viewModel = new SettingsToolViewModel(host.ViewModel, host.AppSettingsStore, host.ThemeSettingsStore, host.Localizer, autoLoad: false); - await viewModel.LoadAsync(TestContext.Current.CancellationToken); - var window = new Window(); - try - { - foreach (var (name, width, height) in new[] - { - ("default", 760d, 520d), - ("wide", 1040d, 620d), - ("narrow", 560d, 640d) - }) - { - window.Content = new SettingsToolView { DataContext = viewModel }; - window.Width = width; - window.Height = height; - window.Show(); - await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window); - - var artifact = await host.CaptureArtifactAsync($"settings-tool-{name}.png", window); - - Assert.True(File.Exists(artifact)); - Assert.True(new FileInfo(artifact).Length > 0); - var tabControl = MainWindowHeadlessTestHost.Descendants<TabControl>(window).Single(); - Assert.True(tabControl.Bounds.Width > 0); - Assert.Contains( - tabControl.GetVisualDescendants().OfType<TextBlock>(), - block => string.Equals(block.Text, host.Localizer.GetString("Settings.General"), StringComparison.Ordinal)); - Assert.Contains( - window.GetVisualDescendants().OfType<TextBlock>(), - block => string.Equals(block.Text, host.Localizer.GetString("Settings.SaveDirectory"), StringComparison.Ordinal)); - Assert.Contains(MainWindowHeadlessTestHost.Descendants<Button>(window), button => button.Command == viewModel.SaveCommand && button.Bounds.Height >= 24); - Assert.Contains(MainWindowHeadlessTestHost.Descendants<Button>(window), button => button.Command == viewModel.ResetCommand && button.Bounds.Height >= 24); - } - } - finally - { - window.Close(); - } - } - private static bool ContainsEncodingArtifact(string text) => text.Contains('\uFFFD', StringComparison.Ordinal) || InvalidTextControlCharacterRegex().IsMatch(text); diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs index e46d20b..7b00330 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTestHost.cs @@ -30,7 +30,7 @@ public MainWindowHeadlessTestHost( IShellService? shellService = null) : this( loadResult is null - ? [ImportResult("movie.txt", Option("OGM", "movie.txt", "Intro"))] + ? [ImportResult("movie.txt", Entry(ChapterImportFormat.Ogm, "movie.txt", "Intro"))] : [loadResult], localizer, appSettings, @@ -48,7 +48,7 @@ public MainWindowHeadlessTestHost( { Localizer = localizer ?? new AppLocalizationManager("en-US"); LoadService = new FakeLoadService(loadResults.Count == 0 - ? [ImportResult("movie.txt", Option("OGM", "movie.txt", "Intro"))] + ? [ImportResult("movie.txt", Entry(ChapterImportFormat.Ogm, "movie.txt", "Intro"))] : loadResults); SaveService = new FakeSaveService(); WindowService = new FakeWindowService(); @@ -98,28 +98,28 @@ public MainWindowHeadlessTestHost( public IApplicationLogService LogService => logService; - public static ChapterImportResult ImportResult(string path, params ChapterSourceOption[] options) => - new(true, [new ChapterInfoGroup(path, options)], []); + public static ChapterImportResult ImportResult(string path, params ChapterImportEntry[] entries) => + new(true, [new ChapterImportSource(path, entries)], []); - public static ChapterImportResult ImportResult(string path, int defaultOptionIndex, params ChapterSourceOption[] options) => - new(true, [new ChapterInfoGroup(path, options, defaultOptionIndex)], []); + public static ChapterImportResult ImportResult(string path, int defaultEntryIndex, params ChapterImportEntry[] entries) => + new(true, [new ChapterImportSource(path, entries, defaultEntryIndex)], []); - public static ChapterSourceOption Option(string sourceType, string sourceName, params string[] chapterNames) + public static ChapterImportEntry Entry(ChapterImportFormat sourceType, string sourceName, params string[] chapterNames) { var chapters = chapterNames.Length == 0 ? [new Chapter(1, TimeSpan.Zero, "Intro")] : chapterNames .Select((name, index) => new Chapter(index + 1, TimeSpan.FromSeconds(index * 10), name)) .ToArray(); - var duration = chapters.Length == 0 ? TimeSpan.Zero : chapters[^1].Time; - var info = new ChapterInfo(sourceName, sourceName, 0, sourceType, 24, duration, chapters); - return new ChapterSourceOption(sourceName, sourceName, info); + var duration = chapters.Length == 0 ? TimeSpan.Zero : chapters[^1].StartTime; + var info = new ChapterSet(sourceName, sourceName, sourceType, 24, duration, chapters); + return new ChapterImportEntry(sourceName, sourceName, info); } - public static ChapterSourceOption OptionWithMedia(string sourceType, string sourceName, SourceMediaReference media, params string[] chapterNames) + public static ChapterImportEntry OptionWithMedia(ChapterImportFormat sourceType, string sourceName, ReferencedMediaFile referencedMedia, params string[] chapterNames) { - var option = Option(sourceType, sourceName, chapterNames); - return option with { MediaReferences = [media] }; + var entry = Entry(sourceType, sourceName, chapterNames); + return entry with { ReferencedMediaFiles = [referencedMedia] }; } public async ValueTask LoadAsync(string path) @@ -138,17 +138,6 @@ public async ValueTask LayoutAsync(double width = 736, double height = 576) await ExecuteLayoutAsync(Window); } - public async ValueTask LayoutAtAsync(UiTestSize size) - { - var (width, height) = size switch - { - UiTestSize.Narrow => (760d, 640d), - UiTestSize.Wide => (1180d, 780d), - _ => (920d, 720d) - }; - await LayoutAsync(width, height); - } - public static async ValueTask ExecuteLayoutAsync(Window window) { Dispatcher.UIThread.RunJobs(); @@ -248,19 +237,6 @@ public void SelectRows(params int[] indexes) ViewModel.UpdateSelectedRows(indexes.ToHashSet()); } - public async ValueTask<string> CaptureArtifactAsync(string name, Window? window = null) - { - var target = window ?? Window; - await ExecuteLayoutAsync(target); - var artifactPath = Path.Combine(RepositoryRoot(), "artifacts", name); - Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!); - var bitmap = target.CaptureRenderedFrame() - ?? throw new InvalidOperationException($"Frame '{name}' was not rendered."); - await using var stream = File.Create(artifactPath); - bitmap.Save(stream); - return artifactPath; - } - public static string RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); @@ -300,13 +276,13 @@ public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken c internal sealed class FakeSaveService : IChapterSaveService { - public ChapterInfo? LastInfo { get; private set; } + public ChapterSet? LastInfo { get; private set; } public ChapterExportOptions? LastOptions { get; private set; } public string? LastDirectory { get; private set; } public int Calls { get; private set; } public ValueTask<ChapterExportResult> SaveAsync( - ChapterInfo info, + ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs index 1ca54b5..661fedb 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Models; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Headless; @@ -15,27 +16,13 @@ namespace ChapterTool.Avalonia.Tests.Headless; [Collection(AvaloniaHeadlessTestCollection.Name)] public sealed class MainWindowHeadlessTests { - [AvaloniaFact] - public async Task MainWindow_loads_compiled_xaml_resources_and_layout() - { - using var host = new MainWindowHeadlessTestHost(); - - await host.LayoutAsync(); - - Assert.StartsWith("[VCB-Studio] ChapterTool", host.Window.Title); - Assert.NotNull(host.RequiredControl<DataGrid>("ChapterGrid")); - Assert.NotNull(host.RequiredControl<Button>("LoadButton")); - Assert.True(host.Window.Bounds.Width > 0); - Assert.True(host.Window.Bounds.Height > 0); - } - [AvaloniaFact] public async Task Xml_edition_selection_displays_selected_chapter_names() { using var host = CreateMultiOptionHost( "movie.xml", - MainWindowHeadlessTestHost.Option("XML", "edition-1", "XML A1", "XML A2"), - MainWindowHeadlessTestHost.Option("XML", "edition-2", "XML B1", "XML B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.MatroskaXml, "edition-1", "XML A1", "XML A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.MatroskaXml, "edition-2", "XML B1", "XML B2")); await AssertSelectingOptionDisplaysNamesAsync(host, "movie.xml", selectedIndex: 1, "XML B1", "XML B2"); } @@ -45,8 +32,8 @@ public async Task Ifo_option_selection_displays_selected_chapter_names() { using var host = CreateMultiOptionHost( "VIDEO_TS.IFO", - MainWindowHeadlessTestHost.Option("DVD", "pgc-1", "IFO A1", "IFO A2"), - MainWindowHeadlessTestHost.Option("DVD", "pgc-2", "IFO B1", "IFO B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.DvdIfo, "pgc-1", "IFO A1", "IFO A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.DvdIfo, "pgc-2", "IFO B1", "IFO B2")); await AssertSelectingOptionDisplaysNamesAsync(host, "VIDEO_TS.IFO", selectedIndex: 1, "IFO B1", "IFO B2"); } @@ -56,8 +43,8 @@ public async Task Mpls_clip_selection_displays_selected_chapter_names() { using var host = CreateMultiOptionHost( "00000.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "MPLS A1", "MPLS A2"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "MPLS B1", "MPLS B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "MPLS A1", "MPLS A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "MPLS B1", "MPLS B2")); await AssertSelectingOptionDisplaysNamesAsync(host, "00000.mpls", selectedIndex: 1, "MPLS B1", "MPLS B2"); } @@ -67,8 +54,8 @@ public async Task Clip_selector_keeps_selected_text_after_selection_refreshes_op { using var host = CreateMultiOptionHost( "00000.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "MPLS A1", "MPLS A2"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "MPLS B1", "MPLS B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "MPLS A1", "MPLS A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "MPLS B1", "MPLS B2")); await host.LoadAsync("00000.mpls"); var clipSelector = host.RequiredControl<ComboBox>("ClipBox"); @@ -88,8 +75,8 @@ public async Task Clip_combine_context_menu_shows_checked_toggle_state() { using var host = CreateMultiOptionHost( "00000.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "MPLS A1", "MPLS A2"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "MPLS B1", "MPLS B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "MPLS A1", "MPLS A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "MPLS B1", "MPLS B2")); await host.LoadAsync("00000.mpls"); var menuItem = host.RequiredControl<MenuItem>("ClipCombineMenuItem"); @@ -114,8 +101,8 @@ public async Task Clip_combine_binding_updates_when_view_model_command_runs() { using var host = CreateMultiOptionHost( "00000.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "MPLS A1", "MPLS A2"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "MPLS B1", "MPLS B2")); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "MPLS A1", "MPLS A2"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "MPLS B1", "MPLS B2")); await host.LoadAsync("00000.mpls"); var clipMenuItem = host.RequiredControl<MenuItem>("ClipCombineMenuItem"); @@ -186,83 +173,10 @@ public async Task Mpls_importer_option_labels_render_in_clip_selector() await AssertSelectorDisplaysLabelAsync(host, path, selectedIndex: 1, "00003(6 chapters)"); } - [AvaloniaFact] - public async Task Main_window_selector_display_captures_screenshot_artifacts() - { - using var host = CreateMultiOptionHost( - "00000.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00002", "A1", "A2", "A3", "A4", "A5", "A6"), - MainWindowHeadlessTestHost.Option("MPLS", "00003", "B1", "B2", "B3", "B4", "B5", "B6")); - - await host.LoadAsync("00000.mpls"); - host.ViewModel.SaveFormat = ChapterExportFormat.Xml; - host.ViewModel.XmlLanguageIndex = host.ViewModel.XmlLanguageOptions.ToList().IndexOf("jpn"); - - foreach (var (name, width, height) in new[] - { - ("default", 736d, 576d), - ("wide", 944d, 576d), - ("narrow", 608d, 576d) - }) - { - await host.LayoutAsync(width, height); - var artifactPath = Path.Combine(MainWindowHeadlessTestHost.RepositoryRoot(), "artifacts", $"main-window-selectors-{name}.png"); - Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!); - var bitmap = host.Window.CaptureRenderedFrame() - ?? throw new InvalidOperationException($"Main window selector frame '{name}' was not rendered."); - await using (var stream = File.Create(artifactPath)) - { - bitmap.Save(stream); - } - - Assert.True(File.Exists(artifactPath)); - Assert.True(new FileInfo(artifactPath).Length > 0); - var clipBox = host.RequiredControl<ComboBox>("ClipBox"); - var chapterNameModeBox = host.RequiredControl<ComboBox>("ChapterNameModeBox"); - var xmlLanguageBox = host.RequiredControl<ComboBox>("XmlLanguageBox"); - Assert.True(clipBox.IsVisible); - Assert.True(clipBox.Bounds.Width > 0); - Assert.Contains("00002", clipBox.SelectionBoxItem?.ToString(), StringComparison.Ordinal); - Assert.False(string.IsNullOrWhiteSpace(chapterNameModeBox.SelectionBoxItem?.ToString())); - Assert.Contains("jpn", xmlLanguageBox.SelectionBoxItem?.ToString(), StringComparison.Ordinal); - } - } - - [AvaloniaFact] - public async Task Main_window_grid_keeps_compact_column_alignment() - { - using var host = CreateMultiOptionHost( - "00000.mpls", - MainWindowHeadlessTestHost.Option( - "MPLS", - "00002", - "A1", - "A2", - "A3", - "A4", - "A5", - "A6", - "A7", - "A8", - "A9", - "A10")); - - await host.LoadAsync("00000.mpls"); - await host.LayoutAsync(608, 576); - var grid = host.RequiredControl<DataGrid>("ChapterGrid"); - - Assert.True(grid.Columns[0].ActualWidth >= 56); - Assert.True(grid.Columns[0].ActualWidth < 70); - AssertCellTextAlignment(grid, "2", TextAlignment.Center); - AssertCellTextAlignment(grid, "00:00:10.000", TextAlignment.Center); - AssertCellTextAlignment(grid, "A2", TextAlignment.Center); - AssertCellTextAlignment(grid, "240", TextAlignment.Center); - } - private static MainWindowHeadlessTestHost CreateMultiOptionHost( string path, - params Core.Models.ChapterSourceOption[] options) => - new(MainWindowHeadlessTestHost.ImportResult(path, options)); + params ChapterImportEntry[] entries) => + new(MainWindowHeadlessTestHost.ImportResult(path, entries)); private static async Task AssertSelectingOptionDisplaysNamesAsync( MainWindowHeadlessTestHost host, @@ -322,16 +236,4 @@ private static async Task AssertDefaultSelectionDisplaysLabelAsync( host.ContainsRenderedText(clipSelector, expectedLabel), $"Expected default selected label '{expectedLabel}'. Rendered selector texts:{Environment.NewLine}{host.DescribeRenderedTexts(clipSelector)}"); } - - private static void AssertCellTextAlignment(Control scope, string text, TextAlignment expected) - { - var textBlock = scope - .GetVisualDescendants() - .OfType<TextBlock>() - .FirstOrDefault(block => string.Equals(block.Text, text, StringComparison.Ordinal)); - - Assert.NotNull(textBlock); - Assert.Equal(expected, textBlock.TextAlignment); - Assert.Equal(HorizontalAlignment.Stretch, textBlock.HorizontalAlignment); - } } diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs index c30f0d1..a3794a5 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowInteractionHeadlessTests.cs @@ -36,7 +36,7 @@ public async Task Row_context_commands_are_exposed_from_visible_grid_surface() { using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( "movie.txt", - MainWindowHeadlessTestHost.Option("OGM", "movie.txt", "Intro", "Ending"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Ogm, "movie.txt", "Intro", "Ending"))); await host.LoadAsync("movie.txt"); host.SelectRows(1); @@ -54,8 +54,8 @@ public async Task Keyboard_shortcuts_route_through_rendered_window() { using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( "movie.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "A"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "B"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "A"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "B"))); host.FilePickerService.SourcePath = "movie.mpls"; host.FilePickerService.SaveDirectoryPath = "out"; @@ -79,7 +79,7 @@ public async Task Delete_key_in_expression_editor_does_not_delete_selected_chapt { using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( "movie.txt", - MainWindowHeadlessTestHost.Option("OGM", "movie.txt", "Intro", "Ending"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Ogm, "movie.txt", "Intro", "Ending"))); await host.LoadAsync("movie.txt"); host.SelectRows(1); @@ -106,11 +106,11 @@ public async Task Context_menu_items_respect_capability_flags() using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( Path.Combine(root, "movie.mpls"), MainWindowHeadlessTestHost.OptionWithMedia( - "MPLS", + ChapterImportFormat.Mpls, "00001", - new SourceMediaReference("clip.m2ts", "clip.m2ts"), + new ReferencedMediaFile("clip.m2ts", "clip.m2ts"), "A"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "B"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "B"))); await host.LoadAsync(Path.Combine(root, "movie.mpls")); var loadButton = host.RequiredControl<Button>("LoadButton"); @@ -139,33 +139,6 @@ public async Task Context_menu_items_respect_capability_flags() Assert.False(emptyHost.RequiredMenuItem(emptyGrid, "DeleteMenuItem").IsEnabled); } - [AvaloniaFact] - public async Task Auxiliary_command_surfaces_are_visible_and_bound() - { - using var host = new MainWindowHeadlessTestHost(); - await host.LoadAsync("movie.txt"); - - var previewButton = host.RequiredControl<Button>("PreviewButton"); - var settingsButton = host.RequiredControl<Button>("SettingsButton"); - var logButton = host.RequiredControl<Button>("LogButton"); - var grid = host.RequiredControl<DataGrid>("ChapterGrid"); - - Assert.True(previewButton.IsVisible); - Assert.True(settingsButton.IsVisible); - Assert.True(logButton.IsVisible); - Assert.NotNull(previewButton.Command); - Assert.NotNull(settingsButton.Command); - Assert.NotNull(logButton.Command); - Assert.NotNull(host.RequiredMenuItem(grid, "PreviewMenuItem").Command); - Assert.NotNull(host.RequiredMenuItem(grid, "ZonesMenuItem").Command); - Assert.NotNull(host.RequiredMenuItem(grid, "ForwardShiftMenuItem").Command); - - await host.Window.PreviewCommand.ExecuteAsync(); - - Assert.Equal(["preview"], host.WindowService.Opened); - Assert.Same(host.ViewModel, host.WindowService.Parameters.Single()); - } - [AvaloniaFact] public async Task Icon_only_main_window_buttons_have_accessible_names() { diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowStateHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowStateHeadlessTests.cs index 0f8e487..e9c3132 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowStateHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/MainWindowStateHeadlessTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Models; using Avalonia.Controls; using Avalonia.Headless.XUnit; using ChapterTool.Avalonia.Views.Controls; @@ -29,7 +30,7 @@ public async Task Initial_state_renders_from_view_model_bindings() Assert.False(saveButton.IsEnabled); Assert.False(clipBox.IsVisible); Assert.Empty(chapterGrid.ItemsSource!.Cast<object>()); - Assert.Equal((int)ChapterExportFormat.Txt, formatBox.SelectedIndex); + Assert.Equal(ChapterExportFormats.IndexOf(ChapterExportFormat.Txt), formatBox.SelectedIndex); Assert.Equal(0, frameRateBox.SelectedIndex); Assert.Equal(host.ViewModel.StatusText, status.Text); Assert.Equal(0, progress.Value); @@ -40,8 +41,8 @@ public async Task Load_action_updates_rendered_path_status_grid_and_options() { using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( "movie.mpls", - MainWindowHeadlessTestHost.Option("MPLS", "00001", "Opening", "Middle"), - MainWindowHeadlessTestHost.Option("MPLS", "00002", "Alt"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00001", "Opening", "Middle"), + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Mpls, "00002", "Alt"))); await host.LoadAsync("movie.mpls"); @@ -69,7 +70,7 @@ public async Task Save_options_changed_through_rendered_controls_route_to_save_s { using var host = new MainWindowHeadlessTestHost(MainWindowHeadlessTestHost.ImportResult( "movie.txt", - MainWindowHeadlessTestHost.Option("OGM", "movie.txt", "Intro"))); + MainWindowHeadlessTestHost.Entry(ChapterImportFormat.Ogm, "movie.txt", "Intro"))); await host.LoadAsync("movie.txt"); var formatBox = host.RequiredControl<ComboBox>("FormatBox"); @@ -82,7 +83,7 @@ public async Task Save_options_changed_through_rendered_controls_route_to_save_s var frameRateBox = host.RequiredControl<ComboBox>("FrameRateBox"); var roundFramesBox = host.RequiredControl<CheckBox>("RoundFramesBox"); - formatBox.SelectedIndex = (int)ChapterExportFormat.Xml; + formatBox.SelectedIndex = ChapterExportFormats.IndexOf(ChapterExportFormat.Xml); await host.LayoutAsync(); Assert.True(xmlLanguageGroup.IsEnabled); @@ -105,11 +106,11 @@ public async Task Save_options_changed_through_rendered_controls_route_to_save_s Assert.Equal("jpn", host.SaveService.LastOptions.XmlLanguage); Assert.Equal("out", host.SaveService.LastDirectory); Assert.NotNull(host.SaveService.LastInfo); - Assert.Equal(3, host.SaveService.LastInfo.Chapters[0].Number); + Assert.Equal(3, host.SaveService.LastInfo.Chapters[0].DisplayNumber); Assert.Equal("Chapter 01", host.SaveService.LastInfo.Chapters[0].Name); - Assert.Equal(TimeSpan.FromSeconds(1), host.SaveService.LastInfo.Chapters[0].Time); + Assert.Equal(TimeSpan.FromSeconds(1), host.SaveService.LastInfo.Chapters[0].StartTime); - formatBox.SelectedIndex = (int)ChapterExportFormat.Txt; + formatBox.SelectedIndex = ChapterExportFormats.IndexOf(ChapterExportFormat.Txt); await host.LayoutAsync(); Assert.False(xmlLanguageGroup.IsEnabled); } diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/SettingsToolHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/SettingsToolHeadlessTests.cs index 8719a7b..35d1e04 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/SettingsToolHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/SettingsToolHeadlessTests.cs @@ -13,129 +13,6 @@ namespace ChapterTool.Avalonia.Tests.Headless; [Collection(AvaloniaHeadlessTestCollection.Name)] public sealed class SettingsToolHeadlessTests { - [AvaloniaFact] - public async Task Appearance_tab_renders_color_pickers_for_theme_slots() - { - using var host = new MainWindowHeadlessTestHost(); - var viewModel = new SettingsToolViewModel( - host.ViewModel, - host.AppSettingsStore, - host.ThemeSettingsStore, - host.Localizer, - autoLoad: false); - await viewModel.LoadAsync(TestContext.Current.CancellationToken); - var view = new SettingsToolView { DataContext = viewModel }; - var window = new Window - { - Content = view, - Width = 760, - Height = 520 - }; - - try - { - window.Show(); - var tabControl = window.GetVisualDescendants().OfType<TabControl>().Single(); - tabControl.SelectedIndex = 3; - var layoutManager = window.GetLayoutManager() - ?? throw new InvalidOperationException("Settings window layout manager was not available."); - layoutManager.ExecuteInitialLayoutPass(); - layoutManager.ExecuteLayoutPass(); - - var colorPickers = window.GetVisualDescendants().OfType<ColorPicker>().ToArray(); - - Assert.Equal(ThemeColorSettings.Default.OrderedSlots.Count, colorPickers.Length); - } - finally - { - window.Close(); - } - } - - [AvaloniaFact] - public async Task Settings_panel_renders_and_captures_screenshot_artifact() - { - foreach (var culture in new[] { "en-US", "zh-CN", "ja-JP" }) - { - using var host = new MainWindowHeadlessTestHost( - localizer: new AppLocalizationManager(culture), - appSettings: new AppSettings(Language: culture)); - var viewModel = new SettingsToolViewModel(host.ViewModel, host.AppSettingsStore, host.ThemeSettingsStore, host.Localizer, autoLoad: false); - await viewModel.LoadAsync(TestContext.Current.CancellationToken); - - foreach (var (name, width, height) in new[] - { - ("default", 760d, 520d), - ("wide", 1040d, 620d), - ("narrow", 560d, 640d) - }) - { - var window = new Window - { - Content = new SettingsToolView { DataContext = viewModel }, - Width = width, - Height = height - }; - - try - { - window.Show(); - var layoutManager = window.GetLayoutManager() - ?? throw new InvalidOperationException("Settings window layout manager was not available."); - layoutManager.ExecuteInitialLayoutPass(); - layoutManager.ExecuteLayoutPass(); - var tabControl = window.GetVisualDescendants().OfType<TabControl>().Single(); - Assert.Equal("Top", tabControl.TabStripPlacement.ToString()); - if (culture == "en-US") - { - var tabHeaders = tabControl.GetVisualDescendants() - .OfType<TextBlock>() - .Where(block => block.Classes.Contains("tabHeader")) - .ToArray(); - var generalWidth = tabHeaders.Single(block => block.Text == "General").Bounds.Width; - var externalToolsWidth = tabHeaders.Single(block => block.Text == "External Tools").Bounds.Width; - var outputDefaultsWidth = tabHeaders.Single(block => block.Text == "Output Defaults").Bounds.Width; - Assert.True(externalToolsWidth > generalWidth); - Assert.True(outputDefaultsWidth > externalToolsWidth); - } - - Assert.All( - window.GetVisualDescendants() - .OfType<ScrollViewer>() - .Where(scrollViewer => scrollViewer.Classes.Contains("settingsPageScroller")), - scrollViewer => Assert.Equal("Disabled", scrollViewer.HorizontalScrollBarVisibility.ToString())); - tabControl.SelectedIndex = 2; - layoutManager.ExecuteLayoutPass(); - - var saveFormatCombo = window.GetVisualDescendants() - .OfType<ComboBox>() - .Single(comboBox => comboBox.Name == "DefaultSaveFormatCombo"); - var xmlLanguageCombo = window.GetVisualDescendants() - .OfType<ComboBox>() - .Single(comboBox => comboBox.Name == "DefaultXmlLanguageCombo"); - Assert.Equal(xmlLanguageCombo.Bounds.Width, saveFormatCombo.Bounds.Width, precision: 1); - Assert.True(saveFormatCombo.Bounds.Width <= 180.5); - - var artifactPath = Path.Combine(RepositoryRoot(), "artifacts", $"settings-panel-{culture.ToLowerInvariant()}-{name}.png"); - Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!); - var bitmap = window.CaptureRenderedFrame() - ?? throw new InvalidOperationException($"Settings panel frame '{culture}-{name}' was not rendered."); - await using (var stream = File.Create(artifactPath)) - { - bitmap.Save(stream); - } - - Assert.True(File.Exists(artifactPath)); - Assert.True(new FileInfo(artifactPath).Length > 0); - } - finally - { - window.Close(); - } - } - } - } - [AvaloniaFact] public async Task Xml_language_selection_remains_visible_after_runtime_language_switch() { @@ -221,20 +98,4 @@ public async Task Icon_only_settings_buttons_have_accessible_names() window.Close(); } } - - private static string RepositoryRoot() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null) - { - if (File.Exists(Path.Combine(directory.FullName, "ChapterTool.Avalonia.slnx"))) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new DirectoryNotFoundException("Could not locate repository root from test output directory."); - } } diff --git a/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs b/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs index 50cb3d9..3c503c0 100644 --- a/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Headless/ToolViewsHeadlessTests.cs @@ -11,59 +11,6 @@ namespace ChapterTool.Avalonia.Tests.Headless; [Collection(AvaloniaHeadlessTestCollection.Name)] public sealed class ToolViewsHeadlessTests { - [AvaloniaFact] - public async Task Secondary_tool_views_render_and_bind_representative_state() - { - using var host = new MainWindowHeadlessTestHost(); - await host.LoadAsync("movie.txt"); - - var textToolViewModel = new TextToolViewModel( - () => "CHAPTER01=00:00:00.000", - new TextToolOptions { ClearAction = () => { } }); - var rendered = new List<Window>(); - try - { - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new ColorSettingsView(), - new ColorSettingsViewModel(host.ThemeSettingsStore))); - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new LanguageToolView(), - new LanguageToolViewModel(host.ViewModel))); - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new ExpressionToolView(), - new ExpressionToolViewModel(host.ViewModel))); - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new TemplateNamesToolView(), - new TemplateNamesToolViewModel(host.ViewModel))); - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new ForwardShiftToolView(), - new ForwardShiftToolViewModel(host.ViewModel))); - rendered.Add(await MainWindowHeadlessTestHost.RenderToolAsync( - new TextToolView(), - textToolViewModel)); - - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Legacy color slots")); - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Language")); - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Expression")); - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Template Names")); - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Forward Shift")); - Assert.Contains(rendered, window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Refresh")); - Assert.Equal("CHAPTER01=00:00:00.000", Assert.Single(textToolViewModel.Lines).Spans.Single().Text); - - var expressionWindow = rendered.Single(window => MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Expression")); - Assert.NotNull(MainWindowHeadlessTestHost.RequiredDescendant<ExpressionEditor>(expressionWindow, _ => true, "expression editor")); - Assert.NotNull(MainWindowHeadlessTestHost.RequiredDescendant<CheckBox>(expressionWindow, _ => true, "expression apply checkbox")); - Assert.NotNull(MainWindowHeadlessTestHost.RequiredDescendant<Button>(expressionWindow, button => button.Command is not null, "expression apply button")); - } - finally - { - foreach (var window in rendered) - { - window.Close(); - } - } - } - [AvaloniaFact] public async Task Expression_editor_shows_diagnostics_and_accepts_tab_completion() { @@ -284,51 +231,4 @@ public async Task Text_tool_renders_selectable_text_for_copy_operations() window.Close(); } } - - [AvaloniaFact] - public async Task Settings_tool_renders_grouped_configuration_controls() - { - using var host = new MainWindowHeadlessTestHost( - localizer: new AppLocalizationManager("en-US")); - var viewModel = new SettingsToolViewModel( - host.ViewModel, - host.AppSettingsStore, - host.ThemeSettingsStore, - host.Localizer, - host.SettingsPickerService, - shellService: host.ShellService, - autoLoad: false); - await viewModel.LoadAsync(TestContext.Current.CancellationToken); - - var window = await MainWindowHeadlessTestHost.RenderToolAsync(new SettingsToolView(), viewModel); - try - { - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "General")); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "External Tools")); - - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Output Defaults")); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "Appearance")); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "About")); - Assert.Contains(MainWindowHeadlessTestHost.Descendants<TextBox>(window), textBox => textBox.Text == viewModel.SaveDirectory); - Assert.Contains(MainWindowHeadlessTestHost.Descendants<ComboBox>(window), comboBox => Equals(comboBox.ItemsSource, viewModel.Languages)); - Assert.Contains(MainWindowHeadlessTestHost.Descendants<Button>(window), button => button.Command == viewModel.SaveCommand); - - var tabControl = MainWindowHeadlessTestHost.Descendants<TabControl>(window).Single(); - tabControl.SelectedIndex = 4; - await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window); - - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, viewModel.AvaloniaRuntimeDisplay)); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, viewModel.DotNetRuntimeDisplay)); - Assert.True(MainWindowHeadlessTestHost.ContainsRenderedTextStatic(window, "https://github.com/tautcony/ChapterTool")); - - await viewModel.OpenRepositoryCommand.ExecuteAsync(cancellationToken: TestContext.Current.CancellationToken); - - var shellService = Assert.IsType<MainWindowHeadlessTestHost.FakeShellService>(host.ShellService); - Assert.Equal(["https://github.com/tautcony/ChapterTool"], shellService.Opened); - } - finally - { - window.Close(); - } - } } diff --git a/tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs b/tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs index 662e520..f320bd0 100644 --- a/tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs @@ -3,6 +3,7 @@ using ChapterTool.Avalonia.Services; using ChapterTool.Avalonia.ViewModels; using ChapterTool.Core.Editing; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Exporting; using ChapterTool.Core.Importing; using ChapterTool.Core.Models; @@ -73,19 +74,20 @@ public void LocalizerFallsBackAndFormatsMessages() public void DiagnosticKeysLocalizeAcrossCultures() { var localizer = new AppLocalizationManager("en-US"); + var invalidIndexKey = $"Diagnostic.{ChapterDiagnosticCode.InvalidChapterIndex.ToDisplayCode()}"; - Assert.True(localizer.TryGetString("Diagnostic.InvalidChapterIndex", out _)); - Assert.True(localizer.TryGetString("Diagnostic.MissingDependency", out _)); + Assert.True(localizer.TryGetString(invalidIndexKey, out _)); + Assert.True(localizer.TryGetString($"Diagnostic.{ChapterDiagnosticCode.MissingDependency.ToDisplayCode()}", out _)); var arguments = new Dictionary<string, object?> { ["index"] = 7 }; Assert.Equal( - AppLocalizationResources.All["en-US"]["Diagnostic.InvalidChapterIndex"].Replace("{index}", "7", StringComparison.Ordinal), - localizer.Format("Diagnostic.InvalidChapterIndex", arguments)); + AppLocalizationResources.All["en-US"][invalidIndexKey].Replace("{index}", "7", StringComparison.Ordinal), + localizer.Format(invalidIndexKey, arguments)); localizer.SetCulture("zh-CN"); Assert.Equal( - AppLocalizationResources.All["zh-CN"]["Diagnostic.InvalidChapterIndex"].Replace("{index}", "7", StringComparison.Ordinal), - localizer.Format("Diagnostic.InvalidChapterIndex", arguments)); + AppLocalizationResources.All["zh-CN"][invalidIndexKey].Replace("{index}", "7", StringComparison.Ordinal), + localizer.Format(invalidIndexKey, arguments)); } [Fact] @@ -138,13 +140,13 @@ private sealed class FakeLoadService : IChapterLoadService public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken cancellationToken) => ValueTask.FromResult(new ChapterImportResult( true, - [new ChapterInfoGroup(path, [new ChapterSourceOption("default", "default", new ChapterInfo(path, path, 0, "OGM", 24, TimeSpan.Zero, []))])], + [new ChapterImportSource(path, [new ChapterImportEntry("default", "default", new ChapterSet(path, path, ChapterImportFormat.Ogm, 24, TimeSpan.Zero, []))])], [])); } private sealed class FakeSaveService : IChapterSaveService { - public ValueTask<ChapterExportResult> SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => + public ValueTask<ChapterExportResult> SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => ValueTask.FromResult(new ChapterExportResult(true, "ok", ".txt", [])); } diff --git a/tests/ChapterTool.Avalonia.Tests/Services/ChapterImporterRegistryTests.cs b/tests/ChapterTool.Avalonia.Tests/Services/ChapterImporterRegistryTests.cs index 78486e6..cf13122 100644 --- a/tests/ChapterTool.Avalonia.Tests/Services/ChapterImporterRegistryTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Services/ChapterImporterRegistryTests.cs @@ -93,7 +93,7 @@ public async Task RuntimeRegistryRoutesMp4FamilyThroughMediaReader(string fileNa var result = await importer!.ImportAsync(new ChapterImportRequest(testPath), TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03", "Chapter 04"], result.Groups.Single().Options.Single().ChapterInfo.Chapters.Select(static chapter => chapter.Name)); + Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03", "Chapter 04"], result.Groups.Single().Entries.Single().ChapterSet.Chapters.Select(static chapter => chapter.Name)); } finally { @@ -105,11 +105,11 @@ public async Task RuntimeRegistryRoutesMp4FamilyThroughMediaReader(string fileNa public void RuntimeRegistryResolvesAtlFallbackForLegacyMp4WhenFfprobeCannotBeInvoked() { var registry = CreateFakeRegistry( - ffprobeResult: MediaChapterReadResult.Failed("FfprobeMissingDependency", "missing"), + ffprobeResult: MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeMissingDependency, "missing"), mp4Result: MediaChapterReadResult.Succeeded(MediaEntry("Fallback", 0, 1000))); var primary = registry.Resolve("movie.mp4")!; - var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FfprobeMissingDependency", "missing")); + var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FfprobeMissingDependency, "missing")); var fallback = registry.ResolveFallback("movie.mp4", primary, primaryResult); Assert.NotNull(fallback); @@ -124,10 +124,10 @@ public void RuntimeRegistryResolvesFfprobeFallbackForMatroskaWhenMkvextractCanno ffprobeResult: MediaChapterReadResult.Succeeded(new MediaChapterEntry( 0, "1/1000", 0, 1000, "0.000000", "1.000000", new Dictionary<string, string> { ["title"] = "Intro" }, 0)), - mp4Result: MediaChapterReadResult.Failed("Mp4ReadFailed", "failed")); + mp4Result: MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4ReadFailed, "failed")); var primary = registry.Resolve("movie.mkv")!; - var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "MatroskaMissingDependency", "missing")); + var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.MatroskaMissingDependency, "missing")); var fallback = registry.ResolveFallback("movie.mkv", primary, primaryResult); Assert.NotNull(fallback); @@ -141,10 +141,10 @@ public void RuntimeRegistryResolvesFfprobeFallbackForFlacWithoutEmbeddedCue() ffprobeResult: MediaChapterReadResult.Succeeded(new MediaChapterEntry( 0, "1/1000", 0, 1000, "0.000000", "1.000000", new Dictionary<string, string> { ["title"] = "Intro" }, 0)), - mp4Result: MediaChapterReadResult.Failed("Mp4ReadFailed", "failed")); + mp4Result: MediaChapterReadResult.Failed(ChapterDiagnosticCode.Mp4ReadFailed, "failed")); var primary = registry.Resolve("music.flac")!; - var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FlacEmbeddedCueNotFound", "missing")); + var primaryResult = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FlacEmbeddedCueNotFound, "missing")); var fallback = registry.ResolveFallback("music.flac", primary, primaryResult); Assert.NotNull(fallback); @@ -155,13 +155,13 @@ public void RuntimeRegistryResolvesFfprobeFallbackForFlacWithoutEmbeddedCue() public void RuntimeRegistryDoesNotFallbackForInvokedFfprobeFailureOrNonLegacyMp4() { var registry = CreateFakeRegistry( - ffprobeResult: MediaChapterReadResult.Failed("FfprobeProcessFailed", "failed"), + ffprobeResult: MediaChapterReadResult.Failed(ChapterDiagnosticCode.FfprobeProcessFailed, "failed"), mp4Result: MediaChapterReadResult.Succeeded(MediaEntry("Fallback", 0, 1000))); var mp4Primary = registry.Resolve("movie.mp4")!; - var invokedFailure = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FfprobeProcessFailed", "failed")); + var invokedFailure = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FfprobeProcessFailed, "failed")); var movPrimary = registry.Resolve("movie.mov")!; - var missingFfprobe = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FfprobeMissingDependency", "missing")); + var missingFfprobe = ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FfprobeMissingDependency, "missing")); Assert.Null(registry.ResolveFallback("movie.mp4", mp4Primary, invokedFailure)); Assert.Null(registry.ResolveFallback("movie.mov", movPrimary, missingFfprobe)); @@ -223,16 +223,16 @@ private sealed class FakeImporter : IChapterImporter public ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest request, CancellationToken cancellationToken) { LastRequest = request; - var info = new ChapterInfo("title", request.Path, 0, "TEST", 24, TimeSpan.Zero, []); - var option = new ChapterSourceOption("0", "test", info); - return ValueTask.FromResult(new ChapterImportResult(true, [new ChapterInfoGroup(request.Path, [option])], [])); + var info = new ChapterSet("title", request.Path, ChapterImportFormat.Unknown, 24, TimeSpan.Zero, []); + var entry = new ChapterImportEntry("0", "test", info); + return ValueTask.FromResult(new ChapterImportResult(true, [new ChapterImportSource(request.Path, [entry])], [])); } } private sealed class FakeExternalToolLocator : IExternalToolLocator { public ValueTask<ExternalToolLocation> LocateAsync(string toolName, CancellationToken cancellationToken) => - ValueTask.FromResult(new ExternalToolLocation(false, null, "MissingDependency", toolName)); + ValueTask.FromResult(new ExternalToolLocation(false, null, ChapterDiagnosticCode.MissingDependency, toolName)); } private sealed class FakeProcessRunner : IProcessRunner @@ -251,5 +251,4 @@ public ValueTask<MediaChapterReadResult> ReadAsync(string path, CancellationToke return ValueTask.FromResult(result); } } - } diff --git a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs index e042213..d357021 100644 --- a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs @@ -21,7 +21,7 @@ public async Task RuntimeRoutesTextCueAndWebVttSources(string extension, string var result = await CreateService().LoadAsync(path, TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - Assert.Single(result.Groups.Single().Options.Single().ChapterInfo.Chapters); + Assert.Single(result.Groups.Single().Entries.Single().ChapterSet.Chapters); } finally { @@ -51,7 +51,7 @@ await File.WriteAllTextAsync(path, var result = await CreateService().LoadAsync(path, TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - Assert.Single(result.Groups.Single().Options.Single().ChapterInfo.Chapters); + Assert.Single(result.Groups.Single().Entries.Single().ChapterSet.Chapters); } finally { @@ -81,9 +81,9 @@ public async Task RuntimeRoutesMp4FamilyThroughMediaReader(string extension) var result = await CreateService().LoadAsync(path, TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03", "Chapter 04"], chapters.Select(static chapter => chapter.Name)); - Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(30)], chapters.Select(static chapter => chapter.Time)); + Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(30)], chapters.Select(static chapter => chapter.StartTime)); } finally { @@ -101,8 +101,12 @@ public async Task RuntimeSurfacesMp4ReaderFailureDiagnostics() var result = await CreateService().LoadAsync(path, TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code is "FfprobeMissingDependency" or "FfprobeCannotStart" or "FfprobeProcessFailed" or "FfprobeParseFailed"); - Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedSource"); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Code == ChapterDiagnosticCode.FfprobeMissingDependency || + diagnostic.Code == ChapterDiagnosticCode.FfprobeCannotStart || + diagnostic.Code == ChapterDiagnosticCode.FfprobeProcessFailed || + diagnostic.Code == ChapterDiagnosticCode.FfprobeParseFailed); + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedSource); } finally { @@ -114,8 +118,8 @@ public async Task RuntimeSurfacesMp4ReaderFailureDiagnostics() public async Task RuntimeFallsBackWhenPrimaryCannotBeInvoked() { var path = await CreateTempFileAsync(".mp4"); - var primary = new StubImporter("ffprobe-media", ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FfprobeMissingDependency", "missing"))); - var fallback = new StubImporter("mp4", SuccessfulImport(path, "MP4")); + var primary = new StubImporter("ffprobe-media", ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FfprobeMissingDependency, "missing"))); + var fallback = new StubImporter("mp4", SuccessfulImport(path, ChapterImportFormat.Media)); var registry = new StubRegistry(primary, fallback); var service = new RuntimeChapterLoadService(registry); try @@ -125,13 +129,13 @@ public async Task RuntimeFallsBackWhenPrimaryCannotBeInvoked() Assert.True(result.Success, Diagnostics(result)); Assert.Equal(1, primary.CallCount); Assert.Equal(1, fallback.CallCount); - var fallbackDiagnostic = Assert.Single(result.Diagnostics, static diagnostic => diagnostic.Code == "ImporterFallbackUsed"); + var fallbackDiagnostic = Assert.Single(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.ImporterFallbackUsed); Assert.Equal(DiagnosticSeverity.Info, fallbackDiagnostic.Severity); Assert.Equal(path, fallbackDiagnostic.Location); Assert.Contains("primary=ffprobe-media", fallbackDiagnostic.Details, StringComparison.Ordinal); Assert.Contains("fallback=mp4", fallbackDiagnostic.Details, StringComparison.Ordinal); - Assert.Contains("reason=FfprobeMissingDependency", fallbackDiagnostic.Details, StringComparison.Ordinal); - Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "FfprobeMissingDependency"); + Assert.Contains("reason=Ffprobe.MissingDependency", fallbackDiagnostic.Details, StringComparison.Ordinal); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.FfprobeMissingDependency); } finally { @@ -143,7 +147,7 @@ public async Task RuntimeFallsBackWhenPrimaryCannotBeInvoked() public async Task RuntimePassesProgressToImporter() { var path = await CreateTempFileAsync(".txt"); - var importer = new StubImporter("stub", SuccessfulImport(path, "TXT")); + var importer = new StubImporter("stub", SuccessfulImport(path, ChapterImportFormat.Ogm)); var registry = new StubRegistry(importer, fallback: null); var service = new RuntimeChapterLoadService(registry); var progress = new StubProgress(); @@ -152,7 +156,7 @@ public async Task RuntimePassesProgressToImporter() var result = await service.LoadAsync(path, progress, TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - Assert.Same(progress, importer.LastRequest?.Progress); + Assert.Same(progress, importer.LastRequest?.ProgressReporter); } finally { @@ -164,8 +168,8 @@ public async Task RuntimePassesProgressToImporter() public async Task RuntimeDoesNotFallbackAfterInvokedPrimaryFailure() { var path = await CreateTempFileAsync(".mp4"); - var primary = new StubImporter("ffprobe-media", ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "FfprobeProcessFailed", "process failed"))); - var fallback = new StubImporter("mp4", SuccessfulImport(path, "MP4")); + var primary = new StubImporter("ffprobe-media", ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, ChapterDiagnosticCode.FfprobeProcessFailed, "process failed"))); + var fallback = new StubImporter("mp4", SuccessfulImport(path, ChapterImportFormat.Media)); var registry = new StubRegistry(primary, fallback); var service = new RuntimeChapterLoadService(registry); try @@ -175,8 +179,8 @@ public async Task RuntimeDoesNotFallbackAfterInvokedPrimaryFailure() Assert.False(result.Success); Assert.Equal(1, primary.CallCount); Assert.Equal(0, fallback.CallCount); - Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "FfprobeProcessFailed"); - Assert.DoesNotContain(result.Diagnostics, static diagnostic => diagnostic.Code == "ImporterFallbackUsed"); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.FfprobeProcessFailed); + Assert.DoesNotContain(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.ImporterFallbackUsed); } finally { @@ -198,37 +202,37 @@ public async Task RuntimeRoutesMatroskaFamilyToMkvextractWithChapterFixture(stri if (result.Success) { - var options = result.Groups.Single().Options; - var chapters = options[0].ChapterInfo.Chapters; + var entries = result.Groups.Single().Entries; + var chapters = entries[0].ChapterSet.Chapters; Assert.Equal(["Intro", "Act 1", "Act 2", "Credits"], chapters.Select(static chapter => chapter.Name)); Assert.Equal( [TimeSpan.Zero, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(330), TimeSpan.FromSeconds(740)], - chapters.Select(static chapter => chapter.Time)); + chapters.Select(static chapter => chapter.StartTime)); - if (result.Diagnostics.Any(static diagnostic => diagnostic.Code == "MatroskaMissingDependency")) + if (result.Diagnostics.Any(static diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaMissingDependency)) { - Assert.Single(options); + Assert.Single(entries); Assert.Equal( [TimeSpan.FromSeconds(29.15), null, null, TimeSpan.FromSeconds(775)], - chapters.Select(static chapter => chapter.End)); - Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "ImporterFallbackUsed"); - Assert.Equal("MEDIA", options.Single().ChapterInfo.SourceType); + chapters.Select(static chapter => chapter.EndTime)); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.ImporterFallbackUsed); + Assert.Equal(ChapterImportFormat.Media, entries.Single().ChapterSet.ImportFormat); } else { - Assert.Equal(2, options.Count); + Assert.Equal(2, entries.Count); Assert.Equal( [null, null, null, TimeSpan.FromSeconds(775)], - chapters.Select(static chapter => chapter.End)); - Assert.Equal("XML", options[0].ChapterInfo.SourceType); + chapters.Select(static chapter => chapter.EndTime)); + Assert.Equal(ChapterImportFormat.MatroskaXml, entries[0].ChapterSet.ImportFormat); } } else { - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MatroskaMissingDependency"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaMissingDependency); } - Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedSource"); + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedSource); } finally { @@ -246,8 +250,10 @@ public async Task RuntimeRoutesBdmvDirectoryToBdmvImporter() var result = await CreateService().LoadAsync(root, TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code is "MissingDependency" or "DependencyExecutionFailed"); - Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedSource"); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Code == ChapterDiagnosticCode.MissingDependency || + diagnostic.Code == ChapterDiagnosticCode.DependencyExecutionFailed); + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedSource); } finally { @@ -264,8 +270,8 @@ public async Task RuntimeLoadsExistingIfoFixture() var result = await CreateService().LoadAsync(path, TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("DVD", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.DvdIfo, info.ImportFormat); Assert.Equal(7, info.Chapters.Count); } @@ -303,10 +309,10 @@ private static async Task<string> CreateTempFileAsync(string extension) return path; } - private static ChapterImportResult SuccessfulImport(string path, string sourceType) + private static ChapterImportResult SuccessfulImport(string path, ChapterImportFormat sourceType) { - var info = new ChapterInfo(Path.GetFileNameWithoutExtension(path), Path.GetFileName(path), 0, sourceType, 0, TimeSpan.Zero, [new Chapter(1, TimeSpan.Zero, "Intro")]); - return new ChapterImportResult(true, [new ChapterInfoGroup(path, [new ChapterSourceOption("default", sourceType, info)])], []); + var info = new ChapterSet(Path.GetFileNameWithoutExtension(path), Path.GetFileName(path), sourceType, 0, TimeSpan.Zero, [new Chapter(1, TimeSpan.Zero, "Intro")]); + return new ChapterImportResult(true, [new ChapterImportSource(path, [new ChapterImportEntry("default", ChapterImportFormats.DisplayName(sourceType), info)])], []); } private static string Diagnostics(ChapterImportResult result) => @@ -320,7 +326,12 @@ private sealed class StubRegistry(IChapterImporter primary, IChapterImporter? fa public IChapterImporter? Resolve(string path) => primary; public IChapterImporter? ResolveFallback(string path, IChapterImporter primaryImporter, ChapterImportResult primaryResult) => - primaryResult.Diagnostics.Any(static diagnostic => diagnostic.Code is "FfprobeMissingDependency" or "FfprobeCannotStart" or "MatroskaMissingDependency" or "MatroskaCannotStart" or "FlacEmbeddedCueNotFound") + primaryResult.Diagnostics.Any(static diagnostic => + diagnostic.Code == ChapterDiagnosticCode.FfprobeMissingDependency || + diagnostic.Code == ChapterDiagnosticCode.FfprobeCannotStart || + diagnostic.Code == ChapterDiagnosticCode.MatroskaMissingDependency || + diagnostic.Code == ChapterDiagnosticCode.MatroskaCannotStart || + diagnostic.Code == ChapterDiagnosticCode.FlacEmbeddedCueNotFound) ? fallback : null; } @@ -343,9 +354,9 @@ public ValueTask<ChapterImportResult> ImportAsync(ChapterImportRequest request, } } - private sealed class StubProgress : IProgress<ChapterLoadProgress> + private sealed class StubProgress : IChapterImportProgressReporter { - public void Report(ChapterLoadProgress value) + public void Report(ChapterImportProgress progress) { } } diff --git a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterSaveServiceTests.cs b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterSaveServiceTests.cs index 8a61f79..b640e4f 100644 --- a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterSaveServiceTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterSaveServiceTests.cs @@ -13,15 +13,14 @@ public async Task RuntimeSaveWritesCueBesideRequestedDirectory() { var directory = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); - var info = new ChapterInfo( + var info = new ChapterSet( "audio", "audio.flac", - 0, - "CUE", + ChapterImportFormat.Cue, 75, TimeSpan.FromMinutes(1), [new Chapter(1, TimeSpan.Zero, "Intro")]); - var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter(), new ExpressionService())); + var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter())); try { @@ -43,18 +42,17 @@ public async Task RuntimeSavePreservesExporterDiagnostics() { var directory = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); - var info = new ChapterInfo( + var info = new ChapterSet( "test", "test.xml", - 0, - "XML", + ChapterImportFormat.MatroskaXml, 24, TimeSpan.FromMinutes(1), [ new Chapter(1, TimeSpan.Zero, "Chapter 01"), new Chapter(2, TimeSpan.FromSeconds(30), "Chapter 02") ]); - var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter(), new ExpressionService())); + var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter())); try { @@ -66,9 +64,9 @@ public async Task RuntimeSavePreservesExporterDiagnostics() Assert.True(result.Success); Assert.True(result.Diagnostics.Count >= 2, $"Expected at least 2 diagnostics but got {result.Diagnostics.Count}"); - Assert.Contains(result.Diagnostics, d => d.Code == "OrderShiftNormalized"); - Assert.Contains(result.Diagnostics, d => d.Code == "Saved"); - var savedDiagnostic = result.Diagnostics.Single(d => d.Code == "Saved"); + Assert.Contains(result.Diagnostics, d => d.Code == ChapterDiagnosticCode.OrderShiftNormalized); + Assert.Contains(result.Diagnostics, d => d.Code == ChapterDiagnosticCode.Saved); + var savedDiagnostic = result.Diagnostics.Single(d => d.Code == ChapterDiagnosticCode.Saved); Assert.Equal(DiagnosticSeverity.Info, savedDiagnostic.Severity); Assert.Contains("test.xml", savedDiagnostic.Message); } @@ -77,4 +75,66 @@ public async Task RuntimeSavePreservesExporterDiagnostics() Directory.Delete(directory, recursive: true); } } + + [Fact] + public async Task RuntimeSaveHonorsUtf8BomOption() + { + var directory = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var info = new ChapterSet( + "test", + "test.txt", + ChapterImportFormat.Ogm, + 24, + TimeSpan.FromMinutes(1), + [new Chapter(1, TimeSpan.Zero, "Chapter 01")]); + var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter())); + + try + { + await service.SaveAsync( + info, + new ChapterExportOptions(ChapterExportFormat.Txt, EmitBom: true), + directory, + TestContext.Current.CancellationToken); + var withBom = await File.ReadAllBytesAsync(Path.Combine(directory, "test.txt"), TestContext.Current.CancellationToken); + + await service.SaveAsync( + info, + new ChapterExportOptions(ChapterExportFormat.Txt, EmitBom: false), + directory, + TestContext.Current.CancellationToken); + var withoutBom = await File.ReadAllBytesAsync(Path.Combine(directory, "test.txt"), TestContext.Current.CancellationToken); + + byte[] utf8Bom = [0xEF, 0xBB, 0xBF]; + Assert.True(withBom.Take(3).SequenceEqual(utf8Bom)); + Assert.False(withoutBom.Take(3).SequenceEqual(utf8Bom)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task RuntimeSaveReturnsDiagnosticWhenFileSystemWriteFails() + { + var info = new ChapterSet( + "test", + "test.xml", + ChapterImportFormat.MatroskaXml, + 24, + TimeSpan.FromMinutes(1), + [new Chapter(1, TimeSpan.Zero, "Chapter 01")]); + var service = new RuntimeChapterSaveService(new ChapterExportService(new ChapterTimeFormatter())); + + var result = await service.SaveAsync( + info, + new ChapterExportOptions(ChapterExportFormat.Txt), + "bad\0path", + TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.SaveFailed); + } } diff --git a/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs index a51d08f..0bfdc36 100644 --- a/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs @@ -65,7 +65,7 @@ public void ConstructsDocumentedCommands() [Fact] public async Task LoadUpdatesStateAndClipSelection() { - var load = new FakeLoadService(ImportResult("movie.mpls", Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A")), Info("MPLS", "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); + var load = new FakeLoadService(ImportResult("movie.mpls", Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A")), Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); var vm = CreateViewModel(load); await vm.LoadCommand.ExecuteAsync("movie.mpls"); @@ -84,9 +84,9 @@ public async Task LoadUpdatesStateAndClipSelection() [Fact] public async Task LoadAppliesProgressUpdatesBeforeCompletion() { - var load = new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro")))) + var load = new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro")))) { - OnLoad = progress => progress?.Report(new ChapterLoadProgress(0.42, "Status.LoadingSource.Export")) + OnLoad = progress => progress?.Report(new ChapterImportProgress(ChapterImportProgressPhase.ExportingChapters, 0.42)) }; var vm = CreateViewModel(load); var progressValues = new List<double>(); @@ -116,8 +116,8 @@ public async Task AsyncLoadUpdatesObservableStateThroughViewModel() { var load = new AsyncLoadService(ImportResult( "movie.mpls", - Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A")), - Info("MPLS", "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); + Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A")), + Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); var vm = CreateViewModel(load); var rowNotifications = 0; var clipNotifications = 0; @@ -152,15 +152,76 @@ public async Task AsyncLoadUpdatesObservableStateThroughViewModel() Assert.Contains(progressNotifications, value => value is > 0 and < 1); } + [Fact] + public async Task OlderLoadResultDoesNotOverwriteNewerLoad() + { + var load = new ControlledLoadService(new Dictionary<string, ChapterImportResult>(StringComparer.Ordinal) + { + ["slow.txt"] = ImportResult("slow.txt", Info(ChapterImportFormat.Ogm, "slow.txt", new Chapter(1, TimeSpan.Zero, "Slow"))), + ["fast.txt"] = ImportResult("fast.txt", Info(ChapterImportFormat.Ogm, "fast.txt", new Chapter(1, TimeSpan.Zero, "Fast"))) + }); + var vm = CreateViewModel(load); + + var slow = vm.LoadCommand.ExecuteAsync("slow.txt").AsTask(); + await load.WaitForRequestAsync("slow.txt"); + var fast = vm.DropPathLoadCommand.ExecuteAsync("fast.txt").AsTask(); + await load.WaitForRequestAsync("fast.txt"); + + load.Complete("fast.txt"); + await fast; + Assert.Equal("fast.txt", vm.CurrentPath); + Assert.Equal("Fast", vm.Rows.Single().Name); + + load.Complete("slow.txt"); + await slow; + + Assert.Equal("fast.txt", vm.CurrentPath); + Assert.Equal("Fast", vm.Rows.Single().Name); + } + + [Fact] + public async Task OlderAppendResultDoesNotOverwriteNewerLoad() + { + var load = new ControlledLoadService(new Dictionary<string, ChapterImportResult>(StringComparer.Ordinal) + { + ["base.mpls"] = ImportResult("base.mpls", Info(ChapterImportFormat.Mpls, "base", new Chapter(1, TimeSpan.Zero, "Base"))), + ["append.mpls"] = ImportResult("append.mpls", Info(ChapterImportFormat.Mpls, "append", new Chapter(1, TimeSpan.Zero, "Append"))), + ["new.txt"] = ImportResult("new.txt", Info(ChapterImportFormat.Ogm, "new.txt", new Chapter(1, TimeSpan.Zero, "New"))) + }); + var vm = CreateViewModel(load); + + var baseLoad = vm.LoadCommand.ExecuteAsync("base.mpls").AsTask(); + await load.WaitForRequestAsync("base.mpls"); + load.Complete("base.mpls"); + await baseLoad; + + var append = vm.AppendMplsCommand.ExecuteAsync("append.mpls").AsTask(); + await load.WaitForRequestAsync("append.mpls"); + var newerLoad = vm.LoadCommand.ExecuteAsync("new.txt").AsTask(); + await load.WaitForRequestAsync("new.txt"); + + load.Complete("new.txt"); + await newerLoad; + Assert.Equal("new.txt", vm.CurrentPath); + Assert.Equal("New", vm.Rows.Single().Name); + + load.Complete("append.mpls"); + await append; + + Assert.Equal("new.txt", vm.CurrentPath); + Assert.Equal("New", vm.Rows.Single().Name); + Assert.False(vm.IsClipCombineChecked); + } + [Fact] public async Task ClipDisplayOptionsExposeMainContentWithRemarksWithoutChangingSourceOptions() { - var firstInfo = Info("MPLS", "00002", new Chapter(1, TimeSpan.Zero, "A")); - var secondInfo = Info("MPLS", "00003", new Chapter(1, TimeSpan.Zero, "B")); + var firstInfo = Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.Zero, "A")); + var secondInfo = Info(ChapterImportFormat.Mpls, "00003", new Chapter(1, TimeSpan.Zero, "B")); var load = new FakeLoadService(new ChapterImportResult(true, [ - new ChapterInfoGroup("movie.mpls", [ - new ChapterSourceOption("clip-0", "00002__6", firstInfo), - new ChapterSourceOption("clip-1", "00003__8", secondInfo) + new ChapterImportSource("movie.mpls", [ + new ChapterImportEntry("clip-0", "00002__6", firstInfo), + new ChapterImportEntry("clip-1", "00003__8", secondInfo) ]) ], [])); var vm = CreateViewModel(load); @@ -183,8 +244,8 @@ public async Task CombineCommandTogglesMplsClipsBetweenCombinedAndSplitOptions() { var load = new FakeLoadService(ImportResult( "movie.mpls", - Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromSeconds(10), "B")), - Info("MPLS", "00002", new Chapter(1, TimeSpan.Zero, "C"), new Chapter(2, TimeSpan.FromSeconds(5), "D")))); + Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromSeconds(10), "B")), + Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.Zero, "C"), new Chapter(2, TimeSpan.FromSeconds(5), "D")))); var vm = CreateViewModel(load); await vm.LoadCommand.ExecuteAsync("movie.mpls"); @@ -211,8 +272,8 @@ public async Task LoadMplsRaisesSelectedClipIndexChangeAfterClipOptionsPopulate( { var load = new FakeLoadService(ImportResult( "movie.mpls", - Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A")), - Info("MPLS", "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); + Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A")), + Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); var vm = CreateViewModel(load); var indexNotifications = new List<int>(); ((INotifyPropertyChanged)vm).PropertyChanged += (_, args) => @@ -236,13 +297,13 @@ public async Task LoadAfterPreviousLoadRaisesSelectedClipIndexNotification() { var firstLoad = ImportResult( "first.mpls", - Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A")), - Info("MPLS", "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B"))); + Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A")), + Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B"))); var secondLoad = ImportResult( "second.mpls", - Info("MPLS", "00010", new Chapter(1, TimeSpan.Zero, "X")), - Info("MPLS", "00020", new Chapter(1, TimeSpan.FromSeconds(2), "Y")), - Info("MPLS", "00030", new Chapter(1, TimeSpan.FromSeconds(4), "Z"))); + Info(ChapterImportFormat.Mpls, "00010", new Chapter(1, TimeSpan.Zero, "X")), + Info(ChapterImportFormat.Mpls, "00020", new Chapter(1, TimeSpan.FromSeconds(2), "Y")), + Info(ChapterImportFormat.Mpls, "00030", new Chapter(1, TimeSpan.FromSeconds(4), "Z"))); var load = new FakeLoadService(firstLoad, secondLoad); var vm = CreateViewModel(load); @@ -325,11 +386,11 @@ public void XmlLanguageDisplayOptionsExposeReadableNamesWithoutChangingCodes() var vm = CreateViewModel(); var index = vm.XmlLanguageOptions.ToList().IndexOf("jpn"); - var option = vm.XmlLanguageDisplayOptions[index]; + var entry = vm.XmlLanguageDisplayOptions[index]; - Assert.Equal("jpn", option.MainText); - Assert.Equal("Japanese", option.RemarkText); - Assert.Equal("jpn(Japanese)", option.DisplayText); + Assert.Equal("jpn", entry.MainText); + Assert.Equal("Japanese", entry.RemarkText); + Assert.Equal("jpn(Japanese)", entry.DisplayText); vm.XmlLanguageIndex = index; @@ -401,7 +462,7 @@ public async Task LoadRaisesCommandAvailabilityChanges() [Fact] public async Task RefreshCommandRecalculatesFramesFromSelectedFrameOptions() { - var info = Info("OGM", "movie.txt", new Chapter(1, TimeSpan.FromSeconds(0.5), "Intro")); + var info = Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.FromSeconds(0.5), "Intro")); var load = new FakeLoadService(ImportResult("movie.txt", info)); var save = new FakeSaveService(); var vm = CreateViewModel(load, save); @@ -423,7 +484,7 @@ public async Task RefreshCommandRecalculatesFramesFromSelectedFrameOptions() public async Task ConfiguredFrameAccuracyToleranceControlsFrameStylingState() { var store = new FakeSettingsStore(new AppSettings(FrameAccuracyTolerance: 0.001m)); - var load = new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.FromSeconds(1.004), "Intro")))); + var load = new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.FromSeconds(1.004), "Intro")))); var vm = CreateViewModel(load, appSettingsStore: store); await vm.LoadSettingsAsync(TestContext.Current.CancellationToken); @@ -445,7 +506,7 @@ public async Task ConfiguredFrameAccuracyToleranceControlsFrameStylingState() public async Task AutoFrameRateRunsDetectionAndUpdatesStatusText() { var info = Info( - "OGM", + ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromMilliseconds(40), "B"), @@ -466,7 +527,7 @@ public async Task AutoFrameRateRunsDetectionAndUpdatesStatusText() public async Task ManualFrameRateChoiceDoesNotEmitDetectedStatusText() { var info = Info( - "OGM", + ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromMilliseconds(40), "B")); @@ -484,11 +545,11 @@ public async Task ManualFrameRateChoiceDoesNotEmitDetectedStatusText() [Fact] public async Task ChangeFpsCommandPreservesFramesWhenApplyingSelectedFrameRate() { - var load = new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "A")))); + var load = new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "A")))); var vm = CreateViewModel(load); await vm.LoadCommand.ExecuteAsync("movie.txt"); - var targetIndex = new FrameRateService().Options.Single(option => option.Code == "Fps5994").LegacyMplsCode; + var targetIndex = new FrameRateService().Options.Single(entry => entry.Code == "Fps5994").LegacyMplsCode; vm.SetFrameOptions(frameRateIndex: targetIndex, roundFrames: true); await vm.ChangeFpsCommand.ExecuteAsync(); @@ -501,11 +562,11 @@ public async Task ChangeFpsCommandPreservesFramesWhenApplyingSelectedFrameRate() public async Task ChangeFpsCommandLogsSourceAndSelectedTargetFrameRates() { var log = new ApplicationLogPanelProvider(); - var load = new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "A")))); + var load = new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "A")))); var vm = CreateViewModel(load, logService: log); await vm.LoadCommand.ExecuteAsync("movie.txt"); - var targetIndex = new FrameRateService().Options.Single(option => option.Code == "Fps50").LegacyMplsCode; + var targetIndex = new FrameRateService().Options.Single(entry => entry.Code == "Fps50").LegacyMplsCode; vm.SetFrameOptions(frameRateIndex: targetIndex, roundFrames: true); await vm.RefreshCommand.ExecuteAsync(); await vm.ChangeFpsCommand.ExecuteAsync(); @@ -543,12 +604,12 @@ public async Task SaveProjectsChaptersAndDelegatesNeutralOptions() Assert.Equal(0, save.LastOptions.OrderShift); Assert.False(save.LastOptions.ApplyExpression); Assert.Equal("t + 1", save.LastOptions.Expression); - Assert.Equal(string.Empty, save.LastOptions.LuaExpressionPresetId); - Assert.Equal(string.Empty, save.LastOptions.LuaExpressionSourceName); + Assert.Equal(string.Empty, save.LastOptions.ExpressionPresetId); + Assert.Equal(string.Empty, save.LastOptions.ExpressionSourceName); Assert.NotNull(save.LastInfo); - Assert.Equal(3, save.LastInfo.Chapters[0].Number); + Assert.Equal(3, save.LastInfo.Chapters[0].DisplayNumber); Assert.Equal("Chapter 01", save.LastInfo.Chapters[0].Name); - Assert.Equal(TimeSpan.FromSeconds(1), save.LastInfo.Chapters[0].Time); + Assert.Equal(TimeSpan.FromSeconds(1), save.LastInfo.Chapters[0].StartTime); Assert.Equal("out", save.LastDirectory); } @@ -562,7 +623,7 @@ public async Task ExpressionAppliesToRowsPreviewAndSavedInfo() vm.ApplyExpression = true; vm.Expression = "t + 1"; - vm.LuaExpressionSourceName = "inline"; + vm.ExpressionSourceName = "inline"; Assert.Equal("00:00:01.000", vm.Rows[0].TimeText); Assert.Equal("24", vm.Rows[0].FramesInfo); @@ -572,13 +633,13 @@ public async Task ExpressionAppliesToRowsPreviewAndSavedInfo() await vm.SaveDirectoryCommand.ExecuteAsync("out"); Assert.NotNull(save.LastInfo); - Assert.Equal(TimeSpan.FromSeconds(1), save.LastInfo.Chapters[0].Time); + Assert.Equal(TimeSpan.FromSeconds(1), save.LastInfo.Chapters[0].StartTime); Assert.Equal("24", save.LastInfo.Chapters[0].FramesInfo); Assert.Equal(FrameAccuracy.Accurate, save.LastInfo.Chapters[0].FrameAccuracy); Assert.NotNull(save.LastOptions); Assert.False(save.LastOptions.ApplyExpression); Assert.Equal("t + 1", save.LastOptions.Expression); - Assert.Equal("inline", save.LastOptions.LuaExpressionSourceName); + Assert.Equal("inline", save.LastOptions.ExpressionSourceName); } @@ -600,9 +661,9 @@ public async Task PreviewAndSaveUseSameLuaProjectionForTimesNamesAndNumbers() Assert.Contains("CHAPTER04=00:00:02.000", preview, StringComparison.Ordinal); Assert.Contains("CHAPTER04NAME=Chapter 01", preview, StringComparison.Ordinal); Assert.NotNull(save.LastInfo); - Assert.Equal(4, save.LastInfo.Chapters[0].Number); + Assert.Equal(4, save.LastInfo.Chapters[0].DisplayNumber); Assert.Equal("Chapter 01", save.LastInfo.Chapters[0].Name); - Assert.Equal(TimeSpan.FromSeconds(2), save.LastInfo.Chapters[0].Time); + Assert.Equal(TimeSpan.FromSeconds(2), save.LastInfo.Chapters[0].StartTime); } [Fact] @@ -650,7 +711,7 @@ public async Task NegativeOrderShiftNormalizesToZeroAcrossRowsPreviewAndSave() await vm.SaveDirectoryCommand.ExecuteAsync("out"); Assert.NotNull(save.LastInfo); - Assert.Equal(1, save.LastInfo.Chapters[0].Number); + Assert.Equal(1, save.LastInfo.Chapters[0].DisplayNumber); Assert.NotNull(save.LastOptions); Assert.Equal(0, save.LastOptions.OrderShift); } @@ -661,10 +722,10 @@ public async Task OrderShiftUsesOutputChapterOrderAndSkipsSeparators() var load = new FakeLoadService(ImportResult( "album.cue", Info( - "CUE", + ChapterImportFormat.Cue, "album.cue", new Chapter(1, TimeSpan.Zero, "A", "0"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(7), "B", "168")))); var vm = CreateViewModel(load); await vm.LoadCommand.ExecuteAsync("album.cue"); @@ -697,7 +758,7 @@ public async Task ZeroOrderShiftUsesFirstChapterNumber() public async Task NegativeExpressionResultNormalizesRowsAndSavedInfoToZero() { var save = new FakeSaveService(); - var load = new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "Intro", "240")))); + var load = new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.FromSeconds(10), "Intro", "240")))); var vm = CreateViewModel(load, save); await vm.LoadCommand.ExecuteAsync("movie.txt"); @@ -711,7 +772,7 @@ public async Task NegativeExpressionResultNormalizesRowsAndSavedInfoToZero() await vm.SaveDirectoryCommand.ExecuteAsync("out"); Assert.NotNull(save.LastInfo); - Assert.Equal(TimeSpan.Zero, save.LastInfo.Chapters[0].Time); + Assert.Equal(TimeSpan.Zero, save.LastInfo.Chapters[0].StartTime); Assert.Equal("0", save.LastInfo.Chapters[0].FramesInfo); Assert.Equal(FrameAccuracy.Accurate, save.LastInfo.Chapters[0].FrameAccuracy); } @@ -719,7 +780,7 @@ public async Task NegativeExpressionResultNormalizesRowsAndSavedInfoToZero() [Fact] public async Task ShortcutsRouteToCommandsAndClipSelection() { - var load = new FakeLoadService(ImportResult("movie.mpls", Info("MPLS", "00001", new Chapter(1, TimeSpan.Zero, "A")), Info("MPLS", "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); + var load = new FakeLoadService(ImportResult("movie.mpls", Info(ChapterImportFormat.Mpls, "00001", new Chapter(1, TimeSpan.Zero, "A")), Info(ChapterImportFormat.Mpls, "00002", new Chapter(1, TimeSpan.FromSeconds(1), "B")))); var save = new FakeSaveService(); var vm = CreateViewModel(load, save); await vm.LoadCommand.ExecuteAsync("movie.mpls"); @@ -803,9 +864,9 @@ public async Task OpenRelatedMediaUsesShellServiceWhenReferenceExists() var media = Path.Combine(root, "movie.m2ts"); await File.WriteAllBytesAsync(media, [0]); var shell = new FakeShellService(); - var info = Info("MPLS", "movie", new Chapter(1, TimeSpan.Zero, "A")); - var option = new ChapterSourceOption("clip-0", "movie__1", info, MediaReferences: [new SourceMediaReference("movie.m2ts", "movie.m2ts")]); - var load = new FakeLoadService(new ChapterImportResult(true, [new ChapterInfoGroup(Path.Combine(root, "movie.mpls"), [option])], [])); + var info = Info(ChapterImportFormat.Mpls, "movie", new Chapter(1, TimeSpan.Zero, "A")); + var entry = new ChapterImportEntry("clip-0", "movie__1", info, ReferencedMediaFiles: [new ReferencedMediaFile("movie.m2ts", "movie.m2ts")]); + var load = new FakeLoadService(new ChapterImportResult(true, [new ChapterImportSource(Path.Combine(root, "movie.mpls"), [entry])], [])); var vm = CreateViewModel(load, shellService: shell); try @@ -853,6 +914,22 @@ public async Task SettingsLoadAndSaveDirectoryPersistThroughStore() Assert.Equal("new-out", store.Current.SavingPath); } + [Fact] + public async Task FailedSaveDirectoryDoesNotPersistThroughStore() + { + var store = new FakeSettingsStore(new AppSettings(SavingPath: "out", Language: "en-US")); + var save = new FakeSaveService { Result = new ChapterExportResult(false, "", "", []) }; + var vm = CreateViewModel(saveService: save, appSettingsStore: store); + + await vm.LoadSettingsAsync(TestContext.Current.CancellationToken); + await vm.LoadCommand.ExecuteAsync("movie.txt"); + await vm.SaveDirectoryCommand.ExecuteAsync("bad-out"); + + Assert.Equal("bad-out", save.LastDirectory); + Assert.Equal("out", store.Current.SavingPath); + Assert.Equal("out", vm.SaveDirectory); + } + [Fact] public async Task UiLanguagePersistsThroughSettingsStore() { @@ -899,9 +976,9 @@ public async Task LocalizedStatusAndLogRefreshAfterLanguageSwitch() [Fact] public async Task DiagnosticLogsCaptureSeverityAndFormatForLogWindow() { - var diagnostic = new ChapterDiagnostic(DiagnosticSeverity.Warning, "PartialParse", "stopped", "line 5", "tail"); + var diagnostic = new ChapterDiagnostic(DiagnosticSeverity.Warning, ChapterDiagnosticCode.PartialParse, "stopped", "line 5", "tail"); var log = new ApplicationLogPanelProvider(); - var vm = CreateViewModel(new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro"))) with + var vm = CreateViewModel(new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro"))) with { Diagnostics = [diagnostic] }), logService: log); @@ -910,9 +987,9 @@ public async Task DiagnosticLogsCaptureSeverityAndFormatForLogWindow() var entry = Assert.Single(log.Entries, static item => item.MessageKey == "Log.Diagnostic"); Assert.Equal(LogLevel.Warning, entry.Level); - Assert.Equal("PartialParse", entry.Arguments?["code"]); + Assert.Equal("Parse.Partial", entry.Arguments?["code"]); Assert.Equal("tail", entry.TechnicalDetail); - Assert.Contains("Load diagnostic: severity=Warning, code=PartialParse", vm.LogText(), StringComparison.Ordinal); + Assert.Contains("Load diagnostic: severity=Warning, code=Parse.Partial", vm.LogText(), StringComparison.Ordinal); } private static MainWindowViewModel CreateViewModel( @@ -927,7 +1004,7 @@ private static MainWindowViewModel CreateViewModel( logService ??= new ApplicationLogPanelProvider(); return new MainWindowViewModel( - loadService ?? new FakeLoadService(ImportResult("movie.txt", Info("OGM", "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro")))), + loadService ?? new FakeLoadService(ImportResult("movie.txt", Info(ChapterImportFormat.Ogm, "movie.txt", new Chapter(1, TimeSpan.Zero, "Intro")))), saveService ?? new FakeSaveService(), new ChapterEditingService(new ChapterTimeFormatter()), new ChapterSegmentService(), @@ -941,13 +1018,13 @@ private static MainWindowViewModel CreateViewModel( localizer ?? new AppLocalizationManager("en-US")); } - private static ChapterInfo Info(string sourceType, string sourceName, params Chapter[] chapters) => - new(sourceName, sourceName, 0, sourceType, 24, chapters.Last().Time, chapters); + private static ChapterSet Info(ChapterImportFormat sourceType, string sourceName, params Chapter[] chapters) => + new(sourceName, sourceName, sourceType, 24, chapters.Last().StartTime, chapters); - private static ChapterImportResult ImportResult(string path, params ChapterInfo[] infos) + private static ChapterImportResult ImportResult(string path, params ChapterSet[] infos) { - var options = infos.Select((info, index) => new ChapterSourceOption($"option-{index}", info.SourceName ?? info.Title, info)).ToArray(); - return new ChapterImportResult(true, [new ChapterInfoGroup(path, options)], []); + var entries = infos.Select((info, index) => new ChapterImportEntry($"entry-{index}", info.SourceName ?? info.Title, info)).ToArray(); + return new ChapterImportResult(true, [new ChapterImportSource(path, entries)], []); } private static string RepositoryRoot() @@ -970,14 +1047,14 @@ private sealed class FakeLoadService(params ChapterImportResult[] results) : ICh { private readonly Queue<ChapterImportResult> results = new(results); - public Action<IProgress<ChapterLoadProgress>?>? OnLoad { get; init; } + public Action<IChapterImportProgressReporter?>? OnLoad { get; init; } public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken cancellationToken) { return LoadAsync(path, progress: null, cancellationToken); } - public ValueTask<ChapterImportResult> LoadAsync(string path, IProgress<ChapterLoadProgress>? progress, CancellationToken cancellationToken) + public ValueTask<ChapterImportResult> LoadAsync(string path, IChapterImportProgressReporter? progress, CancellationToken cancellationToken) { if (results.Count == 0) { @@ -999,27 +1076,78 @@ public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken c return LoadAsync(path, progress: null, cancellationToken); } - public async ValueTask<ChapterImportResult> LoadAsync(string path, IProgress<ChapterLoadProgress>? progress, CancellationToken cancellationToken) + public async ValueTask<ChapterImportResult> LoadAsync(string path, IChapterImportProgressReporter? progress, CancellationToken cancellationToken) { await Task.Yield(); - progress?.Report(new ChapterLoadProgress(0.25, "Status.LoadingSource.Parse")); + progress?.Report(new ChapterImportProgress(ChapterImportProgressPhase.ParsingChapters, 0.25)); CompletedAfterAwait = true; return result; } } + private sealed class ControlledLoadService(IReadOnlyDictionary<string, ChapterImportResult> results) : IChapterLoadService + { + private readonly Dictionary<string, TaskCompletionSource> started = []; + private readonly Dictionary<string, TaskCompletionSource<ChapterImportResult>> completions = []; + + public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken cancellationToken) + { + return LoadAsync(path, progress: null, cancellationToken); + } + + public async ValueTask<ChapterImportResult> LoadAsync(string path, IChapterImportProgressReporter? progress, CancellationToken cancellationToken) + { + var startedSource = SourceFor(started, path); + var completion = CompletionFor(path); + startedSource.TrySetResult(); + progress?.Report(new ChapterImportProgress(ChapterImportProgressPhase.ParsingChapters, 0.25)); + using var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken)); + return await completion.Task; + } + + public Task WaitForRequestAsync(string path) => SourceFor(started, path).Task; + + public void Complete(string path) + { + CompletionFor(path).TrySetResult(results[path]); + } + + private TaskCompletionSource<ChapterImportResult> CompletionFor(string path) + { + if (!completions.TryGetValue(path, out var source)) + { + source = new TaskCompletionSource<ChapterImportResult>(TaskCreationOptions.RunContinuationsAsynchronously); + completions[path] = source; + } + + return source; + } + + private static TaskCompletionSource SourceFor(Dictionary<string, TaskCompletionSource> sources, string path) + { + if (!sources.TryGetValue(path, out var source)) + { + source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sources[path] = source; + } + + return source; + } + } + private sealed class FakeSaveService : IChapterSaveService { - public ChapterInfo? LastInfo { get; private set; } + public ChapterSet? LastInfo { get; private set; } public ChapterExportOptions? LastOptions { get; private set; } public string? LastDirectory { get; private set; } + public ChapterExportResult Result { get; init; } = new(true, "ok", ".txt", []); - public ValueTask<ChapterExportResult> SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) + public ValueTask<ChapterExportResult> SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) { LastInfo = info; LastOptions = options; LastDirectory = directory; - return ValueTask.FromResult(new ChapterExportResult(true, "ok", ".txt", [])); + return ValueTask.FromResult(Result); } } diff --git a/tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs b/tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs index fec2a31..e92cb63 100644 --- a/tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs @@ -2,6 +2,7 @@ using ChapterTool.Avalonia.Localization; using ChapterTool.Avalonia.Services; using ChapterTool.Avalonia.ViewModels; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Editing; using ChapterTool.Core.Exporting; using ChapterTool.Core.Importing; @@ -27,6 +28,7 @@ public async Task LoadsAndSavesDurablePreferences() FfmpegPath: "ffmpeg", DefaultSaveFormat: "Xml", DefaultXmlLanguage: "ja", + EmitBom: true, FrameAccuracyTolerance: 0.02m)); var themeStore = new FakeThemeSettingsStore(ThemeColorSettings.Default); var owner = CreateOwner(appStore); @@ -41,6 +43,7 @@ public async Task LoadsAndSavesDurablePreferences() viewModel.FfmpegPath = "new-ffmpeg"; viewModel.DefaultSaveFormatIndex = viewModel.SaveFormatOptions.ToList().IndexOf("JSON"); viewModel.DefaultXmlLanguageIndex = viewModel.XmlLanguageOptions.ToList().IndexOf("jpn"); + viewModel.EmitBom = false; viewModel.FrameAccuracyTolerance = 0.2m; viewModel.ColorSlots[0].Value = "#010203"; @@ -54,9 +57,11 @@ public async Task LoadsAndSavesDurablePreferences() Assert.Equal("new-ffmpeg", appStore.Current.FfmpegPath); Assert.Equal("Json", appStore.Current.DefaultSaveFormat); Assert.Equal("jpn", appStore.Current.DefaultXmlLanguage); + Assert.False(appStore.Current.EmitBom); Assert.Equal(0.2m, appStore.Current.FrameAccuracyTolerance); Assert.Equal(ChapterExportFormat.Json, owner.SaveFormat); Assert.Equal("jpn", owner.XmlLanguage); + Assert.False(owner.EmitBom); Assert.Equal(0.2m, owner.FrameAccuracyTolerance); Assert.Equal("new-out", owner.SaveDirectory); Assert.Equal("ja-JP", owner.UiLanguage); @@ -64,6 +69,29 @@ public async Task LoadsAndSavesDurablePreferences() Assert.False(viewModel.HasUnsavedChanges); } + [Fact] + public async Task LoadFallsBackToDefaultsWhenSettingsStoresFail() + { + var owner = CreateOwner(); + var themeApplication = new FakeThemeApplicationService(); + var localizer = new AppLocalizationManager("en-US"); + var viewModel = CreateViewModel( + owner, + new ThrowingAppSettingsStore(), + new ThrowingThemeSettingsStore(), + localizer, + themeApplicationService: themeApplication); + + await viewModel.LoadAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ChapterExportFormat.Txt, owner.SaveFormat); + Assert.Equal(ThemeColorSettings.Default.BackChange, viewModel.ColorSlots[0].Value); + Assert.Equal(ThemeColorSettings.Default.BackChange, themeApplication.LastApplied?.BackChange); + Assert.True(viewModel.SettingsLoadFailed); + Assert.Contains("defaults", viewModel.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.False(viewModel.HasUnsavedChanges); + } + [Fact] public async Task RuntimeSafeSettingsApplyImmediatelyWithoutSavingStore() { @@ -72,6 +100,7 @@ public async Task RuntimeSafeSettingsApplyImmediatelyWithoutSavingStore() Language: "en-US", DefaultSaveFormat: "Txt", DefaultXmlLanguage: "und", + EmitBom: true, FrameAccuracyTolerance: 0.10m)); var owner = CreateOwner(appStore); var viewModel = CreateViewModel(owner, appStore, new FakeThemeSettingsStore(ThemeColorSettings.Default), new AppLocalizationManager("en-US")); @@ -81,12 +110,14 @@ public async Task RuntimeSafeSettingsApplyImmediatelyWithoutSavingStore() viewModel.SaveDirectory = "live"; viewModel.DefaultSaveFormatIndex = viewModel.SaveFormatOptions.ToList().IndexOf("JSON"); viewModel.DefaultXmlLanguageIndex = viewModel.XmlLanguageOptions.ToList().IndexOf("jpn"); + viewModel.EmitBom = false; viewModel.FrameAccuracyTolerance = 0.20m; Assert.Equal("ja-JP", owner.UiLanguage); Assert.Equal("live", owner.SaveDirectory); Assert.Equal(ChapterExportFormat.Json, owner.SaveFormat); Assert.Equal("jpn", owner.XmlLanguage); + Assert.False(owner.EmitBom); Assert.Equal(0.20m, owner.FrameAccuracyTolerance); Assert.Equal("saved", appStore.Current.SavingPath); Assert.Equal("en-US", appStore.Current.Language); @@ -246,8 +277,8 @@ public void SaveFormatOptionsExposeSingleQpfileEntry() Assert.Contains("CUE", viewModel.SaveFormatOptions); Assert.Contains("JSON", viewModel.SaveFormatOptions); Assert.DoesNotContain("Qpf", viewModel.SaveFormatOptions); - Assert.Contains("Chapter2Qpfile", viewModel.SaveFormatOptions); - Assert.Equal(10, viewModel.SaveFormatOptions.Count); + Assert.DoesNotContain("Chapter2Qpfile", viewModel.SaveFormatOptions); + Assert.Equal(9, viewModel.SaveFormatOptions.Count); } [Fact] @@ -260,11 +291,11 @@ public void XmlLanguageDisplayOptionsMatchMainWindowFormat() new AppLocalizationManager("en-US")); var index = viewModel.XmlLanguageOptions.ToList().IndexOf("jpn"); - var option = viewModel.XmlLanguageDisplayOptions[index]; + var entry = viewModel.XmlLanguageDisplayOptions[index]; - Assert.Equal("jpn", option.MainText); - Assert.Equal("Japanese", option.RemarkText); - Assert.Equal("jpn(Japanese)", option.DisplayText); + Assert.Equal("jpn", entry.MainText); + Assert.Equal("Japanese", entry.RemarkText); + Assert.Equal("jpn(Japanese)", entry.DisplayText); } [Fact] @@ -277,11 +308,32 @@ public void XmlLanguageDisplayOptionsRefreshAfterUiLanguageSwitch() viewModel.PropertyChanged += (_, args) => notifications.Add(args.PropertyName); localizer.SetCulture("zh-CN"); - var option = viewModel.XmlLanguageDisplayOptions[viewModel.XmlLanguageOptions.ToList().IndexOf("und")]; + var entry = viewModel.XmlLanguageDisplayOptions[viewModel.XmlLanguageOptions.ToList().IndexOf("und")]; Assert.Contains(nameof(SettingsToolViewModel.XmlLanguageDisplayOptions), notifications); - Assert.Equal("未确定", option.RemarkText); - Assert.Equal("und(未确定)", option.DisplayText); + Assert.Equal("未确定", entry.RemarkText); + Assert.Equal("und(未确定)", entry.DisplayText); + } + + [Fact] + public void DisposedViewModelStopsRefreshingLocalizedOptions() + { + var localizer = new AppLocalizationManager("en-US"); + var owner = CreateOwner(localizer: localizer); + var viewModel = CreateViewModel(owner, null, null, localizer); + var notifications = 0; + viewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(SettingsToolViewModel.XmlLanguageDisplayOptions)) + { + notifications++; + } + }; + + (viewModel as IDisposable)?.Dispose(); + localizer.SetCulture("zh-CN"); + + Assert.Equal(0, notifications); } [Fact] @@ -552,6 +604,22 @@ public ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken cancel } } + private sealed class ThrowingAppSettingsStore : ISettingsStore<AppSettings> + { + public ValueTask<AppSettings> LoadAsync(CancellationToken cancellationToken) => + ValueTask.FromException<AppSettings>(new CorruptSettingsFileException("appsettings.json", "appsettings.json.bad", new InvalidDataException())); + + public ValueTask SaveAsync(AppSettings settings, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + private sealed class ThrowingThemeSettingsStore : ISettingsStore<ThemeColorSettings> + { + public ValueTask<ThemeColorSettings> LoadAsync(CancellationToken cancellationToken) => + ValueTask.FromException<ThemeColorSettings>(new CorruptSettingsFileException("theme-colors.json", "theme-colors.json.bad", new InvalidDataException())); + + public ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + private sealed class FakeThemeApplicationService : IThemeApplicationService { public ThemeColorSettings? LastApplied { get; private set; } @@ -572,7 +640,7 @@ public ValueTask<ExternalToolLocation> LocateAsync(string toolId, CancellationTo ValueTask.FromResult( locations.TryGetValue(toolId, out var location) ? location - : new ExternalToolLocation(false, null, "MissingDependency", toolId)); + : new ExternalToolLocation(false, null, ChapterDiagnosticCode.MissingDependency, toolId)); } private sealed class FakeLoadService : IChapterLoadService @@ -580,7 +648,7 @@ private sealed class FakeLoadService : IChapterLoadService public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken cancellationToken) => ValueTask.FromResult(new ChapterImportResult( true, - [new ChapterInfoGroup(path, [new ChapterSourceOption("default", "default", new ChapterInfo(path, path, 0, "OGM", 24, TimeSpan.Zero, []))])], + [new ChapterImportSource(path, [new ChapterImportEntry("default", "default", new ChapterSet(path, path, ChapterImportFormat.Ogm, 24, TimeSpan.Zero, []))])], [])); } @@ -588,7 +656,7 @@ public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken c private sealed class FakeSaveService : IChapterSaveService { - public ValueTask<ChapterExportResult> SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => + public ValueTask<ChapterExportResult> SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => ValueTask.FromResult(new ChapterExportResult(true, "ok", ".txt", [])); } diff --git a/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs b/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs index 9a392e6..eec260c 100644 --- a/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs +++ b/tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs @@ -1,5 +1,6 @@ using ChapterTool.Avalonia.Services; using ChapterTool.Avalonia.ViewModels; +using ChapterTool.Avalonia.Localization; using ChapterTool.Core.Editing; using ChapterTool.Core.Exporting; using ChapterTool.Core.Importing; @@ -59,7 +60,7 @@ public void TextToolFormatSelectorUpdatesOwnerAndRefreshesPreviewKind() var owner = CreateOwner(); var vm = new TextToolViewModel(owner.BuildPreview, new TextToolOptions { FormatSelector = new TextToolFormatSelector(owner) }) { - SelectedFormatIndex = (int)ChapterExportFormat.Json + SelectedFormatIndex = ChapterExportFormats.IndexOf(ChapterExportFormat.Json) }; Assert.Equal(ChapterExportFormat.Json, owner.SaveFormat); @@ -67,8 +68,8 @@ public void TextToolFormatSelectorUpdatesOwnerAndRefreshesPreviewKind() Assert.True(vm.CanSelectFormat); Assert.False(vm.CanClear); Assert.Contains("QPFile", vm.FormatOptions); - Assert.Contains("Chapter2Qpfile", vm.FormatOptions); - Assert.Equal(10, vm.FormatOptions.Count); + Assert.DoesNotContain("Chapter2Qpfile", vm.FormatOptions); + Assert.Equal(9, vm.FormatOptions.Count); } [Fact] @@ -85,6 +86,41 @@ public async Task ColorSettingsToolPersistsSixNormalizedSlots() Assert.Equal(ThemeColorSettings.Default.TextBack, store.Current.TextBack); } + [Fact] + public async Task ColorSettingsToolFallsBackToDefaultsWhenThemeLoadFails() + { + var themeApplication = new FakeThemeApplicationService(); + var vm = new ColorSettingsViewModel(new ThrowingThemeSettingsStore(), themeApplication); + + await vm.InitializationTask; + + Assert.Equal(ThemeColorSettings.Default.BackChange, vm.Slots[0].Value); + Assert.Equal(ThemeColorSettings.Default.BackChange, themeApplication.LastApplied?.BackChange); + Assert.True(vm.ThemeLoadFailed); + Assert.Contains("defaults", vm.LoadWarningText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DisposedLanguageToolStopsRefreshingLocalizedOptions() + { + var localizer = new AppLocalizationManager("en-US"); + var owner = CreateOwner(localizer); + var vm = new LanguageToolViewModel(owner); + var notifications = 0; + vm.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(LanguageToolViewModel.Languages)) + { + notifications++; + } + }; + + (vm as IDisposable)?.Dispose(); + localizer.SetCulture("zh-CN"); + + Assert.Equal(0, notifications); + } + [Fact] public async Task ExpressionTemplateAndForwardShiftToolsApplyToOwner() { @@ -122,15 +158,15 @@ public async Task ExpressionToolAppliesLuaPresetAndExternalScriptToOwner() Assert.Equal("round-to-frame", expression.SelectedPreset?.Id); Assert.Contains("fps", expression.Expression, StringComparison.Ordinal); - Assert.Equal("Round to nearest frame", expression.LuaExpressionSourceName); + Assert.Equal("Round to nearest frame", expression.ExpressionSourceName); await expression.BrowseScriptCommand.ExecuteAsync(); await expression.ApplyCommand.ExecuteAsync(expression); Assert.Equal("t + 2", owner.Expression); Assert.True(owner.ApplyExpression); - Assert.Equal(string.Empty, owner.LuaExpressionPresetId); - Assert.Equal(Path.GetFileName(scriptPath), owner.LuaExpressionSourceName); + Assert.Equal(string.Empty, owner.ExpressionPresetId); + Assert.Equal(Path.GetFileName(scriptPath), owner.ExpressionSourceName); Assert.Equal("00:00:07.000", owner.Rows[0].TimeText); } finally @@ -139,14 +175,14 @@ public async Task ExpressionToolAppliesLuaPresetAndExternalScriptToOwner() } } - private static MainWindowViewModel CreateOwner() + private static MainWindowViewModel CreateOwner(IAppLocalizer? localizer = null) { var formatter = new ChapterTimeFormatter(); var logService = new ApplicationLogPanelProvider(); return new MainWindowViewModel( new FakeLoadService(new ChapterImportResult( true, - [new ChapterInfoGroup("movie.txt", [new ChapterSourceOption("0", "movie", new ChapterInfo("movie.txt", "movie.txt", 0, "OGM", 24, TimeSpan.FromSeconds(10), [new Chapter(1, TimeSpan.FromSeconds(5), "Intro")]))])], + [new ChapterImportSource("movie.txt", [new ChapterImportEntry("0", "movie", new ChapterSet("movie.txt", "movie.txt", ChapterImportFormat.Ogm, 24, TimeSpan.FromSeconds(10), [new Chapter(1, TimeSpan.FromSeconds(5), "Intro")]))])], [])), new FakeSaveService(), new ChapterEditingService(formatter), @@ -154,7 +190,8 @@ private static MainWindowViewModel CreateOwner() new FakeWindowService(), formatter, logService, - TestApplicationLogger.Create<MainWindowViewModel>(logService)); + TestApplicationLogger.Create<MainWindowViewModel>(logService), + localizer: localizer); } private sealed class FakeThemeSettingsStore : ISettingsStore<ThemeColorSettings> @@ -170,6 +207,21 @@ public ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken cancel } } + private sealed class ThrowingThemeSettingsStore : ISettingsStore<ThemeColorSettings> + { + public ValueTask<ThemeColorSettings> LoadAsync(CancellationToken cancellationToken) => + ValueTask.FromException<ThemeColorSettings>(new CorruptSettingsFileException("theme-colors.json", "theme-colors.json.bad", new InvalidDataException())); + + public ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + + private sealed class FakeThemeApplicationService : IThemeApplicationService + { + public ThemeColorSettings? LastApplied { get; private set; } + + public void Apply(ThemeColorSettings settings) => LastApplied = settings; + } + private sealed class FakeLoadService(ChapterImportResult result) : IChapterLoadService { public ValueTask<ChapterImportResult> LoadAsync(string path, CancellationToken cancellationToken) => ValueTask.FromResult(result); @@ -177,7 +229,7 @@ private sealed class FakeLoadService(ChapterImportResult result) : IChapterLoadS private sealed class FakeSaveService : IChapterSaveService { - public ValueTask<ChapterExportResult> SaveAsync(ChapterInfo info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => + public ValueTask<ChapterExportResult> SaveAsync(ChapterSet info, ChapterExportOptions options, string? directory, CancellationToken cancellationToken) => ValueTask.FromResult(new ChapterExportResult(true, string.Empty, ".txt", [])); } diff --git a/tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs b/tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs index 7873265..3e8ffa5 100644 --- a/tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Editing; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; @@ -13,7 +14,7 @@ public void EditTime_resets_values_over_one_day() { var result = service.EditTime(Sample(), 1, "25:00:00.000"); - Assert.Equal(TimeSpan.Zero, result.ChapterInfo.Chapters[1].Time); + Assert.Equal(TimeSpan.Zero, result.ChapterSet.Chapters[1].StartTime); } [Fact] @@ -21,9 +22,22 @@ public void EditFrame_uses_current_fps() { var result = service.EditFrame(Sample(), 1, "240 frames", 24); - Assert.Equal(TimeSpan.FromSeconds(10), result.ChapterInfo.Chapters[1].Time); - Assert.Equal("240", result.ChapterInfo.Chapters[1].FramesInfo); - Assert.Equal(FrameAccuracy.Accurate, result.ChapterInfo.Chapters[1].FrameAccuracy); + Assert.Equal(TimeSpan.FromSeconds(10), result.ChapterSet.Chapters[1].StartTime); + Assert.Equal("240", result.ChapterSet.Chapters[1].FramesInfo); + Assert.Equal(FrameAccuracy.Accurate, result.ChapterSet.Chapters[1].FrameAccuracy); + } + + [Theory] + [InlineData("999999999999999999999999999999999999999 frames", 24)] + [InlineData("99999999999999999999999999999 frames", 0.000000001)] + public void EditFrame_returns_diagnostic_for_overflowing_frame_text(string text, decimal framesPerSecond) + { + var sample = Sample(); + + var result = service.EditFrame(sample, 1, text, framesPerSecond); + + Assert.Equal(sample.Chapters[1].StartTime, result.ChapterSet.Chapters[1].StartTime); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidFrameText); } [Fact] @@ -31,8 +45,8 @@ public void Delete_first_chapter_shifts_remaining_times_to_zero() { var result = service.Delete(Sample(), new HashSet<int> { 0 }); - Assert.Equal(TimeSpan.Zero, result.ChapterInfo.Chapters[0].Time); - Assert.Equal(1, result.ChapterInfo.Chapters[0].Number); + Assert.Equal(TimeSpan.Zero, result.ChapterSet.Chapters[0].StartTime); + Assert.Equal(1, result.ChapterSet.Chapters[0].DisplayNumber); } [Fact] @@ -40,8 +54,8 @@ public void InsertBefore_inserts_new_chapter_and_renumbers() { var result = service.InsertBefore(Sample(), 1); - Assert.Equal("New Chapter", result.ChapterInfo.Chapters[1].Name); - Assert.Equal([1, 2, 3, 4], result.ChapterInfo.Chapters.Select(static c => c.Number).ToArray()); + Assert.Equal("New Chapter", result.ChapterSet.Chapters[1].Name); + Assert.Equal([1, 2, 3, 4], result.ChapterSet.Chapters.Select(static c => c.DisplayNumber).ToArray()); } [Fact] @@ -49,9 +63,9 @@ public void ApplyTemplate_replaces_names_in_order_and_preserves_missing() { var result = service.ApplyTemplate(Sample(), "One\nTwo"); - Assert.Equal("One", result.ChapterInfo.Chapters[0].Name); - Assert.Equal("Two", result.ChapterInfo.Chapters[1].Name); - Assert.Equal("End", result.ChapterInfo.Chapters[2].Name); + Assert.Equal("One", result.ChapterSet.Chapters[0].Name); + Assert.Equal("Two", result.ChapterSet.Chapters[1].Name); + Assert.Equal("End", result.ChapterSet.Chapters[2].Name); } [Fact] @@ -59,8 +73,8 @@ public void ApplyOrderShift_normalizes_negative_shift_to_zero() { var result = service.ApplyOrderShift(Sample(), -2); - Assert.Equal([1, 2, 3], result.ChapterInfo.Chapters.Select(static chapter => chapter.Number).ToArray()); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "OrderShiftNormalized"); + Assert.Equal([1, 2, 3], result.ChapterSet.Chapters.Select(static chapter => chapter.DisplayNumber).ToArray()); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.OrderShiftNormalized); } [Fact] @@ -71,14 +85,14 @@ public void ApplyOrderShift_uses_non_separator_output_order() Chapters = [ new Chapter(1, TimeSpan.Zero, "Intro"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(10), "Middle") ] }; var result = service.ApplyOrderShift(info, 2); - Assert.Equal([3, 0, 4], result.ChapterInfo.Chapters.Select(static chapter => chapter.Number).ToArray()); + Assert.Equal([3, 0, 4], result.ChapterSet.Chapters.Select(static chapter => chapter.DisplayNumber).ToArray()); Assert.Empty(result.Diagnostics); } @@ -87,9 +101,9 @@ public void ShiftFramesForward_subtracts_shift_and_removes_negative_chapters() { var result = service.ShiftFramesForward(Sample(), 240, 24); - Assert.Equal(TimeSpan.Zero, result.ChapterInfo.Chapters[0].Time); - Assert.Equal(TimeSpan.FromSeconds(10), result.ChapterInfo.Chapters[1].Time); - Assert.Equal([1, 2], result.ChapterInfo.Chapters.Select(static chapter => chapter.Number).ToArray()); + Assert.Equal(TimeSpan.Zero, result.ChapterSet.Chapters[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(10), result.ChapterSet.Chapters[1].StartTime); + Assert.Equal([1, 2], result.ChapterSet.Chapters.Select(static chapter => chapter.DisplayNumber).ToArray()); } [Fact] @@ -111,12 +125,11 @@ public void CreateZones_uses_selected_row_frame_ranges() Assert.Empty(result.Diagnostics); } - private static ChapterInfo Sample() => + private static ChapterSet Sample() => new( "Title", "source", - 0, - "OGM", + ChapterImportFormat.Ogm, 24, TimeSpan.FromSeconds(30), [ diff --git a/tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs b/tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs index dcb156a..e46b6c6 100644 --- a/tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Editing; using ChapterTool.Core.Models; @@ -8,80 +9,80 @@ public sealed class ChapterSegmentServiceTests [Fact] public void CombineOffsetsMplsSegmentsAndRenumbers() { - var group = new ChapterInfoGroup( + var group = new ChapterImportSource( "playlist", [ - new ChapterSourceOption("a", "a", Info("MPLS", TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromSeconds(10), "B"))), - new ChapterSourceOption("b", "b", Info("MPLS", TimeSpan.FromSeconds(30), new Chapter(1, TimeSpan.Zero, "C"), new Chapter(2, TimeSpan.FromSeconds(5), "D"))) + new ChapterImportEntry("a", "a", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A"), new Chapter(2, TimeSpan.FromSeconds(10), "B"))), + new ChapterImportEntry("b", "b", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(30), new Chapter(1, TimeSpan.Zero, "C"), new Chapter(2, TimeSpan.FromSeconds(5), "D"))) ]); var result = ChapterSegmentService.Combine(group); Assert.Empty(result.Diagnostics); - Assert.Equal("FULL Chapter", result.ChapterInfo.Title); - Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(25)], result.ChapterInfo.Chapters.Select(chapter => chapter.Time)); - Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03", "Chapter 04"], result.ChapterInfo.Chapters.Select(chapter => chapter.Name)); - Assert.Equal(TimeSpan.FromSeconds(50), result.ChapterInfo.Duration); + Assert.Equal("FULL Chapter", result.ChapterSet.Title); + Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(25)], result.ChapterSet.Chapters.Select(chapter => chapter.StartTime)); + Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03", "Chapter 04"], result.ChapterSet.Chapters.Select(chapter => chapter.Name)); + Assert.Equal(TimeSpan.FromSeconds(50), result.ChapterSet.Duration); } [Fact] public void CombineRejectsUnsupportedSource() { - var group = new ChapterInfoGroup("x", [new ChapterSourceOption("x", "x", Info("CUE", TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "A")))]); + var group = new ChapterImportSource("x", [new ChapterImportEntry("x", "x", Info(ChapterImportFormat.Cue, TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "A")))]); var result = ChapterSegmentService.Combine(group); Assert.NotEmpty(result.Diagnostics); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedCombineSource"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedCombineSource); } [Fact] public void CombineRejectsMixedSupportedSources() { - var group = new ChapterInfoGroup( + var group = new ChapterImportSource( "mixed", [ - new ChapterSourceOption("a", "a", Info("MPLS", TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "A"))), - new ChapterSourceOption("b", "b", Info("DVD", TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "B"))) + new ChapterImportEntry("a", "a", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "A"))), + new ChapterImportEntry("b", "b", Info(ChapterImportFormat.DvdIfo, TimeSpan.FromSeconds(1), new Chapter(1, TimeSpan.Zero, "B"))) ]); var result = ChapterSegmentService.Combine(group); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedCombineSource"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedCombineSource); } [Fact] public void AppendCombinesMplsGroups() { - var existing = new ChapterInfoGroup( + var existing = new ChapterImportSource( "a", - [new ChapterSourceOption("a", "a", Info("MPLS", TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A")))]); - var appended = new ChapterInfoGroup( + [new ChapterImportEntry("a", "a", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A")))]); + var appended = new ChapterImportSource( "b", - [new ChapterSourceOption("b", "b", Info("MPLS", TimeSpan.FromSeconds(10), new Chapter(1, TimeSpan.Zero, "B")))]); + [new ChapterImportEntry("b", "b", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(10), new Chapter(1, TimeSpan.Zero, "B")))]); var result = ChapterSegmentService.Append(existing, appended); Assert.Empty(result.Diagnostics); - Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(20)], result.ChapterInfo.Chapters.Select(chapter => chapter.Time)); - Assert.Equal(TimeSpan.FromSeconds(30), result.ChapterInfo.Duration); + Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(20)], result.ChapterSet.Chapters.Select(chapter => chapter.StartTime)); + Assert.Equal(TimeSpan.FromSeconds(30), result.ChapterSet.Duration); } [Fact] public void AppendRejectsNonMplsGroups() { - var existing = new ChapterInfoGroup( + var existing = new ChapterImportSource( "a", - [new ChapterSourceOption("a", "a", Info("MPLS", TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A")))]); - var appended = new ChapterInfoGroup( + [new ChapterImportEntry("a", "a", Info(ChapterImportFormat.Mpls, TimeSpan.FromSeconds(20), new Chapter(1, TimeSpan.Zero, "A")))]); + var appended = new ChapterImportSource( "b", - [new ChapterSourceOption("b", "b", Info("DVD", TimeSpan.FromSeconds(10), new Chapter(1, TimeSpan.Zero, "B")))]); + [new ChapterImportEntry("b", "b", Info(ChapterImportFormat.DvdIfo, TimeSpan.FromSeconds(10), new Chapter(1, TimeSpan.Zero, "B")))]); var result = ChapterSegmentService.Append(existing, appended); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "UnsupportedAppendSource"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.UnsupportedAppendSource); } - private static ChapterInfo Info(string sourceType, TimeSpan duration, params Chapter[] chapters) => - new(sourceType, sourceType, 0, sourceType, 24, duration, chapters); + private static ChapterSet Info(ChapterImportFormat sourceType, TimeSpan duration, params Chapter[] chapters) => + new(ChapterImportFormats.DisplayName(sourceType), ChapterImportFormats.DisplayName(sourceType), sourceType, 24, duration, chapters); } diff --git a/tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs b/tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs index 972c8f4..1fbe398 100644 --- a/tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs +++ b/tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs @@ -23,15 +23,14 @@ C Part Assert.Equal( ["Avant", "OP", "A Part", "B Part", "ED", "C Part"], - result.ChapterInfo.Chapters.Select(static chapter => chapter.Name)); + result.ChapterSet.Chapters.Select(static chapter => chapter.Name)); } - private static ChapterInfo Sample() => + private static ChapterSet Sample() => new( "Title", "source", - 0, - "OGM", + ChapterImportFormat.Ogm, 24, TimeSpan.FromMinutes(6), Enumerable.Range(1, 6) diff --git a/tests/ChapterTool.Core.Tests/Exporting/ChapterConversionServiceTests.cs b/tests/ChapterTool.Core.Tests/Exporting/ChapterConversionServiceTests.cs index 4c448b5..1683612 100644 --- a/tests/ChapterTool.Core.Tests/Exporting/ChapterConversionServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Exporting/ChapterConversionServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Exporting; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; @@ -30,7 +31,7 @@ public void Celltimes_uses_compatibility_rounding_and_rejects_invalid_fps() Assert.Equal("2", result.Content); Assert.False(invalid.Success); - Assert.Contains(invalid.Diagnostics, diagnostic => diagnostic.Code == "InvalidFrameRate"); + Assert.Contains(invalid.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidFrameRate); } [Fact] @@ -145,20 +146,19 @@ public void ChapterTextToQpfile_invalid_input_returns_diagnostic() var result = service.ChapterTextToQpfile("not chapters", 24m); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidChapterText"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidChapterText); } - private static ChapterInfo Sample() => + private static ChapterSet Sample() => new( "Title", "source", - 0, - "OGM", + ChapterImportFormat.Ogm, 24, TimeSpan.FromSeconds(30), [ new Chapter(1, TimeSpan.Zero, "Intro"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(10), "Middle") ]); } diff --git a/tests/ChapterTool.Core.Tests/Exporting/ChapterExportServiceTests.cs b/tests/ChapterTool.Core.Tests/Exporting/ChapterExportServiceTests.cs index 0a7f348..d579781 100644 --- a/tests/ChapterTool.Core.Tests/Exporting/ChapterExportServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Exporting/ChapterExportServiceTests.cs @@ -3,12 +3,13 @@ using ChapterTool.Core.Transform; using System.Globalization; using System.Xml.Linq; +using ChapterTool.Core.Diagnostics; namespace ChapterTool.Core.Tests.Exporting; public sealed class ChapterExportServiceTests { - private readonly ChapterExportService service = new(new ChapterTimeFormatter(), new ExpressionService()); + private readonly ChapterExportService service = new(new ChapterTimeFormatter()); [Fact] public void Txt_export_writes_ogm_pairs() @@ -66,6 +67,43 @@ public void Tsmuxer_export_has_no_trailing_semicolon() Assert.Equal($"--custom-{Environment.NewLine}chapters=00:00:00.000;00:00:10.000;00:00:20.000", result.Content); } + [Fact] + public void Webvtt_export_preserves_explicit_chapter_end_times() + { + var info = Sample() with + { + Duration = TimeSpan.FromSeconds(60), + Chapters = + [ + new Chapter(1, TimeSpan.Zero, "Intro", EndTime: TimeSpan.FromSeconds(4)), + new Chapter(2, TimeSpan.FromSeconds(10), "Middle", EndTime: TimeSpan.FromSeconds(15)), + new Chapter(3, TimeSpan.FromSeconds(30), "End") + ] + }; + + var result = service.Export(info, new ChapterExportOptions(ChapterExportFormat.WebVtt)); + + Assert.True(result.Success); + Assert.Contains("00:00:00.000 --> 00:00:04.000", result.Content, StringComparison.Ordinal); + Assert.Contains("00:00:10.000 --> 00:00:15.000", result.Content, StringComparison.Ordinal); + Assert.Contains("00:00:30.000 --> 00:01:00.000", result.Content, StringComparison.Ordinal); + } + + [Fact] + public void Webvtt_export_rejects_unsupported_cue_text_control_sequences() + { + var info = Sample() with + { + Chapters = [new Chapter(1, TimeSpan.Zero, "Line 1\r\nLine 2 --> marker", EndTime: TimeSpan.FromSeconds(1))] + }; + + var result = service.Export(info, new ChapterExportOptions(ChapterExportFormat.WebVtt)); + + Assert.False(result.Success); + Assert.Empty(result.Content); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidWebVttCueText); + } + [Fact] public void Txt_export_applies_order_shift_and_generated_names() { @@ -136,7 +174,7 @@ public void Negative_order_shift_is_normalized_to_zero() Assert.Contains("CHAPTER03=00:00:20.000", result.Content, StringComparison.Ordinal); Assert.DoesNotContain("CHAPTER00", result.Content, StringComparison.Ordinal); Assert.DoesNotContain("CHAPTER-01", result.Content, StringComparison.Ordinal); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "OrderShiftNormalized"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.OrderShiftNormalized); } [Fact] @@ -147,7 +185,7 @@ public void Order_shift_ignores_separator_markers() Chapters = [ new Chapter(1, TimeSpan.Zero, "A", "0"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(7), "B", "168") ] }; @@ -176,13 +214,13 @@ public void Cue_export_numbers_tracks_by_output_order() [Fact] public void Json_export_uses_mpls_source_name_and_separator_base_time() { - var info = Sample("MPLS") with + var info = Sample(ChapterImportFormat.Mpls) with { SourceName = "00001", Chapters = [ new Chapter(1, TimeSpan.FromSeconds(5), "A"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(7), "B") ] }; @@ -217,7 +255,7 @@ public void Expression_export_normalizes_negative_times_to_zero() Assert.Contains("CHAPTER01=00:00:00.000", result.Content, StringComparison.Ordinal); Assert.DoesNotContain("-", result.Content, StringComparison.Ordinal); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidExpressionTime"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidExpressionTime); } [Fact] @@ -240,11 +278,34 @@ public void Qpfile_export_uses_expression_adjusted_frames_when_enabled() Assert.StartsWith("24 I", result.Content, StringComparison.Ordinal); } - private static ChapterInfo Sample(string sourceType = "OGM") => + [Theory] + [InlineData(ChapterExportFormat.Qpfile)] + [InlineData(ChapterExportFormat.Celltimes)] + public void Frame_based_exports_return_diagnostic_for_non_finite_frame_rate(ChapterExportFormat format) + { + var result = service.Export( + Sample() with { FramesPerSecond = double.NaN }, + new ChapterExportOptions(format)); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidFrameRate); + } + + [Fact] + public void Expression_projection_returns_diagnostic_for_non_finite_frame_rate() + { + var result = service.Export( + Sample() with { FramesPerSecond = double.PositiveInfinity }, + new ChapterExportOptions(ChapterExportFormat.Txt, ApplyExpression: true, Expression: "t + 1")); + + Assert.True(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidFrameRate); + } + + private static ChapterSet Sample(ChapterImportFormat sourceType = ChapterImportFormat.Ogm) => new( "Title", "source", - 0, sourceType, 24, TimeSpan.FromSeconds(30), diff --git a/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs b/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs index 35bb75e..b967e66 100644 --- a/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Exporting/ChapterOutputProjectionServiceTests.cs @@ -1,6 +1,8 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Exporting; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; +using LuaExpressionScriptService = ChapterTool.Core.Transform.Expressions.Lua.LuaExpressionScriptService; namespace ChapterTool.Core.Tests.Exporting; @@ -23,12 +25,12 @@ public void Projection_applies_lua_before_numbering_and_names_without_mutating_s ApplyExpression: true, Expression: "t + index")); - Assert.Equal(TimeSpan.FromSeconds(10), source.Chapters[0].Time); + Assert.Equal(TimeSpan.FromSeconds(10), source.Chapters[0].StartTime); Assert.Same(originalFirst, source.Chapters[0]); Assert.Empty(result.Diagnostics); - Assert.Equal([3, 4, 5], result.OutputChapters.Select(static chapter => chapter.Number)); + Assert.Equal([3, 4, 5], result.OutputChapters.Select(static chapter => chapter.DisplayNumber)); Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03"], result.OutputChapters.Select(static chapter => chapter.Name)); - Assert.Equal([11, 22, 33], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds)); + Assert.Equal([11, 22, 33], result.OutputChapters.Select(static chapter => (int)chapter.StartTime.TotalSeconds)); Assert.Equal("264", result.OutputChapters[0].FramesInfo); Assert.True(result.OutputChapters[0].FrameAccuracy is FrameAccuracy.Accurate); } @@ -36,7 +38,7 @@ public void Projection_applies_lua_before_numbering_and_names_without_mutating_s [Fact] public void Projection_preserves_separators_and_excludes_them_from_output_chapters() { - var separator = new Chapter(0, Chapter.SeparatorTime, "---"); + var separator = Chapter.Separator("---"); var info = Sample() with { Chapters = [Sample().Chapters[0], separator, Sample().Chapters[1]] @@ -48,9 +50,9 @@ public void Projection_preserves_separators_and_excludes_them_from_output_chapte Assert.Equal(3, result.Info.Chapters.Count); Assert.True(result.Info.Chapters[1].IsSeparator); - Assert.Equal(0, result.Info.Chapters[1].Number); + Assert.Equal(0, result.Info.Chapters[1].DisplayNumber); Assert.Equal(2, result.OutputChapters.Count); - Assert.Equal([1, 2], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds)); + Assert.Equal([1, 2], result.OutputChapters.Select(static chapter => (int)chapter.StartTime.TotalSeconds)); } [Fact] @@ -65,8 +67,8 @@ public void Projection_keeps_original_time_for_failed_lua_and_continues_numberin Expression: "return bad()")); Assert.Equal(3, result.Diagnostics.Count); - Assert.All(result.Diagnostics, diagnostic => Assert.Equal("InvalidExpression.LuaRuntime", diagnostic.Code)); - Assert.Equal([10, 20, 30], result.OutputChapters.Select(static chapter => (int)chapter.Time.TotalSeconds)); + Assert.All(result.Diagnostics, diagnostic => Assert.Equal(ChapterDiagnosticCode.InvalidExpressionLuaRuntime, diagnostic.Code)); + Assert.Equal([10, 20, 30], result.OutputChapters.Select(static chapter => (int)chapter.StartTime.TotalSeconds)); Assert.Equal(["Chapter 01", "Chapter 02", "Chapter 03"], result.OutputChapters.Select(static chapter => chapter.Name)); } @@ -78,16 +80,15 @@ public void Projection_normalizes_lua_time_and_reports_diagnostic() new ChapterExportOptions(ChapterExportFormat.Txt, ApplyExpression: true, Expression: "-1")); Assert.Equal(3, result.Diagnostics.Count); - Assert.All(result.Diagnostics, diagnostic => Assert.Equal("InvalidExpressionTime", diagnostic.Code)); - Assert.All(result.OutputChapters, chapter => Assert.Equal(TimeSpan.Zero, chapter.Time)); + Assert.All(result.Diagnostics, diagnostic => Assert.Equal(ChapterDiagnosticCode.InvalidExpressionTime, diagnostic.Code)); + Assert.All(result.OutputChapters, chapter => Assert.Equal(TimeSpan.Zero, chapter.StartTime)); } - private static ChapterInfo Sample() => + private static ChapterSet Sample() => new( "Movie", "movie.mkv", - 0, - "Text", + ChapterImportFormat.Ogm, 24, TimeSpan.FromMinutes(1), [ diff --git a/tests/ChapterTool.Core.Tests/Fixtures/Transform/expression.in b/tests/ChapterTool.Core.Tests/Fixtures/Transform/UVa-12803.in similarity index 100% rename from tests/ChapterTool.Core.Tests/Fixtures/Transform/expression.in rename to tests/ChapterTool.Core.Tests/Fixtures/Transform/UVa-12803.in diff --git a/tests/ChapterTool.Core.Tests/Fixtures/Transform/expression.out b/tests/ChapterTool.Core.Tests/Fixtures/Transform/UVa-12803.out similarity index 100% rename from tests/ChapterTool.Core.Tests/Fixtures/Transform/expression.out rename to tests/ChapterTool.Core.Tests/Fixtures/Transform/UVa-12803.out diff --git a/tests/ChapterTool.Core.Tests/Importing/CueImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/CueImporterTests.cs index e0e2a0a..177ebd4 100644 --- a/tests/ChapterTool.Core.Tests/Importing/CueImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/CueImporterTests.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Text; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Exporting; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Cue; @@ -45,13 +46,13 @@ ISRC JPPI01051120 var result = await importer.ImportAsync(new ChapterImportRequest("ARCHIVES 2.cue", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("CUE", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.Cue, info.ImportFormat); Assert.Equal("ARCHIVES 2.flac", info.SourceName); Assert.Equal("とある科学の超電磁砲 ARCHIVES 2", info.Title); Assert.Equal(4, info.Chapters.Count); Assert.Equal("初色bloomy [初春飾利(豊崎愛生)]", info.Chapters[1].Name); - Assert.Equal(new TimeSpan(0, 0, 15, 19, 280), info.Chapters[1].Time); + Assert.Equal(new TimeSpan(0, 0, 15, 19, 280), info.Chapters[1].StartTime); } [Fact] @@ -63,7 +64,7 @@ public async Task CueImporterReadsNonAsciiFixtureName() TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Contains("のんのんバイオリン", result.Groups.Single().Options.Single().ChapterInfo.Chapters[0].Name, StringComparison.Ordinal); + Assert.Contains("のんのんバイオリン", result.Groups.Single().Entries.Single().ChapterSet.Chapters[0].Name, StringComparison.Ordinal); } [Fact] @@ -75,13 +76,13 @@ public async Task CueImporterReadsCopiedExampleFixture() TestContext.Current.CancellationToken); Assert.True(result.Success); - var info = result.Groups.Single().Options.Single().ChapterInfo; + var info = result.Groups.Single().Entries.Single().ChapterSet; Assert.Equal("Back To Mine", info.Title); Assert.Equal("Orbital - Back To Mine.mp3", info.SourceName); Assert.Equal(19, info.Chapters.Count); Assert.Equal("John Barry & His Orchestra - The Knack [Orbital]", info.Chapters[0].Name); Assert.Equal("Robert Mellin Orchestra - The Adventures Of Robinson Crusoe [Orbital]", info.Chapters[^1].Name); - Assert.Equal(new TimeSpan(0, 1, 11, 17, 707), info.Chapters[^1].Time); + Assert.Equal(new TimeSpan(0, 1, 11, 17, 707), info.Chapters[^1].StartTime); } [Theory] @@ -94,7 +95,7 @@ public async Task CueImporterSupportsExpectedEncodings(byte[] bytes) var result = await importer.ImportAsync(new ChapterImportRequest("encoded.cue", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("Track 1", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Track 1", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Fact] @@ -113,20 +114,20 @@ TRACK 02 AUDIO """); Assert.True(result.Success); - Assert.Equal(["Track 1", "Track 2"], result.Groups.Single().Options.Single().ChapterInfo.Chapters.Select(chapter => chapter.Name)); + Assert.Equal(["Track 1", "Track 2"], result.Groups.Single().Entries.Single().ChapterSet.Chapters.Select(chapter => chapter.Name)); } [Theory] - [InlineData("", "EmptyCueFile")] - [InlineData("TITLE \"x\"", "EmptyCueFile")] - [InlineData("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"x\"\n INDEX 02 00:00:00", "MalformedCueSyntax")] - [InlineData("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"x\"\n INDEX 01 bad", "MalformedCueSyntax")] - public void CueParserFailsEmptyOrMalformedText(string text, string code) + [InlineData("", ChapterDiagnosticSource.CueFile, ChapterDiagnosticReason.Empty)] + [InlineData("TITLE \"x\"", ChapterDiagnosticSource.CueFile, ChapterDiagnosticReason.Empty)] + [InlineData("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"x\"\n INDEX 02 00:00:00", ChapterDiagnosticSource.CueSyntax, ChapterDiagnosticReason.Malformed)] + [InlineData("FILE \"a.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"x\"\n INDEX 01 bad", ChapterDiagnosticSource.CueSyntax, ChapterDiagnosticReason.Malformed)] + public void CueParserFailsEmptyOrMalformedText(string text, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var result = CueSheetParser.Parse(text); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } [Fact] @@ -137,7 +138,7 @@ public async Task FlacImporterFailsInvalidHeader() TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidContainerHeader"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidContainerHeader); } [Fact] @@ -149,7 +150,7 @@ public async Task FlacImporterReadsVorbisCuesheetAndSkipsNativeCuesheetBlock() var result = await new FlacCueImporter().ImportAsync(new ChapterImportRequest("music.flac", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("Track 1", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Track 1", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Fact] @@ -161,7 +162,7 @@ public async Task FlacImporterAcceptsUppercaseVorbisCuesheetKeyAsIntentionalExpa var result = await new FlacCueImporter().ImportAsync(new ChapterImportRequest("music.flac", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("Track 1", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Track 1", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Fact] @@ -172,7 +173,20 @@ public async Task FlacImporterFailsMissingOrMalformedVorbisComment() var result = await new FlacCueImporter().ImportAsync(new ChapterImportRequest("music.flac", stream), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "FlacEmbeddedCueNotFound"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.FlacEmbeddedCueNotFound); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FlacImporterFailsNegativeVorbisCommentLengths(bool negativeVendorLength) + { + using var stream = new MemoryStream(CreateFlacWithNegativeVorbisLength(negativeVendorLength)); + + var result = await new FlacCueImporter().ImportAsync(new ChapterImportRequest("music.flac", stream), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.FlacEmbeddedCueNotFound); } [Fact] @@ -183,7 +197,7 @@ public async Task TakImporterFailsInvalidHeader() TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidContainerHeader"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidContainerHeader); } [Fact] @@ -196,7 +210,7 @@ public async Task TakImporterExtractsMarkerUntilTerminator() var result = await new TakCueImporter().ImportAsync(new ChapterImportRequest("music.tak", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("Track 1", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Track 1", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Fact] @@ -209,7 +223,7 @@ public async Task TakImporterFindsUppercaseMarkerAfterNonAsciiPadding() var result = await new TakCueImporter().ImportAsync(new ChapterImportRequest("music.tak", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("Track 1", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Track 1", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Fact] @@ -221,25 +235,24 @@ public async Task TakImporterFailsMissingMarkerForSmallFile() var result = await new TakCueImporter().ImportAsync(new ChapterImportRequest("music.tak", stream), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "EmbeddedCueNotFound"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.EmbeddedCueNotFound); } [Fact] public void CueExporterWritesHeaderSkipsSeparatorsAndUsesOutputOrder() { - var info = new ChapterInfo( + var info = new ChapterSet( "Album", "fallback.flac", - 0, - "CUE", + ChapterImportFormat.Cue, 0, TimeSpan.FromSeconds(90), [ new Chapter(7, TimeSpan.Zero, "A"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(3, TimeSpan.FromSeconds(65.5), "B") ]); - var exporter = new ChapterExportService(new ChapterTimeFormatter(), new ExpressionService()); + var exporter = new ChapterExportService(new ChapterTimeFormatter()); var result = exporter.Export(info, new ChapterExportOptions(ChapterExportFormat.Cue, SourceFileName: "source.wav")); @@ -255,11 +268,10 @@ public void CueExporterWritesHeaderSkipsSeparatorsAndUsesOutputOrder() [Fact] public void WebVttExporterWritesHeaderAndCuesWithEndTimes() { - var info = new ChapterInfo( + var info = new ChapterSet( "WebVTT", "video.mp4", - 0, - "WebVTT", + ChapterImportFormat.WebVtt, 0, TimeSpan.FromMinutes(2), [ @@ -267,7 +279,7 @@ public void WebVttExporterWritesHeaderAndCuesWithEndTimes() new Chapter(2, TimeSpan.FromSeconds(30), "Main Content"), new Chapter(3, TimeSpan.FromSeconds(90), "Conclusion") ]); - var exporter = new ChapterExportService(new ChapterTimeFormatter(), new ExpressionService()); + var exporter = new ChapterExportService(new ChapterTimeFormatter()); var result = exporter.Export(info, new ChapterExportOptions(ChapterExportFormat.WebVtt)); @@ -328,6 +340,26 @@ private static byte[] CreateVorbisBlock(string? cue, string cueKey) return stream.ToArray(); } + private static byte[] CreateFlacWithNegativeVorbisLength(bool negativeVendorLength) + { + using var stream = new MemoryStream(); + stream.Write("fLaC"u8); + using var block = new MemoryStream(); + if (negativeVendorLength) + { + WriteLittleEndianInt32(block, -1); + } + else + { + WriteLittleEndianInt32(block, 0); + WriteLittleEndianInt32(block, 1); + WriteLittleEndianInt32(block, -1); + } + + WriteBlock(stream, type: 4, isLast: true, block.ToArray()); + return stream.ToArray(); + } + private static void WriteVorbisComment(Stream stream, string text) { var bytes = Encoding.UTF8.GetBytes(text); diff --git a/tests/ChapterTool.Core.Tests/Importing/DiscImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/DiscImporterTests.cs index 0d175a6..b2ac74f 100644 --- a/tests/ChapterTool.Core.Tests/Importing/DiscImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/DiscImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Disc; using ChapterTool.Core.Importing.Media; @@ -85,22 +87,22 @@ public async Task MplsImporterReadsSinglePlayItemSample() TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var option = result.Groups.Single().Options.Single(); - var info = option.ChapterInfo; - Assert.Equal("MPLS", info.SourceType); + var entry = result.Groups.Single().Entries.Single(); + var info = entry.ChapterSet; + Assert.Equal(ChapterImportFormat.Mpls, info.ImportFormat); Assert.Equal("00002", info.SourceName); Assert.Equal(24, info.FramesPerSecond); Assert.Equal(46, info.Chapters.Count); - Assert.Equal(TimeSpan.Zero, info.Chapters[0].Time); - Assert.Equal(TimeSpan.FromMilliseconds(14417), info.Chapters[1].Time); + Assert.Equal(TimeSpan.Zero, info.Chapters[0].StartTime); + Assert.Equal(TimeSpan.FromMilliseconds(14417), info.Chapters[1].StartTime); Assert.Equal(MplsTimes( 0, 648750, 984375, 23799375, 27487500, 28044375, 28276875, 28918125, 29195625, 36823125, 41679375, 52321875, 56593125, 62563125, 73524375, 83199375, 95167500, 100741875, 106155000, 116420625, 120845625, 126307500, 129403125, 139273125, 141071250, 142704375, 147866250, 151578750, 157603125, 163599375, 170810625, 178768125, 186941250, 191786250, 192165000, 202076250, 213168750, 222028125, 228003750, 236915625, 244306875, 253316250, 260053125, 271863750, 284366250, 285738750), - info.Chapters.Select(chapter => chapter.Time)); - Assert.Contains(option.MediaReferences ?? [], reference => reference.RelativePath == Path.Combine("..", "STREAM", "00002.m2ts")); + info.Chapters.Select(chapter => chapter.StartTime)); + Assert.Contains(entry.ReferencedMediaFiles ?? [], reference => reference.RelativePath == Path.Combine("..", "STREAM", "00002.m2ts")); } [Fact] @@ -112,11 +114,11 @@ public async Task MplsImporterReadsFchSampleWithLegacyTimestamps() TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var info = result.Groups.Single().Options.Single().ChapterInfo; + var info = result.Groups.Single().Entries.Single().ChapterSet; Assert.Equal("00001", info.SourceName); Assert.Equal(24000d / 1001d, info.FramesPerSecond); Assert.Equal(MplsChapterImporter.PtsToTime(163027149 - 90000), info.Duration); - Assert.Equal(MplsTimes(0, 41963170, 96516418, 96831733, 98138038, 102186457, 131841081, 158573411, 162621830), info.Chapters.Select(chapter => chapter.Time)); + Assert.Equal(MplsTimes(0, 41963170, 96516418, 96831733, 98138038, 102186457, 131841081, 158573411, 162621830), info.Chapters.Select(chapter => chapter.StartTime)); } [Fact] @@ -128,13 +130,13 @@ public async Task MplsImporterReadsMultiAngleSample() TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var infos = result.Groups.Single().Options.Select(option => option.ChapterInfo).ToArray(); + var infos = result.Groups.Single().Entries.Select(entry => entry.ChapterSet).ToArray(); Assert.Equal(9, infos.Length); Assert.Equal(["00005", "00006&00007", "00008", "00009&00010", "00011", "00012", "00013&00014", "00015", "00016"], infos.Select(info => info.SourceName)); Assert.All(infos, info => Assert.Equal(24000d / 1001d, info.FramesPerSecond)); - Assert.Equal(TimeSpan.Zero, infos[1].Chapters.Single().Time); - Assert.Equal(MplsTimes(0, 20609964), infos[2].Chapters.Select(chapter => chapter.Time)); - Assert.Equal(MplsTimes(0, 4185431, 8233850, 23263865), infos[5].Chapters.Select(chapter => chapter.Time)); + Assert.Equal(TimeSpan.Zero, infos[1].Chapters.Single().StartTime); + Assert.Equal(MplsTimes(0, 20609964), infos[2].Chapters.Select(chapter => chapter.StartTime)); + Assert.Equal(MplsTimes(0, 4185431, 8233850, 23263865), infos[5].Chapters.Select(chapter => chapter.StartTime)); } [Fact] @@ -146,7 +148,7 @@ public async Task MplsImporterRejectsInvalidHeader() var result = await importer.ImportAsync(new ChapterImportRequest("bad.mpls", stream), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidMpls"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidMpls); } [Fact] @@ -158,14 +160,14 @@ public async Task IfoImporterReadsExistingSample() TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var option = result.Groups.Single().Options.Single(); - var info = option.ChapterInfo; - Assert.Equal("DVD", info.SourceType); + var entry = result.Groups.Single().Entries.Single(); + var info = entry.ChapterSet; + Assert.Equal(ChapterImportFormat.DvdIfo, info.ImportFormat); Assert.Equal("VTS_05_1", info.SourceName); Assert.Equal(7, info.Chapters.Count); Assert.Equal("Chapter 07", info.Chapters[6].Name); - Assert.Equal("01:49:12.679", new ChapterTimeFormatter().Format(info.Chapters[6].Time)); - Assert.Contains(option.MediaReferences ?? [], reference => reference.RelativePath == "VTS_05_1.VOB"); + Assert.Equal("01:49:12.679", new ChapterTimeFormatter().Format(info.Chapters[6].StartTime)); + Assert.Contains(entry.ReferencedMediaFiles ?? [], reference => reference.RelativePath == "VTS_05_1.VOB"); } [Fact] @@ -241,7 +243,7 @@ public async Task IfoImporterRejectsInvalidStructure() { var result = await importer.ImportAsync(new ChapterImportRequest(path), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidIfo"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidIfo); } finally { @@ -270,12 +272,12 @@ public async Task XplImporterReadsSyntheticTitle() var result = await importer.ImportAsync(new ChapterImportRequest("movie.xpl", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("HD-DVD", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.HdDvdXpl, info.ImportFormat); Assert.Equal("Main", info.Title); Assert.Equal("ADV_OBJ/main.evo", info.SourceName); - Assert.Equal(TimeSpan.FromSeconds(60.5), info.Chapters[1].Time); - Assert.Contains(result.Groups.Single().Options.Single().MediaReferences ?? [], reference => reference.RelativePath == Path.Combine("..", "HVDVD_TS", "main.evo")); + Assert.Equal(TimeSpan.FromSeconds(60.5), info.Chapters[1].StartTime); + Assert.Contains(result.Groups.Single().Entries.Single().ReferencedMediaFiles ?? [], reference => reference.RelativePath == Path.Combine("..", "HVDVD_TS", "main.evo")); } [Fact] @@ -303,15 +305,15 @@ public async Task XplImporterPreservesLegacyDefaultsAndNamePrecedence() var result = await importer.ImportAsync(new ChapterImportRequest("movie.xpl", stream), TestContext.Current.CancellationToken); Assert.True(result.Success); - var infos = result.Groups.Single().Options.Select(option => option.ChapterInfo).ToArray(); + var infos = result.Groups.Single().Entries.Select(entry => entry.ChapterSet).ToArray(); Assert.Equal(2, infos.Length); Assert.Equal("Display Title", infos[0].Title); Assert.Equal("Display Chapter", infos[0].Chapters.Single().Name); - Assert.Equal(TimeSpan.FromSeconds(1.5), infos[0].Chapters.Single().Time); + Assert.Equal(TimeSpan.FromSeconds(1.5), infos[0].Chapters.Single().StartTime); Assert.Equal(TimeSpan.FromSeconds(10.5), infos[0].Duration); Assert.Equal("Second Title", infos[1].Title); Assert.Equal("Second Chapter", infos[1].Chapters.Single().Name); - Assert.Equal(TimeSpan.FromSeconds(2), infos[1].Chapters.Single().Time); + Assert.Equal(TimeSpan.FromSeconds(2), infos[1].Chapters.Single().StartTime); } [Theory] @@ -326,7 +328,7 @@ public async Task XplImporterDiagnosesMalformedXml(string xml) var result = await importer.ImportAsync(new ChapterImportRequest("bad.xpl", stream), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code is "XplParseFailed" or "XplNoChapters"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.XplParseFailed || diagnostic.Code == ChapterDiagnosticCode.XplNoChapters); } private static TimeSpan[] MplsTimes(params uint[] ptsOffsets) => diff --git a/tests/ChapterTool.Core.Tests/Importing/IfoImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/IfoImporterTests.cs index eafee7d..aef2b9f 100644 --- a/tests/ChapterTool.Core.Tests/Importing/IfoImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/IfoImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Disc; using ChapterTool.Core.Transform; @@ -18,8 +20,8 @@ public async Task Vts05SampleMatchesLegacyChapterTimes() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Select(static option => option.ChapterInfo).First(); - Assert.Equal("DVD", info.SourceType); + var info = result.Groups.Single().Entries.Select(static entry => entry.ChapterSet).First(); + Assert.Equal(ChapterImportFormat.DvdIfo, info.ImportFormat); Assert.Equal("VTS_05_1", info.SourceName); Assert.Equal( [ @@ -31,7 +33,7 @@ public async Task Vts05SampleMatchesLegacyChapterTimes() "01:32:31.813", "01:49:12.679" ], - info.Chapters.Select(chapter => formatter.Format(chapter.Time))); + info.Chapters.Select(chapter => formatter.Format(chapter.StartTime))); } [Fact] @@ -44,7 +46,7 @@ public async Task Vts33SampleImportsHighTitleNumber() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var infos = result.Groups.Single().Options.Select(static option => option.ChapterInfo).ToArray(); + var infos = result.Groups.Single().Entries.Select(static entry => entry.ChapterSet).ToArray(); Assert.Equal(3, infos.Length); Assert.All(infos, info => { @@ -52,8 +54,8 @@ public async Task Vts33SampleImportsHighTitleNumber() Assert.Equal(47, info.Chapters.Count); Assert.Equal(25, info.FramesPerSecond); Assert.Equal(TimeSpan.FromMilliseconds(1411200), info.Duration); - Assert.Equal(TimeSpan.Zero, info.Chapters[0].Time); - Assert.Equal(TimeSpan.FromMinutes(23), info.Chapters[^1].Time); + Assert.Equal(TimeSpan.Zero, info.Chapters[0].StartTime); + Assert.Equal(TimeSpan.FromMinutes(23), info.Chapters[^1].StartTime); }); Assert.Equal(["VTS_33_1", "VTS_33_2", "VTS_33_3"], infos.Select(static info => info.SourceName)); } @@ -70,15 +72,15 @@ public async Task ImportAsyncReadsRequestContentStream() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Select(static option => option.ChapterInfo).First(); + var info = result.Groups.Single().Entries.Select(static entry => entry.ChapterSet).First(); Assert.Equal("VTS_05_1", info.SourceName); Assert.Equal(7, info.Chapters.Count); } [Theory] - [InlineData("NULL.IFO", "NoChaptersFound")] - [InlineData("OUT_OF_RANGE.IFO", "InvalidIfo")] - public async Task InvalidIfoSamplesReturnExpectedDiagnostics(string fileName, string expectedCode) + [InlineData("NULL.IFO", ChapterDiagnosticSource.Chapters, ChapterDiagnosticReason.NotFound)] + [InlineData("OUT_OF_RANGE.IFO", ChapterDiagnosticSource.Ifo, ChapterDiagnosticReason.Invalid)] + public async Task InvalidIfoSamplesReturnExpectedDiagnostics(string fileName, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var importer = new IfoChapterImporter(); @@ -87,7 +89,7 @@ public async Task InvalidIfoSamplesReturnExpectedDiagnostics(string fileName, st TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == expectedCode); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } private static string Diagnostics(ChapterImportResult result) => diff --git a/tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs index a8b1469..0853fc0 100644 --- a/tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Media; @@ -17,13 +19,13 @@ public async Task ImportAsyncMapsOrderedStartsEndsDurationAndUnicodeTitles() var result = await importer.ImportAsync(new ChapterImportRequest("movie.mp4"), TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("MEDIA", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.Media, info.ImportFormat); Assert.Equal(0, info.FramesPerSecond); Assert.Equal(TimeSpan.FromSeconds(30), info.Duration); Assert.Equal(["Chapter 01", "Chapter 02", "章节 03", "Chapter 04"], info.Chapters.Select(static chapter => chapter.Name)); - Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(20)], info.Chapters.Select(static chapter => chapter.Time)); - Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(30)], info.Chapters.Select(static chapter => chapter.End)); + Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(20)], info.Chapters.Select(static chapter => chapter.StartTime)); + Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(30)], info.Chapters.Select(static chapter => chapter.EndTime)); } [Fact] @@ -38,8 +40,8 @@ public async Task ImportAsyncFallsBackToTimeBaseAndTitleFallbacks() var result = await importer.ImportAsync(new ChapterImportRequest("audio.ogg"), TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; - Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(35)], chapters.Select(static chapter => chapter.Time)); + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; + Assert.Equal([TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(35)], chapters.Select(static chapter => chapter.StartTime)); Assert.Equal(["Start with decimals, end with decimals", "Time base fallback only", "Uppercase TITLE tag", "Chapter 04"], chapters.Select(static chapter => chapter.Name)); } @@ -55,12 +57,12 @@ public async Task ImportAsyncPreservesMissingAndNonContiguousEnds() var result = await importer.ImportAsync(new ChapterImportRequest("movie.nut"), TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; - Assert.Equal(TimeSpan.FromSeconds(8), chapters[0].End); - Assert.Equal(TimeSpan.FromSeconds(15), chapters[1].End); - Assert.Null(chapters[2].End); - Assert.Null(chapters[3].End); - Assert.Equal(TimeSpan.FromSeconds(15), result.Groups.Single().Options.Single().ChapterInfo.Duration); + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; + Assert.Equal(TimeSpan.FromSeconds(8), chapters[0].EndTime); + Assert.Equal(TimeSpan.FromSeconds(15), chapters[1].EndTime); + Assert.Null(chapters[2].EndTime); + Assert.Null(chapters[3].EndTime); + Assert.Equal(TimeSpan.FromSeconds(15), result.Groups.Single().Entries.Single().ChapterSet.Duration); } [Fact] @@ -71,7 +73,7 @@ public async Task ImportAsyncFailsEmptyChapterOutput() var result = await importer.ImportAsync(new ChapterImportRequest("empty.wav"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "NoChaptersFound"); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.NoChaptersFound); } [Fact] @@ -84,7 +86,20 @@ public async Task ImportAsyncSkipsInvalidStartsAndFailsWhenNoneRemain() var result = await importer.ImportAsync(new ChapterImportRequest("bad.mp3"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "InvalidChapterTimestamp"); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidChapterTimestamp); + } + + [Fact] + public async Task ImportAsyncSkipsTimestampsBeyondTimeSpanRange() + { + var importer = CreateImporter( + Entry(0, 0, "1/1000", 0, 1000, "999999999999999999999", "1000000000000000000000", ("title", "Huge decimal")), + Entry(1, 1, "999999999999999999999/1", long.MaxValue, long.MaxValue, null, null, ("title", "Huge time base"))); + + var result = await importer.ImportAsync(new ChapterImportRequest("huge.mp4"), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidChapterTimestamp); } [Fact] @@ -99,12 +114,12 @@ public async Task ImportAsyncGroupsEditionsByEditionUidWithUntaggedLast() var result = await importer.ImportAsync(new ChapterImportRequest("movie.mkv"), TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(["edition-0", "edition-1"], options.Select(static option => option.Id)); - Assert.Equal(["Edition 01", "Edition 02"], options.Select(static option => option.DisplayName)); - Assert.All(options, static option => Assert.False(option.CanCombine)); - Assert.Equal(["Tagged Chapter 1", "Tagged Chapter 2"], options[0].ChapterInfo.Chapters.Select(static chapter => chapter.Name)); - Assert.Equal(["Untagged Chapter 1", "Untagged Chapter 2"], options[1].ChapterInfo.Chapters.Select(static chapter => chapter.Name)); + var entries = result.Groups.Single().Entries; + Assert.Equal(["edition-0", "edition-1"], entries.Select(static entry => entry.Id)); + Assert.Equal(["Edition 01", "Edition 02"], entries.Select(static entry => entry.DisplayName)); + Assert.All(entries, static entry => Assert.False(entry.CanCombine)); + Assert.Equal(["Tagged Chapter 1", "Tagged Chapter 2"], entries[0].ChapterSet.Chapters.Select(static chapter => chapter.Name)); + Assert.Equal(["Untagged Chapter 1", "Untagged Chapter 2"], entries[1].ChapterSet.Chapters.Select(static chapter => chapter.Name)); } private static MediaChapterImporter CreateImporter(params MediaChapterEntry[] entries) => diff --git a/tests/ChapterTool.Core.Tests/Importing/MplsImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/MplsImporterTests.cs index cd0833c..a6d554e 100644 --- a/tests/ChapterTool.Core.Tests/Importing/MplsImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/MplsImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Disc; @@ -16,21 +18,21 @@ public async Task SampleMplsFilesMatchExpectedPlaylistShape(SampleExpectation sa TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(sample.ExpectedOptionCount, options.Count); + var entries = result.Groups.Single().Entries; + Assert.Equal(sample.ExpectedOptionCount, entries.Count); for (var i = 0; i < sample.ExpectedOptions.Length; i++) { var expected = sample.ExpectedOptions[i]; - var actual = options[i]; - var info = actual.ChapterInfo; - Assert.Equal("MPLS", info.SourceType); + var actual = entries[i]; + var info = actual.ChapterSet; + Assert.Equal(ChapterImportFormat.Mpls, info.ImportFormat); Assert.Equal(expected.SourceName, info.SourceName); Assert.Equal(expected.ChapterCount, info.Chapters.Count); Assert.Equal(expected.FramesPerSecond, info.FramesPerSecond, precision: 3); Assert.Equal(expected.Duration, info.Duration); - Assert.Equal(expected.FirstTime, info.Chapters[0].Time); - Assert.Equal(expected.LastTime, info.Chapters[^1].Time); - Assert.Equal(expected.MediaReferenceCount, actual.MediaReferences?.Count ?? 0); + Assert.Equal(expected.FirstTime, info.Chapters[0].StartTime); + Assert.Equal(expected.LastTime, info.Chapters[^1].StartTime); + Assert.Equal(expected.MediaReferenceCount, actual.ReferencedMediaFiles?.Count ?? 0); } } @@ -44,7 +46,19 @@ public async Task InvalidSampleMplsReturnsInvalidMplsDiagnostic() TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidMpls"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidMpls); + } + + [Fact] + public async Task InvalidExtensionDataAddressReturnsInvalidMplsDiagnostic() + { + var importer = new MplsChapterImporter(); + using var stream = new MemoryStream(MplsWithInvalidExtensionDataAddress()); + + var result = await importer.ImportAsync(new ChapterImportRequest("bad-extension.mpls", stream), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidMpls); } public static TheoryData<SampleExpectation> SampleExpectations() => @@ -216,4 +230,53 @@ private static SampleExpectation Sample(string fileName, int expectedOptionCount private static string Diagnostics(ChapterImportResult result) => string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}")); + + private static byte[] MplsWithInvalidExtensionDataAddress() + { + using var stream = new MemoryStream(); + stream.Write("MPLS"u8); + stream.Write("0200"u8); + WriteUInt32BigEndian(stream, 50); + WriteUInt32BigEndian(stream, 70); + WriteUInt32BigEndian(stream, 82); + stream.Write(new byte[20]); + + WriteUInt32BigEndian(stream, 14); + stream.WriteByte(0); + stream.WriteByte(0); + WriteUInt16BigEndian(stream, 0); + stream.Write(new byte[8]); + WriteUInt16BigEndian(stream, 0); + + stream.Position = 50; + WriteUInt32BigEndian(stream, 6); + WriteUInt16BigEndian(stream, 0); + WriteUInt16BigEndian(stream, 0); + WriteUInt16BigEndian(stream, 0); + + stream.Position = 70; + WriteUInt32BigEndian(stream, 2); + WriteUInt16BigEndian(stream, 0); + + stream.Position = 82; + WriteUInt32BigEndian(stream, 4); + WriteUInt32BigEndian(stream, 8); + stream.Write(new byte[3]); + stream.WriteByte(0); + return stream.ToArray(); + } + + private static void WriteUInt16BigEndian(Stream stream, ushort value) + { + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteUInt32BigEndian(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } } diff --git a/tests/ChapterTool.Core.Tests/Importing/TextImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/TextImporterTests.cs index efbb6dd..f5c846b 100644 --- a/tests/ChapterTool.Core.Tests/Importing/TextImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/TextImporterTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Models; using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Text; @@ -39,10 +40,10 @@ public async Task OgmImporterReadsExistingSampleLeniently() Assert.True(result.Success); Assert.True(result.IsPartial); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "PartialParse"); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.PartialParse); + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(6, chapters.Count); - Assert.Equal(TimeSpan.Zero, chapters[0].Time); + Assert.Equal(TimeSpan.Zero, chapters[0].StartTime); Assert.Equal("Chapter 06", chapters[5].Name); } @@ -58,9 +59,9 @@ public async Task OgmImporterNormalizesFirstTimestamp() CHAPTER02NAME=Middle """); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; - Assert.Equal(TimeSpan.Zero, chapters[0].Time); - Assert.Equal(TimeSpan.FromSeconds(30), chapters[1].Time); + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; + Assert.Equal(TimeSpan.Zero, chapters[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(30), chapters[1].StartTime); } [Fact] @@ -70,7 +71,7 @@ public async Task OgmImporterFailsInvalidFirstLine() var result = importer.ImportText("CHAPTER01NAME=Intro"); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "OgmInvalidFirstLine"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.OgmInvalidFirstLine); } [Fact] @@ -86,7 +87,7 @@ public async Task OgmImporterReturnsPartialAfterMalformedLaterContent() Assert.True(result.Success); Assert.True(result.IsPartial); - Assert.Single(result.Groups.Single().Options.Single().ChapterInfo.Chapters); + Assert.Single(result.Groups.Single().Entries.Single().ChapterSet.Chapters); Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Severity == DiagnosticSeverity.Warning); } @@ -103,8 +104,8 @@ public void TextImporterFallsBackToOgmForNonPremiereTxt() """); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("OGM", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.Ogm, info.ImportFormat); Assert.Equal(["Intro", "Main"], info.Chapters.Select(static chapter => chapter.Name)); } @@ -121,14 +122,14 @@ public void PremiereImporterReadsTabSeparatedMarkerListAndIgnoresNonChapterRows( var result = importer.ImportText(content, "markers.txt"); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("Adobe Premiere Pro", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.PremiereMarkers, info.ImportFormat); Assert.Equal("markers.txt", info.SourceName); Assert.Equal(2, info.Chapters.Count); Assert.Equal("Intro", info.Chapters[0].Name); - Assert.Equal(TimeSpan.Zero, info.Chapters[0].Time); + Assert.Equal(TimeSpan.Zero, info.Chapters[0].StartTime); Assert.Equal("Part A", info.Chapters[1].Name); - Assert.Equal(TimeSpan.FromMilliseconds(83456), info.Chapters[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(83456), info.Chapters[1].StartTime); } [Fact] @@ -142,9 +143,9 @@ public void PremiereImporterUsesCommentAsFallbackNameForCsv() var result = importer.ImportText(content); Assert.True(result.Success, Diagnostics(result)); - var chapter = result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single(); + var chapter = result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single(); Assert.Equal("Scene 02", chapter.Name); - Assert.Equal(TimeSpan.FromMilliseconds(12345), chapter.Time); + Assert.Equal(TimeSpan.FromMilliseconds(12345), chapter.StartTime); } [Fact] @@ -158,9 +159,9 @@ public void PremiereImporterHandlesQuotedCommaAndFrameTime() var result = importer.ImportText(content); Assert.True(result.Success, Diagnostics(result)); - var chapter = result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single(); + var chapter = result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single(); Assert.Equal("Act \"One, Start\"", chapter.Name); - Assert.Equal(TimeSpan.FromSeconds(10) + TimeSpan.FromTicks((long)Math.Round(12 * TimeSpan.TicksPerSecond / 23.976M)), chapter.Time); + Assert.Equal(TimeSpan.FromSeconds(10) + TimeSpan.FromTicks((long)Math.Round(12 * TimeSpan.TicksPerSecond / 23.976M)), chapter.StartTime); } [Fact] @@ -174,22 +175,22 @@ public void TextImporterDetectsPremiereTxtBeforeOgmFallback() var result = importer.ImportText(content, "markers.txt"); Assert.True(result.Success, Diagnostics(result)); - var info = result.Groups.Single().Options.Single().ChapterInfo; - Assert.Equal("Adobe Premiere Pro", info.SourceType); + var info = result.Groups.Single().Entries.Single().ChapterSet; + Assert.Equal(ChapterImportFormat.PremiereMarkers, info.ImportFormat); Assert.Equal("Intro", info.Chapters.Single().Name); } [Theory] - [InlineData("Marker Name,Comment\r\nIntro,Missing time", "PremiereMarkerListInvalid")] - [InlineData("Marker Name,In,Marker Type\r\nNote,not-time,Chapter", "PremiereMarkerListInvalid")] - public void PremiereImporterFailsInvalidMarkerLists(string text, string code) + [InlineData("Marker Name,Comment\r\nIntro,Missing time", ChapterDiagnosticSource.PremiereMarkerList, ChapterDiagnosticReason.Invalid)] + [InlineData("Marker Name,In,Marker Type\r\nNote,not-time,Chapter", ChapterDiagnosticSource.PremiereMarkerList, ChapterDiagnosticReason.Invalid)] + public void PremiereImporterFailsInvalidMarkerLists(string text, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var importer = new PremiereMarkerListImporter(formatter); var result = importer.ImportText(text); Assert.False(result.Success); Assert.Empty(result.Groups); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } [Fact] @@ -205,8 +206,8 @@ public void OgmImporterReturnsPartialAfterDanglingTime() Assert.True(result.Success); Assert.True(result.IsPartial); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "PartialParse"); - Assert.Single(result.Groups.Single().Options.Single().ChapterInfo.Chapters); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.PartialParse); + Assert.Single(result.Groups.Single().Entries.Single().ChapterSet.Chapters); } [Fact] @@ -247,13 +248,13 @@ The Colossus of Rhodes var result = WebVttChapterImporter.ImportText(vttText); Assert.True(result.Success); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(7, chapters.Count); Assert.Equal("Introduction", chapters[0].Name); - Assert.Equal(TimeSpan.FromMilliseconds(28206), chapters[1].Time); - Assert.Equal(TimeSpan.FromSeconds(26), chapters[0].End); - Assert.Equal(TimeSpan.FromMilliseconds(547500), chapters[^1].End); - Assert.Equal(TimeSpan.FromMilliseconds(547500), result.Groups.Single().Options.Single().ChapterInfo.Duration); + Assert.Equal(TimeSpan.FromMilliseconds(28206), chapters[1].StartTime); + Assert.Equal(TimeSpan.FromSeconds(26), chapters[0].EndTime); + Assert.Equal(TimeSpan.FromMilliseconds(547500), chapters[^1].EndTime); + Assert.Equal(TimeSpan.FromMilliseconds(547500), result.Groups.Single().Entries.Single().ChapterSet.Duration); } [Fact] @@ -269,21 +270,21 @@ public async Task WebVttImporterSkipsCueIds() """); Assert.True(result.Success); - Assert.Equal("Intro", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("Intro", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Theory] - [InlineData("BAD\n\n00:00:00.000 --> 00:00:01.000\nIntro", "WebVttInvalidHeader")] - [InlineData("WEBVTT\n\nbad timing\nIntro", "WebVttMalformedCue")] - [InlineData("WEBVTT\n\n00:00:00.000 --> 00:00:01.000 align:start\nIntro", "WebVttUnsupportedTimingSettings")] - public async Task WebVttImporterFailsMalformedInput(string text, string code) + [InlineData("BAD\n\n00:00:00.000 --> 00:00:01.000\nIntro", ChapterDiagnosticSource.WebVttHeader, ChapterDiagnosticReason.Invalid)] + [InlineData("WEBVTT\n\nbad timing\nIntro", ChapterDiagnosticSource.WebVttCue, ChapterDiagnosticReason.Malformed)] + [InlineData("WEBVTT\n\n00:00:00.000 --> 00:00:01.000 align:start\nIntro", ChapterDiagnosticSource.WebVttTimingSettings, ChapterDiagnosticReason.Unsupported)] + public async Task WebVttImporterFailsMalformedInput(string text, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var importer = new WebVttChapterImporter(); var result = WebVttChapterImporter.ImportText(text); Assert.False(result.Success); Assert.Empty(result.Groups); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } [Fact] @@ -309,9 +310,9 @@ public async Task XmlImporterCreatesOneOptionPerEdition() """); Assert.True(result.Success); - Assert.Equal(2, result.Groups.Single().Options.Count); - Assert.Equal("Edition 1", result.Groups.Single().Options[0].ChapterInfo.Chapters.Single().Name); - Assert.Equal("Edition 2", result.Groups.Single().Options[1].ChapterInfo.Chapters.Single().Name); + Assert.Equal(2, result.Groups.Single().Entries.Count); + Assert.Equal("Edition 1", result.Groups.Single().Entries[0].ChapterSet.Chapters.Single().Name); + Assert.Equal("Edition 2", result.Groups.Single().Entries[1].ChapterSet.Chapters.Single().Name); } [Fact] @@ -336,10 +337,10 @@ public async Task XmlImporterFlattensNestedAtomsAndPreservesEndMetadata() """); Assert.True(result.Success); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(["Parent", "Child"], chapters.Select(chapter => chapter.Name)); - Assert.Equal(TimeSpan.FromSeconds(30), chapters[0].End); - Assert.Null(chapters[1].End); + Assert.Equal(TimeSpan.FromSeconds(30), chapters[0].EndTime); + Assert.Null(chapters[1].EndTime); } [Fact] @@ -362,21 +363,21 @@ public async Task XmlImporterRemovesAdjacentDuplicateTimes() </Chapters> """); - var chapter = result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single(); + var chapter = result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single(); Assert.Equal("Kept", chapter.Name); - Assert.Equal(1, chapter.Number); + Assert.Equal(1, chapter.DisplayNumber); } [Theory] - [InlineData("<NotChapters />", "XmlInvalidRoot")] - [InlineData("<Chapters><EditionEntry><ChapterAtom><ChapterDisplay><ChapterString>No Time</ChapterString></ChapterDisplay></ChapterAtom></EditionEntry></Chapters>", "XmlNoChapters")] - public async Task XmlImporterFailsInvalidDocuments(string xml, string code) + [InlineData("<NotChapters />", ChapterDiagnosticSource.XmlRoot, ChapterDiagnosticReason.Invalid)] + [InlineData("<Chapters><EditionEntry><ChapterAtom><ChapterDisplay><ChapterString>No Time</ChapterString></ChapterDisplay></ChapterAtom></EditionEntry></Chapters>", ChapterDiagnosticSource.XmlChapters, ChapterDiagnosticReason.None)] + public async Task XmlImporterFailsInvalidDocuments(string xml, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var importer = new XmlChapterImporter(formatter); var result = importer.ImportText(xml); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } [Fact] @@ -389,11 +390,11 @@ public async Task XmlLiveSamplePreservesUtf8ChapterNames() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(30, chapters.Count); Assert.Equal("01 High-energy Particle", chapters[0].Name); Assert.Contains(chapters, chapter => chapter.Name.Contains("カゲロウデイズ", StringComparison.Ordinal)); - Assert.Equal(TimeSpan.FromMilliseconds(6789383), chapters[^1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(6789383), chapters[^1].StartTime); } [Fact] @@ -406,10 +407,10 @@ public async Task XmlChapter25SampleMatchesDeduplicatedLegacyOutput() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(17, chapters.Count); Assert.Equal("Chapter 01", chapters[0].Name.Trim()); - Assert.Equal(TimeSpan.FromMilliseconds(267080), chapters[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(267080), chapters[1].StartTime); } [Fact] @@ -472,12 +473,12 @@ public async Task XmlHiddenChapterSampleImportsBothEditionsWithEndTimes() var result = importer.ImportText(xmlText); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(2, options.Count); - Assert.Equal(["Intro", "Act 1", "Act 2", "Credits"], options[0].ChapterInfo.Chapters.Select(static chapter => chapter.Name)); - Assert.Equal(TimeSpan.FromMinutes(2), options[1].ChapterInfo.Chapters.Single().Time); - Assert.Equal(TimeSpan.FromMinutes(4), options[1].ChapterInfo.Chapters.Single().End); - Assert.Equal("A hidden and not enabled chapter.", options[1].ChapterInfo.Chapters.Single().Name); + var entries = result.Groups.Single().Entries; + Assert.Equal(2, entries.Count); + Assert.Equal(["Intro", "Act 1", "Act 2", "Credits"], entries[0].ChapterSet.Chapters.Select(static chapter => chapter.Name)); + Assert.Equal(TimeSpan.FromMinutes(2), entries[1].ChapterSet.Chapters.Single().StartTime); + Assert.Equal(TimeSpan.FromMinutes(4), entries[1].ChapterSet.Chapters.Single().EndTime); + Assert.Equal("A hidden and not enabled chapter.", entries[1].ChapterSet.Chapters.Single().Name); } [Fact] @@ -490,13 +491,13 @@ public async Task XmlNestedChapterSampleFlattensSubChapters() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Equal(6, chapters.Count); Assert.Equal("Ouvertüre", chapters[0].Name); - Assert.Equal(TimeSpan.Zero, chapters[0].Time); - Assert.Equal(TimeSpan.FromMinutes(6).Add(TimeSpan.FromSeconds(24)), chapters[0].End); + Assert.Equal(TimeSpan.Zero, chapters[0].StartTime); + Assert.Equal(TimeSpan.FromMinutes(6).Add(TimeSpan.FromSeconds(24)), chapters[0].EndTime); Assert.Equal("Dialog: Er erwacht!", chapters[^1].Name); - Assert.Equal(TimeSpan.FromMinutes(27).Add(TimeSpan.FromSeconds(27)), chapters[^1].Time); + Assert.Equal(TimeSpan.FromMinutes(27).Add(TimeSpan.FromSeconds(27)), chapters[^1].StartTime); } [Fact] @@ -509,12 +510,12 @@ public async Task XmlImporterPreservesIso88591EncodingFromDeclaration() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var chapters = result.Groups.Single().Options.Single().ChapterInfo.Chapters; + var chapters = result.Groups.Single().Entries.Single().ChapterSet.Chapters; Assert.Contains(chapters, c => c.Name.Contains("Schätzchen", StringComparison.Ordinal)); } [Fact] - public async Task XmlImporterSetsDefaultOptionIndexFromEditionFlagDefault() + public async Task XmlImporterSetsDefaultEntryIndexFromEditionFlagDefault() { var importer = new XmlChapterImporter(formatter); var xml = """ @@ -541,9 +542,41 @@ public async Task XmlImporterSetsDefaultOptionIndexFromEditionFlagDefault() Assert.True(result.Success, Diagnostics(result)); var group = result.Groups.Single(); - Assert.Equal(2, group.Options.Count); - Assert.Equal(1, group.DefaultOptionIndex); - Assert.Equal("Default Edition", group.Options[1].ChapterInfo.Chapters.Single().Name); + Assert.Equal(2, group.Entries.Count); + Assert.Equal(1, group.DefaultEntryIndex); + Assert.Equal("Default Edition", group.Entries[1].ChapterSet.Chapters.Single().Name); + } + + [Fact] + public async Task XmlImporterKeepsFirstDefaultEditionWhenMultipleEditionsAreDefault() + { + var importer = new XmlChapterImporter(formatter); + var xml = """ + <?xml version="1.0" encoding="UTF-8"?> + <Chapters> + <EditionEntry> + <EditionFlagDefault>1</EditionFlagDefault> + <ChapterAtom> + <ChapterTimeStart>00:00:00.000000000</ChapterTimeStart> + <ChapterDisplay><ChapterString>First Default</ChapterString></ChapterDisplay> + </ChapterAtom> + </EditionEntry> + <EditionEntry> + <EditionFlagDefault>1</EditionFlagDefault> + <ChapterAtom> + <ChapterTimeStart>00:00:10.000000000</ChapterTimeStart> + <ChapterDisplay><ChapterString>Second Default</ChapterString></ChapterDisplay> + </ChapterAtom> + </EditionEntry> + </Chapters> + """; + + var result = importer.ImportText(xml); + + Assert.True(result.Success, Diagnostics(result)); + var group = result.Groups.Single(); + Assert.Equal(0, group.DefaultEntryIndex); + Assert.Equal("First Default", group.Entries[group.DefaultEntryIndex].ChapterSet.Chapters.Single().Name); } [Fact] @@ -556,10 +589,10 @@ public async Task XmlFourEditionLegacySampleCreatesOneOptionPerEdition() TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(4, options.Count); - Assert.All(options, option => Assert.NotEmpty(option.ChapterInfo.Chapters)); - Assert.Equal("Prologue", options[0].ChapterInfo.Chapters[0].Name); + var entries = result.Groups.Single().Entries; + Assert.Equal(4, entries.Count); + Assert.All(entries, entry => Assert.NotEmpty(entry.ChapterSet.Chapters)); + Assert.Equal("Prologue", entries[0].ChapterSet.Chapters[0].Name); } [Theory] @@ -573,18 +606,18 @@ public async Task AdditionalXmlSamplesMatchExpectedEditionShape(XmlSampleExpecta TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(sample.Options.Length, options.Count); - for (var i = 0; i < sample.Options.Length; i++) + var entries = result.Groups.Single().Entries; + Assert.Equal(sample.Entries.Length, entries.Count); + for (var i = 0; i < sample.Entries.Length; i++) { - var expected = sample.Options[i]; - var chapters = options[i].ChapterInfo.Chapters; + var expected = sample.Entries[i]; + var chapters = entries[i].ChapterSet.Chapters; Assert.Equal(expected.ChapterCount, chapters.Count); - Assert.Equal(expected.Duration, options[i].ChapterInfo.Duration); + Assert.Equal(expected.Duration, entries[i].ChapterSet.Duration); Assert.Equal(expected.FirstName, chapters[0].Name); - Assert.Equal(expected.FirstTime, chapters[0].Time); + Assert.Equal(expected.FirstTime, chapters[0].StartTime); Assert.Equal(expected.LastName, chapters[^1].Name); - Assert.Equal(expected.LastTime, chapters[^1].Time); + Assert.Equal(expected.LastTime, chapters[^1].StartTime); } } @@ -598,7 +631,7 @@ public static TheoryData<XmlSampleExpectation> XmlSampleExpectations() => XmlSample("Angel Beats! - NCOP_Ordered_Chapter.xml", AngelBeatsEditions()) ]; - public sealed record XmlSampleExpectation(string FileName, XmlOptionExpectation[] Options); + public sealed record XmlSampleExpectation(string FileName, XmlOptionExpectation[] Entries); public sealed record XmlOptionExpectation( int ChapterCount, @@ -613,8 +646,8 @@ private static XmlOptionExpectation[] AngelBeatsEditions() => .Select(static _ => new XmlOptionExpectation(3, Ms(59601), "Part A", TimeSpan.Zero, "Part C", Ms(59601))) .ToArray(); - private static XmlSampleExpectation XmlSample(string fileName, XmlOptionExpectation[] options) => - new(fileName, options); + private static XmlSampleExpectation XmlSample(string fileName, XmlOptionExpectation[] entries) => + new(fileName, entries); private static TimeSpan Ms(int milliseconds) => TimeSpan.FromMilliseconds(milliseconds); diff --git a/tests/ChapterTool.Core.Tests/Importing/XplImporterTests.cs b/tests/ChapterTool.Core.Tests/Importing/XplImporterTests.cs index 578b344..e47df79 100644 --- a/tests/ChapterTool.Core.Tests/Importing/XplImporterTests.cs +++ b/tests/ChapterTool.Core.Tests/Importing/XplImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Disc; @@ -16,21 +18,21 @@ public async Task SampleXplFilesMatchExpectedTitleShape(SampleExpectation sample TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var options = result.Groups.Single().Options; - Assert.Equal(sample.Options.Length, options.Count); - for (var i = 0; i < sample.Options.Length; i++) + var entries = result.Groups.Single().Entries; + Assert.Equal(sample.Entries.Length, entries.Count); + for (var i = 0; i < sample.Entries.Length; i++) { - var expected = sample.Options[i]; - var actual = options[i].ChapterInfo; - Assert.Equal("HD-DVD", actual.SourceType); + var expected = sample.Entries[i]; + var actual = entries[i].ChapterSet; + Assert.Equal(ChapterImportFormat.HdDvdXpl, actual.ImportFormat); Assert.Equal(expected.Title, actual.Title); Assert.Equal(expected.SourceName, actual.SourceName); Assert.Equal(24, actual.FramesPerSecond); AssertTimeNear(expected.Duration, actual.Duration); Assert.Equal(expected.ChapterCount, actual.Chapters.Count); - AssertTimeNear(expected.FirstTime, actual.Chapters[0].Time); + AssertTimeNear(expected.FirstTime, actual.Chapters[0].StartTime); Assert.Equal(expected.FirstName, actual.Chapters[0].Name); - AssertTimeNear(expected.LastTime, actual.Chapters[^1].Time); + AssertTimeNear(expected.LastTime, actual.Chapters[^1].StartTime); Assert.Equal(expected.LastName, actual.Chapters[^1].Name); } } @@ -64,8 +66,8 @@ public async Task Vplst000MatchesLegacyExpectedTimes() TimeSpan.FromSeconds(3028), TimeSpan.FromSeconds(3226) }; - var actual = xplResult.Groups.Single().Options.Single(option => option.ChapterInfo.Chapters.Count == expectedTimes.Length); - Assert.Equal(expectedTimes, actual.ChapterInfo.Chapters.Select(static chapter => chapter.Time)); + var actual = xplResult.Groups.Single().Entries.Single(entry => entry.ChapterSet.Chapters.Count == expectedTimes.Length); + Assert.Equal(expectedTimes, actual.ChapterSet.Chapters.Select(static chapter => chapter.StartTime)); } [Fact] @@ -90,7 +92,7 @@ public async Task PlaylistXmlSampleReturnsParseDiagnosticBecauseItIsNotHddvdXpl( var result = await importer.ImportAsync(new ChapterImportRequest("test.xml", stream), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "XplParseFailed"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.XplParseFailed); } private static string Diagnostics(ChapterImportResult result) => @@ -137,7 +139,7 @@ public static TheoryData<SampleExpectation> SampleExpectations() => ]) ]; - public sealed record SampleExpectation(string FileName, OptionExpectation[] Options); + public sealed record SampleExpectation(string FileName, OptionExpectation[] Entries); public sealed record OptionExpectation( string Title, @@ -149,8 +151,8 @@ public sealed record OptionExpectation( TimeSpan LastTime, string LastName); - private static SampleExpectation Sample(string fileName, OptionExpectation[] options) => - new(fileName, options); + private static SampleExpectation Sample(string fileName, OptionExpectation[] entries) => + new(fileName, entries); private static TimeSpan Ms(int milliseconds) => TimeSpan.FromMilliseconds(milliseconds); diff --git a/tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs index 5b3d1aa..de3abe7 100644 --- a/tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; @@ -15,7 +16,7 @@ public void ChangeFps_preserves_chapter_frame_numbers() var result = ChapterFpsTransformService.ChangeFps(info, 24m, 48m); Assert.True(result.Success); - Assert.Equal(TimeSpan.FromSeconds(5), result.Info.Chapters[1].Time); + Assert.Equal(TimeSpan.FromSeconds(5), result.Info.Chapters[1].StartTime); Assert.Equal(48, result.Info.FramesPerSecond); } @@ -26,15 +27,15 @@ public void ChangeFps_preserves_frame_span_when_end_exists() { Chapters = [ - new Chapter(1, TimeSpan.FromSeconds(10), "A", End: TimeSpan.FromSeconds(12)) + new Chapter(1, TimeSpan.FromSeconds(10), "A", EndTime: TimeSpan.FromSeconds(12)) ] }; var result = ChapterFpsTransformService.ChangeFps(info, 24m, 48m); Assert.True(result.Success); - Assert.Equal(TimeSpan.FromSeconds(5), result.Info.Chapters[0].Time); - Assert.Equal(TimeSpan.FromSeconds(6), result.Info.Chapters[0].End); + Assert.Equal(TimeSpan.FromSeconds(5), result.Info.Chapters[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(6), result.Info.Chapters[0].EndTime); } [Fact] @@ -46,15 +47,14 @@ public void ChangeFps_invalid_fps_returns_diagnostic_and_preserves_input() Assert.False(result.Success); Assert.Same(info, result.Info); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "InvalidFrameRate"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.InvalidFrameRate); } - private static ChapterInfo Sample() => + private static ChapterSet Sample() => new( "Title", "source", - 0, - "OGM", + ChapterImportFormat.Ogm, 24, TimeSpan.FromSeconds(20), [ diff --git a/tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs b/tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs index 58a7370..730ceab 100644 --- a/tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs +++ b/tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs @@ -83,7 +83,21 @@ public void Parse_returns_invalid_time_diagnostic_for_malformed_text() Assert.Equal(TimeSpan.Zero, actual.Value); var diagnostic = Assert.Single(actual.Diagnostics); Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); - Assert.Equal("InvalidTimeText", diagnostic.Code); + Assert.Equal(ChapterDiagnosticCode.InvalidTimeText, diagnostic.Code); + } + + [Theory] + [InlineData("999999999999999999999:00:00.000")] + [InlineData("00:999999999999999999999:00.000")] + [InlineData("00:00:999999999999999999999.000")] + public void Parse_returns_invalid_time_diagnostic_for_overflowing_numbers(string text) + { + var actual = formatter.Parse(text); + + Assert.Equal(TimeSpan.Zero, actual.Value); + var diagnostic = Assert.Single(actual.Diagnostics); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Equal(ChapterDiagnosticCode.InvalidTimeText, diagnostic.Code); } [Theory] diff --git a/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs index 4efe10f..f419888 100644 --- a/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Transform/ExpressionAuthoringServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Transform; namespace ChapterTool.Core.Tests.Transform; @@ -98,7 +99,8 @@ public void Analyze_reports_lua_diagnostic_with_suggestion_for_invalid_shorthand var result = service.Analyze("t +", 3); var diagnostic = Assert.Single(result.Diagnostics); - Assert.StartsWith("InvalidExpression.Lua", diagnostic.Diagnostic.Code, StringComparison.Ordinal); + Assert.Equal(ChapterDiagnosticSource.LuaExpression, diagnostic.Diagnostic.Code.Source); + Assert.Equal(ChapterDiagnosticReason.CompileFailed, diagnostic.Diagnostic.Code.Reason); Assert.Equal("Expression.Suggestion.AddOperand", diagnostic.Suggestion.Code); Assert.False(string.IsNullOrWhiteSpace(diagnostic.Suggestion.Message)); Assert.Equal(2, diagnostic.Start); @@ -111,7 +113,7 @@ public void Analyze_reports_lua_runtime_suggestion_for_unknown_function() var result = service.Analyze("missing(t)", 10); var diagnostic = Assert.Single(result.Diagnostics); - Assert.Equal("InvalidExpression.LuaRuntime", diagnostic.Diagnostic.Code); + Assert.Equal(ChapterDiagnosticCode.InvalidExpressionLuaRuntime, diagnostic.Diagnostic.Code); Assert.Equal("Expression.Suggestion.CheckLuaRuntime", diagnostic.Suggestion.Code); } @@ -121,7 +123,7 @@ public void Analyze_reports_return_number_suggestion_for_invalid_return_type() var result = service.Analyze("return 'bad'", 12); var diagnostic = Assert.Single(result.Diagnostics); - Assert.Equal("InvalidExpression.LuaInvalidReturn", diagnostic.Diagnostic.Code); + Assert.Equal(ChapterDiagnosticCode.InvalidExpressionLuaInvalidReturn, diagnostic.Diagnostic.Code); Assert.Equal("Expression.Suggestion.ReturnNumber", diagnostic.Suggestion.Code); } } diff --git a/tests/ChapterTool.Core.Tests/Transform/ExpressionServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/ExpressionServiceTests.cs deleted file mode 100644 index 3964ecd..0000000 --- a/tests/ChapterTool.Core.Tests/Transform/ExpressionServiceTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -using ChapterTool.Core.Transform; - -namespace ChapterTool.Core.Tests.Transform; - -public sealed class ExpressionServiceTests -{ - private readonly ExpressionService service = new(); - - [Theory] - [InlineData("t + 1", 11)] - [InlineData("fps / 2", 12)] - [InlineData("floor(1.9) + ceil(1.1)", 3)] - [InlineData("max(1, 3) + min(2, 4)", 5)] - [InlineData("M_PI > 3", 1)] - [InlineData("M_LOG2E > 1", 1)] - [InlineData("1 + (2 * 3)", 7)] - [InlineData("(1 + 2) * 3", 9)] - [InlineData("2 ^ 3 ^ 2", 512)] - [InlineData("2 + 3 * 4 ^ 2", 50)] - [InlineData("-t + +2", -8)] - [InlineData("1 + -2 * 3", -5)] - [InlineData("10 % 3", 1)] - [InlineData("pow(2, 5)", 32)] - [InlineData("1 ? 2 : 3", 2)] - [InlineData("0 ? 2 : 3", 3)] - [InlineData("t > 5 ? t + 1 : fps / 2", 11)] - [InlineData("t < 5 ? t + 1 : fps / 2", 12)] - [InlineData("1 ? 0 ? 2 : 3 : 4", 3)] - [InlineData("0 ? 1 : 0 ? 2 : 3", 3)] - [InlineData("0 ? 1 : -2", -2)] - [InlineData("(0 ? 1 : 2) ^ 3", 8)] - public void EvaluateInfix_supports_documented_tokens(string expression, decimal expected) - { - var result = service.EvaluateInfix(expression, 10, 24); - - Assert.True(result.Success); - Assert.Equal(expected, Math.Round(result.Value, 6)); - } - - [Theory] - [InlineData("1+1/2+1/3+1/4+1/5+1/6+1/7+1/8+1/9+1/10", "2.9289682539682539682539682540")] - [InlineData("1-1/2-1/4+1/8-1/16+1/32-1/64+1/128-1/256+1/512-1/1024", "0.3330078125")] - [InlineData("1-(1/2)-(1/4)+(1/8)-(1/16)+(1/32)-(1/64)+(1/128)-(1/256)+(1/512)-(1/1024)", "0.3330078125")] - [InlineData("2^2^2^2", "65536")] - [InlineData("2^(2^(2^2))", "65536")] - public void EvaluateInfix_matches_legacy_arithmetic_cases(string expression, string expected) - { - var result = service.EvaluateInfix(expression, 0, 0); - - Assert.True(result.Success); - Assert.Equal(decimal.Parse(expected), result.Value); - } - - [Theory] - [InlineData("floor(1.133) + floor(log10(1023)) - ceil(0.9)", 3)] - [InlineData("abs(-1908.8976)", 1908.8976)] - [InlineData("abs(1908.8976)", 1908.8976)] - [InlineData("abs(-1908)", 1908)] - [InlineData("abs(1908)", 1908)] - [InlineData("log10(1000.0)", 3)] - [InlineData("log10(10 ^ 14)", 14)] - public void EvaluateInfix_matches_legacy_function_cases(string expression, decimal expected) - { - var result = service.EvaluateInfix(expression, 0, 0); - - Assert.True(result.Success); - Assert.Equal(expected, Math.Round(result.Value, 10)); - } - - [Theory] - [InlineData("sin(asin(1))", 1)] - [InlineData("cos(acos(1))", 1)] - [InlineData("tan(atan(1))", 1)] - [InlineData("log10(5482.2158)", 3.73895612695404)] - [InlineData("log10(458723662312872.125782332587)", 14.6615511428938)] - [InlineData("log10(0.12348583358871)", -0.908382862219234)] - public void EvaluateInfix_matches_legacy_near_function_cases(string expression, decimal expected) - { - var result = service.EvaluateInfix(expression, 0, 0); - - Assert.True(result.Success); - Assert.InRange(result.Value, expected - 0.0000000001m, expected + 0.0000000001m); - } - - [Fact] - public void EvaluateInfix_stops_at_comment() - { - var result = service.EvaluateInfix("2^10%10 + 6 \t///comment sample", 0, 0); - - Assert.True(result.Success); - Assert.Equal(10, result.Value); - } - - [Fact] - public void EvaluatePostfix_matches_legacy_postfix_case() - { - var result = service.EvaluatePostfix("2 10 ^ 10 % 6 +".Split(), 0, 0); - - Assert.True(result.Success); - Assert.Equal(10, result.Value); - } - - [Fact] - public void EvaluateInfix_matches_legacy_expression_fixture_cases() - { - var input = File.ReadAllLines(FixtureResolver.Fixture("Transform", "expression.in")); - var output = File.ReadAllLines(FixtureResolver.Fixture("Transform", "expression.out")); - - Assert.Equal(input.Length, output.Length); - - for (var i = 0; i < input.Length; i++) - { - var result = service.EvaluateInfix(input[i], 0, 0); - - Assert.True(result.Success, $"Expression #{i + 1} failed: {input[i]}"); - Assert.Equal(output[i], result.Value.ToString("0.00")); - } - } - - [Fact] - public void EvaluatePostfix_stops_at_comment() - { - var result = service.EvaluatePostfix(["t", "1", "+", "//", "100", "*"], 10, 24); - - Assert.True(result.Success); - Assert.Equal(11, result.Value); - } - - [Theory] - [InlineData("t +")] - [InlineData("and(1, 2)")] - [InlineData("1 and 2")] - [InlineData("(t + 1")] - [InlineData("t + 1)")] - [InlineData("max(1 2)")] - [InlineData("max(1, )")] - [InlineData("1 2")] - [InlineData("sin 1")] - [InlineData("1 * * 2")] - [InlineData("1 ? 2")] - [InlineData("1 : 2")] - [InlineData("1 ? : 2")] - [InlineData("1 ? 2 :")] - [InlineData("1 ? 2 : 3 : 4")] - public void Invalid_expression_returns_warning_and_original_time(string expression) - { - var result = service.EvaluateInfix(expression, 10, 24); - - Assert.False(result.Success); - Assert.Equal(10, result.Value); - Assert.StartsWith("InvalidExpression", Assert.Single(result.Diagnostics).Code); - } - - [Theory] - [InlineData("2+", "InvalidExpression.InsufficientOperands")] - [InlineData("2-", "InvalidExpression.InsufficientOperands")] - [InlineData("2*", "InvalidExpression.InsufficientOperands")] - [InlineData("2/", "InvalidExpression.InsufficientOperands")] - [InlineData("2%", "InvalidExpression.InsufficientOperands")] - [InlineData("2^", "InvalidExpression.InsufficientOperands")] - [InlineData("2>", "InvalidExpression.InsufficientOperands")] - [InlineData("2<", "InvalidExpression.InsufficientOperands")] - [InlineData("2>=", "InvalidExpression.InsufficientOperands")] - [InlineData("2<=", "InvalidExpression.InsufficientOperands")] - [InlineData("2?", "InvalidExpression.TernaryUnmatchedQuestion")] - [InlineData("2?3:", "InvalidExpression.InsufficientOperands")] - [InlineData("2?3", "InvalidExpression.TernaryUnmatchedQuestion")] - [InlineData("floor(", "InvalidExpression.UnbalancedParentheses")] - [InlineData("floor()", "InvalidExpression.MissingOperandBeforeParen")] - [InlineData("floor(2,", "InvalidExpression.MisplacedComma")] - [InlineData("(2+", "InvalidExpression.InsufficientOperands")] - [InlineData("+", "InvalidExpression.InsufficientOperands")] - [InlineData("-", "InvalidExpression.InsufficientOperands")] - public void Incomplete_expression_reports_specific_warning_code(string expression, string expectedCode) - { - var result = service.EvaluateInfix(expression, 10, 24); - - Assert.False(result.Success); - Assert.Equal(10, result.Value); - Assert.Equal(expectedCode, Assert.Single(result.Diagnostics).Code); - } -} diff --git a/tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs index ca24733..0ced347 100644 --- a/tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs @@ -10,18 +10,18 @@ public sealed class FrameRateServiceTests [Fact] public void Options_expose_legacy_mpls_frame_rate_codes_without_combo_box_indexes() { - var options = service.Options; + var entries = service.Options; Assert.Collection( - options, - option => AssertFrameRate(option, "Auto", "Auto", 0m, false, 0), - option => AssertFrameRate(option, "Fps23976", "24000 / 1001", 24000m / 1001m, true, 1), - option => AssertFrameRate(option, "Fps24", "24000 / 1000", 24m, true, 2), - option => AssertFrameRate(option, "Fps25", "25000 / 1000", 25m, true, 3), - option => AssertFrameRate(option, "Fps2997", "30000 / 1001", 30000m / 1001m, true, 4), - option => AssertFrameRate(option, "Reserved", "RESER / VED", 0m, false, 5), - option => AssertFrameRate(option, "Fps50", "50000 / 1000", 50m, true, 6), - option => AssertFrameRate(option, "Fps5994", "60000 / 1001", 60000m / 1001m, true, 7)); + entries, + entry => AssertFrameRate(entry, "Auto", "Auto", 0m, false, 0), + entry => AssertFrameRate(entry, "Fps23976", "24000 / 1001", 24000m / 1001m, true, 1), + entry => AssertFrameRate(entry, "Fps24", "24000 / 1000", 24m, true, 2), + entry => AssertFrameRate(entry, "Fps25", "25000 / 1000", 25m, true, 3), + entry => AssertFrameRate(entry, "Fps2997", "30000 / 1001", 30000m / 1001m, true, 4), + entry => AssertFrameRate(entry, "Reserved", "RESER / VED", 0m, false, 5), + entry => AssertFrameRate(entry, "Fps50", "50000 / 1000", 50m, true, 6), + entry => AssertFrameRate(entry, "Fps5994", "60000 / 1001", 60000m / 1001m, true, 7)); } [Fact] @@ -156,7 +156,7 @@ public void DetectDetailed_skips_separator_chapters_in_count() var chapters = new[] { new Chapter(1, TimeSpan.Zero, "Chapter 1"), - new Chapter(-1, Chapter.SeparatorTime, ""), + Chapter.Separator(), new Chapter(2, TimeSpan.FromSeconds(7d / 25d), "Chapter 2") }; var info = NewInfo(0m, chapters); @@ -229,30 +229,29 @@ public void UpdateFrames_displays_raw_decimal_without_marker_when_rounding_is_di Assert.Equal(FrameAccuracy.Neutral, actual.Chapters.Single().FrameAccuracy); } - private static ChapterInfo NewInfo(decimal fps, IReadOnlyList<Chapter> chapters) + private static ChapterSet NewInfo(decimal fps, IReadOnlyList<Chapter> chapters) { - return new ChapterInfo( + return new ChapterSet( Title: "Title", SourceName: null, - SourceIndex: 0, - SourceType: "OGM", + ImportFormat: ChapterImportFormat.Ogm, FramesPerSecond: (double)fps, Duration: TimeSpan.Zero, Chapters: chapters); } private static void AssertFrameRate( - FrameRateOption option, + FrameRateOption entry, string code, string displayName, decimal value, bool isValid, int legacyMplsCode) { - Assert.Equal(code, option.Code); - Assert.Equal(displayName, option.DisplayName); - Assert.Equal(value, option.Value); - Assert.Equal(isValid, option.IsValid); - Assert.Equal(legacyMplsCode, option.LegacyMplsCode); + Assert.Equal(code, entry.Code); + Assert.Equal(displayName, entry.DisplayName); + Assert.Equal(value, entry.Value); + Assert.Equal(isValid, entry.IsValid); + Assert.Equal(legacyMplsCode, entry.LegacyMplsCode); } } diff --git a/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs b/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs index d0f1bb4..86b6575 100644 --- a/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs +++ b/tests/ChapterTool.Core.Tests/Transform/LuaExpressionScriptServiceTests.cs @@ -1,5 +1,9 @@ +using System.Globalization; +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; +using LuaExpressionScriptService = ChapterTool.Core.Transform.Expressions.Lua.LuaExpressionScriptService; namespace ChapterTool.Core.Tests.Transform; @@ -25,6 +29,30 @@ public void Evaluates_shorthand_arithmetic_without_return() Assert.Equal(11, result.Value); } + [Theory] + [InlineData("t + 1", 11)] + [InlineData("fps / 2", 12)] + [InlineData("math.floor(1.9) + math.ceil(1.1)", 3)] + [InlineData("math.max(1, 3) + math.min(2, 4)", 5)] + [InlineData("math.pi > 3 and 1 or 0", 1)] + [InlineData("1 + (2 * 3)", 7)] + [InlineData("(1 + 2) * 3", 9)] + [InlineData("2 ^ 3 ^ 2", 512)] + [InlineData("2 + 3 * 4 ^ 2", 50)] + [InlineData("-t + 2", -8)] + [InlineData("1 + -2 * 3", -5)] + [InlineData("10 % 3", 1)] + [InlineData("math.pow(2, 5)", 32)] + [InlineData("t > 5 and t + 1 or fps / 2", 11)] + [InlineData("t < 5 and t + 1 or fps / 2", 12)] + public void Evaluates_lua_arithmetic_and_context_tokens(string expression, decimal expected) + { + var result = service.Evaluate(expression, Context(timeSeconds: 10, fps: 24)); + + Assert.True(result.Success, DiagnosticText(result)); + Assert.Equal(expected, Math.Round(result.Value, 6)); + } + [Fact] public void Evaluates_direct_return_script() { @@ -54,6 +82,37 @@ public void Supports_safe_math_aliases_for_low_friction_arithmetic() Assert.Equal(10.5m, result.Value); } + [Theory] + [InlineData("math.sin(math.asin(1))", 1)] + [InlineData("math.cos(math.acos(1))", 1)] + [InlineData("math.tan(math.atan(1))", 1)] + [InlineData("math.log(1000, 10)", 3)] + [InlineData("math.sqrt(81)", 9)] + public void Evaluates_lua_math_library_functions(string expression, decimal expected) + { + var result = service.Evaluate(expression, Context(timeSeconds: 0, fps: 24)); + + Assert.True(result.Success, DiagnosticText(result)); + Assert.InRange(result.Value, expected - 0.0000000001m, expected + 0.0000000001m); + } + + [Fact] + public void Evaluates_uva_12803_expression_fixture_cases_with_lua() + { + var input = File.ReadAllLines(FixtureResolver.Fixture("Transform", "UVa-12803.in")); + var output = File.ReadAllLines(FixtureResolver.Fixture("Transform", "UVa-12803.out")); + + Assert.Equal(input.Length, output.Length); + + for (var i = 0; i < input.Length; i++) + { + var result = service.Evaluate(input[i], Context(timeSeconds: 0, fps: 24)); + + Assert.True(result.Success, $"Expression #{i + 1} failed: {input[i]}{Environment.NewLine}{DiagnosticText(result)}"); + Assert.Equal(output[i], result.Value.ToString("0.00", CultureInfo.InvariantCulture)); + } + } + [Theory] [InlineData("return nil")] [InlineData("return 'bad'")] @@ -64,20 +123,34 @@ public void Invalid_return_preserves_original_time(string script) Assert.False(result.Success); Assert.Equal(10, result.Value); - Assert.StartsWith("InvalidExpression.Lua", Assert.Single(result.Diagnostics).Code, StringComparison.Ordinal); + Assert.Equal(ChapterDiagnosticSource.LuaExpressionReturn, Assert.Single(result.Diagnostics).Code.Source); } [Theory] - [InlineData("return t +", "InvalidExpression.LuaCompile")] - [InlineData("return missing()", "InvalidExpression.LuaRuntime")] - [InlineData("t 1 +", "InvalidExpression.LuaCompile")] - public void Lua_failures_are_structured_diagnostics(string script, string expectedCode) + [InlineData("return t +", ChapterDiagnosticReason.CompileFailed)] + [InlineData("return missing()", ChapterDiagnosticReason.RuntimeFailed)] + [InlineData("t 1 +", ChapterDiagnosticReason.CompileFailed)] + public void Lua_failures_are_structured_diagnostics(string script, ChapterDiagnosticReason reason) { var result = service.Evaluate(script, Context(timeSeconds: 10, fps: 24)); Assert.False(result.Success); Assert.Equal(10, result.Value); - Assert.Equal(expectedCode, Assert.Single(result.Diagnostics).Code); + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal(ChapterDiagnosticSource.LuaExpression, diagnostic.Code.Source); + Assert.Equal(reason, diagnostic.Code.Reason); + } + + [Fact(Timeout = 2000)] + public void Infinite_loop_is_cancelled_with_structured_diagnostic() + { + var result = service.Evaluate( + "function transform(chapter) while true do end end", + Context(timeSeconds: 10, fps: 24)); + + Assert.False(result.Success); + Assert.Equal(10, result.Value); + Assert.Equal(ChapterDiagnosticCode.InvalidExpressionLuaCanceled, Assert.Single(result.Diagnostics).Code); } [Fact] @@ -86,12 +159,12 @@ public void Script_cannot_use_io_library() var result = service.Evaluate("return io.open('chapters.txt')", Context(timeSeconds: 10, fps: 24)); Assert.False(result.Success); - Assert.Equal("InvalidExpression.LuaRuntime", Assert.Single(result.Diagnostics).Code); + Assert.Equal(ChapterDiagnosticCode.InvalidExpressionLuaRuntime, Assert.Single(result.Diagnostics).Code); } - private static LuaExpressionContext Context(decimal timeSeconds, decimal fps, int index = 1, int count = 1, int number = 1) => + private static ChapterExpressionContext Context(decimal timeSeconds, decimal fps, int index = 1, int count = 1, int number = 1) => new(new Chapter(number, TimeSpan.FromSeconds((double)timeSeconds), "Intro", ""), index, count, timeSeconds, fps); - private static string DiagnosticText(LuaExpressionEvaluationResult result) => + private static string DiagnosticText(ChapterExpressionEvaluationResult result) => string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}")); } diff --git a/tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs b/tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs index 6f90f0a..78ebcfc 100644 --- a/tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs @@ -1,4 +1,5 @@ -using ChapterTool.Infrastructure.Platform; +using ChapterTool.Core.Diagnostics; +using ChapterTool.Infrastructure.Platform; using Microsoft.Extensions.Logging; namespace ChapterTool.Infrastructure.Tests; @@ -14,7 +15,7 @@ public void CapturesSeverityCategoryEventAndStructuredState() logger.LogWarning( new EventId(42, "Log.Test"), "Import diagnostic {Code} at {Location}", - "PartialParse", + ChapterDiagnosticCode.PartialParse, "line 5"); var entry = Assert.Single(service.Entries); @@ -22,8 +23,8 @@ public void CapturesSeverityCategoryEventAndStructuredState() Assert.Equal("ChapterTool.Tests", entry.Category); Assert.Equal(42, entry.EventId); Assert.Equal("Log.Test", entry.EventName); - Assert.Equal("Import diagnostic PartialParse at line 5", entry.Message); - Assert.Equal("PartialParse", entry.StructuredState?["Code"]); + Assert.Equal("Import diagnostic Parse.Partial at line 5", entry.Message); + Assert.Equal(ChapterDiagnosticCode.PartialParse, entry.StructuredState?["Code"]); Assert.Equal("line 5", entry.StructuredState?["Location"]); } @@ -36,7 +37,7 @@ public void PreservesMessageKeysArgumentsAndTechnicalDetails() { ["MessageKey"] = "Log.Diagnostic", ["operation"] = "Load", - ["code"] = "FfprobeProcessFailed", + ["code"] = ChapterDiagnosticCode.FfprobeProcessFailed, ["TechnicalDetail"] = "exitCode=1 stderr=failed" }; @@ -47,7 +48,7 @@ public void PreservesMessageKeysArgumentsAndTechnicalDetails() Assert.Equal("Log.Diagnostic", entry.MessageKey); Assert.Equal("Log.Diagnostic", entry.Message); Assert.Equal("Load", entry.Arguments?["operation"]); - Assert.Equal("FfprobeProcessFailed", entry.Arguments?["code"]); + Assert.Equal(ChapterDiagnosticCode.FfprobeProcessFailed, entry.Arguments?["code"]); Assert.Equal("exitCode=1 stderr=failed", entry.TechnicalDetail); Assert.DoesNotContain(entry.Arguments!, pair => pair.Key is "MessageKey" or "TechnicalDetail"); } diff --git a/tests/ChapterTool.Infrastructure.Tests/AtlMp4ChapterReaderTests.cs b/tests/ChapterTool.Infrastructure.Tests/AtlMp4ChapterReaderTests.cs index f719ae2..5c40648 100644 --- a/tests/ChapterTool.Infrastructure.Tests/AtlMp4ChapterReaderTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/AtlMp4ChapterReaderTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Importing.Media; namespace ChapterTool.Infrastructure.Tests; @@ -54,7 +55,7 @@ public async Task ReaderDiagnosesOffsetBasedChaptersAsUnsupported() var result = await reader.ReadAsync("offset.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("Mp4UnsupportedMetadata", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.Mp4UnsupportedMetadata, result.DiagnosticCode); } [Theory] @@ -68,12 +69,12 @@ public async Task ReaderDiagnosesMalformedChapterTiming(uint startTime, uint end var result = await reader.ReadAsync("bad.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("Mp4MalformedMetadata", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.Mp4MalformedMetadata, result.DiagnosticCode); } [Theory] [MemberData(nameof(ExceptionDiagnostics))] - public async Task ReaderMapsAtlAndFileExceptionsToStructuredDiagnostics(Exception exception, string expectedCode) + public async Task ReaderMapsAtlAndFileExceptionsToStructuredDiagnostics(Exception exception, ChapterDiagnosticCode expectedCode) { var reader = new AtlMp4ChapterReader(new ThrowingAtlTrackChapterSource(exception)); @@ -84,16 +85,16 @@ public async Task ReaderMapsAtlAndFileExceptionsToStructuredDiagnostics(Exceptio Assert.Contains(exception.Message, result.Message, StringComparison.Ordinal); } - public static TheoryData<Exception, string> ExceptionDiagnostics() => new() + public static TheoryData<Exception, ChapterDiagnosticCode> ExceptionDiagnostics() => new() { - { new FileNotFoundException("missing"), "Mp4FileNotFound" }, - { new DirectoryNotFoundException("missing directory"), "Mp4FileNotFound" }, - { new UnauthorizedAccessException("denied"), "Mp4FileInaccessible" }, - { new IOException("read failed"), "Mp4ReadFailed" }, - { new InvalidDataException("malformed"), "Mp4MalformedMetadata" }, - { new InvalidOperationException("unsupported"), "Mp4UnsupportedMetadata" }, - { new NotSupportedException("not supported"), "Mp4UnsupportedMetadata" }, - { new ArgumentException("bad argument"), "Mp4UnsupportedMetadata" } + { new FileNotFoundException("missing"), ChapterDiagnosticCode.Mp4FileNotFound }, + { new DirectoryNotFoundException("missing directory"), ChapterDiagnosticCode.Mp4FileNotFound }, + { new UnauthorizedAccessException("denied"), ChapterDiagnosticCode.Mp4FileInaccessible }, + { new IOException("read failed"), ChapterDiagnosticCode.Mp4ReadFailed }, + { new InvalidDataException("malformed"), ChapterDiagnosticCode.Mp4MalformedMetadata }, + { new InvalidOperationException("unsupported"), ChapterDiagnosticCode.Mp4UnsupportedMetadata }, + { new NotSupportedException("not supported"), ChapterDiagnosticCode.Mp4UnsupportedMetadata }, + { new ArgumentException("bad argument"), ChapterDiagnosticCode.Mp4UnsupportedMetadata } }; private sealed class FakeAtlTrackChapterSource(params AtlChapterEntry[] chapters) : IAtlTrackChapterSource diff --git a/tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs b/tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs index c5b2836..6b78578 100644 --- a/tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs @@ -1,3 +1,5 @@ +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; @@ -31,19 +33,19 @@ public async Task ImportAsyncReadsMetadataTitleAndParsesEac3toChapterText() var progressValues = new List<double>(); var progress = new ListProgress(progressValues); - var result = await importer.ImportAsync(new ChapterImportRequest(root, Progress: progress), TestContext.Current.CancellationToken); + var result = await importer.ImportAsync(new ChapterImportRequest(root, ProgressReporter: progress), TestContext.Current.CancellationToken); Assert.True(result.Success); - var info = result.Groups.Single().Options.Single().ChapterInfo; + var info = result.Groups.Single().Entries.Single().ChapterSet; Assert.Equal("Disc Title", info.Title); - Assert.Equal("BDMV", info.SourceType); + Assert.Equal(ChapterImportFormat.Bdmv, info.ImportFormat); Assert.Equal("00001.m2ts", info.SourceName); Assert.Equal(2, info.Chapters.Count); Assert.Equal(["Opening", "Middle"], info.Chapters.Select(static chapter => chapter.Name)); - Assert.Equal(TimeSpan.FromMilliseconds(754567), info.Chapters[1].Time); + Assert.Equal(TimeSpan.FromMilliseconds(754567), info.Chapters[1].StartTime); Assert.Equal(TimeSpan.FromHours(1).Add(TimeSpan.FromSeconds(20)), info.Duration); - Assert.Equal("00001.m2ts", result.Groups.Single().Options.Single().MediaReferences!.Single().DisplayName); - Assert.Equal(Path.Combine("..", "STREAM", "00001.m2ts"), result.Groups.Single().Options.Single().MediaReferences!.Single().RelativePath); + Assert.Equal("00001.m2ts", result.Groups.Single().Entries.Single().ReferencedMediaFiles!.Single().DisplayName); + Assert.Equal(Path.Combine("..", "STREAM", "00001.m2ts"), result.Groups.Single().Entries.Single().ReferencedMediaFiles!.Single().RelativePath); Assert.Equal([root, "-showall"], runner.Requests[0].Arguments); Assert.Null(runner.Requests[0].WorkingDirectory); Assert.Equal([root, "1)", $"1:{runner.ExportedPaths.Single()}", "-showall"], runner.Requests[1].Arguments); @@ -53,24 +55,63 @@ public async Task ImportAsyncReadsMetadataTitleAndParsesEac3toChapterText() Assert.Equal(2, runner.Requests.Count); Assert.False(File.Exists(runner.ExportedPaths.Single())); Assert.Contains(progressValues, value => value is > 0 and < 1); - Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == "Stdout"); + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.Stdout); + } + + [Fact] + public async Task ImportAsyncTreatsUnreadableMetadataTitleAsBestEffort() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var root = CreateBdmvRoot(writeMeta: true); + var metaPath = Path.Combine(root, "BDMV", "META", "DL", "disc.xml"); + File.SetUnixFileMode(metaPath, UnixFileMode.None); + try + { + File.Copy( + Path.Combine(FixtureResolver.RepositoryRoot, "tests", "ChapterTool.Core.Tests", "Fixtures", "Importing", "Disc", "Mpls", "00001_fch.mpls"), + Path.Combine(root, "BDMV", "PLAYLIST", "00001.mpls")); + var runner = new FakeRunner([ + Success(""" + 1) 00001.mpls, 00001.m2ts, 01:00:20 + - Chapters, 9 chapters + """), + new ProcessRunResult(0, "", "", false, false, "eac3to", [], null) + ], ExportText(""" + CHAPTER01=00:00:00.000 + CHAPTER01NAME=Opening + """)); + var importer = NewImporter(runner); + + var result = await importer.ImportAsync(new ChapterImportRequest(root), TestContext.Current.CancellationToken); + + Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => diagnostic.Message))); + Assert.Equal(string.Empty, result.Groups.Single().Entries.Single().ChapterSet.Title); + } + finally + { + File.SetUnixFileMode(metaPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } } [Fact] public async Task ImportAsyncFailsMissingDependency() { - var importer = new BdmvChapterImporter(new FakeLocator(new ExternalToolLocation(false, null, "MissingDependency", "missing")), new FakeRunner([]), new ChapterTimeFormatter()); + var importer = new BdmvChapterImporter(new FakeLocator(new ExternalToolLocation(false, null, ChapterDiagnosticCode.MissingDependency, "missing")), new FakeRunner([]), new ChapterTimeFormatter()); var result = await importer.ImportAsync(new ChapterImportRequest(CreateBdmvRoot()), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MissingDependency"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MissingDependency); } [Theory] - [InlineData("", "DependencyOutputUnrecognized")] - [InlineData("stderr", "DependencyExecutionFailed")] - public async Task ImportAsyncDiagnosesBadDependencyOutput(string stderr, string code) + [InlineData("", ChapterDiagnosticSource.DependencyOutput, ChapterDiagnosticReason.Unrecognized)] + [InlineData("stderr", ChapterDiagnosticSource.DependencyExecution, ChapterDiagnosticReason.Failed)] + public async Task ImportAsyncDiagnosesBadDependencyOutput(string stderr, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var resultToReturn = stderr.Length == 0 ? Success("not a playlist") @@ -80,7 +121,32 @@ public async Task ImportAsyncDiagnosesBadDependencyOutput(string stderr, string var result = await importer.ImportAsync(new ChapterImportRequest(CreateBdmvRoot()), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); + } + + [Fact] + public async Task ImportAsyncRejectsTruncatedPlaylistOutput() + { + var importer = NewImporter(new FakeRunner([ + new ProcessRunResult( + 0, + """ + 1) 00001.mpls, 00001.m2ts, 01:00:20 + - Chapters, 9 chapters + """, + "", + false, + false, + "eac3to", + [], + null, + OutputTruncated: true) + ])); + + var result = await importer.ImportAsync(new ChapterImportRequest(CreateBdmvRoot()), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.DependencyOutputTruncated); } [Fact] @@ -102,7 +168,7 @@ public async Task ImportAsyncFailsWhenChapterExportIsNotParseable() var result = await importer.ImportAsync(new ChapterImportRequest(root), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "OgmInvalidFirstLine"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.OgmInvalidFirstLine); Assert.Equal(2, runner.Requests.Count); } @@ -125,10 +191,85 @@ public async Task ImportAsyncFailsWhenChapterExportFileIsMissing() var result = await importer.ImportAsync(new ChapterImportRequest(root), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "DependencyOutputMissing"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.DependencyOutputMissing); + } + + [Fact] + public async Task ImportAsyncFailsWhenChapterExportProcessFailsEvenIfFileExists() + { + var root = CreateBdmvRoot(); + File.Copy( + Path.Combine(FixtureResolver.RepositoryRoot, "tests", "ChapterTool.Core.Tests", "Fixtures", "Importing", "Disc", "Mpls", "00001_fch.mpls"), + Path.Combine(root, "BDMV", "PLAYLIST", "00001.mpls")); + var runner = new FakeRunner([ + Success(""" + 1) 00001.mpls, 00001.m2ts, 01:00:20 + - Chapters, 9 chapters + """), + new ProcessRunResult(7, "", "export failed", false, false, "eac3to", [], null) + ], ExportText(""" + CHAPTER01=00:00:00.000 + CHAPTER01NAME=Opening + """)); + var importer = NewImporter(runner); + + var result = await importer.ImportAsync(new ChapterImportRequest(root), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.DependencyExecutionFailed); + } + + [Theory] + [InlineData(true, false, ChapterDiagnosticReason.TimedOut)] + [InlineData(false, true, ChapterDiagnosticReason.Cancelled)] + public async Task ImportAsyncDiagnosesListTimeoutAndCancellation(bool timedOut, bool cancelled, ChapterDiagnosticReason reason) + { + var importer = NewImporter(new FakeRunner([ + new ProcessRunResult(null, "", "", timedOut, cancelled, "eac3to", [], null) + ])); + + var result = await importer.ImportAsync(new ChapterImportRequest(CreateBdmvRoot()), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(ChapterDiagnosticSource.DependencyExecution, reason)); + } + + [Theory] + [InlineData(true, false, ChapterDiagnosticReason.TimedOut)] + [InlineData(false, true, ChapterDiagnosticReason.Cancelled)] + public async Task ImportAsyncDiagnosesExportTimeoutAndCancellation(bool timedOut, bool cancelled, ChapterDiagnosticReason reason) + { + var root = CreateBdmvRoot(); + File.Copy( + Path.Combine(FixtureResolver.RepositoryRoot, "tests", "ChapterTool.Core.Tests", "Fixtures", "Importing", "Disc", "Mpls", "00001_fch.mpls"), + Path.Combine(root, "BDMV", "PLAYLIST", "00001.mpls")); + var runner = new FakeRunner([ + Success(""" + 1) 00001.mpls, 00001.m2ts, 01:00:20 + - Chapters, 9 chapters + """), + new ProcessRunResult(null, "", "", timedOut, cancelled, "eac3to", [], null) + ]); + var importer = NewImporter(runner); + + var result = await importer.ImportAsync(new ChapterImportRequest(root), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(ChapterDiagnosticSource.DependencyExecution, reason)); + } + + [Fact] + public async Task ImportAsyncReturnsDiagnosticWhenListProcessCannotStart() + { + var importer = NewImporter(new ThrowingRunner(new InvalidOperationException("cannot start"))); + + var result = await importer.ImportAsync(new ChapterImportRequest(CreateBdmvRoot()), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.DependencyCannotStart); } - private static BdmvChapterImporter NewImporter(FakeRunner runner) => + private static BdmvChapterImporter NewImporter(IProcessRunner runner) => new(new FakeLocator(new ExternalToolLocation(true, "eac3to")), runner, new ChapterTimeFormatter()); private static ProcessRunResult Success(string stdout) => @@ -166,9 +307,15 @@ public ValueTask<ExternalToolLocation> LocateAsync(string toolId, CancellationTo ValueTask.FromResult(location); } - private sealed class ListProgress(List<double> values) : IProgress<ChapterLoadProgress> + private sealed class ListProgress(List<double> values) : IChapterImportProgressReporter { - public void Report(ChapterLoadProgress value) => values.Add(value.Value); + public void Report(ChapterImportProgress progress) + { + if (progress.Fraction is { } fraction) + { + values.Add(fraction); + } + } } private sealed class FakeRunner(IReadOnlyList<ProcessRunResult> results, Func<ProcessRunRequest, Task>? onRun = null) : IProcessRunner @@ -203,4 +350,10 @@ public async ValueTask<ProcessRunResult> RunAsync(ProcessRunRequest request, Can }; } } + + private sealed class ThrowingRunner(Exception exception) : IProcessRunner + { + public ValueTask<ProcessRunResult> RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) => + ValueTask.FromException<ProcessRunResult>(exception); + } } diff --git a/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs b/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs index 1cd71d2..3f5619e 100644 --- a/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Configuration; using ChapterTool.Infrastructure.Tools; @@ -112,7 +113,7 @@ public async Task LocateAsync_does_not_use_platform_discovery_for_eac3to() Assert.False(location.Found); Assert.Null(location.Path); - Assert.Equal("MissingDependency", location.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.MissingDependency, location.DiagnosticCode); } [Fact] @@ -126,7 +127,7 @@ public async Task LocateAsync_returns_missing_dependency_when_tool_is_absent() Assert.False(location.Found); Assert.Null(location.Path); - Assert.Equal("MissingDependency", location.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.MissingDependency, location.DiagnosticCode); } [Fact] @@ -143,7 +144,7 @@ public async Task LocateAsync_ignores_configured_file_that_is_not_executable() Assert.False(location.Found); Assert.Null(location.Path); - Assert.Equal("MissingDependency", location.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.MissingDependency, location.DiagnosticCode); } [Fact] @@ -322,7 +323,7 @@ await settingsStore.SaveAsync( Assert.False(location.Found); Assert.Null(location.Path); - Assert.Equal("MissingDependency", location.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.MissingDependency, location.DiagnosticCode); } [Fact] diff --git a/tests/ChapterTool.Infrastructure.Tests/FfprobeMediaChapterReaderTests.cs b/tests/ChapterTool.Infrastructure.Tests/FfprobeMediaChapterReaderTests.cs index 6377207..fbd2b8d 100644 --- a/tests/ChapterTool.Infrastructure.Tests/FfprobeMediaChapterReaderTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/FfprobeMediaChapterReaderTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Services; using ChapterTool.Infrastructure.Importing.Media; @@ -52,13 +53,13 @@ public async Task ReadAsyncBuildsExpectedFfprobeCommandAndParsesJson() public async Task ReadAsyncReturnsMissingDependencyDiagnostic() { var reader = new FfprobeMediaChapterReader( - new FakeToolLocator(new ExternalToolLocation(false, null, "MissingDependency", "ffprobe missing")), + new FakeToolLocator(new ExternalToolLocation(false, null, ChapterDiagnosticCode.MissingDependency, "ffprobe missing")), new FakeProcessRunner(SuccessfulJson("""{"chapters":[]}"""))); var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("FfprobeMissingDependency", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.FfprobeMissingDependency, result.DiagnosticCode); } [Fact] @@ -71,13 +72,13 @@ public async Task ReadAsyncReturnsCannotStartDiagnostic() var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("FfprobeCannotStart", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.FfprobeCannotStart, result.DiagnosticCode); } [Theory] - [InlineData(true, false, "FfprobeProcessTimedOut")] - [InlineData(false, true, "FfprobeProcessCancelled")] - public async Task ReadAsyncMapsTimeoutAndCancellation(bool timedOut, bool cancelled, string expectedCode) + [InlineData(true, false, ChapterDiagnosticReason.TimedOut)] + [InlineData(false, true, ChapterDiagnosticReason.Cancelled)] + public async Task ReadAsyncMapsTimeoutAndCancellation(bool timedOut, bool cancelled, ChapterDiagnosticReason reason) { var reader = new FfprobeMediaChapterReader( new FakeToolLocator(new ExternalToolLocation(true, "ffprobe")), @@ -86,7 +87,7 @@ public async Task ReadAsyncMapsTimeoutAndCancellation(bool timedOut, bool cancel var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal(expectedCode, result.DiagnosticCode); + Assert.Equal(new ChapterDiagnosticCode(ChapterDiagnosticSource.FfprobeProcess, reason), result.DiagnosticCode); } [Fact] @@ -99,15 +100,15 @@ public async Task ReadAsyncMapsNonZeroExitWithStderrDetails() var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("FfprobeProcessFailed", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.FfprobeProcessFailed, result.DiagnosticCode); Assert.Contains("错误", result.Details, StringComparison.Ordinal); } [Theory] - [InlineData("", "FfprobeEmptyOutput")] - [InlineData("{not json", "FfprobeParseFailed")] - [InlineData("""{"chapters":"not an array"}""", "FfprobeParseFailed")] - public async Task ReadAsyncMapsEmptyAndMalformedOutput(string stdout, string expectedCode) + [InlineData("", ChapterDiagnosticSource.FfprobeOutput, ChapterDiagnosticReason.Empty)] + [InlineData("{not json", ChapterDiagnosticSource.Ffprobe, ChapterDiagnosticReason.ParseFailed)] + [InlineData("""{"chapters":"not an array"}""", ChapterDiagnosticSource.Ffprobe, ChapterDiagnosticReason.ParseFailed)] + public async Task ReadAsyncMapsEmptyAndMalformedOutput(string stdout, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var reader = new FfprobeMediaChapterReader( new FakeToolLocator(new ExternalToolLocation(true, "ffprobe")), @@ -116,7 +117,20 @@ public async Task ReadAsyncMapsEmptyAndMalformedOutput(string stdout, string exp var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal(expectedCode, result.DiagnosticCode); + Assert.Equal(new ChapterDiagnosticCode(source, reason), result.DiagnosticCode); + } + + [Fact] + public async Task ReadAsyncRejectsTruncatedJsonOutput() + { + var reader = new FfprobeMediaChapterReader( + new FakeToolLocator(new ExternalToolLocation(true, "ffprobe")), + new FakeProcessRunner(SuccessfulJson("""{"chapters":[]}""") with { OutputTruncated = true })); + + var result = await reader.ReadAsync("movie.mp4", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(ChapterDiagnosticCode.FfprobeOutputTruncated, result.DiagnosticCode); } private static ProcessRunResult SuccessfulJson(string stdout) => diff --git a/tests/ChapterTool.Infrastructure.Tests/Importing/FfprobeMediaChapterIntegrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/Importing/FfprobeMediaChapterIntegrationTests.cs index e9429ab..0811a05 100644 --- a/tests/ChapterTool.Infrastructure.Tests/Importing/FfprobeMediaChapterIntegrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/Importing/FfprobeMediaChapterIntegrationTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Models; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Media; using ChapterTool.Infrastructure.Services; @@ -27,14 +28,14 @@ public async Task FfprobeReaderAndMediaImporterReadRealChapteredContainer( var result = await importer.ImportAsync(new ChapterImportRequest(fixturePath), TestContext.Current.CancellationToken); Assert.True(result.Success, Diagnostics(result)); - var option = result.Groups.Single().Options.Single(); - var info = option.ChapterInfo; - Assert.Equal("MEDIA", info.SourceType); - Assert.Equal("FFprobe Chapters", option.DisplayName); + var entry = result.Groups.Single().Entries.Single(); + var info = entry.ChapterSet; + Assert.Equal(ChapterImportFormat.Media, info.ImportFormat); + Assert.Equal("FFprobe Chapters", entry.DisplayName); Assert.Equal(expectedDuration, info.Duration); Assert.Equal(expectedNames, info.Chapters.Select(static chapter => chapter.Name)); - Assert.Equal(expectedStarts, info.Chapters.Select(static chapter => chapter.Time)); - Assert.Equal(expectedEnds, info.Chapters.Select(static chapter => chapter.End)); + Assert.Equal(expectedStarts, info.Chapters.Select(static chapter => chapter.StartTime)); + Assert.Equal(expectedEnds, info.Chapters.Select(static chapter => chapter.EndTime)); } public static TheoryData<string, TimeSpan, string[], TimeSpan[], TimeSpan?[]> ChapteredContainerFixtures() => new() diff --git a/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs index 2384b11..ac5f625 100644 --- a/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; @@ -48,23 +49,23 @@ public async Task Importer_reads_chapters_from_real_mkv_file() var result = await importer.ImportAsync(new ChapterImportRequest(fixturePath), TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - var options = result.Groups.Single().Options; - Assert.Equal(2, options.Count); + var entries = result.Groups.Single().Entries; + Assert.Equal(2, entries.Count); - var chapters = options[0].ChapterInfo.Chapters; + var chapters = entries[0].ChapterSet.Chapters; Assert.Equal(["Intro", "Act 1", "Act 2", "Credits"], chapters.Select(static chapter => chapter.Name)); Assert.Equal( [TimeSpan.Zero, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(330), TimeSpan.FromSeconds(740)], - chapters.Select(static chapter => chapter.Time)); + chapters.Select(static chapter => chapter.StartTime)); Assert.Equal( [null, null, null, TimeSpan.FromSeconds(775)], - chapters.Select(static chapter => chapter.End)); + chapters.Select(static chapter => chapter.EndTime)); - var hiddenEditionChapters = options[1].ChapterInfo.Chapters; + var hiddenEditionChapters = entries[1].ChapterSet.Chapters; var hiddenChapter = Assert.Single(hiddenEditionChapters); Assert.Equal("A hidden and not enabled chapter.", hiddenChapter.Name); - Assert.Equal(TimeSpan.FromSeconds(120), hiddenChapter.Time); - Assert.Equal(TimeSpan.FromSeconds(240), hiddenChapter.End); + Assert.Equal(TimeSpan.FromSeconds(120), hiddenChapter.StartTime); + Assert.Equal(TimeSpan.FromSeconds(240), hiddenChapter.EndTime); } [Fact] @@ -82,7 +83,7 @@ public async Task Importer_returns_error_for_nonexistent_mkv_file() TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MatroskaProcessFailed"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaProcessFailed); } private void RequireMkvToolNix() diff --git a/tests/ChapterTool.Infrastructure.Tests/Importing/Mp4IntegrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/Importing/Mp4IntegrationTests.cs index 391f38f..420ed95 100644 --- a/tests/ChapterTool.Infrastructure.Tests/Importing/Mp4IntegrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/Importing/Mp4IntegrationTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing; using ChapterTool.Core.Importing.Media; using ChapterTool.Infrastructure.Importing.Media; @@ -38,8 +39,8 @@ public async Task Importer_with_real_reader_converts_durations_to_cumulative_sta Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); Assert.Equal( [TimeSpan.Zero, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20)], - result.Groups.Single().Options.Single().ChapterInfo.Chapters.Select(static chapter => chapter.Time)); - Assert.Contains(result.Groups.Single().Options.Single().MediaReferences ?? [], + result.Groups.Single().Entries.Single().ChapterSet.Chapters.Select(static chapter => chapter.StartTime)); + Assert.Contains(result.Groups.Single().Entries.Single().ReferencedMediaFiles ?? [], reference => reference.AbsolutePath == FixturePath); } @@ -65,6 +66,6 @@ public async Task AtlReader_reports_invalid_path(string path) var result = await reader.ReadAsync(path, TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Equal("Mp4InvalidPath", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.Mp4InvalidPath, result.DiagnosticCode); } } diff --git a/tests/ChapterTool.Infrastructure.Tests/MatroskaChapterImporterTests.cs b/tests/ChapterTool.Infrastructure.Tests/MatroskaChapterImporterTests.cs index e2226af..dfbc6aa 100644 --- a/tests/ChapterTool.Infrastructure.Tests/MatroskaChapterImporterTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/MatroskaChapterImporterTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing; using ChapterTool.Infrastructure.Services; using ChapterTool.Core.Transform; @@ -38,12 +39,12 @@ public sealed class MatroskaChapterImporterTests [Fact] public async Task ImportAsyncReturnsMissingToolDiagnostic() { - var importer = NewImporter(location: new ExternalToolLocation(false, null, "MissingDependency", "mkvextract missing")); + var importer = NewImporter(location: new ExternalToolLocation(false, null, ChapterDiagnosticCode.MissingDependency, "mkvextract missing")); var result = await importer.ImportAsync(new ChapterImportRequest(@"C:\media\movie.mkv"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MatroskaMissingDependency"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaMissingDependency); } [Fact] @@ -54,8 +55,8 @@ public async Task ImportAsyncDelegatesStdoutXmlAndPreservesEditions() var result = await importer.ImportAsync(new ChapterImportRequest(@"C:\media\movie.mkv"), TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal(2, result.Groups.Single().Options.Count); - Assert.Equal("Intro", result.Groups.Single().Options[0].ChapterInfo.Chapters.Single().Name); + Assert.Equal(2, result.Groups.Single().Entries.Count); + Assert.Equal("Intro", result.Groups.Single().Entries[0].ChapterSet.Chapters.Single().Name); } [Fact] @@ -66,20 +67,20 @@ public async Task ImportAsyncPreservesNonAsciiStdoutXmlChapterNames() var result = await importer.ImportAsync(new ChapterImportRequest("/media/movie.mkv"), TestContext.Current.CancellationToken); Assert.True(result.Success, string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => $"{diagnostic.Code}: {diagnostic.Message}"))); - Assert.Equal("序章", result.Groups.Single().Options.Single().ChapterInfo.Chapters.Single().Name); + Assert.Equal("序章", result.Groups.Single().Entries.Single().ChapterSet.Chapters.Single().Name); } [Theory] - [InlineData("", "", "MatroskaNoChapters")] - [InlineData("", "warnings only", "MatroskaProcessFailed")] - public async Task ImportAsyncFailsEmptyStdout(string stdout, string stderr, string code) + [InlineData("", "", ChapterDiagnosticSource.MatroskaChapters, ChapterDiagnosticReason.None)] + [InlineData("", "warnings only", ChapterDiagnosticSource.MatroskaProcess, ChapterDiagnosticReason.Failed)] + public async Task ImportAsyncFailsEmptyStdout(string stdout, string stderr, ChapterDiagnosticSource source, ChapterDiagnosticReason reason) { var importer = NewImporter(result: Successful(stdout, stderr)); var result = await importer.ImportAsync(new ChapterImportRequest(@"C:\media\movie.mkv"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == code); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == new ChapterDiagnosticCode(source, reason)); } [Fact] @@ -99,7 +100,7 @@ public async Task ImportAsyncFailsNonZeroExitWithProcessMetadata() Assert.False(result.Success); var diagnostic = Assert.Single(result.Diagnostics); - Assert.Equal("MatroskaProcessFailed", diagnostic.Code); + Assert.Equal(ChapterDiagnosticCode.MatroskaProcessFailed, diagnostic.Code); Assert.Contains("ExitCode: 2", diagnostic.Message, StringComparison.Ordinal); } @@ -120,7 +121,7 @@ public async Task ImportAsyncPreservesNonAsciiStderrDiagnostics() Assert.False(result.Success); var diagnostic = Assert.Single(result.Diagnostics); - Assert.Equal("MatroskaProcessFailed", diagnostic.Code); + Assert.Equal(ChapterDiagnosticCode.MatroskaProcessFailed, diagnostic.Code); Assert.Contains("错误", diagnostic.Message, StringComparison.Ordinal); } @@ -132,7 +133,7 @@ public async Task ImportAsyncFailsTimeout() var result = await importer.ImportAsync(new ChapterImportRequest("movie.mkv"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MatroskaProcessTimedOut"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaProcessTimedOut); } [Fact] @@ -143,7 +144,18 @@ public async Task ImportAsyncFailsCancellation() var result = await importer.ImportAsync(new ChapterImportRequest("movie.mkv"), TestContext.Current.CancellationToken); Assert.False(result.Success); - Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MatroskaProcessCancelled"); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaProcessCancelled); + } + + [Fact] + public async Task ImportAsyncRejectsTruncatedXmlOutput() + { + var importer = NewImporter(result: Successful(ValidXml) with { OutputTruncated = true }); + + var result = await importer.ImportAsync(new ChapterImportRequest("movie.mkv"), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == ChapterDiagnosticCode.MatroskaOutputTruncated); } [Fact] diff --git a/tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs b/tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs index 697c9ce..2eb6c27 100644 --- a/tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs @@ -1,3 +1,4 @@ +using ChapterTool.Core.Diagnostics; using ChapterTool.Infrastructure.Services; using ChapterTool.Infrastructure.Platform; using Microsoft.Extensions.Logging; @@ -14,7 +15,7 @@ public async Task Native_dependency_service_reports_missing_dependency() var result = await service.ResolveAsync("missing-tool", TestContext.Current.CancellationToken); Assert.False(result.Found); - Assert.Equal("NativeLibraryMissing", result.DiagnosticCode); + Assert.Equal(ChapterDiagnosticCode.NativeLibraryMissing, result.DiagnosticCode); } [Fact] diff --git a/tests/ChapterTool.Infrastructure.Tests/ProcessRunnerTests.cs b/tests/ChapterTool.Infrastructure.Tests/ProcessRunnerTests.cs index beb0eec..25b40cd 100644 --- a/tests/ChapterTool.Infrastructure.Tests/ProcessRunnerTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/ProcessRunnerTests.cs @@ -68,6 +68,32 @@ public async Task RunAsync_marks_cancellation() Assert.True(result.Cancelled); } + [Fact] + public async Task RunAsync_truncates_large_redirected_output() + { + var runner = new ProcessRunner(); + var request = ShellCommand.Create("printf 'abcdef'; printf 'uvwxyz' 1>&2") with { MaxOutputCharacters = 3 }; + + var result = await runner.RunAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal("abc", result.StandardOutput); + Assert.Equal("uvw", result.StandardError); + Assert.True(result.OutputTruncated); + } + + [Fact] + public async Task RunAsync_preserves_partial_output_after_timeout() + { + var runner = new ProcessRunner(); + + var result = await runner.RunAsync( + ShellCommand.Create("printf 'before-timeout' 1>&2; sleep 5", timeout: TimeSpan.FromMilliseconds(100)), + TestContext.Current.CancellationToken); + + Assert.True(result.TimedOut); + Assert.Contains("before-timeout", result.StandardError, StringComparison.Ordinal); + } + [Fact] public async Task RunAsync_can_disable_output_redirection() { diff --git a/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs index 43182af..8dbced3 100644 --- a/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs @@ -53,10 +53,83 @@ public async Task Theme_settings_preserves_corrupt_current_file_and_surfaces_err Assert.Equal("{", await File.ReadAllTextAsync(exception.BackupPath)); } + [Fact] + public async Task Theme_settings_concurrent_corrupt_loads_surface_structured_errors() + { + var root = CreateTempDirectory(); + await File.WriteAllTextAsync(Path.Combine(root, "theme-colors.json"), "{"); + var first = new ThemeSettingsStore(root); + var second = new ThemeSettingsStore(root); + + var results = await Task.WhenAll( + CaptureCorruptLoadAsync(first), + CaptureCorruptLoadAsync(second)); + + Assert.All(results, exception => + { + Assert.NotNull(exception); + Assert.True(File.Exists(exception!.BackupPath)); + }); + } + + [Fact] + public async Task App_settings_concurrent_saves_do_not_race_on_temp_file() + { + var root = CreateTempDirectory(); + var store = new AppSettingsStore(root); + var payload = new string('x', 100_000); + + await Task.WhenAll(Enumerable.Range(0, 30).Select(index => + store.SaveAsync(new AppSettings(Language: "en-US", FfprobePath: $"{payload}-{index}"), TestContext.Current.CancellationToken).AsTask())); + + var saved = await store.LoadAsync(TestContext.Current.CancellationToken); + Assert.Equal("en-US", saved.Language); + Assert.Empty(Directory.EnumerateFiles(root, "appsettings.json.*.tmp")); + } + + [Fact] + public async Task Theme_settings_concurrent_saves_do_not_race_on_temp_file() + { + var root = CreateTempDirectory(); + var store = new ThemeSettingsStore(root); + + await Task.WhenAll(Enumerable.Range(0, 30).Select(index => + store.SaveAsync(ThemeColorSettings.Default with { BackChange = $"#{index % 10}{index % 10}{index % 10}{index % 10}{index % 10}{index % 10}" }, TestContext.Current.CancellationToken).AsTask())); + + _ = await store.LoadAsync(TestContext.Current.CancellationToken); + Assert.Empty(Directory.EnumerateFiles(root, "theme-colors.json.*.tmp")); + } + private static string CreateTempDirectory() { var path = Path.Combine(Path.GetTempPath(), "ChapterTool.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } + + private static async Task<CorruptSettingsFileException?> CaptureCorruptLoadAsync(AppSettingsStore store) + { + try + { + _ = await store.LoadAsync(TestContext.Current.CancellationToken); + return null; + } + catch (CorruptSettingsFileException exception) + { + return exception; + } + } + + private static async Task<CorruptSettingsFileException?> CaptureCorruptLoadAsync(ThemeSettingsStore store) + { + try + { + _ = await store.LoadAsync(TestContext.Current.CancellationToken); + return null; + } + catch (CorruptSettingsFileException exception) + { + return exception; + } + } }