diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..d20c0fe4 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..be6043b4 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.codegraph] +command = "codegraph" +args = ["serve", "--mcp"] diff --git a/.codex/skills/.openspec-target b/.codex/skills/.openspec-target new file mode 100644 index 00000000..4a33ff62 --- /dev/null +++ b/.codex/skills/.openspec-target @@ -0,0 +1 @@ +agents diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md index 1375861c..fc2d9ee5 100644 --- a/.codex/skills/openspec-apply-change/SKILL.md +++ b/.codex/skills/openspec-apply-change/SKILL.md @@ -7,14 +7,14 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Implement tasks from an OpenSpec change. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. -**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -23,9 +23,9 @@ Implement tasks from an OpenSpec change. If a name is provided, use it. Otherwise: - Infer from conversation context if the user mentioned a change - Auto-select if only one active change exists - - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one - Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + Always announce: "Using change: " and how to override (e.g., `/openspec-apply-change `). 2. **Check status to understand the schema** ```bash @@ -47,12 +47,29 @@ Implement tasks from an OpenSpec change. - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change + - If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + 4. **Read context files** Read every file path listed under `contextFiles` from the apply instructions output. @@ -60,6 +77,9 @@ Implement tasks from an OpenSpec change. - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + 5. **Show current progress** Display: @@ -80,6 +100,7 @@ Implement tasks from an OpenSpec change. **Pause if:** - Task is unclear → ask for clarification - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently - Error or blocker encountered → report and wait for guidance - User interrupts @@ -119,7 +140,7 @@ Working on task 4/7: - [x] Task 2 ... -All tasks complete! Ready to archive this change. +All tasks complete! You can archive this change with `/openspec-archive-change`. ``` **Output On Pause (Issue Encountered)** @@ -150,7 +171,14 @@ What would you like to do? - Keep code changes minimal and scoped to each task - Update task checkbox immediately after completing each task - Pause on errors, blockers, or unclear requirements - don't guess +- When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior +- Only mark a task `- [x]` when its specified behavior is fully implemented, not when it is partially done or deferred - Use contextFiles from CLI output, don't assume specific file names +- Do not use context or operation guidance as proof that a task is complete +- Apply relevant project context; report conflicts with controlling workflow inputs +- Consider every guidance entry; explain any inapplicable or conflicting advice +- Do not copy runtime context or operation guidance into implementation files or planning artifacts +- Preserve CLI-controlled blocked/ready/all-done behavior and completion criteria **Fluid Workflow Integration** diff --git a/.codex/skills/openspec-archive-change/SKILL.md b/.codex/skills/openspec-archive-change/SKILL.md index c0c169d1..fb20c367 100644 --- a/.codex/skills/openspec-archive-change/SKILL.md +++ b/.codex/skills/openspec-archive-change/SKILL.md @@ -7,25 +7,57 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Archive a completed change in the experimental workflow. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one - Show only active changes (not already archived). + When prompting, show only active changes (not already archived). Include the schema used for each change if available. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: " and how to override (e.g., `/openspec-archive-change `). + + **Load current archive inputs before the existing archive checks:** + + After resolving the selected change and planning root, run: + ```bash + openspec instructions archive --change "" --json + ``` + Keep the same selected-root flags on this command. This lookup is advisory and + optional: it only supplies extra prompt inputs, so it must never block archiving. + If it exits non-zero or returns invalid JSON — for example on an older CLI that + does not support this command yet — continue the archive workflow with no + context and no operation guidance. Do not report an error and do not stop. + + A successful response may omit both optional fields. Treat `context` as a + required prompt-level input: read and consider it, and apply relevant project + facts, conventions, and constraints. Treat `operationGuidance` as optional + additive advice: read and consider every entry, and follow entries that are + applicable and compatible with the built-in archive workflow. + + Keep both fields separate from built-in steps, explicit user choices, resolved + paths, CLI checks, and command contracts. If context conflicts with one of those + controlling inputs, report the conflict and preserve the controlling value. If + guidance is inapplicable or conflicts with a controlling input, do not follow it + and explain why. Do not infer replacement paths, skipped prompts, or flags from + either field, and do not copy their text verbatim into specs, change artifacts, + or archive summaries unless the user separately asks for it. These are + prompt-level behavior contracts, not enforceable checks. 2. **Check artifact completion status** @@ -34,11 +66,11 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - - `artifacts`: List of artifacts with their status (`done` or other) + - `artifacts`: List of artifacts with their status (`done`, `skipped`, or other) - **If any artifacts are not `done`:** + **If any artifacts are neither `done` nor `skipped`** (skipped artifacts satisfy the requirement - the change declares skip_specs): - Display warning listing incomplete artifacts - - Use **AskUserQuestion tool** to confirm user wants to proceed + - Ask the user to confirm they want to proceed - Proceed if user confirms 3. **Check task completion status** @@ -49,17 +81,20 @@ Archive a completed change in the experimental workflow. **If incomplete tasks found:** - Display warning showing count of incomplete tasks - - Use **AskUserQuestion tool** to confirm user wants to proceed + - Ask the user to confirm they want to proceed - Proceed if user confirms **If no tasks file exists:** Proceed without task-related warning. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON as the only + delta-spec source. If the `specs` entry is missing or + `existingOutputPaths` is empty, proceed without a sync prompt and do not infer + delta specs from other artifacts. **If delta specs exist:** - - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` + - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -67,7 +102,30 @@ Archive a completed change in the experimental workflow. - If changes needed: "Sync now (recommended)", "Archive without syncing" - If already synced: "Archive now", "Sync anyway", "Cancel" - If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice. + Route on the answer: + - "Cancel" — stop, do not archive + - "Archive without syncing" or "Archive now" — proceed to archive + - "Sync now" or "Sync anyway" — sync, then verify (below) + - Anything else — ask again rather than archiving + + Before a selected sync writes any main spec, run + `openspec instructions specs --change "" --json` once with the same + selected-root flags. Require a zero exit status and valid artifact-instruction + JSON. If the lookup fails or returns invalid JSON, report the error and stop + before writing any main spec or moving the change. A valid response with omitted + `rules` is the no-rules case. Apply returned `rules` only to the content and + form of main specs produced by this merge; do not use them as archive guidance, + change CLI behavior, or copy the rule text into any output file. + + Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. + + Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + - ADDED requirements present + - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + + If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and `changeRoot` is intact, so the user can fix the mismatch or re-run the sync and start the archive again. 5. **Perform the archive** @@ -76,14 +134,14 @@ Archive a completed change in the experimental workflow. mkdir -p "/archive" ``` - Generate target name using current date: `YYYY-MM-DD-` + Generate the target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-`. Never stack a second date (same rule as `openspec archive`). **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - If no: Move `changeRoot` to the archive directory ```bash - mv "" "/archive/YYYY-MM-DD-" + mv "" "/archive/" ``` 6. **Display summary** @@ -97,22 +155,28 @@ Archive a completed change in the experimental workflow. **Output On Success** -``` +```markdown ## Archive Complete **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ -**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") +**Archived to:** the archive path derived from `planningHome.changesDir`// +**Specs:** <"✓ Synced to main specs" only if the step 4 verification passed; otherwise "No delta specs" or "Sync skipped"> -All artifacts complete. All tasks complete. +<"All artifacts complete. All tasks complete." — or, if archived with warnings, list them instead (e.g. "Archived with 2 incomplete tasks")> ``` **Guardrails** -- Always prompt for change selection if not provided +- Announce the selected change; prompt for selection when it is ambiguous - Use artifact graph (openspec status --json) for completion checking - Don't block archive on warnings - just inform and confirm - Preserve .openspec.yaml when moving to archive (it moves with the directory) - Show clear summary of what happened -- If sync is requested, use openspec-sync-specs approach (agent-driven) +- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) +- Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving `changeRoot` - If delta specs exist, always run the sync assessment and show the combined summary before prompting +- Apply relevant runtime context and report conflicts; operation guidance remains advisory +- Consider every guidance entry and explain any inapplicable or conflicting advice +- Existing CLI checks, resolved paths, prompts, and command contracts are unchanged +- Artifact rules constrain only the specs being written and are never operation guidance +- Never copy runtime context, operation guidance, or artifact-rule text verbatim into output files diff --git a/.codex/skills/openspec-bulk-archive-change/SKILL.md b/.codex/skills/openspec-bulk-archive-change/SKILL.md index 309152af..7afa25f4 100644 --- a/.codex/skills/openspec-bulk-archive-change/SKILL.md +++ b/.codex/skills/openspec-bulk-archive-change/SKILL.md @@ -7,14 +7,16 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Archive multiple completed changes in a single operation. This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) @@ -28,13 +30,38 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig 2. **Prompt for change selection** - Use **AskUserQuestion tool** with multi-select to let user choose changes: + Ask the user to choose changes (multi-select): - Show each change with its schema - Include an option for "All changes" - Allow any number of selections (1+ works, 2+ is the typical use case) **IMPORTANT**: Do NOT auto-select. Always let the user choose. + **Load current archive inputs once for the selected root before batch validation:** + + Choose one selected change from this root and run + `openspec instructions archive --change "" --json` with the + same selected-root flags. This lookup is advisory and optional: it only supplies + extra prompt inputs, so it must never block the batch. If it fails or returns + invalid JSON — for example on an older CLI that does not support this command + yet — continue the batch with no context and no operation guidance. Do not + report an error and do not stop. + + A valid response may omit `context` and `operationGuidance`. Treat + `context` as a required prompt-level input across the batch: read and consider + it, and apply relevant project facts, conventions, and constraints. Treat + `operationGuidance` as optional additive advice: read and consider every + entry, and follow entries that are applicable and compatible with the built-in + batch workflow. + + Keep both fields separate from conflict analysis, explicit user choices, + resolved paths, CLI checks, and command contracts. If context conflicts with one + of those controlling inputs, report the conflict and preserve the controlling + value. If guidance is inapplicable or conflicts with a controlling input, do not + follow it and explain why. Do not infer skipped prompts, replacement paths, or + flags from either field, and do not copy their text verbatim into specs, changes, + or summaries. These are prompt-level behavior contracts, not enforceable checks. + 3. **Batch validation - gather status for all selected changes** For each selected change, collect: @@ -50,17 +77,21 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON - List which capability specs exist - For each, extract requirement names (lines matching `### Requirement: `) - + - Treat this list as the only delta-spec source. If the `specs` entry is + missing or the list is empty, perform no spec sync or specs-instruction + lookup for that change; do not infer deltas from unrelated artifacts. + - Evaluate this independently for every change, including mixed-schema + batches where some schemas have no `specs` artifact. 4. **Detect spec conflicts** - Build a map of `capability -> [changes that touch it]`: + Build a map keyed by ``, the exact path relative to `specs/`: - ``` - auth -> [change-a, change-b] <- CONFLICT (2+ changes) - api -> [change-c] <- OK (only 1 change) + ```text + identity/user-auth -> [change-a, change-b] <- CONFLICT (2+ changes) + billing/user-auth -> [change-c] <- OK (different full path) ``` - A conflict exists when 2+ selected changes have delta specs for the same capability. + A conflict exists when 2+ selected changes have delta specs for the exact same ``. 5. **Resolve conflicts agentically** @@ -78,38 +109,39 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - If neither implemented -> skip spec sync, warn user d. **Record resolution** for each conflict: - - Which change's specs to apply - - In what order (if both) + - An inclusion or exclusion decision for every delta spec, keyed by change and `` + - Which included delta specs to apply and in what order + - Which delta specs to exclude from sync because their implementation is missing - Rationale (what was found in codebase) 6. **Show consolidated status table** Display a table summarizing all changes: - ``` + ```markdown | Change | Artifacts | Tasks | Specs | Conflicts | Status | |---------------------|-----------|-------|---------|-----------|--------| | schema-management | Done | 5/5 | 2 delta | None | Ready | | project-config | Done | 3/3 | 1 delta | None | Ready | - | add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* | + | add-oauth | Done | 4/4 | 1 delta | identity/user-auth (!) | Ready* | | add-verify-skill | 1 left | 2/5 | None | None | Warn | ``` For conflicts, show the resolution: - ``` + ```text * Conflict resolution: - - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) + - identity/user-auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) ``` For incomplete changes, show warnings: - ``` + ```text Warnings: - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks ``` 7. **Confirm batch operation** - Use **AskUserQuestion tool** with a single confirmation: + Ask the user a single confirmation question: - "Archive N changes?" with options based on status - Options might include: @@ -119,31 +151,73 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig If there are incomplete changes, make clear they'll be archived with warnings. + Route on the answer by intent, not by exact label — you wrote these labels, + so match what the user picked rather than the wording above: + - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. + - The archive-everything option — proceed with every selected change + - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - Anything else — ask again rather than archiving + + Before step 8 writes the first main spec or moves any change, fetch every + required specs-rule snapshot for the confirmed batch. For each change that will + sync concrete `artifactPaths.specs.existingOutputPaths`, run + `openspec instructions specs --change "" --json` exactly once with the + same selected-root flags. Obtain all snapshots before the first write or move. + If any lookup exits non-zero or returns invalid artifact-instruction JSON, + identify the affected change, report the error, and stop the whole batch before + any main-spec write or change move. Do not treat lookup failure as omitted + rules. A valid response without `rules` is the no-rules case. + 8. **Execute archive for each confirmed change** + Before processing, carry the recorded decisions from step 5 (after any step 7 re-derivation) into two per-delta sets: + - `includedDeltas`: all non-conflicting delta specs from confirmed changes plus conflict deltas selected for sync + - `excludedDeltas`: conflict deltas from confirmed changes excluded because their implementation is missing + - A single change can have both included and excluded delta specs. Keep the decision per delta; do not collapse it into a per-change sync flag. + Process changes in the determined order (respecting conflict resolution): - a. **Sync specs** if delta specs exist: - - Use the openspec-sync-specs approach (agent-driven intelligent merge) - - For conflicts, apply in resolved order - - Track if sync was done + a. **Sync included delta specs**: + - Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) only for changes with entries in `includedDeltas`, passing only the included delta paths and explicitly instructing it to ignore that change's `excludedDeltas`. Wait for it to finish. + - For conflicts, apply in resolved order. + - Pass that change's fetched specs-rule snapshot into inline sync; inline + sync must reuse it without fetching instructions again + - Apply artifact rules only to main specs produced by that change. They do + not change conflict resolution, archive behavior, or CLI contracts, and + their text is not copied into an output file + - Do not delegate to a background task — step 8c would move `changeRoot` out from under a sync that is still reading it. + - If a change has no included delta specs, do not run the sync workflow for it. + + b. **Verify included delta specs before moving changeRoot**: + - Re-run the comparison only for delta specs in `includedDeltas` against main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 3 status JSON, not a hardcoded repo path). + - Verify that main specs are updated: + - ADDED requirements present + - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact + - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match + - RENAMED requirements present under the new name and absent under the old one + - Do not verify delta specs in `excludedDeltas`; they are intentionally left unsynced. + - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's `changeRoot` — do not archive that change. `changeRoot` remains intact. + + c. **Perform the archive**: + + Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-` (same rule as `openspec archive`). - b. **Perform the archive**: ```bash mkdir -p "/archive" - mv "" "/archive/YYYY-MM-DD-" + mv "" "/archive/" ``` - c. **Track outcome** for each change: + d. **Track outcome** for each change: - Success: archived successfully - - Failed: error during archive (record error) + - Failed: error during archive or spec verification (record error) - Skipped: user chose not to archive (if applicable) + - Sync skipped: for every delta in `excludedDeltas`, report `sync skipped` with the change, ``, and recorded reason. This is distinct from skipping the archive. 9. **Display summary** Show final results: - ``` + ```markdown ## Bulk Archive Complete Archived 3 changes: @@ -156,11 +230,12 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig Spec sync summary: - 4 delta specs synced to main specs - - 1 conflict resolved (auth: applied both in chronological order) + - 1 delta spec sync skipped (add-jwt, identity/user-auth: implementation not found) + - 1 conflict resolved (identity/user-auth: synced add-oauth, skipped add-jwt) ``` If any failures: - ``` + ```text Failed 1 change: - some-change: Archive directory already exists ``` @@ -168,8 +243,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Conflict Resolution Examples** Example 1: Only one implemented -``` -Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt] +```text +Conflict: /openspec/specs/auth/spec.md touched by [add-oauth, add-jwt] Checking add-oauth: - Delta adds "OAuth Provider Integration" requirement @@ -183,8 +258,8 @@ Resolution: Only add-oauth is implemented. Will sync add-oauth specs only. ``` Example 2: Both implemented -``` -Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql] +```text +Conflict: /openspec/specs/api/spec.md touched by [add-rest-api, add-graphql] Checking add-rest-api (created 2026-01-10): - Delta adds "REST Endpoints" requirement @@ -200,12 +275,12 @@ then add-graphql specs (chronological order, newer takes precedence). **Output On Success** -``` +```markdown ## Bulk Archive Complete Archived N changes: -- -> archive/YYYY-MM-DD-/ -- -> archive/YYYY-MM-DD-/ +- -> archive// +- -> archive// Spec sync summary: - N delta specs synced to main specs @@ -214,11 +289,11 @@ Spec sync summary: **Output On Partial Success** -``` +```markdown ## Bulk Archive Complete (partial) Archived N changes: -- -> archive/YYYY-MM-DD-/ +- -> archive// Skipped M changes: - (user chose not to archive incomplete) @@ -229,7 +304,7 @@ Failed K changes: **Output When No Changes** -``` +```markdown ## No Changes to Archive No active changes found. Create a new change to get started. @@ -243,7 +318,22 @@ No active changes found. Create a new change to get started. - Skip spec sync only when implementation is missing (warn user) - Show clear per-change status before confirming - Use single confirmation for entire batch +- Never archive after the user cancels the confirmation — a cancelled batch archives nothing - Track and report all outcomes (success/skip/fail) - Preserve .openspec.yaml when moving to archive -- Archive directory target uses current date: YYYY-MM-DD- +- Archive directory target uses current date: YYYY-MM-DD-; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) - If archive target exists, fail that change but continue with others +- If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs +- Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas +- Report every excluded delta as `sync skipped` without treating the archive itself as skipped +- Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at `/openspec/specs//spec.md` before moving `changeRoot` +- Fetch archive inputs once per selected root before spec inspection or moves +- Fetch all required specs-rule snapshots before the batch's first main-spec write or move +- A failed archive-inputs lookup never blocks the batch; it proceeds with no context or guidance +- A failed specs instruction lookup stops the whole batch atomically +- Changes without concrete `artifactPaths.specs.existingOutputPaths` continue without spec sync +- Apply relevant runtime context across the batch and report conflicts +- Operation guidance remains advisory; consider every entry and explain rejected advice +- Keep runtime inputs, conflict analysis, CLI-derived values, and artifact rules separate +- Artifact rules constrain only written specs +- Never copy runtime input or artifact-rule text verbatim into output files diff --git a/.codex/skills/openspec-continue-change/SKILL.md b/.codex/skills/openspec-continue-change/SKILL.md index 31b476f2..934d7834 100644 --- a/.codex/skills/openspec-continue-change/SKILL.md +++ b/.codex/skills/openspec-continue-change/SKILL.md @@ -7,22 +7,25 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Continue working on a change by creating the next artifact. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes sorted by most recently modified, and ask the user to select one - Present the top 3-4 most recently modified changes as options, showing: + When prompting, present the top 3-4 most recently modified changes as options, showing: - Change name - Schema (from `schema` field if present, otherwise "spec-driven") - Status (e.g., "0/5 tasks", "complete", "no tasks") @@ -30,7 +33,7 @@ Continue working on a change by creating the next artifact. Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue. - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: " and how to override (e.g., `/openspec-continue-change `). 2. **Check current status** ```bash @@ -38,18 +41,18 @@ Continue working on a change by creating the next artifact. ``` Parse the JSON to understand current state. The response includes: - `schemaName`: The workflow schema being used (e.g., "spec-driven") - - `artifacts`: Array of artifacts with their status ("done", "ready", "blocked") - - `isComplete`: Boolean indicating if all artifacts are complete + - `artifacts`: Array of artifacts with their status ("done", "skipped", "ready", "blocked") + - `isPlanningComplete`: Boolean indicating if all planning artifacts are complete. Older CLI versions expose the same value as `isComplete`. - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 3. **Act based on status**: --- - **If all artifacts are complete (`isComplete: true`)**: + **If all planning artifacts are complete (`isPlanningComplete: true`, or legacy `isComplete: true`)**: - Congratulate the user - Show final status including the schema used - - Suggest: "All artifacts created! You can now implement this change or archive it." + - Suggest: "Planning is complete! You can now implement this change. Once implementation and any tracked work are complete, archive it." - STOP --- @@ -66,10 +69,12 @@ Continue working on a change by creating the next artifact. - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance - `resolvedOutputPath`: Resolved path or pattern to write the artifact - - `dependencies`: Completed artifacts to read for context + - `dependencies`: Completed artifacts to read for context (entries with `skipped: true` have no files - do not look for them) + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - pick another artifact - **Create the artifact file**: - - Read any completed dependency files for context - - Use `template` as the structure - fill in its sections + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath` + - Otherwise use `template` as the structure - fill in its sections - Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file - Write to the `resolvedOutputPath` specified in instructions. If it is a glob pattern, choose the concrete file path using the schema instruction and the change's context - Show what was created and what's now unlocked @@ -97,22 +102,13 @@ After each invocation, show: **Artifact Creation Guidelines** -The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create. +The artifact types and their purpose depend on the schema. The `instruction` field from the instructions output is the authoritative guidance for each artifact - follow it even when the artifact has a familiar name (proposal.md, tasks.md, etc.), since custom schemas may define different content or a different process for the same file names. -Common artifact patterns: - -**spec-driven schema** (proposal → specs → design → tasks): -- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact. - - The Capabilities section is critical - each capability listed will need a spec file. -- **specs//spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name). -- **design.md**: Document technical decisions, architecture, and implementation approach. -- **tasks.md**: Break down implementation into checkboxed tasks. - -For other schemas, follow the `instruction` field from the CLI output. +If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly. **Guardrails** - Create ONE artifact per invocation -- Always read dependency artifacts before creating a new one +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - Never skip artifacts or create out of order - If context is unclear, ask the user before creating - Verify the artifact file exists after writing before marking progress diff --git a/.codex/skills/openspec-explore/SKILL.md b/.codex/skills/openspec-explore/SKILL.md index 771271a8..0906b921 100644 --- a/.codex/skills/openspec-explore/SKILL.md +++ b/.codex/skills/openspec-explore/SKILL.md @@ -7,16 +7,16 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. --- @@ -55,22 +55,25 @@ Depending on what the user brings, you might: **Visualize** ``` -┌─────────────────────────────────────────┐ -│ Use ASCII diagrams liberally │ -├─────────────────────────────────────────┤ -│ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ -│ │ -│ System diagrams, state machines, │ -│ data flows, architecture sketches, │ -│ dependency graphs, comparison tables │ -│ │ -└─────────────────────────────────────────┘ ++------------------------------------------+ +| Use ASCII diagrams liberally | ++------------------------------------------+ +| | +| [State A] -------> [State B] | +| | | +| v | +| [State C] | +| | +| System diagrams, state machines, | +| data flows, architecture sketches, | +| dependency graphs, comparison tables | +| | ++------------------------------------------+ ``` +**Draw with plain ASCII only** — borders `+` `-` `|`, arrows `-->` `<--` `^` `v`, markers `*` `x`. +Unicode diagram glyphs can render at different widths across terminals, fonts, and locales, so padded boxes and aligned tables can drift. Keep every diagram character ASCII. + **Surface risks and unknowns** - Identify what could go wrong - Find gaps in understanding @@ -94,6 +97,12 @@ This tells you: - Their names, schemas, and status - What the user might be working on +Then read the project's own context from the resolved root - `/openspec/config.yaml` (or `config.yml`). Use the `root.path` returned above, and skip this if neither file exists: +- `context`: project background - tech stack, conventions, constraints +- `rules`: keyed by artifact id - the entries for an artifact apply only when you write that artifact + +Ground your thinking in these. They are constraints for you to follow, not content to reproduce: do NOT copy them into the conversation or into any artifact you create. + ### When no change exists Think freely. When insights crystallize, you might offer: @@ -101,6 +110,15 @@ Think freely. When insights crystallize, you might offer: - "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize +If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: + +1. Run `openspec new change ""` (with `--store ` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store ` on every applicable follow-up `status` and `instructions` command. +2. Run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. +3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. +4. After creating each artifact, re-run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. + +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. + ### When a change exists If the user mentions a change or you detect one is relevant: @@ -116,14 +134,16 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |----------------------------|--------------------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + + | Insight Type | Where to Capture | + |----------------------------|-------------------------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -154,17 +174,17 @@ User: I'm thinking about adding real-time collaboration You: Real-time collab is a big space. Let me think about this... COLLABORATION SPECTRUM - ════════════════════════════════════════════ + ============================================ Awareness Coordination Sync - │ │ │ - ▼ ▼ ▼ - ┌────────┐ ┌────────┐ ┌────────┐ - │Presence│ │Cursors │ │ CRDT │ - │ "3 │ │ Multi │ │Conflict│ - │online" │ │ select │ │ free │ - └────────┘ └────────┘ └────────┘ - │ │ │ + | | | + v v v + +--------+ +--------+ +--------+ + |Presence| |Cursors | | CRDT | + | "3 | | Multi | |Conflict| + |online" | | select | | free | + +--------+ +--------+ +--------+ + | | | trivial moderate complex Where's your head at? @@ -176,34 +196,34 @@ User: The auth system is a mess You: [reads codebase] - ┌─────────────────────────────────────────────┐ - │ CURRENT AUTH FLOW │ - └─────────────────────────────────────────────┘ - │ - ┌─────────────┼─────────────┐ - ▼ ▼ ▼ - ┌─────────┐ ┌─────────┐ ┌─────────┐ - │ Google │ │ GitHub │ │ Email │ - │ OAuth │ │ OAuth │ │ Magic │ - └────┬────┘ └────┬────┘ └────┬────┘ - │ │ │ - └─────────────┼─────────────┘ - ▼ - ┌───────────┐ - │ Session │ - └─────┬─────┘ - │ - ▼ - ┌───────────┐ - │ Perms │ - └───────────┘ + +---------------------------------------------+ + | CURRENT AUTH FLOW | + +---------------------------------------------+ + | + +-------------+-------------+ + v v v + +---------+ +---------+ +---------+ + | Google | | GitHub | | Email | + | OAuth | | OAuth | | Magic | + +----+----+ +----+----+ +----+----+ + | | | + +-------------+-------------+ + v + +-----------+ + | Session | + +-----+-----+ + | + v + +-----------+ + | Perms | + +-----------+ I see three tangles. Which one's burning? ``` **User is stuck mid-implementation:** ``` -User: /opsx:explore add-auth-system +User: /openspec-explore add-auth-system The OAuth integration is more complex than expected You: [reads change artifacts] @@ -228,19 +248,19 @@ User: A CLI tool that tracks local dev environments You: That changes everything. - ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ - └─────────────────────────────────────────────────┘ + +-------------------------------------------------+ + | CLI TOOL DATA STORAGE | + +-------------------------------------------------+ Key constraints: - • No daemon running - • Must work offline - • Single user + - No daemon running + - Must work offline + - Single user SQLite Postgres - Deployment embedded ✓ needs server ✗ - Offline yes ✓ no ✗ - Single file yes ✓ no ✗ + Deployment embedded needs server + Offline yes no + Single file yes no SQLite. Not even close. @@ -280,11 +300,12 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally -- **Don't auto-capture** - Offer to save insights, don't just do it +- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. +- **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change ""` (with `--store ` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts. - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality - **Do question assumptions** - Including the user's and your own diff --git a/.codex/skills/openspec-ff-change/SKILL.md b/.codex/skills/openspec-ff-change/SKILL.md index 61fab93d..5a8a7f1e 100644 --- a/.codex/skills/openspec-ff-change/SKILL.md +++ b/.codex/skills/openspec-ff-change/SKILL.md @@ -7,12 +7,12 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Fast-forward through artifact creation - generate everything needed to start implementation in one go. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. @@ -20,7 +20,7 @@ Fast-forward through artifact creation - generate everything needed to start imp 1. **If no clear input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). @@ -39,12 +39,12 @@ Fast-forward through artifact creation - generate everything needed to start imp ``` Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - - `artifacts`: list of all artifacts with their status and dependencies + - `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on) - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +4. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -58,20 +58,27 @@ Fast-forward through artifact creation - generate everything needed to start imp - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath` + - Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "✓ Created " - b. **Continue until all `applyRequires` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just `apply.requires`)** - After creating each artifact, re-run `openspec status --change "" --json` - - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array - - Stop when all `applyRequires` artifacts are done + - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation 5. **Show final status** @@ -83,13 +90,14 @@ Fast-forward through artifact creation - generate everything needed to start imp After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "Run `/openspec-apply-change` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** -- Follow the `instruction` field from `openspec instructions` for each artifact type +- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use `template` as the structure for your output file - fill in its sections @@ -98,8 +106,8 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) -- Always read dependency artifacts before creating a new one +- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) - If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum - If a change with that name already exists, suggest continuing that change instead - Verify each artifact file exists after writing before proceeding to next diff --git a/.codex/skills/openspec-new-change/SKILL.md b/.codex/skills/openspec-new-change/SKILL.md index b4d8a9e7..f0ee97a0 100644 --- a/.codex/skills/openspec-new-change/SKILL.md +++ b/.codex/skills/openspec-new-change/SKILL.md @@ -7,12 +7,12 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Start a new change using the experimental artifact-driven approach. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. @@ -20,7 +20,7 @@ Start a new change using the experimental artifact-driven approach. 1. **If no clear input provided, ask what they want to build** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + Ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). diff --git a/.codex/skills/openspec-onboard/SKILL.md b/.codex/skills/openspec-onboard/SKILL.md index ac4ac6fe..842f6ecc 100644 --- a/.codex/skills/openspec-onboard/SKILL.md +++ b/.codex/skills/openspec-onboard/SKILL.md @@ -7,12 +7,12 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. --- @@ -28,7 +28,7 @@ openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" ``` **If CLI not installed:** -> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`. +> OpenSpec CLI is not installed. Install it first, then come back to `/openspec-onboard`. Stop here if not installed. @@ -155,7 +155,7 @@ Spend 1-2 minutes investigating the relevant code: │ [Optional: ASCII diagram if helpful] │ └─────────────────────────────────────────┘ -Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. +Explore mode (`/openspec-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. Now let's create a change to hold our work. ``` @@ -211,6 +211,11 @@ I'll draft one based on our task. **DO:** Draft the proposal content (don't save yet): +`` is the spec directory relative to `specs/` (for example, +`user-auth` or `identity/user-auth`). Use the exact existing path for modified +capabilities. For new capabilities, follow the project's established spec +organization. + ``` Here's a draft proposal: @@ -227,10 +232,11 @@ Here's a draft proposal: ## Capabilities ### New Capabilities -- ``: [brief description] +- ``: [brief description] ### Modified Capabilities +- ``: [brief description] ## Impact @@ -368,12 +374,12 @@ Here are the implementation tasks: ## 1. [Category or file] -- [ ] 1.1 [Specific task] -- [ ] 1.2 [Specific task] +- [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] +- [ ] 1.2 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] -## 2. Verify +## 2. Integration Verification -- [ ] 2.1 [Verification step] +- [ ] 2.1 Verify [broader integration or system behavior] with [end-to-end test or observable result] --- @@ -431,14 +437,14 @@ When a change is complete, we archive it. The archive path is derived from `plan Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. ``` -**DO:** +**DO:** Archive the change (`--yes` answers the confirmation prompts, which you cannot answer from a tool call): ```bash -openspec archive "" +openspec archive "" --yes ``` **SHOW:** ``` -Archived to: `/archive/YYYY-MM-DD-/` +Archived to: `/archive//` (the target name prepends today's date, unless the name already starts with a `YYYY-MM-DD-` prefix — then it is kept as-is, no second date) The change is now part of your project's history. The code is in your codebase, the decision record is preserved. ``` @@ -471,25 +477,25 @@ This same rhythm works for any size change—a small fix or a major feature. | Command | What it does | |-------------------|--------------------------------------------| - | `/opsx:propose` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems before/during work | - | `/opsx:apply` | Implement tasks from a change | - | `/opsx:archive` | Archive a completed change | + | `/openspec-propose` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems before/during work | + | `/openspec-apply-change` | Implement tasks from a change | + | `/openspec-archive-change` | Archive a completed change | -**Additional commands:** +**Additional commands** (only if installed - availability depends on your profile): | Command | What it does | |--------------------|----------------------------------------------------------| - | `/opsx:new` | Start a new change, step through artifacts one at a time | - | `/opsx:continue` | Continue working on an existing change | - | `/opsx:ff` | Fast-forward: create all artifacts at once | - | `/opsx:verify` | Verify implementation matches artifacts | + | `/openspec-new-change` | Start a new change, step through artifacts one at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | --- ## What's Next? -Try `/opsx:propose` on something you actually want to build. You've got the rhythm now! +Try `/openspec-propose` on something you actually want to build. You've got the rhythm now! ``` --- @@ -504,8 +510,8 @@ If the user says they need to stop, want to pause, or seem disengaged: No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "" --json`. To pick up where we left off later: -- `/opsx:continue ` - Resume artifact creation -- `/opsx:apply ` - Jump to implementation (if tasks exist) +- `/openspec-continue-change ` - Resume artifact creation (if installed; otherwise `openspec status --change "" --json` shows the next artifact) +- `/openspec-apply-change ` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. ``` @@ -523,21 +529,21 @@ If the user says they just want to see the commands or skip the tutorial: | Command | What it does | |--------------------------|--------------------------------------------| - | `/opsx:propose ` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems (no code changes) | - | `/opsx:apply ` | Implement tasks | - | `/opsx:archive ` | Archive when done | + | `/openspec-propose ` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change ` | Implement tasks | + | `/openspec-archive-change ` | Archive when done | -**Additional commands:** +**Additional commands** (only if installed - availability depends on your profile): | Command | What it does | |---------------------------|-------------------------------------| - | `/opsx:new ` | Start a new change, step by step | - | `/opsx:continue ` | Continue an existing change | - | `/opsx:ff ` | Fast-forward: all artifacts at once | - | `/opsx:verify ` | Verify implementation | + | `/openspec-new-change ` | Start a new change, step by step | + | `/openspec-continue-change ` | Continue an existing change | + | `/openspec-ff-change ` | Fast-forward: all artifacts at once | + | `/openspec-verify-change ` | Verify implementation | -Try `/opsx:propose` to start your first change. +Try `/openspec-propose` to start your first change. ``` Exit gracefully. diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md index 716d2d3d..ebc39189 100644 --- a/.codex/skills/openspec-propose/SKILL.md +++ b/.codex/skills/openspec-propose/SKILL.md @@ -7,53 +7,79 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Propose a new change - create the change and generate all artifacts in one step. -I'll create a change with artifacts: +**Planning boundary**: This workflow creates planning artifacts only. The user request that selected or triggered this workflow authorizes planning only, even if it asks to build or fix something. Do not edit project code. After the planning artifacts are complete, stop. Do not start implementation in the same response, even if the initial request asks for it. Wait for a new user request after the artifacts are presented; then start the apply workflow. + +I'll create a change with the artifacts your schema defines. With the default spec-driven schema that is: - proposal.md (what & why) +- `specs//spec.md` (what the system must do - a delta, not the main spec) - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +`` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve an existing capability's full path and follow the project's established organization for new capabilities. + +When the user is ready to implement, they must start the apply workflow explicitly. --- -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** -1. **If no clear input provided, ask what they want to build** +1. **Understand the request and clarify material ambiguity** - Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + If no clear input is provided, ask the user (open-ended, no preset options): > "What change do you want to work on? Describe what you want to build or fix." From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. -2. **Create the change directory** + If the request contains ambiguity that would materially affect scope, externally observable behavior, compatibility, or acceptance criteria, ask the user before creating the change. For minor details, make a reasonable assumption and record it in the planning artifacts. + +2. **Determine the workflow schema** + + Use the configured default schema unless the user explicitly requests a different workflow. + + **Use a different schema only if the user:** + - Explicitly requests a specific schema by name → use `--schema ` + - Asks to "show workflows" or asks "what workflows" exist → resolve the authoritative root by running `openspec context --json` from the current working directory. If the user explicitly selected a registered store, use `openspec context --json --store ""`. Then run `openspec schemas --json` with its working directory set to the returned `root.path` and let them choose. This preserves roots selected by a local `store:` pointer or the global `defaultStore`; when a registered store was explicitly selected, append `--store ""` to `openspec schemas --json` as well. If context reports only `no_openspec_root`, run `openspec schemas --json` from the current working directory instead. Do not use this fallback for invalid or unavailable stores. + + Otherwise, omit `--schema` to preserve the configured default. + +3. **Create the change directory** + + Choose one schema form below. If a registered store is selected, append `--store ""` to that command and each later OpenSpec command shown below that accepts `--store`. + + Using the configured default: ```bash openspec new change "" ``` + + Using an explicitly requested schema: + ```bash + openspec new change "" --schema "" + ``` This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. -3. **Get the artifact build order** +4. **Get the artifact build order** ```bash openspec status --change "" --json ``` Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - - `artifacts`: list of all artifacts with their status and dependencies + - `artifacts`: list of all artifacts, each with its `status` and its `requires` edges (the artifact IDs it directly depends on) - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. -4. **Create artifacts in sequence until apply-ready** +5. **Create every artifact in the required set** - Use the **TodoWrite tool** to track progress through the artifacts. + Use a todo list to track progress through the artifacts. Loop through artifacts in dependency order (artifacts with no pending dependencies first): @@ -67,23 +93,30 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type + - `skipped`/`warning`: present when the change declares skip_specs and this artifact must NOT be created - stop and pick another artifact - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - - Read any completed dependency files for context - - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` + - Read any completed dependency files for context - always re-read them from disk, even if you saw them earlier in the conversation (the user may have edited them) + - If the `instruction` field delegates creation to a specific skill or command, invoke it to produce the artifact instead of writing the file yourself, then verify the artifact file exists at `resolvedOutputPath` + - Otherwise create the artifact file using `template` as the structure and write it to `resolvedOutputPath`. If `resolvedOutputPath` is a glob, follow `instruction` to choose the concrete file path - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " - b. **Continue until all `applyRequires` artifacts are complete** + b. **Continue until every artifact in the required set exists (not just `apply.requires`)** - After creating each artifact, re-run `openspec status --change "" --json` - - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array - - Stop when all `applyRequires` artifacts are done + - The required set is `applyRequires` plus every artifact reachable from those by following the `requires` edges in `status --json` - walk them transitively (spec-driven closes over proposal, specs, design, tasks). Leave artifacts outside that set alone + - `status` is file-existence only, so an `applyRequires` artifact reading `done` does NOT mean its dependencies exist - writing `tasks.md` early marks `tasks` done while `specs` was never written. Use each artifact's `requires` edges, not its `status`, to build the required set: a `done` artifact still lists what it depends on + - An artifact already reading `status: "skipped"` is satisfied: the change declares `skip_specs` in `.openspec.yaml`, so its files must NOT exist. Never try to create one + - Create every artifact in the required set that is missing, then re-check - creating one can unblock others + - Skip one only when `status` already reports it `skipped`, or when its own `instruction` says it is conditional: run `openspec instructions --change "" --json` and skip only if its `instruction` field marks it optional (e.g. "create only if..."). Spec-driven's `design.md` qualifies; `specs` qualifies only via the `skipped` status above, never by your own judgment. Tell the user, and do not reconsider it + - Dependencies are enablers, not gates: if a required artifact is still `blocked` only because you skipped a conditional dependency, write it anyway + - Stop when every artifact in the required set is `done`, `skipped`, or was deliberately skipped c. **If an artifact requires user input** (unclear context): - - Use **AskUserQuestion tool** to clarify + - Ask the user to clarify - Then continue with creation -5. **Show final status** +6. **Show final status** ```bash openspec status --change "" ``` @@ -92,13 +125,14 @@ When ready to implement, run /opsx:apply After completing all artifacts, summarize: - Change name and location -- List of artifacts created with brief descriptions -- What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." +- List of artifacts created with brief descriptions, plus any conditional artifact you skipped and why +- What's ready: "All artifacts needed for implementation are ready." +- Prompt: "The artifacts are ready for review. When you are ready, run `/openspec-apply-change` or ask me to apply this change." **Artifact Creation Guidelines** -- Follow the `instruction` field from `openspec instructions` for each artifact type +- Follow the `instruction` field from `openspec instructions` for each artifact type - it is the authoritative guidance, even for familiar artifact names +- If the `instruction` field directs you to use a specific skill or command to create the artifact, invoke it instead of writing the artifact directly - The schema defines what each artifact should contain - follow it - Read dependency artifacts for context before creating new ones - Use `template` as the structure for your output file - fill in its sections @@ -107,8 +141,9 @@ After completing all artifacts, summarize: - These guide what you write, but should never appear in the output **Guardrails** -- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) -- Always read dependency artifacts before creating a new one -- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- The request that invoked this workflow authorizes planning only. Any implementation or apply instruction in that request does not carry forward. Do NOT implement the change, start the apply workflow, or edit project code during this workflow. After presenting the artifacts, stop and wait for a new user request to start the apply workflow +- Create every artifact the apply phase transitively depends on, not just the ids listed in `apply.requires` +- Always read dependency artifacts before creating a new one - re-read from disk, not from conversation memory (files may have changed since you last saw them) +- Ask about ambiguities that would materially change scope, externally observable behavior, compatibility, or acceptance criteria; for minor details, make reasonable assumptions and record them - If a change with that name already exists, ask if user wants to continue it or create a new one - Verify each artifact file exists after writing before proceeding to next diff --git a/.codex/skills/openspec-sync-specs/SKILL.md b/.codex/skills/openspec-sync-specs/SKILL.md index b6aad67a..ae47ad1c 100644 --- a/.codex/skills/openspec-sync-specs/SKILL.md +++ b/.codex/skills/openspec-sync-specs/SKILL.md @@ -7,26 +7,31 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Sync delta specs from a change to main specs. This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +`` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one - Show changes that have delta specs (under `specs/` directory). + When prompting, show changes that have delta specs (under `specs/` directory). - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: " and how to override (e.g., `/openspec-sync-specs `). 2. **Resolve change context** @@ -35,9 +40,29 @@ This is an **agent-driven** operation - you will read delta specs and directly e openspec status --change "" --json ``` + The JSON includes `planningHome.root`. Main specs live under `/openspec/specs/` — use that (store-aware) root for every main-spec path below, not a hardcoded repo path. When a store is selected it points at the store, not the current repository. + 3. **Find delta specs** - Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files. + Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the + only source of delta spec paths. If the `specs` entry is missing or + `existingOutputPaths` is empty, report that there are no delta specs to sync, + do not infer them from other artifacts, and stop without requesting artifact + instructions or writing a main spec. + + Sync every path in `existingOutputPaths` unless the caller narrowed the set. + A caller narrows it by naming an explicit list of complete entries from + `existingOutputPaths` — copy those absolute values verbatim. Archive does + this inline, and a user can too (for example, by selecting the entry ending + in `/specs/billing/invoices/spec.md`). + Then sync only the named paths and leave the remaining delta specs untouched: + bulk archive excludes a delta whose implementation it could not find, and + syncing it anyway would write a main spec the caller deliberately withheld. + Carry that narrowed selection through step 4; never widen it back to the full + list. If a named path is not in `existingOutputPaths`, do not sync it — + report it and stop, rather than dropping it silently. If the named list is + empty, report that there is nothing to sync and stop without writing a main + spec. Each delta spec file contains sections like: - `## ADDED Requirements` - New requirements to add @@ -49,11 +74,27 @@ This is an **agent-driven** operation - you will read delta specs and directly e 4. **For each delta spec, apply changes to main specs** - For each repo-local capability delta spec path returned by the CLI: + Before the first main-spec write, obtain one current specs-rule snapshot: + - If archive invoked this workflow inline and supplied a valid snapshot from + `openspec instructions specs --change "" --json`, reuse it and do not + fetch the same instructions again. + - Otherwise run that command once now with the same selected-root flags. + - If the direct lookup exits non-zero or returns invalid artifact-instruction + JSON, report the error and stop before writing any main spec. Do not treat the + failure as an absent rule set. + - A valid response with omitted `rules` means no artifact rules are configured + and the existing semantic merge continues. + + Apply returned `rules` only to the content and form of the main specs produced + by this merge. Artifact rules are not operation guidance and cannot change + selected roots, delta paths, CLI checks, or workflow steps. Use their text as + constraints without copying it verbatim into a main spec or summary. + + For each capability delta spec path selected in step 3 — the full `existingOutputPaths` list, or the narrowed subset when a caller supplied one (these may belong to a selected store, not the repo): a. **Read the delta spec** to understand the intended changes - b. **Read the main spec** at `openspec/specs//spec.md` (may not exist yet) + b. **Read the main spec** at `/openspec/specs//spec.md` (may not exist yet) c. **Apply changes intelligently**: @@ -64,31 +105,72 @@ This is an **agent-driven** operation - you will read delta specs and directly e **MODIFIED Requirements:** - Find the requirement in main spec - Apply the changes - this can be: - - Adding new scenarios (don't need to copy existing ones) + - Adding new scenarios the main spec does not have yet - Modifying existing scenarios - Changing the requirement description - Preserve scenarios/content not mentioned in the delta **REMOVED Requirements:** - Remove the entire requirement block from main spec + - Retiring the capability. Delete the whole `spec.md` - and the directory once + nothing else is left in it - only when ALL of these hold: + 1. removing the requirements *this run* left no requirement blocks; + 2. the rest of the spec is well-formed (it still has a `## Purpose`); + 3. the main spec was not already empty before this sync - if you removed + nothing, change nothing; + 4. every other nonblank line in the whole file is accounted for as the + title, Purpose, Requirements header, or a canonical requirement's + statement, scenarios, or fenced examples; + 5. the change's `.openspec.yaml` declares `retire_capabilities: true`; + 6. the `spec.md` resolves inside the real specs root (do not follow a + capability-directory symlink to delete an external file). + If removing the selected requirements would leave no requirement blocks and + any retirement condition is not satisfied, do not modify the main spec. Stop + the sync for that capability, report the blocking condition, and tell the user + how to resolve it. Never write or leave an empty `## Requirements` section. + When only the marker is missing, say that too - it is the one thing the user + can add to make the retirement go through. + - Deleting the file also deletes its `## Purpose`; any other section blocks + retirement. Name Purpose when you report the retirement. Include a pasteable + `git checkout` only when the spec lived in the caller's checkout; + otherwise give checkout-scoped recovery guidance. **RENAMED Requirements:** - Find the FROM requirement, rename to TO + **`## Purpose` in the delta:** + - The main spec already has one and it is authoritative - leave it alone + (this is what `openspec archive` does; it warns and moves on) + d. **Create new main spec** if capability doesn't exist yet: - - Create `openspec/specs//spec.md` - - Add Purpose section (can be brief, mark as TBD) + - Create `/openspec/specs//spec.md` + - Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one + (this is what `openspec archive` does); only write a brief TBD placeholder when it does not - Add Requirements section with the ADDED requirements + - Follow the **Main Spec Format Reference** below + +5. **Validate updated main specs** -5. **Show summary** + Run `openspec validate --specs` with the same selected-root flags used earlier. + If validation fails, report the problems and do not claim the sync succeeded. + +6. **Show summary** After applying all changes, summarize: - Which capabilities were updated - What changes were made (requirements added/modified/removed/renamed) + - Any new main spec left with a TBD Purpose placeholder, so it gets written + now rather than lingering + - Any capability retired, naming the deleted `spec.md`, its Purpose, and + either a pasteable `git checkout` or checkout-scoped recovery guidance **Delta Spec Format Reference** ```markdown +## Purpose + +Only on a delta that introduces a brand-new capability. Seeds the new main spec. + ## ADDED Requirements ### Requirement: New Feature @@ -101,6 +183,12 @@ The system SHALL do something new. ## MODIFIED Requirements ### Requirement: Existing Feature +The system SHALL keep doing the existing thing, now also handling A. + +#### Scenario: Scenario the main spec already has +- **WHEN** user does X +- **THEN** system does Y + #### Scenario: New scenario to add - **WHEN** user does A - **THEN** system does B @@ -115,16 +203,36 @@ The system SHALL do something new. - TO: `### Requirement: New Name` ``` +**Main Spec Format Reference** + +Main specs are what the delta merges INTO. They must never contain delta operation headers (`## ADDED/MODIFIED/REMOVED/RENAMED Requirements`) - after syncing, every requirement lives under a single `## Requirements` section: + +```markdown +# Specification + +## Purpose +Short description of what this capability does and why it exists. + +## Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y +``` + **Key Principle: Intelligent Merging** -Unlike programmatic merging, you can apply **partial updates**: -- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios -- The delta represents *intent*, not a wholesale replacement +Unlike programmatic merging, you merge rather than overwrite: +- A MODIFIED block carries the whole requirement - body plus every scenario that survives the change. `openspec validate` and `openspec archive` both reject one that drops a scenario the main spec still has. +- Keep anything the delta does not mention, in the main spec's existing order - Use your judgment to merge changes sensibly **Output On Success** -``` +```markdown ## Specs Synced: Updated main specs: @@ -143,6 +251,12 @@ Main specs are now updated. The change remains active - archive when implementat **Guardrails** - Read both delta and main specs before making changes - Preserve existing content not mentioned in delta +- Never copy a delta file into a main spec as-is - merge its content so the main spec keeps the Main Spec Format Reference structure, with no delta operation headers - If something is unclear, ask for clarification - Show what you're changing as you go - The operation should be idempotent - running twice should give same result +- Use only `artifactPaths.specs.existingOutputPaths`; never infer delta specs from unrelated artifacts +- Honor a caller-supplied subset of `existingOutputPaths`; never widen it back to the full list +- Fetch specs instructions once for direct sync, or reuse the archive-supplied snapshot inline +- Stop before every main-spec write on a non-zero or invalid JSON specs-instruction response +- Artifact rules constrain only the specs being written and are never copied into output files diff --git a/.codex/skills/openspec-verify-change/SKILL.md b/.codex/skills/openspec-verify-change/SKILL.md index b6557c6f..4d092a9e 100644 --- a/.codex/skills/openspec-verify-change/SKILL.md +++ b/.codex/skills/openspec-verify-change/SKILL.md @@ -7,26 +7,29 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.6.0" + generatedBy: "1.11.0" --- Verify that an implementation matches the change artifacts (specs, tasks, design). -**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** -1. **If no change name provided, prompt for selection** +1. **Select the change** - Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one - Show changes that have implementation tasks (tasks artifact exists). + When prompting, show changes that have implementation tasks (tasks artifact exists). Include the schema used for each change if available. Mark changes with incomplete tasks as "(In Progress)". - **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + Always announce: "Using change: " and how to override (e.g., `/openspec-verify-change `). 2. **Check status to understand the schema** ```bash @@ -114,7 +117,7 @@ Verify that an implementation matches the change artifacts (specs, tasks, design 8. **Generate Verification Report** **Summary Scorecard**: - ``` + ```markdown ## Verification Report: ### Summary diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index f2a21e2d..721c0051 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -2,124 +2,176 @@ name: .NET 10 CI on: push: - paths: - - ".github/workflows/dotnet-ci.yml" - - "Directory.Build.props" - - "Directory.Build.targets" - - "global.json" - - "ChapterTool.slnx" - - "src/**" - - "tests/**" - - "tools/**" - - "scripts/**" - - "packages/chaptertool/**" pull_request: - paths: - - ".github/workflows/dotnet-ci.yml" - - "Directory.Build.props" - - "Directory.Build.targets" - - "global.json" - - "ChapterTool.slnx" - - "src/**" - - "tests/**" - - "tools/**" - - "scripts/**" - - "packages/chaptertool/**" + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + +permissions: + contents: read concurrency: - group: dotnet-ci-${{ github.ref }} + group: dotnet-ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true jobs: - build-test: - name: Build and test + plan: + name: Select affected checks + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + plan: ${{ steps.plan.outputs.plan }} + tests: ${{ steps.plan.outputs.tests }} + resources: ${{ steps.plan.outputs.resources }} + dotnet-tests: ${{ steps.plan.outputs.dotnet-tests }} + solution-build: ${{ steps.plan.outputs.solution-build }} + wasm-browser-e2e: ${{ steps.plan.outputs.wasm-browser-e2e }} + pack-nuget: ${{ steps.plan.outputs.pack-nuget }} + pack-nodejs: ${{ steps.plan.outputs.pack-nodejs }} + pack-dotnet: ${{ steps.plan.outputs.pack-dotnet }} + steps: + - name: Checkout comparison history + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Select consumers + id: plan + run: python3 scripts/ci/plan.py + + wasm-browser-e2e: + name: WASM acceptance + needs: plan + if: needs.plan.outputs.wasm-browser-e2e == 'true' + uses: ./.github/workflows/wasm-browser-e2e.yml + with: + suite: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) && 'full' || 'pr' }} + + resources: + name: Resources and scripts + needs: plan + if: needs.plan.outputs.resources == 'true' + timeout-minutes: 10 runs-on: ubuntu-24.04 - steps: - name: Checkout uses: actions/checkout@v7 - - name: Install uv - uses: astral-sh/setup-uv@v6 - + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Sync locked Python tools + run: python3 scripts/ci/run.py --stage resources --step sync-locked-python-tools - name: Verify localization resources - run: uv run --project scripts scripts/axaml-to-json.py --check - + run: python3 scripts/ci/run.py --stage resources --step verify-localization-resources + - name: Audit UI resources + run: python3 scripts/ci/run.py --stage resources --step audit-ui-resources - name: Lint Python scripts - run: uv run --project scripts ruff check scripts/ + run: python3 scripts/ci/run.py --stage resources --step lint-python-scripts + - name: Test CI runner + run: python3 scripts/ci/run.py --stage resources --step test-ci-runner + - name: Validate PowerShell publish script + run: python3 scripts/ci/run.py --stage resources --step validate-powershell-publish-script + dotnet-tests: + name: .NET tests — ${{ matrix.label }} + needs: plan + if: needs.plan.outputs.dotnet-tests == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.tests) }} + steps: + - name: Checkout + uses: actions/checkout@v7 - name: Install .NET 10 uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - - name: Cache NuGet packages uses: actions/cache@v6 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.config') }} restore-keys: | ${{ runner.os }}-nuget- - - - name: Install .NET wasm-tools workload - run: dotnet workload install wasm-tools - - - name: Install Node.js - uses: actions/setup-node@v7 - with: - node-version: 22.x - cache: npm - cache-dependency-path: packages/chaptertool/package-lock.json - - - name: Install npm dependencies - working-directory: packages/chaptertool - run: npm ci - - - name: Restore - run: dotnet restore ChapterTool.slnx - - - name: Build - run: dotnet build ChapterTool.slnx --configuration Release --no-restore - - - name: Build Node.js package - working-directory: packages/chaptertool - run: npm run build - - - 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-build --no-restore --output artifacts/nuget /p:ContinuousIntegrationBuild=true - - - name: Pack ChapterTool CLI - run: dotnet pack src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj --configuration Release --no-build --no-restore --output artifacts/cli-nuget /p:ContinuousIntegrationBuild=true - - - name: Install external tools + - name: Install integration tools + if: matrix.media run: | sudo apt-get update sudo apt-get install -y ffmpeg mkvtoolnix - - - name: Test .NET - run: dotnet test ChapterTool.slnx --configuration Release --no-build --no-restore --timeout 10m - - - name: Test Node.js package - working-directory: packages/chaptertool - run: npm run typecheck && npm run test:built - - - name: Upload Node.js build output + - name: Restore test dependencies + run: python3 scripts/ci/run.py --stage test-dotnet --test-project ${{ matrix.project }} --step restore-test-project + - name: Build test project + run: python3 scripts/ci/run.py --stage test-dotnet --test-project ${{ matrix.project }} --step build-test-project + - name: Run isolated test process + run: python3 scripts/ci/run.py --stage test-dotnet --test-project ${{ matrix.project }} --step run-tests + - name: Upload test diagnostics + if: always() uses: actions/upload-artifact@v7 with: - name: ChapterTool-node-dist - path: packages/chaptertool/dist - if-no-files-found: error - retention-days: 1 - - - name: Upload NuGet artifact + name: dotnet-test-${{ matrix.project }} + path: | + artifacts/**/*.png + tests/${{ matrix.project }}/TestResults/ + if-no-files-found: ignore + retention-days: 14 + + solution-build: + name: Full solution build + needs: plan + if: needs.plan.outputs.solution-build == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.config') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Install native WASM workload + run: dotnet workload install wasm-tools + - name: Restore solution + run: python3 scripts/ci/run.py --stage dotnet --step restore + - name: Build solution + run: python3 scripts/ci/run.py --stage dotnet --step build-release + + pack-nuget: + name: NuGet packages + needs: plan + if: needs.plan.outputs.pack-nuget == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.config') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Build, pack, and verify NuGet consumers + env: + RELEASE_REF: ${{ github.ref }} + run: | + args=(--stage pack-nuget) + if [[ "$RELEASE_REF" == refs/tags/v* ]]; then + args+=(--package-version "${RELEASE_REF#refs/tags/v}") + fi + python3 scripts/ci/run.py "${args[@]}" + - name: Upload Core package uses: actions/upload-artifact@v7 with: name: ChapterTool-Core-nuget @@ -127,8 +179,7 @@ jobs: artifacts/nuget/*.nupkg artifacts/nuget/*.snupkg if-no-files-found: error - - - name: Upload CLI NuGet artifact + - name: Upload CLI package uses: actions/upload-artifact@v7 with: name: ChapterTool-Cli-nuget @@ -138,8 +189,10 @@ jobs: if-no-files-found: error pack-dotnet: - needs: build-test - name: Pack Avalonia (${{ matrix.runtime }}) + name: Desktop packages (${{ matrix.runtime }}) + needs: plan + if: needs.plan.outputs.pack-dotnet == 'true' + timeout-minutes: 20 runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -168,22 +221,14 @@ jobs: uses: actions/cache@v6 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.config') }} restore-keys: | ${{ runner.os }}-nuget- - - name: Restore .NET projects - run: | - dotnet restore src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj --runtime ${{ matrix.runtime }} - - - name: Publish Avalonia - run: >- - ./scripts/publish.sh - -Configuration Release - -Runtime ${{ matrix.runtime }} - -SelfContained=false - -NoRestore - -PublishSingleFile + - name: Restore desktop runtime + run: python3 scripts/ci/run.py --stage pack-desktop --runtime ${{ matrix.runtime }} --step restore-desktop-${{ matrix.runtime }} + - name: Publish and validate desktop artifact + run: python3 scripts/ci/run.py --stage pack-desktop --runtime ${{ matrix.runtime }} --step publish-desktop-${{ matrix.runtime }} - name: Upload Avalonia artifact uses: actions/upload-artifact@v7 @@ -193,8 +238,10 @@ jobs: if-no-files-found: error pack-nodejs: - needs: build-test - name: Pack Node.js package + name: Node package + needs: plan + if: needs.plan.outputs.pack-nodejs == 'true' + timeout-minutes: 20 runs-on: ubuntu-24.04 steps: @@ -205,16 +252,33 @@ jobs: uses: actions/setup-node@v7 with: node-version: 22.x + cache: npm + cache-dependency-path: packages/chaptertool/package-lock.json - - name: Download Node.js build output - uses: actions/download-artifact@v8 + - name: Install .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - name: Cache NuGet packages + uses: actions/cache@v6 with: - name: ChapterTool-node-dist - path: packages/chaptertool/dist + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.config') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Install .NET wasm-tools workload + run: dotnet workload install wasm-tools + - name: Install npm dependencies + run: python3 scripts/ci/run.py --stage node --step install-npm-dependencies + - name: Build Node.js package + run: python3 scripts/ci/run.py --stage node --step build-node-js-package + - name: Type check Node.js package + run: python3 scripts/ci/run.py --stage node --step type-check-node-js-package + - name: Test built Node.js package + run: python3 scripts/ci/run.py --stage node --step test-built-node-js-package - name: Pack and verify npm tarball - working-directory: packages/chaptertool - run: npm run pack:verify + run: python3 scripts/ci/run.py --stage pack-node - name: Upload npm artifact uses: actions/upload-artifact@v7 @@ -222,3 +286,32 @@ jobs: name: ChapterTool-node-package path: artifacts/npm/*.tgz if-no-files-found: error + + ci-ready: + name: CI ready + if: always() + needs: [plan, resources, dotnet-tests, solution-build, wasm-browser-e2e, pack-nuget, pack-nodejs, pack-dotnet] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Verify required results + env: + CI_PLAN: ${{ needs.plan.outputs.plan }} + CI_NEEDS: ${{ toJSON(needs) }} + run: python3 scripts/ci/plan.py --verify + + deploy-pages: + name: Deploy verified WASM + needs: [plan, ci-ready] + if: >- + !cancelled() && needs.ci-ready.result == 'success' && + github.ref == 'refs/heads/master' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + needs.plan.outputs.wasm-browser-e2e == 'true' + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/github-pages.yml diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 0be94070..c831125a 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -1,22 +1,7 @@ -# Deploy the Blazor WebAssembly app to GitHub Pages. -# Site URL (project pages): https://.github.io// -# -# One-time repo settings: -# Settings → Pages → Build and deployment → Source: GitHub Actions -# Optional: Settings → Actions → General → allow SteveSandersonMS/ghaction-rewrite-base-href name: Deploy WASM (GitHub Pages) on: - push: - branches: - - master - paths: - - "src/ChapterTool.Wasm/**" - - "src/ChapterTool.Core/**" - - "Directory.Build.props" - - "Directory.Build.targets" - - ".github/workflows/github-pages.yml" - workflow_dispatch: + workflow_call: permissions: contents: read @@ -27,105 +12,26 @@ concurrency: group: pages cancel-in-progress: false -env: - PROJECT: src/ChapterTool.Wasm/ChapterTool.Wasm.csproj - PUBLISH_DIR: src/ChapterTool.Wasm/bin/Release/net10.0/publish/wwwroot - jobs: deploy: - name: Build and deploy + name: Deploy tested site runs-on: ubuntu-24.04 + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Install .NET 10 - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 10.0.x - - - name: Publish Blazor WASM app - run: | - set -euo pipefail - dotnet publish "${{ env.PROJECT }}" \ - --configuration Release \ - --nologo - - - name: Prepare GitHub Pages assets - run: | - set -euo pipefail - publish_dir="${{ env.PUBLISH_DIR }}" - test -f "${publish_dir}/index.html" - test -d "${publish_dir}/_framework" - - # Prevent Jekyll from ignoring underscore folders such as _framework. - touch "${publish_dir}/.nojekyll" - - # GitHub Pages serves 404.html for unknown paths (SPA deep links). - cp "${publish_dir}/index.html" "${publish_dir}/404.html" - - # Record deploy metadata for troubleshooting. - { - echo "repository=${{ github.repository }}" - echo "sha=${{ github.sha }}" - echo "ref=${{ github.ref }}" - echo "base_href=/${{ github.event.repository.name }}/" - date -u +'built_at=%Y-%m-%dT%H:%M:%SZ' - } > "${publish_dir}/deploy-info.txt" - - - name: Rewrite base href (index.html) - uses: SteveSandersonMS/ghaction-rewrite-base-href@5b54862a8831e012d4f1a8b2660894415fdde8ec # v1.1.0 + - name: Download tested site + uses: actions/download-artifact@v8 with: - html_path: ${{ env.PUBLISH_DIR }}/index.html - base_href: /${{ github.event.repository.name }}/ - - - name: Rewrite base href (404.html) - uses: SteveSandersonMS/ghaction-rewrite-base-href@5b54862a8831e012d4f1a8b2660894415fdde8ec # v1.1.0 - with: - html_path: ${{ env.PUBLISH_DIR }}/404.html - base_href: /${{ github.event.repository.name }}/ - - - name: Verify published site layout - run: | - set -euo pipefail - publish_dir="${{ env.PUBLISH_DIR }}" - repo_name="${{ github.event.repository.name }}" - - # Project Pages base href must be present on both entry documents. - grep -F "href=\"/${repo_name}/\"" "${publish_dir}/index.html" - grep -F "href=\"/${repo_name}/\"" "${publish_dir}/404.html" - - test -f "${publish_dir}/.nojekyll" - test -f "${publish_dir}/404.html" - test -f "${publish_dir}/js/download.js" - test -f "${publish_dir}/css/app.css" - test -d "${publish_dir}/_framework" - - # Critical Blazor boot assets (fingerprinted names vary by publish). - # Do not use `ls | head` under pipefail: GNU ls exits 2 on SIGPIPE. - shopt -s nullglob - blazor_js=("${publish_dir}/_framework"/blazor.webassembly*.js) - wasm_binary=("${publish_dir}/_framework"/ChapterTool.Wasm*.wasm) - test "${#blazor_js[@]}" -ge 1 - test "${#wasm_binary[@]}" -ge 1 - framework_count="$(find "${publish_dir}/_framework" -type f | wc -l | tr -d ' ')" - test "${framework_count}" -ge 10 - echo "framework_files=${framework_count}" - echo "blazor_js=${blazor_js[0]##*/}" - echo "wasm_binary=${wasm_binary[0]##*/}" - + name: wasm-prepared-site + path: artifacts/wasm-e2e/site/ - name: Setup Pages uses: actions/configure-pages@v6 - - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: - path: ${{ env.PUBLISH_DIR }} - + path: artifacts/wasm-e2e/site/ChapterTool - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5f54b443..a35ae51f 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -21,10 +21,12 @@ jobs: ( github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'v') ) }} runs-on: ubuntu-24.04 + timeout-minutes: 10 environment: npm permissions: id-token: write @@ -45,7 +47,7 @@ jobs: - name: Checkout release source uses: actions/checkout@v7 with: - ref: ${{ env.RELEASE_TAG }} + ref: refs/tags/${{ env.RELEASE_TAG }} - name: Install Node.js uses: actions/setup-node@v7 @@ -54,41 +56,14 @@ jobs: registry-url: https://registry.npmjs.org - name: Install npm Trusted Publishing client - run: npm install --global npm@11.5.1 + run: npm install --global --ignore-scripts npm@11.5.1 - - name: Resolve successful CI run + - name: Resolve tested CI run id: ci-run - shell: bash env: GH_TOKEN: ${{ github.token }} - run: | - if [[ "$GITHUB_EVENT_NAME" == "workflow_run" ]]; then - echo "run-id=${{ github.event.workflow_run.id }}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - run_id="$({ - gh run list \ - --repo "$GITHUB_REPOSITORY" \ - --workflow dotnet-ci.yml \ - --limit 100 \ - --json databaseId,headBranch,event,status,conclusion,createdAt - } | jq -r --arg tag "$RELEASE_TAG" ' - [.[] - | select(.headBranch == $tag) - | select(.event == "push") - | select(.status == "completed" and .conclusion == "success") - ] - | sort_by(.createdAt) - | last - | .databaseId // empty - ') - - if [[ -z "$run_id" ]]; then - echo "No successful .NET CI run found for $RELEASE_TAG" >&2 - exit 1 - fi - echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + run: python3 scripts/ci/resolve-run.py --tag "$RELEASE_TAG" - name: Download tested npm artifact env: @@ -101,8 +76,9 @@ jobs: --name ChapterTool-node-package \ --dir artifacts/npm + shopt -s nullglob archives=(artifacts/npm/*.tgz) - if [[ ! -f "${archives[0]}" || "${archives[1]}" != "" ]]; then + if (( ${#archives[@]} != 1 )); then echo "Expected exactly one npm tarball in the CI artifact" >&2 exit 1 fi diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 49f2ede7..3716b684 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -7,6 +7,11 @@ on: types: - completed workflow_dispatch: + inputs: + tag: + description: Release tag whose successful CI packages should be published + required: true + type: string jobs: publish-nuget: @@ -16,85 +21,67 @@ jobs: ( github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'v') ) }} runs-on: ubuntu-24.04 + timeout-minutes: 10 environment: nuget env: - RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} + RELEASE_TAG: ${{ inputs.tag || github.event.workflow_run.head_branch }} permissions: id-token: write contents: read + actions: read packages: write - steps: - - name: Checkout + - name: Checkout release source uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} - + ref: refs/tags/${{ env.RELEASE_TAG }} + - name: Resolve tested CI run + id: ci-run + env: + GH_TOKEN: ${{ github.token }} + TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + run: python3 scripts/ci/resolve-run.py --tag "$RELEASE_TAG" + - name: Download tested Core package + uses: actions/download-artifact@v8 + with: + name: ChapterTool-Core-nuget + run-id: ${{ steps.ci-run.outputs.run-id }} + github-token: ${{ github.token }} + path: artifacts/nuget + - name: Download tested CLI package + uses: actions/download-artifact@v8 + with: + name: ChapterTool-Cli-nuget + run-id: ${{ steps.ci-run.outputs.run-id }} + github-token: ${{ github.token }} + path: artifacts/cli-nuget + - name: Verify release package versions + run: python3 scripts/ci/verify-nuget.py --metadata-only --expected-version "${RELEASE_TAG#v}" - name: Install .NET 10 uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - - - name: Restore - run: | - dotnet restore tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj - dotnet restore src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj - - - name: Resolve package version - id: version - shell: bash - run: | - if [[ "$RELEASE_TAG" =~ ^v(.+)$ ]]; then - echo "package-version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" - else - echo "package-version=" >> "$GITHUB_OUTPUT" - fi - - - name: Build - run: | - dotnet build src/ChapterTool.Core/ChapterTool.Core.csproj --configuration Release --no-restore - dotnet build tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --configuration Release --no-restore - dotnet build src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj --configuration Release --no-restore - - - name: Test Core - run: dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --configuration Release --no-build - - - name: Pack - shell: bash - run: | - projects=( - src/ChapterTool.Core/ChapterTool.Core.csproj - src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj - ) - for project in "${projects[@]}"; do - args=( - "$project" - --configuration Release - --no-build - --no-restore - --output artifacts/nuget - /p:ContinuousIntegrationBuild=true - ) - if [[ -n "${{ steps.version.outputs.package-version }}" ]]; then - args+=(/p:PackageVersion=${{ steps.version.outputs.package-version }}) - fi - dotnet pack "${args[@]}" - done - - name: NuGet login (OIDC) id: login - uses: NuGet/login@v1.2.0 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: user: ${{ vars.NUGET_USER }} - - - name: Push package and symbols to NuGet.org - run: dotnet nuget push artifacts/nuget/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - - - name: Push package to GitHub Packages + - name: Push tested packages and symbols to NuGet.org + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + for package in artifacts/nuget/*.nupkg artifacts/cli-nuget/*.nupkg; do + dotnet nuget push "$package" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + done + - name: Push tested packages to GitHub Packages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: dotnet nuget push artifacts/nuget/*.nupkg --api-key "$GITHUB_TOKEN" --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --skip-duplicate + run: | + for package in artifacts/nuget/*.nupkg artifacts/cli-nuget/*.nupkg; do + dotnet nuget push "$package" --api-key "$GITHUB_TOKEN" --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --skip-duplicate + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dda6575..00ce7ffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,10 +25,12 @@ jobs: ( github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'v') ) }} runs-on: ubuntu-24.04 + timeout-minutes: 15 env: TAG: ${{ inputs.tag || github.event.workflow_run.head_branch }} @@ -47,47 +49,14 @@ jobs: - name: Checkout release source uses: actions/checkout@v7 with: - ref: ${{ env.TAG }} + ref: refs/tags/${{ env.TAG }} - - name: Locate successful CI run + - name: Resolve tested CI run id: ci-run - shell: bash env: GH_TOKEN: ${{ github.token }} - TAG: ${{ env.TAG }} TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} - run: | - if [[ "$GITHUB_EVENT_NAME" == "workflow_run" ]]; then - echo "run-id=$TRIGGER_RUN_ID" >> "$GITHUB_OUTPUT" - echo "Using triggering CI run $TRIGGER_RUN_ID for $TAG" - exit 0 - fi - - run_id="$( - gh run list \ - --repo "$GITHUB_REPOSITORY" \ - --workflow dotnet-ci.yml \ - --limit 100 \ - --json databaseId,headBranch,event,status,conclusion,createdAt \ - | jq -r --arg tag "$TAG" ' - [.[] - | select(.headBranch == $tag) - | select(.event == "push") - | select(.status == "completed" and .conclusion == "success") - ] - | sort_by(.createdAt) - | last - | .databaseId // empty - ' - )" - - if [[ -z "$run_id" ]]; then - echo "No successful dotnet-ci.yml run found for $TAG" >&2 - exit 1 - fi - - echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - echo "Using CI run $run_id for $TAG" + run: python3 scripts/ci/resolve-run.py --tag "$TAG" - name: Download CI artifacts env: @@ -96,20 +65,16 @@ jobs: run: | mkdir -p release-assets/publish release-assets/staging - artifact_names=() - while IFS= read -r artifact_name; do - [[ -n "$artifact_name" ]] && artifact_names+=("$artifact_name") - done < <( - gh api --paginate \ - "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100" \ - | jq -r '.artifacts[] | select(.expired == false) | .name' + # Only distribution artifacts can become release attachments. + artifact_names=( + ChapterTool-Core-nuget + ChapterTool-Cli-nuget + ChapterTool-node-package + ChapterTool-Avalonia-win-x64 + ChapterTool-Avalonia-linux-x64 + ChapterTool-Avalonia-osx-arm64 ) - if (( ${#artifact_names[@]} == 0 )); then - echo "No non-expired artifacts found for CI run $RUN_ID" >&2 - exit 1 - fi - for artifact_name in "${artifact_names[@]}"; do staging_dir="release-assets/staging/$artifact_name" mkdir -p "$staging_dir" @@ -153,17 +118,33 @@ jobs: TAG: ${{ env.TAG }} run: | version="${TAG#v}" - awk -v version="$version" ' + extract_changelog() { + local changelog="$1" + awk -v version="$version" ' $0 ~ "^## \\[" version "\\]" { found=1; next } found && /^## / { exit } found { print } - ' ChangeLog.md | sed '/^[[:space:]]*$/N;/^\n$/D' > release-notes.md + ' "$changelog" | sed '/^[[:space:]]*$/N;/^\n$/D' + } + + chinese_notes="$(extract_changelog CHANGELOG.zh-CN.md)" + english_notes="$(extract_changelog CHANGELOG.md)" - if [[ ! -s release-notes.md ]]; then - echo "No changelog section found for $version" >&2 + if [[ -z "$chinese_notes" ]]; then + echo "No Chinese changelog section found for $version" >&2 exit 1 fi + if [[ -z "$english_notes" ]]; then + echo "No English changelog section found for $version" >&2 + exit 1 + fi + + { + printf '## 中文\n\n%s\n\n' "$chinese_notes" + printf '## English\n\n%s\n' "$english_notes" + } > release-notes.md + previous_tag="$( gh api --paginate \ "repos/$GITHUB_REPOSITORY/tags?per_page=100" \ diff --git a/.github/workflows/wasm-browser-e2e.yml b/.github/workflows/wasm-browser-e2e.yml new file mode 100644 index 00000000..9a561072 --- /dev/null +++ b/.github/workflows/wasm-browser-e2e.yml @@ -0,0 +1,107 @@ +name: WASM browser acceptance + +on: + workflow_call: + inputs: + suite: + description: Browser suite to run + type: string + default: pr + +jobs: + prepare-site: + name: Publish and prepare WASM site + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + cache-dependency-path: tests/ChapterTool.Wasm.E2E/package-lock.json + + - name: Restore WASM + run: python3 scripts/ci/run.py --stage browser --step restore-wasm + - name: Publish Release WASM + run: python3 scripts/ci/run.py --stage browser --step publish-release-wasm + - name: Restore DocFX tool + run: python3 scripts/ci/run.py --stage browser --step restore-docfx-tool + - name: Generate Core API Markdown + run: python3 scripts/ci/run.py --stage browser --step generate-core-api-markdown + - name: Install VitePress dependencies + run: python3 scripts/ci/run.py --stage browser --step install-vitepress-dependencies + - name: Build VitePress documentation + run: python3 scripts/ci/run.py --stage browser --step build-vitepress-documentation + - name: Install browser test dependencies + run: python3 scripts/ci/run.py --stage browser --step install-browser-test-dependencies + - name: Prepare Pages-shaped site + run: python3 scripts/ci/run.py --stage browser --step prepare-pages-shaped-site + - name: Type check browser tests + run: python3 scripts/ci/run.py --stage browser --step type-check-browser-tests + - name: Upload prepared site + uses: actions/upload-artifact@v7 + with: + name: wasm-prepared-site + path: artifacts/wasm-e2e/site/ + if-no-files-found: error + retention-days: 14 + + browser-acceptance: + name: Browser workflows (${{ matrix.engine }}) + needs: prepare-site + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + engine: ${{ fromJSON((inputs.suite == 'full') && '["chromium","firefox","webkit"]' || '["chromium","webkit"]') }} + env: + BROWSER_SUITE: ${{ (inputs.suite == 'full') && 'full' || 'pr' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + cache-dependency-path: tests/ChapterTool.Wasm.E2E/package-lock.json + - name: Download prepared site + uses: actions/download-artifact@v8 + with: + name: wasm-prepared-site + path: artifacts/wasm-e2e/site/ + - name: Install browser test dependencies + run: python3 scripts/ci/run.py --stage browser --step install-browser-test-dependencies + - name: Install ${{ matrix.engine }} + run: python3 scripts/ci/run.py --stage browser --browser-suite "$BROWSER_SUITE" --browser-engine ${{ matrix.engine }} --install-browser-deps --step install-browser-engines + - name: Run Chromium workflows + if: env.BROWSER_SUITE == 'pr' && matrix.engine == 'chromium' + run: python3 scripts/ci/run.py --stage browser --browser-engine chromium --step run-chromium-e2e + - name: Run WebKit modal and editing regressions + if: env.BROWSER_SUITE == 'pr' && matrix.engine == 'webkit' + run: python3 scripts/ci/run.py --stage browser --browser-engine webkit --step run-webkit-regressions + - name: Run full ${{ matrix.engine }} acceptance without retries + if: inputs.suite == 'full' + run: python3 scripts/ci/run.py --stage browser --browser-suite full --browser-engine ${{ matrix.engine }} --step run-full-browser-acceptance + + - name: Upload browser diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: wasm-browser-acceptance-${{ matrix.engine }} + path: | + artifacts/wasm-e2e/report*/ + artifacts/wasm-e2e/results*/ + artifacts/wasm-e2e/*.xml + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 5ed8f9b4..710ba0b2 100644 --- a/.gitignore +++ b/.gitignore @@ -165,12 +165,17 @@ ClientBin/ *.pfx *.publishsettings node_modules/ +# DocFX API Markdown is generated before VitePress builds. +/docs-site/api/ChapterTool.Core.*.md +/docs-site/api/toc.yml +/docs-site/.vitepress/cache/ bower_components/ # Python bytecode __pycache__/ *.py[cod] .venv/ +*.egg-info/ # RIA/Silverlight projects Generated_Code/ @@ -216,6 +221,10 @@ dist/windows/LICENSE artifacts/ .idea/ +# Original font sources are kept locally and are not checked in. +src/ChapterTool.Avalonia.UI/Assets/Fonts/source/* +!src/ChapterTool.Avalonia.UI/Assets/Fonts/source/.gitkeep + # macOS Finder metadata .DS_Store diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..36c25bf5 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,14 @@ +{ + "servers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp", + "--path", + "/Users/tautcony/Documents/repos/ChapterTool" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 389fb104..216b1010 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,40 +4,23 @@ - This repository contains the current .NET 10 ChapterTool codebase. - Use `ChapterTool.slnx` as the main solution. -- Main projects: - - `src/ChapterTool.Core` (pure managed and browser WebAssembly-capable through stream and text import APIs) - - `src/ChapterTool.Wasm` (Blazor WebAssembly browser application for Core) - - `src/ChapterTool.Node` (Node.js WebAssembly host) - - `src/ChapterTool.CommandLine` (DotMake.CommandLine host and NuGet tool) - - `src/ChapterTool.Infrastructure` - - `src/ChapterTool.Avalonia.UI` (shared Avalonia views and ViewModels) - - `src/ChapterTool.Avalonia` (desktop Avalonia host) - - `tests/ChapterTool.Core.Tests` - - `tests/ChapterTool.Infrastructure.Tests` - - `tests/ChapterTool.Avalonia.Tests` (ViewModel/CLI/service unit tests) - - `tests/ChapterTool.Avalonia.Headless.Tests` (Avalonia Headless UI tests in a separate process) - - `tests/ChapterTool.TestSupport` (shared repository root, fixture paths, and test logger) -- Prefer `rg` for searching files and text. -- Use `docs/code-map/` as the primary navigation index for the current codebase. -- Read current documentation first: `docs/README.md`, `docs/code-map/`, and applicable testing guidance. Treat `docs/archive/` as historical reference unless the task requires it. -- Update the applicable code-map files when feature work changes module ownership, entry points, runtime wiring, or primary tests. -- For WinForms-to-Avalonia work, start with `.agents/skills/README-winforms-to-avalonia.md` and the `winforms-to-avalonia` orchestrator skill. The method has phases A through G. -- Use `reusable-learnings.md` for general rules and `references/execution-corrections.md` for correction patterns. +- Use `docs/code-map/` when you need to locate ownership, entry points, or tests. Read only pages relevant to the task. Small edits do not require a full documentation review. +- `docs/README.md` indexes current documentation. Treat `docs/archive/` as historical reference. +- External reference checkouts, such as `libbluray/`, must remain outside ChapterTool commits unless the user requests vendoring. Do not move or remove their Git metadata to stage them as ordinary files. +- For an explicit WinForms-to-Avalonia migration, use `.agents/skills/README-winforms-to-avalonia.md` and the `winforms-to-avalonia` orchestrator skill. - Store user-facing Chinese strings as valid UTF-8. - Validate localization through behavior, rendered UI, or resource-level checks. Do not hard-code incidental mojibake examples. -- Treat `src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/*.axaml` as the shared translation source. After changing a locale, run `python3 scripts/axaml-to-json.py`, then `python3 scripts/axaml-to-json.py --check`. Do not edit generated Wasm JSON files by hand. CLI JSON resources are separate. +- Treat `src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/*.axaml` as the shared translation source. After changing a locale, run `uv run --project scripts scripts/localization/axaml-to-json.py`, then `uv run --project scripts scripts/localization/axaml-to-json.py --check`. Run `uv sync --project scripts` once when the scripts environment is not installed. Do not edit generated Wasm JSON files by hand. CLI JSON resources are separate. - Define, parse, and bind command-line interface (CLI) arguments through `DotMake.CommandLine`. - Do not write code in `Program.cs` or CLI support files that recognizes or dispatches 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. ## AGENTS.md Maintenance -- This file contains project rules. Keep personal preferences in the user's global `AGENTS.md` or `CLAUDE.md`. -- Keep this file below 2,400 tokens. Measure actual tokens after each update. -- Use transcripts and repository evidence. Require two independent sessions for a new non-safety rule; one confirmed safety, data-loss, or compatibility incident is sufficient. -- Make at most five edits per pass. Each edit needs a verbatim quote or repository path. -- Prefer rewriting or deleting rules. Extract narrow triggered guidance into a skill. Delete narrow guidance without a reliable trigger. -- Review for duplication, stale history, contradictions, and budget impact. Check the budget and run the smallest relevant verification after writing. +- Keep durable project constraints here. Keep personal preferences in global instructions. Omit completed work records and generic agent advice. +- Ground rules in repository evidence or confirmed incidents. Prefer removing or merging rules over adding procedures. +- Put specialized workflows in skills or task-specific documentation with clear triggers. Keep skill descriptions short and precise. Use a small router for skills with multiple workflows. +- Review for stale references, duplication, conflicting boundaries, and unnecessary reading or testing. Keep guidance useful across models. ## Documentation Language (ASD-STE100) @@ -59,7 +42,7 @@ ## OpenSpec Workflow - OpenSpec specs are under `openspec/specs/`; archived changes are under `openspec/changes/archive/`. -- Discover active changes. Before implementation, inspect and validate the selected change: +- Apply this workflow when the task uses an OpenSpec change. Routine edits do not require a new change. Before implementing a selected change, discover, inspect, and validate it: - `openspec list --json` - `openspec status --change "" --json` - `openspec validate "" --strict` @@ -68,30 +51,28 @@ ## Testing And Build -- Run focused Avalonia unit tests after ViewModel/CLI/service changes: - - `dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore` -- Run focused Avalonia Headless tests after XAML or UI shell changes: - - `dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore` -- Run the full solution tests before finalizing broader changes: - - `dotnet test ChapterTool.slnx --no-restore` -- 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, run restore or build once. -- Then run commands that use `--no-restore`. -- The CI workflow is in `.github/workflows/dotnet-ci.yml`. -- If `ChapterTool.Avalonia.exe` is locked, close it or run `Get-Process ChapterTool.Avalonia -ErrorAction SilentlyContinue | Stop-Process`. -- Run solution test projects sequentially. Shared `obj/` outputs can cause file locks when test processes run in parallel. -- Keep Avalonia Headless UI tests in `tests/ChapterTool.Avalonia.Headless.Tests`. Run this project in a separate process from non-UI Avalonia tests. -- Keep `[AvaloniaFact]` and `[AvaloniaTheory]` in the Headless project. Put their classes in `AvaloniaHeadlessTestCollection`. -- Do not add assembly-wide `CollectionBehavior(DisableParallelization = true)` to the non-Headless project. -- Avalonia Headless uses a process-wide UI session. Do not merge Headless and non-Headless tests or use a collection as a substitute for process isolation. -- Run the Avalonia unit project alone, then the Headless project alone, then the full solution. If a mixed run hangs, investigate testhost and UI-session isolation instead of deleting tests. -- After a hung or terminated run, stop leftover `ChapterTool.Avalonia.Headless.Tests` and `ChapterTool.Avalonia.Tests` testhosts before retrying. -- In Headless tests, rely on the runner's UI thread, use `RunJobs` and deterministic state, and avoid fixed delays or static-control-only assertions. -- Drive user actions and verify workflow outcomes. Use `autoLoad: false` before explicit `SettingsToolViewModel.LoadAsync`. -- Do not test source or configuration by reading files as text. Use compiled coverage, behavior tests, runtime checks, public APIs, or integration checks. -- Update tests for changed behavior, especially layout, UTF-8, import/export, and platform boundaries. -- Use `docs/code-map/testing.md` for test ownership, Headless lifecycle rules, and distribution verification details. +- Select checks from the changed behavior and its consumers. Use `docs/code-map/testing.md` for test ownership, Headless lifecycle rules, and distribution checks. Documentation-only edits normally need no .NET build or test run. +- Run affected tests with `dotnet test --no-restore`. CLI tests are in `tests/ChapterTool.CommandLine.Tests`. Avalonia ViewModel and service tests are in `tests/ChapterTool.Avalonia.Tests`. XAML and UI workflow tests are in `tests/ChapterTool.Avalonia.Headless.Tests`. +- Use `dotnet test ChapterTool.slnx --no-restore` for changes with broad impact across projects. Repeat successful checks only when a new change, failure, or unresolved concern warrants it. +- Before pushing code or build changes, run `python scripts/ci/run.py` for the host checks shared with CI. Use `scripts/README.md` for prerequisites, stage options, and platform limits. Keep CI build, test, and packaging commands in `scripts/ci/run.py`. +- Restore once when dependencies, target frameworks, or generated project assets change. Then use `--no-restore`. Build `src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj` when its project files change. CI is in `.github/workflows/dotnet-ci.yml`. +- Run test projects sequentially to avoid shared `obj/` file locks. Avalonia Headless uses a process-wide UI session. Keep Headless and non-Headless tests in separate projects and processes. A test collection does not replace process isolation. +- Keep `[AvaloniaFact]` and `[AvaloniaTheory]` in the Headless project, with classes in `AvaloniaHeadlessTestCollection`. Do not disable parallelization assembly-wide in the non-Headless project. +- After a hung run, stop its leftover Avalonia testhosts before retrying. Investigate UI-session isolation rather than deleting tests. +- In Headless tests, use the runner's UI thread, `RunJobs`, and deterministic state. Verify user workflow outcomes. Avoid fixed delays and static-control-only assertions. Use `autoLoad: false` before explicit `SettingsToolViewModel.LoadAsync`. +- Verify behavior through compiled tests, public APIs, runtime checks, or integration checks. Do not test source or configuration by reading files as text. +- Bug fixes must reproduce the reported workflow before the fix and verify its outcome after the fix. Preserve behavior assertions unless the required behavior changes. +- Desktop startup and import changes must verify a real window and the affected load, selection, edit, or save workflow. A successful build or a process that stays alive does not establish workflow completion. +- When IDE and shell test results differ, check external-tool resolution, working directories, and fixture availability before classifying the failure as unrelated. This applies to ffmpeg, ffprobe, and mkvtoolnix consumers. + +## Editing And Precision + +- Time and frame calculations must use timestamp ticks and authoritative rational frame rates through transforms, previews, edits, and exports. Keep exact arithmetic until the required rounding boundary. Approximate or unknown rates must retain their provenance. Do not invent an exact rate from a display value. +- Importers must not hide timing errors through automatic frame snapping or drift correction. Display rounding must not change committed chapter times. Explicit content transforms and export quantization follow their own contracts. +- Entering and leaving a cell editor without a value change must preserve the chapter, its timestamp ticks, and its history position. Do not parse rounded display text back into unchanged content. +- Expression previews must use the shared Core candidate model. Apply must commit the reviewed candidate atomically. Other content options must not replay an expression already applied on the active track and history branch. Display preference changes must preserve an active candidate and must not create content history. +- Document history must remain in memory for the document session. Do not add SQLite, IndexedDB, history export, or restart recovery to this feature without a requested scope change. +- Saved frame display settings must initialize the main-window display state. The main-window rounding toggle affects the current session only. Live settings changes must refresh existing rows even when the toggle value stays unchanged. ## Avalonia UI Guidelines @@ -108,12 +89,29 @@ - Buttons must 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 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. +- Verify stateful layouts in empty, valid, invalid, pending, and stale states. A screenshot of only a valid state does not establish layout parity. - Preserve accessible names, keyboard navigation, focus behavior, and localization boundaries when changing controls. +- Shared editing, settings, and preview changes must cover Avalonia and Web consumers. Verify complete workflows in both hosts. Record intentional platform differences. +- Web shortcut checks must use real key events to verify document actions, browser default cancellation, and field text undo. +- Selector and localization changes must verify initial selection, reselection, and language switching. Check selected labels and existing rows in Chinese, English, and Japanese. +- Time values must stay on one line. Frame decimals must read as one number. Verify normal, editor, and preview cells for clipping, alignment, and available width. -## Change And PR Expectations +## Completion And Scope -- 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. +- Complete the requested change, run relevant checks, and fix failures caused by the change before handing back the result. Continue routine local edits and verification without asking for approval at each step. +- Stop when the requested outcome is verified or a concrete blocker needs user input. Report blockers and incomplete checks. Respect an explicit request to stop for review. +- Keep work within the requested scope. Preserve unrelated user and generated changes. +- Update relevant `docs/code-map/` pages when module ownership, entry points, runtime wiring, or primary tests change. +- Report the resulting behavior and primary verification commands. Include screenshot paths for UI changes when available. +- When the task includes push and CI verification, check the required jobs for the pushed commit SHA. Continue fixing failures within the authorized scope until those jobs pass or a concrete blocker requires user input. Local checks and a successful push do not establish remote CI success. + + +## CodeGraph + +In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: + +- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. +- **Shell** (always works): `codegraph explore ""` prints the same output. + +If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision. + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..93890daa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,929 @@ +# Release Notes + +## [23.31.0] - 2026.10.14 + +### Features + +- Added DVD `VIDEO_TS` import with title selection and IFO/NAV timing. +- Added branching undo/redo, history inspection, and node restoration for desktop and browser. +- Added live expression previews and shared review/apply workflows for content edits. +- Preserved exact frame-rate provenance and added full-precision frame counts with repeating decimals. +- Added customizable shortcuts, conflict checks, and desktop chapter and selection counts. +- Improved log filtering, inspection, copying, and export. +- Added Core API reference documentation at `/ChapterTool/docs/`. + +### Bug Fixes + +- Preserved timestamps and history for unchanged frame cells; fixed per-track frame-rate commits. +- Fixed stale expression previews and prevented expression replay during later edits or exports. +- Preserved clip edits and rejected combine/append with unknown durations. +- Fixed redo branch selection, history details, and restore messages. +- Fixed browser settings and export-save failures, preserving drafts and restoring active options. +- Fixed null shortcut overrides, shortcut refresh after reattachment, and browser dialog cleanup. +- Fixed expression editor focus and Backspace input; improved responsive layouts and localization. +- Fixed zero or duplicate Matroska XML UIDs. + +### Compatibility Changes + +- Removed numeric frame-rate compatibility APIs: Core uses `FrameRateValue`; Node uses tagged rates and tick strings. +- Scalar combine/append now reject incompatible frame rates. +- Removed transforms from export options. Apply them before Core `Serialize` or Node `serialize`. +- Removed automatic migration and cleanup of old browser settings keys. +- Renamed `LegacyMplsCode` to `MplsFrameRateCode` and removed obsolete expression compatibility members. +- XML UIDs now use stable content hashes; values may differ from older versions. + +### Internals + +- Unified editable documents and atomic operations; simplified sessions and UI composition. +- Expanded automated tests and shared CI/package checks; updated dependencies and resource audits. + +## [23.3.2] - 2026.08.29 + +### Features + +- Added media track information processing and chapter import projection to improve recognition and display of multi-track media. +- Added frame display and chapter deletion timing settings so editing and time display behavior can be adjusted to user preferences. +- Added selection indicators and a context menu to ClipBox. + +### Bug Fixes + +- Improved cross-platform BDMV paths and imported filename formatting for more consistent imports across operating systems. +- Fixed time information being lost after deleting a chapter row. +- Improved DataGrid header and separator styles, and fixed the Advanced Options layout at different window widths and languages. +- Improved language display names and localization resources, aligned Chinese, English, and Japanese text, and standardized the `mkvextract` tool name. + +### Internals + +- Improved desktop publishing configuration and the GitHub Release trigger workflow, including release artifact validation. +- Integrated Python utility scripts with the uv environment and Ruff checks. +- Expanded unit and Headless test coverage for Core, Infrastructure, CLI, and Avalonia to improve release verification reliability. + +## [23.3.1] - 2026.08.13 + +### Features + +- Completed native BDMV import: parse INDEX, CLPI, BDJO, and Movie Object files, discover titles from complete playlists, and merge display names in the eac3to style. + +### Bug Fixes + +- Aligned HDMV offsets and CLPI boundaries with libbluray 1.5.1, and hardened importer, time formatting, and localization handling for invalid input. +- Unified the Avalonia visual design system, improved load entry points and shortcut hints, and removed the Save As entry point. +- Improved log aggregation and readability. + +### Internals + +- Simplified Avalonia composition across hosts and used Autofac to manage service lifetimes on desktop. +- Improved TypeScript types, tests, and documentation for the Node.js package. + +## [23.3.0] - 2026.07.30 + +### Features + +- Extended portable import paths: embedded CUE files in FLAC/TAK are now handled through `ChapterContentService`, allowing direct reads in environments without external tools such as WASM and Node. +- Added Chinese, English, and Japanese error and diagnostic localization to the CLI. Moved desktop strings to axaml resources and improved ready-state text and empty output path display when switching languages. + +### Bug Fixes + +- Adjusted the browser workspace Advanced Options layout to match the desktop workflow and improved Chinese, English, and Japanese UI localization with JSON resources. +- Improved expression tool editing with compact mode and completion anchored to the caret line, and switched to system fonts and FontAwesome icons. + +### Internals + +- Split desktop and command-line hosts: `ChapterTool.CommandLine` is now an independent CLI executable and .NET Tool, while the desktop Avalonia host only starts the graphical interface and no longer dispatches CLI arguments. +- Extracted shared desktop UI into `ChapterTool.Avalonia.UI` (main view, tool windows, workflows, and ViewModels), and moved settings and platform contracts into `ChapterTool.Contracts`. +- Moved workspace session models such as `ChapterWorkspace` and `ClipSession` into `ChapterTool.Core` for use by desktop, browser, and command-line hosts. + +## [23.2.1] - 2026.07.22 + +### Features + +- Added an independent command-line program and .NET Tool package, and extracted the command-line workflow into shared components. +- Extended command-line chapter conversion with Lua expressions, built-in presets, and runtime importer composition. +- Upgraded the browser sample to a complete Blazor WebAssembly workspace. +- Added a .NET WebAssembly-based Node.js API and npm package for chapter import, conversion, validation, and export. + +### Bug Fixes + +- Added localization, settings, and workflow validation to the browser workspace. + +### Internals + +- Improved the NuGet, npm, and GitHub Release publishing pipelines, with consistent version and artifact validation. +- Updated project dependencies and code navigation documentation. + +## [23.2.0] - 2026.07.18 + +### Features + +- Completed the settings system with versioned settings documents, aggregated persistence, theme presets, independent fonts, save directories, output encoding, and frame rate settings. +- Enhanced the expression editing workflow with script loading, multiline editing, validation, chapter context, and more complete diagnostic feedback. +- Added a `ChapterTool.Core` browser WebAssembly sample and made it available through GitHub Pages. + +### Bug Fixes + +- Unified chapter load, save, and export factories for the CLI and GUI, and improved option binding and multi-segment chapter name generation. + +### Internals + +- Split main-window session state and introduced typed ClipSession, ChapterWorkspace, tool ports, and a window registry to clarify workspace state ownership. +- Strengthened GitHub Pages builds and WASM release artifact validation. + +## [23.1.0] - 2026.07.09 + +### Features + +- Introduced a new chapter expression engine and Lua implementation with time, frame rate, and chapter index context, plus a script execution budget. + +### Bug Fixes + +- Standardized chapter time properties as `StartTime` and `EndTime`, abstracted the media chapter reading boundary, and improved media container importer replaceability. +- Strengthened validation and failure diagnostics at the settings file, external importer, core API, and CLI startup boundaries. Corrupt settings are preserved and unsafe process argument handling is avoided. + +### Internals + +- Prepared the `ChapterTool.Core` NuGet package for .NET 8, .NET 9, and .NET 10, including package README, license, documentation, and symbol package configuration. +- Refactored chapter import, editing, and export models with structured import progress, diagnostic codes, diagnostic sources, and unified import/export format descriptions. +- Added Sentry startup diagnostics, used source-generated JSON serialization to improve runtime performance, and reorganized Avalonia Headless tests. +- Improved cross-platform CI, release scripts, and application publishing configuration, and removed deprecated installer assets. + +## [23.0.0] - 2026.07.07 + +### Features + +- Added the official command-line entry point with `formats`, `inspect`, `convert`, and `load` commands. +- Enhanced expression editing with syntax highlighting, completion, and diagnostics. +- Extended and organized import and export support for text, XML, WebVTT, CUE, MPLS, BDMV, IFO, XPL, Matroska, and common media containers. Export supports TXT, XML, QPF, TimeCodes, tsMuxeR meta, CUE, JSON, WebVTT, Celltimes, and Chapter2QPF. + +### Bug Fixes + +- Improved external tool discovery and ffprobe/mkvextract/eac3to diagnostics and fallback paths for more stable messages when files have no chapters, are invalid, or contain multiple editions, clips, or titles. + +### Internals + +- Migrated to .NET 10 and a cross-platform Avalonia desktop architecture while retaining core chapter loading, editing, combining, preview, and export workflows. + +## [2.33.33.3331] - 2023.01.21 + +### Features + +- Added handling for MPLS files whose play items have no marks. + +### Bug Fixes + +- Removed registry usage. + +### Internals + +- Updated dependencies. +- Added a GitHub Action. + +## [2.33.33.333] - 2022.09.27 + +### Bug Fixes + +- Fixed thread invocation when updating the chapter list. + +## [2.33.33.332] - 2021.01.07 + +### Features + +- Added an automatic version update script. + +### Bug Fixes + +- Fixed the x64 build target. + +### Internals + +- Updated dependencies. +- Organized code style. +- Switched JSON output to serialization. + +## [2.33.33.331] - 2020.08.30 + +### Features + +- Added an installer build script. + +### Bug Fixes + +- Fixed exceptions during some time edits. +- Fixed the `Any CPU` build condition that prevented MP4 files from loading correctly on 64-bit systems. +- Fixed time not carrying to the next second when rounding produced 1000 milliseconds. + +## [2.33.33.33] - 2019.03.23 + +### Features + +- Added time and frame count editing. + +### Internals + +- Continued work on new features, bug fixes, and performance improvements. + +## [2.33.33.32] - 2018.07.23 + +### Features + +- Improved DVD chapter reading. + +### Bug Fixes + +- Exported QPF files without a BOM. + +## [2.33.33.31] - 2018.02.25 + +### Features + +- Added the English interface. + +### Bug Fixes + +- Fixed some high-DPI issues. +- Fixed DVD chapter reading. +- General bug fixes and performance improvements. + +## [2.33.33.3] - 2017.05.07 + +### Features + +- Added full-featured UHD chapter reading. +- Added a new JetBrains-style icon. + +### Internals + +- General bug fixes and performance improvements. + +## [2.33.33.2] - 2017.05.04 + +### Features + +- Added JSON output. +- Added basic UHD Blu-ray support. + +### Bug Fixes + +- Fixed reading embedded CUE files. +- Added high-DPI support. +- General bug fixes and performance improvements. + +## [2.33.33.1] - 2017.01.22 + +### Features + +- Added append support for MPLS. +- Added an update prompt for older .NET runtimes. +- Added a new FLAC parser to provide more FLAC-related information (mainly for ATI). + +### Internals + +- General bug fixes and performance improvements. + +## [2.33.32.33] - 2016.09.10 + +### Features + +- Added expressions for controlling timestamp conversion. All operators use left associativity. +- Added infix and reverse Polish expression forms, limited to operators supported by VapourSynth. + +### Internals + +- Logged the converted reverse Polish or infix expression, as requested by QPet. + +## [2.33.32.32] - 2016.08.13 + +### Features + +- Added WebVTT support. +- Added a feature to shift forward by a specified number of frames, as requested by wongyi. +- Added support for BDMV folders. The parent folder containing BDMV must contain only ASCII characters. + +### Bug Fixes + +- Fixed automatically generated chapter names when files were loaded consecutively. +- Fixed row background colors. + +## [2.33.32.31] - 2016.06.24 + +### Features + +- Double-clicking the frame count column now copies frame information to the clipboard. +- Added automatic multiplication by 1.001 for IFO chapters with a 29.970 frame rate. + +### Bug Fixes + +- Fixed cross-thread access to the update-window progress bar. +- Fixed Alt+S not saving the last chapter. + +## [2.33.32.3] - 2016.05.17 + +### Features + +- Added the `--zones` argument. +- Moved the progress bar and related indicators to the status strip. +- Added several hotkeys. + +### Bug Fixes + +- Fixed saving when no language was selected. +- Fixed frame rate display. +- Removed `-1` from qpfile output. + +## [2.33.32.2] - 2016.05.05 + +### Features + +- Added MP4 support. +- Added CUE export. +- Added a context-menu reload command for the Load button. + +### Bug Fixes + +- Fixed crashes during frame recognition at specific frame rates. +- Fixed errors when loading empty MPLS files. + +## [2.33.32.1] - 2016.04.02 + +### Features + +- Added detailed MPLS information display. +- Added an online upgrade page on GitHub. + +### Bug Fixes + +- Fixed the duration of merged MPLS chapters. +- Fixed MPLS frame rates for 50p and 60i. +- Fixed chapter timestamps after chapter deletion. + +## [2.33.31.3] - 2016.03.15 + +### Features + +- Improved online upgrades. +- Improved error messages. + +### Bug Fixes + +- Fixed missing video filenames when saving MPLS chapter files. +- Fixed the UI not updating when two segments had the same number of chapter timestamps. +- Fixed exceptions in some cases. +- Fixed the first chapter not starting at 00:00:00.000 when leading black video existed in an MPLS chapter. + +## [2.33.31.2] - 2016.03.09 + +### Features + +- Added context-menu commands to open the corresponding MPLS and IFO files. +- Added HDDVD chapter (`.xpl`) support. + +### Bug Fixes + +- Fixed CUE parsing. + +## [2.33.31.1] - 2016.02.29 + +### Features + +- Added online updates. + +### Bug Fixes + +- Fixed the color settings file path in some cases. +- Rewrote text chapter parsing for improved stability. +- Improved frame count rounding details. +- Fixed the 1.001 multiplier after switching clips. + +### Internals + +- Added hints for some options. + +## [2.33.3.3] - 2016.02.16 + +### Features + +- Added limited support for child chapters in XML chapter files. +- Added CUE support, including embedded CUE in TAK and FLAC. + +### Bug Fixes + +- Fixed XML chapter parsing. +- Fixed extra spaces in some language options in XML save settings. +- When an MPLS or IFO chapter had no loaded chapter-name template, deleting a row now restarts numbering at Chapter 01. + +### Internals + +- Improved perceived list insertion smoothness. +- Fixed many details. + +## [2.33.3.2] - 2016.01.14 + +### Bug Fixes + +- Fixed the IFO chapter merge function. +- Fixed loaded file format matching. +- Fixed the selection box remaining unselected after loading XML chapters. +- Fixed missing prompts after loading XML or MKV chapters. + +## [2.33.3.1] - 2015.12.20 + +### Features + +- Added display of all corresponding video files for chapter segments with multiple angles. +- Added saving for Time Codes and tsMuxeR Meta information. +- Added IFO chapter merging as an experimental feature. +- Added an option to open `.mpls` files with ChapterTool from the context menu. Administrator privileges are required. + +### Bug Fixes + +- Fixed precision in time correction after extracting IFO chapters; the operation is now manual. +- Fixed duplicate errors after a valid chapter file was followed by an invalid OGM chapter file. +- Fixed a crash when hovering over Save after loading an invalid MPLS file. +- Fixed crashes after clicking Refresh or Preview after loading an invalid file. +- Fixed the QPF output format and duplicate `\r` after OGM chapter names. +- Fixed lag caused by repeatedly clicking the expanded-window button. +- Fixed IFO chapter segment filename display. +- Fixed all loaded IFO chapter numbers being zero. + +### Internals + +- Added color to the progress bar. + +## [2.33.2.3] - 2015.12.05 + +### Features + +- Added the version to the title bar. +- Prefer installed MkvToolnix when available. +- Added invalid-file prompts. + +### Bug Fixes + +- Fixed frame rate reading for MPLS files containing MPEG-II or VC-1 video. +- The interface now shows only the loaded filename instead of the full path. +- Fixed the XML output option "Do not use chapter names". +- Fixed time shifting. +- Fixed frame count calculation after time shifting. + +### Internals + +- Improved robustness. +- Added save-information logging. + +## [2.33.2.2] - 2015.11.18 + +### Features + +- Added saving for interface color settings. + +### Bug Fixes + +- Fixed ordered chapter reading. + +## [2.33.2.1] + +### Features + +- Added XML chapter parsing with support for multi-chapter XML input. +- Added a language option for XML chapter output. + +### Bug Fixes + +- Fixed loading XML after MPLS. +- Added a scroll bar for long previews. +- Fixed frame count calculation for 25 fps IFO files. +- Improved display performance. +- Progress now reaches one-third for segments without chapters. + +### Internals + +- Updated the bundled mkvextract to 8.5.1. + +## [2.33.1.3] + +### Bug Fixes + +- Fixed PlayList Mark parsing when Entry Mark and Link Point entries appeared in an alternating order. This regression was introduced in 2.3.331.1. + +## [2.33.1.2] - 2015.10.09 + +### Features + +- Added blue and white alternating rows to the list. +- Updated the tutorial. + +### Bug Fixes + +- Improved list refresh efficiency and reduced flicker. +- Fixed some IFO reading issues. + +## [2.33.1.1] + +### Features + +- Enabled a new, clearer, and simpler interface. +- Improved chapter-name templates so they remain active after selection. +- Added TXT, XML, and QPF output format options. + +## [2.3.333.2.fix] + +### Bug Fixes + +- Fixed the file dialog filter. + +## [2.3.333.2] - 2015.09.14 + +### Features + +- Added full MPLS chapter output. +- Added IFO file support. +- Added Alt+A to automatically select the latter half of the text box. + +### Bug Fixes + +- Fixed combo-box background colors. +- Improved loaded file path display. +- Fixed excessive decimal places when loading MKV chapters. + +## [2.3.333.1] - 2015.09.13 + +### Features + +- Updated the tutorial with new chapter-splitting steps. +- Began using Git. + +### Bug Fixes + +- Fixed incorrect window sizing. +- Improved calculation precision. +- Fixed many details. +- Fixed loading files with spaces by dragging them to the icon, a regression since 2.3.31.1. + +## [2.3.332.3] + +### Bug Fixes + +- Fixed an error when opening the color settings window for the second time. +- For incorrectly muxed MKV files containing two chapters, only the first is read. +- Fixed the time-shift option not updating unless it was reselected. +- Fixed the 1 ms error caused by `double` values since 2.3.332.1. +- Rounded the millisecond portion of times read from MPLS. + +### Internals + +- Replaced `DateTime` variables with `TimeSpan` variables. + +## [2.3.332.2] + +### Features + +- Added window position persistence after closing the program. + +### Bug Fixes + +- Fixed multiple Log, color settings, or About windows being opened. +- Fixed errors when loading MKV files without chapters and converting another chapter file afterward. +- Disabled editing in the right text box. + +### Internals + +- Expanded log contents. + +## [2.3.332.1] + +### Features + +- Added application runtime logging. + +### Internals + +- Replaced MediaInfo with MKVextract. +- Fixed automatic frame rate detection. + +## [2.3.331.1] + +### Features + +- Added clickable hints in the frame count calculation area. +- Improved frame count calculation details. +- Added detailed hints. +- Added more detailed prompts for segments containing only two chapter points. + +### Bug Fixes + +- Fixed several bugs. +- Fixed the segment selector disappearing when an MPLS file was dragged to the icon. + +### Internals + +- Added new meaning to the "Something happened" message. +- Removed the old "mystical programming" phrase. + +## [2.3.33.4] + +### Bug Fixes + +- Fixed a crash when hovering over Save without an MPLS file loaded. + +## [2.3.33.3] + +### Features + +- Added custom interface colors. These settings are not persisted and are opened from the Convert button context menu. +- Added a prompt asking whether a two-point MPLS segment is a pseudo-chapter when saving. + +### Bug Fixes + +- Fixed a difficult-to-find bug through VS2015. +- Fixed QPF duplicate-name detection. +- Switched back to the light theme. +- Added a prompt for excessively long paths. +- Fixed a black box on the first click of More. + +## [2.3.33.2] + +### Features + +- Remembered the manually selected save path. + +### Bug Fixes + +- Added a prompt when `mediaInfo.dll` could not be loaded. + +## [2.3.33.1] + +### Features + +- Added limited Matroska support. +- Updated the tutorial to v3. + +### Bug Fixes + +- Fixed a crash when the input was an empty or whitespace-only file. + +### Internals + +- Refreshed the visual appearance. + +## [2.3.32.1] + +### Bug Fixes + +- Rewrote MPLS parsing to fix mismatches between some chapters and filenames. +- Fixed a data consistency issue. +- Reduced the number of try-catch blocks through improved regular-expression handling. + +## [2.3.31.1] + +### Features + +- Added drag-and-drop loading from the application icon. +- Added a Save button context menu for setting the save path. +- Updated the tutorial to v2. + +### Bug Fixes + +- Fixed loading read-only MPLS files. + +## [2.3.3.333] + +### Features + +- Added a "Something happened" dialog as a Windows 10 launch commemorative feature. + +### Bug Fixes + +- Fixed missing first digits in filenames inside MPLS files. + +## [2.3.3.332] + +### Features + +- Added drag-and-drop support to the About window. +- Added manual adjustment of the frame rounding error range. +- Added limited QPF support. +- Added the first version of the tutorial. + +### Bug Fixes + +- Fixed values such as 1.001 frames being displayed as 2 frames. +- MPLS chapters are now displayed even when only one video exists. +- Fixed crashes when loading invalid MPLS files. + +## [2.3.3.331] + +### Bug Fixes + +- Fixed the black box shown during resizing. +- Fixed hidden controls still being reachable with Tab. +- Fixed crashes when loading XML files that were not chapter files. + +### Internals + +- Aligned controls. + +## [2.3.3.33] + +### Features + +- Added limited MPLS support. +- Added more selectable frame rates. + +### Internals + +- Temporarily canceled the multilingual interface plan. +- Redrew the logo and reduced its size by 13 KiB. + +## [2.3.3.32] + +### Bug Fixes + +- Improved AUTO calculation to avoid incorrect output. + +## [2.3.3.31] + +### Features + +- Added the English interface. + +### Internals + +- Used mask recognition for shift time. + +## [2.3.3.3] + +### Features + +- Added limited XML chapter support. + +### Bug Fixes + +- Fixed several bugs. +- Fixed path prompts when loading files with the Load button. +- Changed output extension generation to avoid overwriting files. + +### Internals + +- Changed the About page display. +- Removed the unused Save button. + +## [2.3.3.2] + +### Features + +- Added automatic conversion while loading files. +- Added a 1.001 multiplier to all chapter start times for chapters extracted with DVD Decrypter. +- Added shifting for all chapter numbers and timestamps, such as 00:23:23.233. +- Added chapter-name template selection. + +## [2.3.3.1] + +### Features + +- Added an automatic frame rate detection button. + +### Bug Fixes + +- Fixed frame counts being incremented by one when already integers. + +## [2.3.3.0] + +### Internals + +- Merged the TimeCal code. + +## [2.3.2.3] + +### Features + +- Added a root progress bar. + +### Bug Fixes + +- Fixed several bugs. + +### Internals + +- Removed TextBox borders. + +## [2.3.2.2] + +### Bug Fixes + +- Fixed several bugs. + +### Internals + +- Improved prompt and button sizes. +- Changed the target framework to .NET 3.5. + +## [2.3.2.1] + +### Bug Fixes + +- Fixed several bugs. + +## [2.3.2.0] + +### Features + +- Added a button to save the source file. + +## [2.3.1.0] + +### Features + +- Added the Chinese interface. + +## [2.3.0.0] + +### Internals + +- Rewrote Convert button logic to read content from the TextBox instead of directly from the file. + +## [2.2.0.0] + +### Features + +- Added an icon. +- Added support for text content containing blank lines. + +### Bug Fixes + +- Fixed several bugs. + +## [2.1.0.0] - 2015.05.08 + +### Features + +- Added an option to preserve original chapter names. + +---------------------------- + +## [2.0.0.0] + +### Internals + +- First usable C# version. + +## [1.7] + +### Features + +- Added automatic removal of leading blank lines. + +## [1.6] + +### Bug Fixes + +- Fixed issues caused by file extensions. + +### Internals + +- Unified code style. +- Compiled in VS2013 Release mode to reduce program size. +- Changed output file path generation. +- Added a console title. + +## [1.5] + +### Features + +- Removed the file processing limit. + +### Internals + +- Changed output filenames to the original filename plus `_`. + +## [1.4] + +### Bug Fixes + +- Fixed the algorithm to prevent incorrect output in some special cases. + +## [1.3] + +### Features + +- Added prompts when the file processing limit is exceeded or the program is opened directly. + +## [1.2] + +### Features + +- Added multi-file processing. + +### Internals + +- Removed the chapter-name preservation option. + +## [1.1] + +### Internals + +- Not remembered. + +## [1.0] + +### Internals + +- First C++ version. diff --git a/ChangeLog.md b/CHANGELOG.zh-CN.md similarity index 85% rename from ChangeLog.md rename to CHANGELOG.zh-CN.md index 0158df86..067ea25d 100644 --- a/ChangeLog.md +++ b/CHANGELOG.zh-CN.md @@ -1,4 +1,41 @@ -# __Change Log__ +# Release Notes + +## [23.31.0] - 2026.10.14 + +### 功能新增 + +- 新增 DVD `VIDEO_TS` 导入,支持标题选择和 IFO/NAV 时间读取。 +- 桌面与浏览器新增分支撤销/重做、历史检查和节点恢复。 +- 新增实时表达式预览,统一内容编辑的预览与应用流程。 +- 保留精确帧率来源,支持含循环小数的完整精度帧数显示。 +- 新增自定义快捷键、冲突检查及桌面章节数和选中项计数。 +- 改进日志筛选、详情查看、复制和导出。 +- 新增 Core API 参考文档,发布于 `/ChapterTool/docs/`。 + +### 问题改进 + +- 未修改的帧单元格保留时间戳和历史位置;修正单轨帧率变更提交。 +- 修正过期表达式预览,避免后续编辑或导出重复执行表达式。 +- 片段操作保留当前编辑;时长未知时拒绝合并或追加。 +- 修正重做分支选择、历史详情和恢复文案。 +- 修正浏览器设置和导出选项保存失败处理,保留草稿并恢复活动选项。 +- 修正 null 快捷键覆盖、重新挂载后的快捷键更新及浏览器对话框释放。 +- 修正表达式编辑器焦点和 Backspace 输入,改进响应式布局与本地化。 +- 修正 Matroska XML UID 为零或重复的问题。 + +### 兼容性变化 + +- 移除数值帧率兼容接口:Core 使用 `FrameRateValue`,Node 使用类型化帧率和 tick 字符串。 +- 标量合并/追加接口拒绝不兼容的帧率。 +- 导出选项不再执行内容变换;先应用变换,再调用 Core `Serialize` 或 Node `serialize`。 +- 移除浏览器旧设置键的自动迁移与清理。 +- 将 `LegacyMplsCode` 改名为 `MplsFrameRateCode`,移除过时的表达式兼容成员。 +- XML UID 改用稳定内容哈希生成,数值可能与旧版本不同。 + +### 其他 + +- 统一可编辑文档和原子操作,简化会话与 UI 组合。 +- 扩展自动化测试和共用 CI/包验证,更新依赖与资源检查。 ## [23.3.2] - 2026.08.29 @@ -126,46 +163,55 @@ - 迁移到 .NET 10 与跨平台 Avalonia 桌面架构,保留章节载入、编辑、组合、预览、导出等核心工作流。 ## [2.33.33.3331] - 2023.01.21 +### 其他 - 移除注册表的使用 - 更新依赖版本 - 增加对playitem无marks的mpls文件的处理 - 增加github action ## [2.33.33.333] - 2022.09.27 +### 问题改进 - 修正更新章节列表时的线程调用问题 ## [2.33.33.332] - 2021.01.07 +### 其他 - 增加自动版本号更新脚本 - 更新依赖版本并整理代码风格 - 改用序列化处理 JSON 输出 - 修正 x64 编译目标 ## [2.33.33.331] - 2020.08.30 +### 问题改进 - 修正某些时间编辑下抛异常的情况 - 增加安装包构建脚本 - 修正`Any CPU`编译条件,mp4文件在64位系统下无法正确加载的问题 - 修正四舍五入后为1000毫秒时时间未能进位的问题 ## [2.33.33.33] - 2019.03.23 +### 功能新增 - 我始终致力于开发新功能、错误修正及效能改进。请即时更新至最新版本以取得最佳的验证体验 - 增加时间与帧数编辑功能 ## [2.33.33.32] - 2018.07.23 +### 功能新增 - 改进DVD章节读取 - 输出无BOM的QPF文件 ## [2.33.33.31] - 2018.02.25 +### 问题改进 - 错误修正与效能提升 - 增加英语界面 - 修正部分高分屏的问题 - 修正DVD章节读取 ## [2.33.33.3] - 2017.05.07 +### 功能新增 - 错误修正与效能提升 - 完整功能的UHD章节读取 - 全新喷气脑袋风格图标 ## [2.33.33.2] - 2017.05.04 +### 功能新增 - 错误修正与效能提升 - 修正内封cue的读取 - 增加json输出格式 @@ -173,17 +219,20 @@ - 增加对UHD原盘基础支持 ## [2.33.33.1] - 2017.01.22 +### 功能新增 - 错误修正与效能提升 - mpls增加追加功能 - 低版本.Net运行时增加更新提示 - 新增flac parser可获得更多flac相关信息(虽然是ATI用的 ## [2.33.32.33] - 2016.09.10 +### 功能新增 - 使用表达式来控制时间戳的变换(所有运算符均采用左结合) - 表达式形式可选中缀表达式或逆波兰表达式(仅限vapoursynth中支持的操作符) - 输入的表达式将会在log中输出转换后的逆波兰表达式(应QPet需求)或中缀表达式 ## [2.33.32.32] - 2016.08.13 +### 功能新增 - 修正自动生成章节名在连续载入时出现的问题 - 修正行背景色 - 增加对WebVTT的支持 @@ -191,12 +240,14 @@ - 增加对BDMV文件夹的支持(包含BDMV的父文件夹需仅含ASCII字符) ## [2.33.32.31] - 2016.06.24 +### 功能新增 - 修正更新窗口进度条跨线程访问问题 - 双击帧数栏则将帧数信息复制至剪切板 - 修正Atl+S无法保存最后一个章节的问题 - 对帧率为29.970的IFO章节自动乘以1.001 ## [2.33.32.3] - 2016.05.17 +### 功能新增 - 增加`--zones`参数生成功能 - 修正未选择语言时保存出错的问题 - 将进度条等移至 status strip @@ -205,6 +256,7 @@ - 去除qpfile中的 '-1' ## [2.33.32.2] - 2016.05.05 +### 功能新增 - 增加mp4文件格式支持 - 增加cue格式输出 - 修正帧数识别时在特定帧率下崩溃的问题 @@ -212,6 +264,7 @@ - 增加右键载入按键重新载入文件功能 ## [2.33.32.1] - 2016.04.02 +### 功能新增 - 修正mpls合并章节的时长 - 增加mpls详细信息的显示 - 修正mpls帧率(50p与60i) @@ -219,6 +272,7 @@ - 将升级检查的页面移至github ## [2.33.31.3] - 2016.03.15 +### 问题改进 - 修正mpls章节保存文件名中对应视频文件名缺失的问题 - 修正两个片段的章节时间戳数相同时界面上显示不更新的问题 - 改进在线升级 @@ -227,11 +281,13 @@ - 修正mpls章节中当视频存在前黑导致的首章节不处于00:00:00.000的情况 ## [2.33.31.2] - 2016.03.09 +### 功能新增 - 为mpls、ifo增加打开对应文件的右键菜单 - 增加对HDDVD章节(.xpl)的支持 - 修正cue文件的解析 ## [2.33.31.1] - 2016.02.29 +### 功能新增 - 修正界面配色配置文件保存路径在某些情况下异常的问题 - 重写文本格式章节解析,提高稳定性 - 为部分选项增加提示 @@ -240,6 +296,7 @@ - 隆重增加在线更新功能,点击图标有惊喜 ## [2.33.3.3] - 2016.02.16 +### 功能新增 - 假装提高列表插入的流畅度 - 修正XML格式章节的解析 - 增加对XML章节中子章节的有限支持 @@ -249,12 +306,14 @@ - 好多细节修正 ## [2.33.3.2] - 2016.01.14 +### 问题改进 - 修正IFO章节合并函数 - 修正载入文件格式匹配 - 修正载入xml章节后选框未选中的问题 - 修正载入xml或mkv后无提示的问题 ## [2.33.3.1] - 2015.12.20 +### 功能新增 - 修正ifo章节提取后时间修正的精度问题,并将此操作改为手动操作 - 修正在载入正常章节文件后错误载入无效OGM格式章节的两次报错问题 - 修正载入无效mpls文件后鼠标移至保存键时崩溃的问题 @@ -270,6 +329,7 @@ - 修正载入的IFO章节编号均为0的问题 ## [2.33.2.3] - 2015.12.05 +### 问题改进 - 在标题栏增加版本显示 - 假装提升了鲁棒性 - 修正 MPEG-II & VC-1 编码视频的mpls的帧率无法读取的问题 @@ -282,10 +342,12 @@ - 增加保存信息日志(没啥用的 ## [2.33.2.2] - 2015.11.18 +### 功能新增 - 修正ordered chapter的读取 - 增加保存界面配色功能 ## [2.33.2.1] +### 功能新增 - 增加对xml格式章节解析能力,支持多章节xml输入 - 修正载入mpls后无法继续载入xml的问题 - 对过长的预览增加滚动条 @@ -296,23 +358,28 @@ - 更新附带的mkvextract至8.5.1 ## [2.33.1.3] +### 问题改进 - 修正PlayList Mark中 Entry Mark 与 Link Point交错出现时无法正确读取的bug(自2.3.331.1开始出现 ## [2.33.1.2] - 2015.10.09 +### 功能新增 - 提高刷新列表的效率,减少闪烁 - 修复部分ifo文件读取问题 - 为列表加上蓝白条(LP的真知灼见 - 更新教程 ## [2.33.1.1] +### 功能新增 - 启用全新界面,更直观,更简洁的显示 - 增强章节名模板功能,勾选后可持续生效 - 新增输出格式选项,有txt,xml,qpf三种格式可选 ## [2.3.333.2.fix] +### 问题改进 - 修复载入文件框处筛选器异常 ## [2.3.333.2] - 2015.09.14 +### 功能新增 - 支持mpls整体章节输出 - 修复下拉选框的背景色 - 优化已载入文件路径的显示 @@ -321,6 +388,7 @@ - 提供IFO文件支持 ## [2.3.333.1] - 2015.09.13 +### 问题改进 - 修正窗体大小错误 - 计算精度提升 - 大量细节修正 @@ -329,6 +397,7 @@ - 开始尝试使用git ## [2.3.332.3] +### 问题改进 - 修复颜色设置窗口第二次打开报错的情况 - 对错误的封装了两个章节的mkv文件,仅读取其第一个 - 将代码中DateTime类型变量更换为TimeSpan类型 @@ -337,6 +406,7 @@ - 对mpls中读取的时间的毫秒位进行了四舍五入 ## [2.3.332.2] +### 功能新增 - 丰富了Log记录的内容 - 增加保存上次关闭程序时窗体位置 - 修复可打开多个Log窗体,颜色设置窗体或关于窗体的问题 @@ -344,11 +414,13 @@ - 禁用了右侧文本框的编辑功能 ## [2.3.332.1] +### 功能新增 - 放弃MediaInfo改用MKVextract - 修复自动检测帧率失效的问题 - 增加程序运行Log ## [2.3.331.1] +### 问题改进 - 增加帧数计算处点击提示行数的功能(不用怀疑,又是大喵 - 修正帧数计算部分的细节 - 增加细节提示 @@ -359,9 +431,11 @@ - 修正拖拽至图标的mpls,片段选框消失的问题 ## [2.3.33.4] +### 问题改进 - 修复未载入mpls时鼠标移到保存时程序崩溃的情况 ## [2.3.33.3] +### 功能新增 - 透过高贵的VS2015修正了一个难以发现的傻逼BUG - 修正qpf文件的重名检测 - 深色感觉还是太脏了,换回浅色 @@ -371,31 +445,37 @@ - 当mpls片段仅包含两个章节点,在保存时增加是否为伪章节的提示 ## [2.3.33.2] +### 功能新增 - 当mediaInfo.dll无法载入时,增加提示 - 记住上次所手动指定的保存路径 ## [2.3.33.1] +### 功能新增 - 增加有限的Matroska文件支持(正则还是要继续看啊 - 修正输入为空白文件程序崩溃的情况 - 换了张皮 - 教程修正至v3 ## [2.3.32.1] +### 问题改进 - 重写mpls解析部分,修正某些章节与文件名不匹配的情况 - 通过对面向玄学编程的进一步了解,修复了一个数据不一致的问题 - 通过对正则的进一步了解,减少了try catch 的出场次数 ## [2.3.31.1] +### 功能新增 - 支持直接将文件拖到图标上载入(感谢大喵的提议 - 右键保存键,可设置保存路径(感谢大喵的提议 - 修正只读的mpls无法读取的情况 - 教程修正至v2 ## [2.3.3.333] +### 问题改进 - 修正mpls内文件名少第一位数的情况 - 增加 Something happened 提示框(Windows10发布纪念feature ## [2.3.3.332] +### 功能新增 - About界面增加拖拽功能 - 增加对帧取整的误差范围的手动调节 - 修正如1.001帧显示为2帧的情况 @@ -405,25 +485,30 @@ - 教程第一版诞生 ## [2.3.3.331] +### 问题改进 - 修正resize时出现的黑框 - 修正未显示控件依旧可被tab调用 - 对齐控件 - 修正 载入非章节xml 崩溃的问题 ## [2.3.3.33] +### 功能新增 - 增加有限的mpls支持 - 暂时取消多语言界面计划 - 增加可选帧率个数 - 重绘logo,减小体积13kiB ## [2.3.3.32] +### 问题改进 - 改进AUTO的计算方法,避免错误的输出 ## [2.3.3.31] +### 功能新增 - 采取掩码识别位移时间 - 增加英文界面 ## [2.3.3.3] +### 问题改进 - 修复部分BUG - 更换about页面的的显示方式 - 提供xml类型章节的有限支持 @@ -432,6 +517,7 @@ - 去除了无用的保存按键 ## [2.3.3.2] +### 功能新增 - 载入文件时自动转换 - 所有章节的开始时间 x 1.001 (用于DVD Decrypter提取的Chapter) - 平移所有章节号 @@ -439,53 +525,66 @@ - 指定要套用的章节名模板 ## [2.3.3.1] +### 功能新增 - 增加自动检测帧率按键 - 修正若帧数恰为整数时+1的情况 ## [2.3.3.0] +### 其他 - 将TimeCal的代码合并 ## [2.3.2.3] +### 功能新增 - 修复部分bug - 加根进度条 - 去掉了TextBox的边框 ## [2.3.2.2] +### 问题改进 - 修复部分bug - 改进提示&按键大小 - 将目标框架改为.Net 3.5 ## [2.3.2.1] +### 问题改进 - 修复部分bug ## [2.3.2.0] +### 功能新增 - 加入源文件的保存按钮 ## [2.3.1.0] +### 功能新增 - 加入中文界面 ## [2.3.0.0] +### 其他 - 转换按钮逻辑重写,改为从TextBox内而非直接从文件内读取内容 ## [2.2.0.0] +### 功能新增 - 修复部分bug - 加了个 icon - 支持带空行的文本内容 ## [2.1.0.0] - 2015.05.08 +### 功能新增 - 加入保留原章节名的选项 ---------------------------- ## [2.0.0.0] +### 其他 - C# 初版--能够正常使用 ## [1.7] +### 功能新增 - 可自动删除顶部空行 ## [1.6] +### 其他 - 统一了代码风格 - 使用VS2013 Release 模式编译,缩小了程序体积 - 修改了输出文件路径的生成方式 @@ -493,21 +592,27 @@ - 修正文件扩展名可能带来的问题 ## [1.5] +### 其他 - 修改输出文件名为原文件名+ _ - 取消处理文件上限 ## [1.4] +### 问题改进 - 修正算法,防止某些特殊情况下的错误输出 ## [1.3] +### 功能新增 - 增加超出文件处理数上限与直接打开程序的提示 ## [1.2] +### 功能新增 - 增加多文件处理功能 - 取消章节名保留选项 ## [1.1] +### 其他 - 忘了 ## [1.0] +### 其他 - C++ 初版 diff --git a/ChapterTool.ruleset b/ChapterTool.ruleset new file mode 100644 index 00000000..7f4dafe2 --- /dev/null +++ b/ChapterTool.ruleset @@ -0,0 +1,6 @@ + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 21d807cd..d16b1f1d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,11 +5,9 @@ enable true latest - - $(WarningsNotAsErrors);SA0001;SA1009;SA1101;SA1111;SA1116;SA1117;SA1118;SA1200;SA1201;SA1202;SA1204;SA1208;SA1402;SA1407;SA1413;SA1500;SA1507;SA1508;SA1513;SA1514;SA1600;SA1602;SA1611;SA1615;SA1617;SA1623;SA1633;SA1642;SA1649;CA1826;CA1859 + $(MSBuildThisFileDirectory)ChapterTool.ruleset true - 23.3.2 + 23.31.0 TautCony ChapterTool https://github.com/tautcony/ChapterTool diff --git a/README.md b/README.md index 24ac3fd0..2b8cedb2 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,6 @@ console.log(exported.content); The package accepts UTF-8 strings, `Buffer`, and `Uint8Array` input. It provides Core import, export, editing, frame rate, expression, projection, time, conversion, and metadata operations. It does not provide UI workspace state, browser actions, desktop tools, settings, localization, or file pickers. -Portable imports have a 64 MiB byte limit. The package checks the input byte count before it creates a conversion copy. - The package build requires the .NET 10 SDK. The build generates and includes the .NET WebAssembly runtime in the npm package. Run `npm run doctor` to inspect the local SDK and optional WebAssembly build tools. ## Build And Test @@ -148,19 +146,19 @@ dotnet build ChapterTool.slnx --no-restore dotnet test ChapterTool.slnx --no-restore ``` -Run coverage with `uv run --project scripts scripts/test-coverage.py`. The script writes reports to `artifacts/coverage`. The `scripts/` project pins its Python dependencies through `uv`; run `uv sync --project scripts` once before the first invocation. +Run coverage with `uv run --project scripts scripts/testing/collect-coverage.py`. The script writes reports to `artifacts/coverage`. The `scripts/` project pins its Python dependencies through `uv`; run `uv sync --project scripts` once before the first invocation. -Inspect compiler and analyzer diagnostics with `python3 scripts/report-analyzers.py`. The script builds the solution, writes the raw SARIF report to `artifacts/analyzers/analyzers.sarif`, and prints diagnostics grouped by rule, severity, and file. Use `-Prefix SA` to keep only StyleCop diagnostics. +Inspect compiler and analyzer diagnostics with `python3 scripts/diagnostics/report-analyzers.py`. Use `-Prefix SA` to keep only StyleCop diagnostics. Publish a local desktop artifact: ```bash -./scripts/publish.sh -Runtime linux-x64 -./scripts/publish.sh -Runtime osx-arm64 -./scripts/publish.sh -Runtime win-x64 -SelfContained +./scripts/packaging/publish-desktop.sh -Runtime linux-x64 +./scripts/packaging/publish-desktop.sh -Runtime osx-arm64 +./scripts/packaging/publish-desktop.sh -Runtime win-x64 -SelfContained ``` -Use `scripts/publish.ps1` on Windows. +Use `scripts/packaging/publish-desktop.ps1` on Windows. See [`scripts/README.md`](scripts/README.md) for the script platform and dependency matrix. diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts new file mode 100644 index 00000000..0e1a81e4 --- /dev/null +++ b/docs-site/.vitepress/config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vitepress'; + +export default defineConfig({ + lang: 'en-US', + title: 'ChapterTool Documentation', + description: 'Generated API reference for ChapterTool.Core.', + base: '/ChapterTool/docs/', + outDir: '../artifacts/core-api-docs/site', + themeConfig: { + nav: [ + { text: 'API Reference', link: '/api/' }, + { text: 'Browser App', link: 'https://tautcony.github.io/ChapterTool/' }, + ], + sidebar: { + '/api/': [ + { + text: 'ChapterTool.Core', + items: [ + { text: 'Overview', link: '/api/' }, + { text: 'Boundaries', link: '/api/ChapterTool.Core.Boundaries' }, + { text: 'Diagnostics', link: '/api/ChapterTool.Core.Diagnostics' }, + { text: 'Editing', link: '/api/ChapterTool.Core.Editing' }, + { text: 'Exporting', link: '/api/ChapterTool.Core.Exporting' }, + { text: 'Importing', link: '/api/ChapterTool.Core.Importing' }, + { text: 'CUE Import', link: '/api/ChapterTool.Core.Importing.Cue' }, + { text: 'Disc Import', link: '/api/ChapterTool.Core.Importing.Disc' }, + { text: 'Media Import', link: '/api/ChapterTool.Core.Importing.Media' }, + { text: 'Text Import', link: '/api/ChapterTool.Core.Importing.Text' }, + { text: 'Localization', link: '/api/ChapterTool.Core.Localization' }, + { text: 'Models', link: '/api/ChapterTool.Core.Models' }, + { text: 'Session', link: '/api/ChapterTool.Core.Session' }, + { text: 'Transform', link: '/api/ChapterTool.Core.Transform' }, + { text: 'Expressions', link: '/api/ChapterTool.Core.Transform.Expressions' }, + { text: 'Lua', link: '/api/ChapterTool.Core.Transform.Expressions.Lua' }, + ], + }, + ], + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/tautcony/ChapterTool' }, + ], + }, +}); diff --git a/docs-site/api/index.md b/docs-site/api/index.md new file mode 100644 index 00000000..e63cd4be --- /dev/null +++ b/docs-site/api/index.md @@ -0,0 +1,5 @@ +# ChapterTool Core API Reference + +This reference documents the public types and members in `ChapterTool.Core`. It includes class, record, struct, and interface members, plus every enum value. + +[Open ChapterTool in your browser](https://tautcony.github.io/ChapterTool/). diff --git a/docs-site/index.md b/docs-site/index.md new file mode 100644 index 00000000..b15975ce --- /dev/null +++ b/docs-site/index.md @@ -0,0 +1,23 @@ +--- +layout: home + +hero: + name: ChapterTool + text: Core API Reference + tagline: Detailed documentation for public classes, interfaces, members, and enum values. + actions: + - theme: brand + text: Browse the API + link: /api/ + - theme: alt + text: Open the browser app + link: https://tautcony.github.io/ChapterTool/ + +features: + - title: Public API + details: Browse Core classes, records, structs, interfaces, and their members. + - title: Enum Values + details: Read the generated description for each public enum value. + - title: Generated Reference + details: API pages come from the Core project and its XML documentation comments. +--- diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json new file mode 100644 index 00000000..c18d31bc --- /dev/null +++ b/docs-site/package-lock.json @@ -0,0 +1,1987 @@ +{ + "name": "chaptertool-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chaptertool-docs", + "devDependencies": { + "vitepress": "2.0.0-alpha.20" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.1.tgz", + "integrity": "sha512-AsMPnbNmVLDNjhtgfq4ZRskNvWR8vuK6riEegHp3kjBFtxbmTWPzZ5s1FJ04SGr2D0ZXglueUNISsV2sDgV6Jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.7.1.tgz", + "integrity": "sha512-3FWRs18TuemPlLP5NeQG3+aYarmp3gTGnYLnZTLVlTnbFLWAgHafxUdFDWqtsqyvsbCMAeHyzwrX6MG6BuZfZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/sidepanel-js": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.7.1.tgz", + "integrity": "sha512-uyOKbC6rjHCZBVbLCSZVb1NDWzjbD27WqxTuRYnkTW717laEj2dX+W8Ho1fNHSyG4dqssIlPWlXdg+McGxxxxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.100", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.100.tgz", + "integrity": "sha512-536pN2RCEQyy5bYwzayrs4e9egr8cmtVcbdSHLaQl/ULUsihzn1eZ0+Hhn50zgz9cbEotiynKsKQSEjFJ7qKmw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.5.0.tgz", + "integrity": "sha512-LM7twi043qCC3gRyGQfjoC2/sowzbEltWuruyRtT3RlBlk1hKzWk9ETnU7RcDBoVWQBmC8Mebnr7p28W7kPoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.4.0.tgz", + "integrity": "sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.3.tgz", + "integrity": "sha512-LTNHZwcFM7bSdU1cG1ExoaTEzoGmdjw+EzDotolZmLTCbm0ll2k6J3NL+WfqP14nGLxAp+XDJgwlT2DtNhcukA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.5.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/shiki": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.20.tgz", + "integrity": "sha512-uQWKmP9PaBkf9zxHn0iOml/ZPPDaGKqQXVMBHz3yjwyyd9Eg+BKLGxbqmOGlfOMCt6Fj8TLiAnE59qlKZ0A3xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "^4.7.0", + "@docsearch/js": "^4.7.0", + "@docsearch/sidepanel-js": "^4.7.0", + "@iconify-json/simple-icons": "^1.2.93", + "@shikijs/transformers": "^4.4.3", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/devtools-api": "^8.2.1", + "@vue/shared": "^3.5.41", + "@vueuse/core": "^14.4.0", + "@vueuse/integrations": "^14.4.0", + "focus-trap": "^8.2.2", + "mark.js": "8.11.1", + "minisearch": "^7.2.0", + "shiki": "^4.4.3", + "vite": "^8.2.1", + "vue": "^3.5.41" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 00000000..a206bcbe --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,13 @@ +{ + "name": "chaptertool-docs", + "private": true, + "type": "module", + "scripts": { + "docs:dev": "vitepress dev .", + "docs:build": "vitepress build .", + "docs:preview": "vitepress preview ." + }, + "devDependencies": { + "vitepress": "2.0.0-alpha.20" + } +} diff --git a/docs/README.md b/docs/README.md index 99ba1cbf..3472d7ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,16 @@ This directory contains documentation for the current ChapterTool codebase. ## Current Documentation - `code-map/` is the primary maintainer navigation index. Start here when you need to locate code, ownership, entry points, or primary tests. +- [ChapterTool.Core API reference](../src/ChapterTool.Core/README.md#api-reference) links to generated class, interface, enum, and member documentation. The reference is also published at [GitHub Pages](https://tautcony.github.io/ChapterTool/docs/). - `testing/` contains current testing guidance and performance notes. +- `code-map/ui-logging.md` maps shared Avalonia UI logging scenarios and log outputs. +- `tasks/ui-visual-consistency.md` provides the current workflow for investigating Avalonia visual inconsistencies. +- `tasks/unified-editing-and-unbounded-history.md` defines one document editing model and in-memory session history without an operation-count limit. +- `tasks/expression-preview-interaction-design.md` compares expression preview designs for Avalonia and Web. It proposes a shared review and apply workflow. +- `tasks/wasm-modal-layout-and-regression-plan.md` analyzes browser layout failures and defines modal workflows and layout regression checks. +- `testing/headless-performance.md` records the Headless UI lifecycle diagnosis and triage steps. +- `testing/wasm-browser-e2e-plan.md` records the Playwright browser test design, execution commands, CI gates, and acceptance criteria for the Blazor WebAssembly app. +- `testing/wasm-modal-layout-acceptance.md` records the browser dialog implementation and layout regression evidence. ## Historical Documentation diff --git a/docs/archive/code-review/README.md b/docs/archive/code-review/README.md index 04864dd0..980b7243 100644 --- a/docs/archive/code-review/README.md +++ b/docs/archive/code-review/README.md @@ -7,5 +7,6 @@ These reviews record the state of the repository at a specific date. They are no - `review-2026-07-12/` contains the source review and the remediation plan for `src-code-review-remediation`. - `review-2026-07-30/` contains the Blu-ray parser review, native parity notes, and follow-up phases. - `review-2026-08-13/` contains the Core, Infrastructure, Avalonia, host, and test review series with priority plans. +- `review-2026-10-11/codex-transcript-retrospective.md` records Codex execution mistakes, user corrections, and the resulting repository constraints. Use the dated directory that matches the review date. Use current source code and OpenSpec specifications for present behavior. diff --git a/docs/code-map/README.md b/docs/code-map/README.md index 5523d530..fe193613 100644 --- a/docs/code-map/README.md +++ b/docs/code-map/README.md @@ -24,9 +24,37 @@ Use ASD-STE100 principles in every code-map document. Write short, direct senten - shared UI, desktop shell, and runtime service wiring - `program-form-capability-map.md` - all program forms, shared layers, capability comparison, known boundaries, and change tracking +- `ui-logging.md` + - UI logging scenarios, triggers, recorded content, and log sinks - `testing.md` - which test project and test files verify each code area +## Structure + +Use the module maps to find ownership, entry points, runtime wiring, and primary tests. +Use `program-form-capability-map.md` to compare user-facing capabilities across hosts. + +## Contracts + +Repository-wide agent constraints live in the root `AGENTS.md`. +Host-neutral API boundaries live in `contracts.md`. +Behavioral requirements live in `openspec/specs/` and in the relevant code and tests. +Use the module maps to find the contract that applies to a code area. + +Do not copy these rules into a second checklist. Update the source that owns each rule. + +## State + +OpenSpec owns feature proposal, implementation, and completion state. +Use `openspec list --json` to discover changes. +Use `openspec status --change "" --json` to inspect artifact state. +Use `openspec validate "" --strict` before treating a change as ready for implementation. +Git owns the current branch and working-tree state. + +Do not record a dated snapshot of active tasks in this map. Read OpenSpec and Git when you need current state. + +If OpenSpec lists a change without valid artifacts, report it as an incomplete planning record. Do not infer implementation work from its name. + ## WebAssembly Hosts - `src/ChapterTool.Wasm` @@ -48,7 +76,7 @@ Use ASD-STE100 principles in every code-map document. Write short, direct senten - `src/ChapterTool.CommandLine` - standalone process entry point, DotMake.CommandLine commands, binding, console workflows, and `ChapterTool` NuGet Tool package - `.github/workflows/nuget-publish.yml` - - version-tag package build and publication + - publication of tested version-tag packages ## Use This Map @@ -56,6 +84,7 @@ Use ASD-STE100 principles in every code-map document. Write short, direct senten 2. Open the document for the module that owns the behavior. 3. Follow the listed entry points before you search the full repository. 4. Use `testing.md` to select the verification path. +5. Read the owning contract and current OpenSpec state before changing behavior. ## Maintenance Rule @@ -66,3 +95,5 @@ Update these documents in the same change when feature work changes: - runtime wiring between modules - the primary files a maintainer should inspect first - the primary tests used to verify that area + +Keep this index as a router. Keep implementation detail in the module maps, durable rules in `AGENTS.md` or their owning contracts, and changing task state in OpenSpec and Git. diff --git a/docs/code-map/avalonia.md b/docs/code-map/avalonia.md index 2760a20f..cff3ec77 100644 --- a/docs/code-map/avalonia.md +++ b/docs/code-map/avalonia.md @@ -29,10 +29,11 @@ Startup and main shell entry points: Main-window workflow owners under `src/ChapterTool.Avalonia.UI/Workflows/` use the same `ChapterWorkspace`: -- `LoadSaveWorkflow.cs` — revision/session-aware load, append, and save service orchestration -- `ClipEditingCoordinator.cs` — clip selection/combine transitions plus cell and frame edits written through the workspace -- `ProjectionFacade.cs` — workspace-backed projection, preview/save options, and chapter-row materialization +- `LoadSaveWorkflow.cs` — staged replacement, confirmation rechecks, append, and snapshot-bound save orchestration +- `ClipEditingCoordinator.cs` — clip selection, merge/split-by-boundaries, and selected-track edits written through the workspace +- `WorkspaceContentRows.cs` — row materialization from committed content - `StatusDiagnosticsPresenter.cs` — localized status/progress rendering and structured diagnostic logging +- `DisplayOptionCoordinator.cs` renders DVD title numbers with localized labels. The chapter data and English log summaries remain stable when the culture changes. The clip selector exposes its complete label in a tooltip when the visible text is trimmed. Role split: @@ -44,7 +45,7 @@ Role split: - `.cs`: fields, ctor, bindable state, command wiring, window/shell helpers - `.Settings.cs`: load/apply preferences and language persistence - `.ImportExport.cs`: load/save/append workflows, export options, and chapter-name template path application (`LoadChapterNameTemplateFromPathAsync`) - - `.Expression.cs`: Lua expression apply/validate and output projection + - `.Expression.cs`: Lua expression apply/validate and committed chapter row refresh - `.Editing.cs`: clip selection, row edits, combine/split, frame-rate transforms - `.StatusLog.cs`: status text, diagnostics localization, logging, localized option refresh @@ -53,8 +54,8 @@ Role split: Shared session kernel lives in Core: - `src/ChapterTool.Core/Session/ClipSession.cs` — `SplitClipSession` / `CombinedClipSession` and pure transitions -- `src/ChapterTool.Core/Session/ChapterWorkspace.cs` — path, clip session, edit buffer, projection, export preferences, revision commit rules -- `src/ChapterTool.Core/Session/ProjectionState.cs` +- `src/ChapterTool.Core/Session/ChapterWorkspace.cs` — one multi-track content session, transient selected-track views, operation drafts, export preferences, and revision commit rules +- `src/ChapterTool.Core/Session/ContentOperationDraftState.cs` — parameters for explicit, one-shot content operations - `src/ChapterTool.Core/Session/ExportPreferences.cs` Avalonia owns only host ports: @@ -62,7 +63,25 @@ Avalonia owns only host ports: - `src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/ShellPorts.cs` — narrow tool ports (`IExpressionSessionPort`, `IPreferenceSink`, …) - `src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/MainWindowPortAdapters.cs` — concrete main-window adapters -`MainWindowViewModel` is the bindable shell and holds one Core `ChapterWorkspace`. Bindable projection/export properties facade workspace state. Command handlers delegate workflow orchestration to the `Workflows/` collaborators. Load/append commits use workspace revision rules. +`MainWindowViewModel` is the bindable shell and holds one Core `ChapterWorkspace`. Operation parameters remain drafts until an explicit candidate is applied. Row materialization reads committed workspace content. It derives frame text and accuracy from the current display preferences. Display refresh must not create content history. Frame edits and signed shifts use the displayed FPS. Expression preparation can supply detected FPS to the selected track when source FPS is absent. Preview and save serialize committed content. Command handlers delegate workflow orchestration to the `Workflows/` collaborators. Load and append commits use workspace revision rules. Replacement confirms session loss before ending the old session. Closing the desktop window ends its session and cancels its work. + +`MainWindowViewModel` exposes the Core history tree through `IHistorySessionPort`. `UndoCommand`, `RedoCommand`, and `NavigateHistoryCommand` navigate the same `ContentSession`. `HistoryToolView` renders the hierarchy with the native Avalonia `TreeView` in a modal dialog. + +`HistoryToolViewModel` keeps inspection selection separate from the saved history cursor. It requests details through `IHistorySessionPort.InspectAsync`. Selection changes cancel the previous query. Request generation and root identity checks ignore stale results. `SessionState.InspectHistoryNodeAsync` captures one immutable session version and reconstructs the selected node without publishing it. `HistoryNodeDetailsProjector` compares the selected node with its parent by stable identity. Root inspection exposes ordered chapter rows. The view virtualizes root rows and field changes. Wide windows show a split view. Narrow windows show History and Details pages. The inspector hides empty sections and shows meaningful parameters and before/after values. + +`HistoryEntryViewModel` builds stable parent and child models for the native Avalonia `TreeView`. It preserves node instances by ID and exposes mutable expansion state. `HistoryToolViewModel` owns current and inspected node state, detail query lifetime, explicit restore, and direct-child redo choices. `HistoryToolView` binds both wide and narrow TreeViews and keeps Restore in the narrow page footer. `ChapterContentPreview` carries the immutable operation descriptor through candidate application. + +`MainView.axaml` gives the chapter grid one central workspace. Its empty state overlays that workspace. Content previews compare changed values inside the original chapter cells. Arrow icons and semantic colors mark candidate values. Each row keeps the visible frame value captured before preview. The option row keeps apply and discard actions. Its apply tooltip lists changed non-chapter metadata. Expression diagnostics show invalid and stale states. The grid stays read-only while a candidate is valid. + +Content edits use `ChapterWorkspace.ContentSession` through `ClipEditingCoordinator` and `IChapterContentOperationPort`. Clip boundaries and media placement live in track segments. `ClipSession` contains selector metadata only. Batch tools preview, apply, or cancel typed candidates. Cell edits commit to the selected track. Tables and exports read committed chapter values. The browser host uses `ChapterContentOperationSession` for reviewed candidates and `ChapterWorkspace.ExecuteTrackCandidate` for immediate row commands. + +`MainView.OnChapterGridBeginningEdit` captures the initial cell text. `CommitCellEditAsync` skips unchanged editor text before it dispatches a content operation. This preserves timestamp ticks and the history cursor when rounded display text stays unchanged. `ExactFrameRateHeadlessTests` verifies this boundary through the rendered frame editor. + +`MainView.axaml` bounds the numbering offset to 0..1000. `ContentOperationDialog.razor` provides the browser draft and review surface. `WasmWorkspace.Operations.cs` owns preparation, freshness checks, exact application, and cell identity capture. Preferences refresh display and output without replaying content edits. + +`ChapterRowViewModel` keeps committed values and projects typed before/candidate comparisons by track and chapter ID. It captures visible frame text, accuracy, and display rate when preview starts. Candidate frame accuracy comes from the typed candidate. `FrameValueFormatter` formats timestamp ticks with each value's canonical rate. `MainView.axaml` aligns integer and fractional parts and marks repetends with an overline. `MainView.ApplyContentPreviewPreservingGridStateAsync` applies the captured candidate and restores selection and scroll state. Bottom option labels share measured widths across responsive columns. + +`tests/ChapterTool.Avalonia.Tests/ViewModels/` verifies tool and command behavior. `tests/ChapterTool.Avalonia.Headless.Tests/Headless/MainWindowHeadlessTests.cs` verifies visible frame snapshots, inline values, preview lifecycle, localized layouts, control alignment, and viewport bounds. `MainWindowInteractionHeadlessTests.cs` verifies selection and scroll retention through apply and discard. ### Composition root @@ -89,6 +108,12 @@ Window-bound file picker, settings picker, and clipboard services use host-owned `src/ChapterTool.Avalonia.UI/PlatformPorts/AuxiliaryTools.cs` owns `ToolId`, typed auxiliary-tool requests and results, host service groups, catalog descriptors, and the embedded presenter contract. `EmbeddedAuxiliaryToolHost.cs` provides the single-content host implementation. `UnavailableHostAdapters.cs` provides explicit no-op adapters for unavailable capabilities. +`HistoryToolViewModel` reads history through `IHistorySessionPort`. The desktop catalog registers it as a modal tool. The embedded host supplies a tool-close callback. + +`AvaloniaWindowService` disposes tool state and detaches window content on close. It returns focus to the previously focused main-window control when that control remains available. + +`AuxiliaryToolRequest.HostWindow` can supply the owning window. The desktop host uses the application main window when the request omits an owner. + `BrowserPortableAdapters.cs` remains in the shared assembly because it contains no browser API implementation. It defines the bounded source-read behavior and the `IBrowserFileAccess` host port. A browser host owns the `IBrowserFileAccess` implementation. For GUI production paths, one `AppCompositionRoot` shares the formatter, expression engine, authoring service, export service, process runner, and external-tool locator across the main window and tool windows. `ExpressionEditor` receives `IExpressionAuthoringService` through the host composition and the typed `ToolCreationContext`. Its private fallback is limited to direct design-time or test construction. @@ -112,6 +137,16 @@ This is the first file to inspect when dependency wiring or service registration - `src/ChapterTool.Avalonia.UI/Views/Tools/ForwardShiftToolView.axaml` - `src/ChapterTool.Avalonia.UI/Views/Tools/TextToolView.axaml` +### LogTool + +`LogToolViewModel` owns the bounded log projection, severity and text filters, selection, and inspector commands. Selection stays separate from inspection. The initial state has no selected row and a closed inspector. `LogEntryViewModel` owns compact row identity, localized search runs, and on-demand raw, technical, exception, and structured values. + +`LogToolView.axaml` renders a list-first surface. It shows compact one-line rows by default. It renders the inspector beside the list at wide widths and replaces the list at narrow widths. The inspector opens only after an explicit row action or keyboard command. + +`IApplicationLogService` is the shared log boundary. `ApplicationLogPanelProvider` keeps a bounded in-memory snapshot in append order and raises `EntryAdded` and `Cleared` notifications. It does not provide grouping, timestamp indexes, or history cursors. `IApplicationLogExporter` is an optional manual-export boundary. `ApplicationLogFileExporter` writes the selected entries as UTF-8 JSON or CSV below `settings/logs/`. + +Primary entry points are `src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml`, `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs`, `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs`, and `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs`. + ### Imported theme resources `src/ChapterTool.Avalonia.UI/Resources/` owns the shared and imported user interface resources. @@ -127,6 +162,8 @@ This is the first file to inspect when dependency wiring or service registration The Load control is a `SplitButton`. Reload and Append MPLS live in its flyout. Change FPS is a visible `icon_button` next to the frame-rate selector. +Shortcut ownership is shared. `src/ChapterTool.Contracts/Shortcuts/` defines stable action IDs, gesture normalization, conflict checks, and active mappings. `ShortcutRouter` routes undo and redo through the session commands. Windows and Linux use Ctrl+Z and Ctrl+Y or Ctrl+Shift+Z. macOS uses Cmd+Z and Cmd+Shift+Z. Text controls keep their draft undo scope. `SettingsToolViewModel` owns draft rows and saves them through `ISettingsStore`. `SettingsToolView.axaml` exposes the Shortcuts tab. + ### ViewModels - `src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel*.cs` @@ -139,6 +176,7 @@ The Load control is a `SplitButton`. Reload and Append MPLS live in its flyout. - `src/ChapterTool.Avalonia.UI/ViewModels/ChapterRowViewModel.cs` - `src/ChapterTool.Avalonia.UI/ViewModels/UiCommand.cs` - `src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRouter.cs` +- `src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRowViewModel.cs` (shortcut settings rows and reset commands) - `src/ChapterTool.Avalonia.UI/ViewModels/XmlLanguageDisplay.cs` (localized XML chapter language selector options) - `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs` - `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs` (pure log projection and structured-data formatting) @@ -237,6 +275,10 @@ Then inspect the matching pair in: - `src/ChapterTool.Avalonia.UI/Views/Tools/` - `src/ChapterTool.Avalonia.UI/ViewModels/` +The Expression tool uses `ExpressionToolViewModel` and `ExpressionToolView` for draft state and typed candidate review. `IChapterContentOperationPort` prepares and applies the captured candidate. `IExpressionSessionPort.SaveAppliedExpressionPreview` stores accepted settings without evaluating Lua again. `AvaloniaWindowService` hosts this tool as a modal window and blocks close while Apply runs. + +`ExpressionToolView` keeps the editor above the result scroll area. It reflows comparison grids when the view width changes. The summary separates chapter time changes from frame updates and property changes. Property details follow the time comparisons. + ### Application log window Start with: @@ -246,8 +288,9 @@ Start with: - `src/ChapterTool.Avalonia/Services/StandardToolCatalogFactory.cs` - `src/ChapterTool.Contracts/PlatformPorts/IApplicationLogService.cs` - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` -The ViewModel owns the filtered projection, selection, localized display text, and copy commands. The provider owns bounded history and live entry notifications. The view uses the imported master-detail composition and resources. +The ViewModel owns the filtered list projection, localized row text, selection, and inspector state. `LogEntryViewModel` keeps technical values out of the compact row and exposes them when the inspector is open. The provider owns bounded append-order retention and live entry notifications. The optional exporter receives the visible entries from the ViewModel. The view owns responsive list and inspector composition. ### Clip combine / multi-entry session @@ -257,7 +300,7 @@ Start with: - `src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Editing.cs` - `src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.ImportExport.cs` -Pure transition coverage: `tests/ChapterTool.Core.Tests/Session/ClipSessionTests.cs`. Concurrent load/append anti-stale coverage remains in `MainWindowViewModelTests`. +Candidate and session coverage: `tests/ChapterTool.Core.Tests/Editing/ChapterClipCandidateBuilderTests.cs` and `tests/ChapterTool.Core.Tests/Session/ClipSessionTests.cs`. The ViewModel tests cover merge/split labels and selected-track rows. Concurrent load/append anti-stale coverage remains in `MainWindowViewModelTests`. ### Load/save/import behavior exposed in UI @@ -267,7 +310,7 @@ Start with: - `src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs` - `src/ChapterTool.Infrastructure/Importing/Runtime/RuntimeChapterImporterRegistry.cs` -`RuntimeChapterSaveService` applies UI save-file concerns such as output directory selection, generated file path diagnostics, and the selected `ChapterExportOptions.TextEncoding` / `EmitBom` behavior around Core export content. +`RuntimeChapterSaveService` applies UI save-file concerns such as output directory selection, generated file path diagnostics, and the selected `ChapterSerializationOptions.TextEncoding` / `EmitBom` behavior around Core export content. If the wiring looks wrong, inspect: @@ -292,7 +335,7 @@ Start with: - `src/ChapterTool.Avalonia.UI/ViewModels/Tools/` - `src/ChapterTool.Core/Transform/ExpressionAuthoringService.cs` -Behavior coverage is concentrated in `ExpressionAuthoringServiceTests`, `MainWindowViewModelTests`, `MainWindowInteractionHeadlessTests`, and `ToolViewsHeadlessTests` for Lua tokens/completions, delayed edit diagnostics, live valid projections, editing-key routing, and single-editor multiline expansion. +Behavior coverage is concentrated in `ExpressionAuthoringServiceTests`, `MainWindowViewModelTests`, `MainWindowInteractionHeadlessTests`, and `ToolViewsHeadlessTests` for Lua tokens/completions, delayed edit diagnostics, live candidate review, typed before/after values, editing-key routing, and single-editor multiline expansion. AvaloniaEdit keeps IME preedit text inside the editor until it commits text to the bound value; the preview timer responds to committed edits. `AppCompositionRootIdentityHeadlessTests` additionally exercises both production XAML editor hosts with a sentinel authoring service, including initial binding and subsequent text edits. ### Settings / theme / language UI @@ -314,7 +357,7 @@ Output defaults, external-tool paths and statuses, and runtime/footer display st Main-window selectors with runtime-localized display text, including the automatic frame-rate option, use `SelectorDisplayOption` collections owned by `MainWindowViewModel`; item and selection-box templates bind the same mutable display value so open lists and current selections refresh together. `DisplayOptionCoordinator` owns localized option construction, clip-list incremental synchronization, and frame-rate index mapping, while `ChapterCellEdit` and `ChapterGridColumnIds` are standalone binding-contract types. -Secondary tool windows consume the stable interfaces in `PlatformPorts/SessionPorts/ShellPorts.cs` through `IWorkspaceToolSession`. `MainWindowToolSession` owns one concrete `MainWindowPortAdapters` instance internally. The adapters own expression application and validation, live preference application, language persistence, export/naming projection, and chapter-edit commands; `MainWindowViewModel` does not implement or expose those concrete adapters. +Secondary tool windows consume the stable interfaces in `PlatformPorts/SessionPorts/ShellPorts.cs` through `IWorkspaceToolSession`. `MainWindowToolSession` owns one concrete `MainWindowPortAdapters` instance internally. The adapters own expression application and validation, live preference application, language persistence, operation drafts, export options, and chapter-edit commands; `MainWindowViewModel` does not implement or expose those concrete adapters. Appearance is preset-only and owned by `SettingsAppearanceViewModel` (bound as `Appearance.*` from `SettingsToolView`). It owns localized preset options, font family catalogs, live selection, and palette preview metadata. `AvaloniaThemeApplicationService` resolves the catalog preset. It updates ChapterTool semantic brushes, all imported `Color.*` tokens, and the Avalonia light or dark variant. `SharedStyles.axaml` loads the imported theme foundation through `Styles.axaml` after `FluentTheme`. diff --git a/docs/code-map/contracts.md b/docs/code-map/contracts.md index ae149da8..db916562 100644 --- a/docs/code-map/contracts.md +++ b/docs/code-map/contracts.md @@ -17,6 +17,8 @@ Use this project when a desktop, browser, or command-line host needs the same pe `DeleteRowsTimingModes` and `FrameDisplayModes` parse and serialize the persisted editing-preference ids in `AppSettings`. `DeleteRowsTimingMode` selects whether deleted-row timing stays unchanged or shifts to restart at zero. `FrameDisplayMode` selects integer rounding or fixed decimal places for frame values. +Content operation parameters are runtime drafts in Core `ContentOperationDraftState`. They are not persisted in `Contracts` and do not transform exported content. + - settings store contract: - `src/ChapterTool.Contracts/PlatformPorts/ISettingsStore.cs` - shared platform contracts: @@ -26,4 +28,10 @@ Use this project when a desktop, browser, or command-line host needs the same pe - `src/ChapterTool.Contracts/PlatformPorts/IShellService.cs` - `src/ChapterTool.Contracts/PlatformPorts/ExternalToolLocation.cs` +### Application log boundary + +`IApplicationLogService` exposes the current bounded `Entries` snapshot, the `EntryAdded` notification, and the `Cleared` notification. `ApplicationLogEntry` carries the timestamp, level, message identity, operation, category, event identity, exception text, technical detail, arguments, and structured state. The contract has no grouping, timestamp-query, or history-cursor API. + +`LogExportFormat`, `ApplicationLogExportRequest`, and `ApplicationLogExportResult` define the optional manual-export boundary. `IApplicationLogExporter` receives the entries selected by the caller and returns a path or a recoverable error. Hosts may omit this service. The contract does not require Avalonia or filesystem access. + The project has no Avalonia dependency. Infrastructure implements the shared runtime boundaries. Avalonia UI consumes the contracts through its own adapters. diff --git a/docs/code-map/core.md b/docs/code-map/core.md index 8ae8dbac..4cf1d2f4 100644 --- a/docs/code-map/core.md +++ b/docs/code-map/core.md @@ -12,6 +12,8 @@ Use ASD-STE100 principles in this document. Keep each sentence short and direct. Canonical data contracts shared across the pipeline: +- `src/ChapterTool.Core/Models/EditableChapterDocument.cs`: immutable document, track, chapter, identity, duration, and frame-rate values +- `src/ChapterTool.Core/Models/EditableChapterDocumentAdapter.cs`: `ChapterSet` adapters and document validation - `src/ChapterTool.Core/Models/Chapter.cs` - `src/ChapterTool.Core/Models/ChapterSet.cs` - `src/ChapterTool.Core/Models/ChapterImportFormat.cs` @@ -20,7 +22,24 @@ Canonical data contracts shared across the pipeline: - `src/ChapterTool.Core/Models/ChapterImportEntry.cs` - `src/ChapterTool.Core/Models/ReferencedMediaFile.cs` -`ChapterSet` is the main unit passed between import, edit, transform, and export flows. +`EditableChapterTrack.Segments` stores current boundaries and media placement. `ClipSession` stores selector metadata and does not keep editable chapter backups. + +`ChapterSet` is the import and conversion model for stateless workflows. +`ChapterSet.DurationIsKnown` distinguishes unknown duration from known zero duration. +Legacy zero durations default to unknown. Exact inputs set the flag explicitly. +Document, track, and clip metadata adapters must preserve the flag. +Combine and append operations must reject unknown durations before calculating offsets. +Interactive edits use `EditableChapterDocument` snapshots owned by `SessionState`. +`ChapterWorkspace.CurrentChapterSet` is a transient selected-track view. It is not editable workspace storage. + +Internal editing, conversion, transform, and expression call chains use `FrameRateValue` directly. +Public content and transform APIs use `FrameRateValue` for frame-rate authority. +Catalog selectors pass their canonical rational through `FrameRateOption.Rate`. +Hosts may format that value as a decimal for display. + +`EditableChapterDocumentAdapter.TrackDuration` covers every segment in a selected track. `ReplacedTrackDuration` updates document bounds after a reviewed track conversion. `WithFallbackFrameRate` supplies detected FPS to expression preparation when source FPS is absent. `ChapterFpsTransformService` uses one frame quantization rule for chapter times, document duration, and absolute segment boundaries. Source FPS metadata remains unchanged. + +MPLS frame-rate codes provide an authoritative rational rate. `MplsFrameRateCatalog` stores that rate on the imported `ChapterSet`; the editable document and selected-track projection preserve the provenance for exact frame presentation. ### Diagnostics @@ -56,6 +75,12 @@ Important format entry points: - 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 uses `IfoChapterImporter.cs`, `MplsChapterImporter.cs`, `MplsPlaylistProjection.cs`, split `Mpls*.cs` playlist types, and `XplChapterImporter.cs` under `src/ChapterTool.Core/Importing/Disc/`. +- `IfoChapterImporter` reads DVD VTS PTT references and resolves them through PGC program maps. It validates bounded PGC and PTT tables. It maps VMG `VIDEO_TS.IFO` titles to sibling VTS IFO files. It can recover chapters from PGC program starts when the VMG chapter count matches the PGC and the first PTT program is 1 or is missing. When PTT references are invalid, ordinal PGC recovery requires the PTT title count to equal the PGC count and the selected PGC program count to match the VMG chapter count. The importer accepts a `VIDEO_TS` directory or a disc root that contains `VIDEO_TS`. It adds VTS video, audio, and subtitle attributes to each title entry and reports whole-disc metadata. VMG imports need filesystem access to sibling VTS IFO files. +- `DvdIfoMetadataReader` parses VTS video, audio, and subtitle attributes. It adds track summaries to import entries and structured disc metadata to import diagnostics. +- `DvdNavTimingReader` reads 90 kHz presentation times from VOB navigation packs. It follows the DSI VOBU links within each IFO cell sector range. It validates PCI and DSI headers, sector addresses, and cell identity. It supports contiguous numbered VOB files and 32-bit timestamp wrap. It handles clock resets between cells. It caches cell durations and limits navigation reads to 200,000 packs per reader. Cancellation stops the traversal. +- `IfoChapterImporter` uses NAV timing only when all referenced PGCs in a title have complete navigation data. Missing files, invalid packs, interleaved angles, or discontinuities within a cell retain the complete IFO timeline. Stream imports use the same IFO calculation. IFO timecodes preserve the legacy interpretation of 30 nominal frames for NTSC and 25 for PAL. NTSC playback uses the exact rate `30000/1001`. Cell, program, and title calculations accumulate integer frame counts or integer NAV ticks. Each final time converts once to the nearest `TimeSpan` tick. This conversion has at most 50 ns of numeric error. This bound excludes errors in the source metadata. The importer reports the timing source through `Ifo.Captured` or `Ifo.Fallback`. It never applies a frame-alignment adjustment. NAV times between frame boundaries remain unchanged. +- `ChapterImportEntry.DiscTitleNumber` identifies a DVD disc title for localized host labels. The host uses `ChapterImportDisplay.FormatDvdOption` to format the label. A language change preserves chapter names and stored title data. +- Each VTS PTT record contains a PGC number and a program number. Each record uses four bytes. The importer derives the chapter count from the bounded title region. VMG recovery uses the same title boundaries. - `MplsPlaylistProjection` supplies shared chapter, clip-name, frame-rate, duration, and media-reference values to direct MPLS and BDMV import. - BDMV navigation uses typed INDEX references under `Disc/Index/`, bounded MovieObject parsing and HDMV resolution under `Disc/MovieObject/`, and BDJO accessible-playlist parsing under `Disc/Bdjo/`. - `HdmvNavigationResolver.ResolveProfileVariants` creates bounded player profiles only for Player Status Registers (PSRs) that MovieObject commands read. It merges playlist events in stable profile order. @@ -72,7 +97,7 @@ Important format entry points: ### libbluray compatibility map -`libbluray/` is a vendored C reference snapshot at commit `ea3e318b` (the `hdmv: fix INSN_BC instruction` change). ChapterTool does not link to it at runtime. The managed parsers and resolver reproduce the bounded BDMV behavior that the importer needs. +This map uses libbluray 1.5.1 commit `a24f4fad` as a C reference. A local `libbluray/` checkout is optional. It is not part of ChapterTool commits or distribution. ChapterTool does not link to it at runtime. The managed parsers and resolver reproduce the bounded BDMV behavior that the importer needs. | libbluray reference | ChapterTool implementation | Boundary or difference | | --- | --- | --- | @@ -96,6 +121,8 @@ In-memory chapter mutations: - `src/ChapterTool.Core/Editing/ChapterEditingOptions.cs` - `src/ChapterTool.Core/Editing/ChapterSegmentService.cs` - `src/ChapterTool.Core/Editing/ChapterEditResult.cs` +- `src/ChapterTool.Core/Editing/ChapterContentCandidateBuilder.cs` — identity-scoped content candidates for cell edits, structure, metadata, naming, offsets, frame operations, and expressions +- `src/ChapterTool.Core/Editing/ChapterClipCandidateBuilder.cs` — merge, boundary split, and append candidates over current document tracks `ChapterEditingOptions` controls delete-rows timing (`Preserve` or `Normalize`) and frame display (`Round` or `DecimalPlaces` with one to six places). `IChapterEditingService.Delete` applies the delete-rows timing mode. @@ -104,12 +131,17 @@ In-memory chapter mutations: Host-agnostic interactive session state shared by Avalonia and WASM: - `src/ChapterTool.Core/Session/ClipSession.cs` — split/combined clip sessions and pure transitions -- `src/ChapterTool.Core/Session/ChapterWorkspace.cs` — host-neutral session state, edit buffer, revision and session-token commit rules for Avalonia and WASM -- `src/ChapterTool.Core/Session/ProjectionState.cs` — naming, order shift, expression fields, projection cache +- `src/ChapterTool.Core/Session/ChapterWorkspace.cs` — one multi-track content session, transient selected-track views, staged replacement checks, and export baselines +- `src/ChapterTool.Core/Session/ChapterExportBaseline.cs` — snapshot-only export state and canonical content and format fingerprints +- `src/ChapterTool.Core/Session/SessionState.cs` — immutable document snapshots, serialized atomic transactions, session-end cancellation, request idempotency, and history navigation +- `src/ChapterTool.Core/Session/SessionEditHistory.cs` — retained history tree and reversible document, track, and chapter deltas +- `src/ChapterTool.Core/Session/ChapterContentOperationSession.cs` — typed preview diffs and stale-safe apply for content candidates +- `src/ChapterTool.Core/Session/ContentOperationDraftState.cs` — naming, numbering, and expression parameters for explicit operations +- `src/ChapterTool.Core/Session/ExpressionPreviewProjection.cs` — host-neutral before/candidate comparison rows, change categories, property ownership, and frame-unit projection - `src/ChapterTool.Core/Session/ExportPreferences.cs` — export format, language, encoding, BOM, save directory - `src/ChapterTool.Core/Session/ChapterSourceDocument.cs` — host-neutral chapter source identity (`LocalPathChapterSource`, `BufferedChapterSource`) -Primary tests: `tests/ChapterTool.Core.Tests/Session/` +Primary tests: `tests/ChapterTool.Core.Tests/Session/` and `tests/ChapterTool.Core.Tests/Editing/ChapterContentCandidateBuilderTests.cs`. The editing tests verify candidate commit, undo, redo, stale-preview rejection, and all-or-nothing batch failure. ### Boundaries and localization @@ -130,6 +162,9 @@ Frame/time and expression logic: - `src/ChapterTool.Core/Transform/IFrameRateService.cs` - `src/ChapterTool.Core/Transform/FrameRateService.cs` +- `src/ChapterTool.Core/Transform/ExactFrameMath.cs`: exact tick and frame-rate arithmetic +- `src/ChapterTool.Core/Transform/FrameValueFormatter.cs`: structured formatting for exact frame values +- `src/ChapterTool.Core/Transform/FramePresentationInput.cs`: tick, rate provenance, and numeric fallback - `src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs` - `src/ChapterTool.Core/Transform/ChapterExpressionService.cs` - `src/ChapterTool.Core/Transform/Expressions/ChapterExpressionEngine.cs` @@ -138,19 +173,23 @@ Frame/time and expression logic: - `src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs` - `src/ChapterTool.Core/Transform/ChapterRounding.cs` +`FrameRateService` keeps supported frame rates as canonical rationals. `FrameRateValue` retains Rational, Approximate, or Unknown authority. `ExactFrameMath` calculates frames from timestamp ticks and integer rate coefficients. `FrameValueFormatter` formats committed and candidate values. Approximate display uses plain decimals. Unknown display may use a detection suggestion without changing computation authority. + `ChapterExpressionService` evaluates only non-separator chapters. Each expression context contains the ordered non-separator chapter snapshot. The Lua engine exposes this snapshot as the one-based `chapters` array. The `chapter` value equals `chapters[index]`. ### Exporting -Output projection and format serialization: +Content projection and pure format serialization: - `src/ChapterTool.Core/Exporting/ChapterExportService.cs` +- `ChapterOutputProjectionService` applies explicit one-shot CLI output projection options. +- `ChapterExportService.Serialize` writes prepared content without transforms. Interactive hosts call it with committed document content. - `src/ChapterTool.Core/Exporting/SaveFormatOption.cs`: host-facing export format metadata -- `src/ChapterTool.Core/Exporting/ChapterExportOptions.cs` +- `src/ChapterTool.Core/Exporting/ChapterSerializationOptions.cs` - `src/ChapterTool.Core/Exporting/ChapterExportFormat.cs` - `src/ChapterTool.Core/Exporting/ChapterExportFormats.cs` - `src/ChapterTool.Core/Exporting/OutputTextEncoding.cs` -- `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` +- `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` — one-shot content projection used by CLI conversion - `src/ChapterTool.Core/Exporting/ChapterConversionService.cs` - `src/ChapterTool.Core/Exporting/XmlChapterLanguageCatalog.cs` - `src/ChapterTool.Core/Exporting/ChapterSavePath.cs` — deterministic output file names and non-colliding path allocation @@ -169,13 +208,17 @@ Browser host: - `src/ChapterTool.Wasm` is the Blazor WebAssembly browser app. It uses `Microsoft.NET.Sdk.BlazorWebAssembly`. - `src/ChapterTool.Wasm/Pages/Home.razor` is the browser workspace page. -- `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` owns buffered load, reload, append, selection, projection, export orchestration, diagnostics, activity logs, and localized status strings. +- `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` owns buffered load, reload, append, selection, candidate commits, export orchestration, history commands, diagnostics, activity logs, and localized status strings. It navigates the shared Core `ChapterWorkspace.ContentSession`. +- `Services/WasmWorkspace.MainOptions.cs` prepares one Core candidate for main-window naming, numbering, and Lua drafts. `Home.razor` renders its before and candidate values in the chapter grid. +- Main-window options derive applied expression state from typed operations on the current history branch and track. Naming and numbering do not replay an applied expression. +- `Services/WasmWorkspace.History.cs` owns deferred redo choices. `Services/WasmHistoryProjection.cs` formats history operations and field details. `Components/HistoryDialog.razor` owns read-only inspection and explicit restoration. - `src/ChapterTool.Wasm/Services/WasmBrowserShortcutGuard.cs` blocks browser shortcut keys during text editing. -- `WasmWorkspace` uses Core session and service types such as `ChapterWorkspace`, editing, segment, projection, and export services. +- `WasmWorkspace` uses Core session and service types such as `ChapterWorkspace`, editing, segment, and export services. `WasmWorkspaceProjection` maps content and diagnostics to browser view models. - Browser localization uses embedded JSON resources under `src/ChapterTool.Wasm/Resources/Locales/` through `WasmLocalizer`. -- The JSON resources are generated from the Avalonia AXAML locales by `scripts/axaml-to-json.py`. Use its `--check` mode to detect drift. +- The JSON resources are generated from the Avalonia AXAML locales by `scripts/localization/axaml-to-json.py`. Use its `--check` mode to detect drift. - Browser settings use the `WasmSettings` document with `schemaVersion`/`application`/`theme`/`font` fields. `WasmApplicationSettings` mirrors the Contracts `AppSettings` fields, including the delete-rows timing and frame display preferences. The host stores settings in browser storage through the workspace path. -- `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs` covers workspace load, clip session, template, and export behavior. `tests/ChapterTool.Wasm.Tests/WasmBrowserShortcutGuardTests.cs` covers the shortcut guard. +- `Home.razor` applies valid settings drafts to runtime preferences. Save persists one aggregate. Discard restores the runtime snapshot captured when the dialog opened. Saved output defaults do not replace active session output choices. +- `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs` covers workspace load, clip session, template, export, independent tab sessions, and history navigation. `tests/ChapterTool.Wasm.Tests/WasmBrowserShortcutGuardTests.cs` covers the shortcut guard. - GitHub Pages deploys the app through `.github/workflows/github-pages.yml` (`https://tautcony.github.io/ChapterTool/`). ## Feature Lookup @@ -213,13 +256,16 @@ Start with: - 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` +- deterministic document frame conversion: `src/ChapterTool.Core/Transform/ChapterFrameTimeConverter.cs` - time parse/format bugs: `src/ChapterTool.Core/Transform/ChapterTimeFormatter.cs` ### Export behavior Start with: -- projection before serialization: `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` +- explicit one-shot projection: `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` - format-specific serialization: `src/ChapterTool.Core/Exporting/ChapterExportService.cs` +- snapshot serialization options: `src/ChapterTool.Core/Exporting/ChapterSerializationOptions.cs` +- immutable snapshot entry point: `ChapterExportService.Serialize(EditableChapterDocument, ChapterSerializationOptions)` - supported file encodings, display names, BOM-aware encoders, and XML encoding names: `src/ChapterTool.Core/Exporting/OutputTextEncoding.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 index 1b19a009..cff588c6 100644 --- a/docs/code-map/infrastructure.md +++ b/docs/code-map/infrastructure.md @@ -19,9 +19,16 @@ Use ASD-STE100 principles in this document. Keep each sentence short and direct. - `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvMetadataReader.cs`: optional disc title reader - `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvSourceLayout.cs`: accepted input shapes and primary/backup paths - `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvPlaylistScanner.cs`: bounded playlist discovery and parity filtering +- DVD path: + - `src/ChapterTool.Infrastructure/Importing/Dvd/DvdSourceLayout.cs`: DVD disc root and `VIDEO_TS` directory detection + - `src/ChapterTool.Core/Importing/Disc/DvdIfoMetadataReader.cs`: VTS video, audio, subtitle, and disc metadata `BdmvImporter` preserves global INDEX title numbers, skips prohibited titles, reports hidden titles, and consumes bounded HDMV navigation evidence. +`RuntimeChapterImporterRegistry` routes DVD disc directories to `IfoChapterImporter`. The importer reads every VMG title and exposes VTS stream attributes in the import summary. + +DVD timing uses the managed `DvdNavTimingReader` in Core. It needs sibling title VOB files to read navigation timestamps. It does not need an external executable. Missing or unsupported VOB navigation data produces a warning and preserves IFO estimates. + ### Runtime importer composition - registry contract: @@ -95,10 +102,18 @@ All runtime consumers receive the same aggregate store. `SettingsToolViewModel` - `src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs` - app log surface: - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- manual log export: + - `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` - test/dummy platform services: - `src/ChapterTool.Infrastructure/Platform/MemoryClipboardService.cs` - `src/ChapterTool.Infrastructure/Platform/ScriptedDialogService.cs` +`ApplicationLogPanelProvider` implements `IApplicationLogService` and `ILoggerProvider`. It captures entries at or above its configured minimum level. It keeps a bounded in-memory snapshot in append order. `Entries` returns a copy of the snapshot. `EntryAdded` fires after an entry is retained. `Cleared` fires after the snapshot is empty. The provider has no grouping, timestamp index, or older-page cursor. + +`ApplicationLogFileExporter` implements the optional `IApplicationLogExporter` contract. It creates `settings/logs/` below the host settings directory. It writes the requested entries as UTF-8 JSON or RFC 4180 CSV. It orders exported rows by timestamp and returns filesystem failures as `ApplicationLogExportResult` values. + +The desktop `LoggingModule` registers the provider and exporter as application singletons. It also keeps the Serilog rolling archive separate from the in-memory panel. The archive rolls daily, keeps up to 14 files, and rolls at 10 MB. + ## Feature Lookup ### ffprobe import issues @@ -158,3 +173,4 @@ Start with: - `src/ChapterTool.Infrastructure/Platform/ShellService.cs` - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` diff --git a/docs/code-map/program-form-capability-map.md b/docs/code-map/program-form-capability-map.md index 1b35521f..5acbda07 100644 --- a/docs/code-map/program-form-capability-map.md +++ b/docs/code-map/program-form-capability-map.md @@ -18,7 +18,7 @@ Use this document to compare the supported functions of every program form. Keep code identifiers, paths, commands, and user interface strings exact. -Last reviewed: 2026-08-26 +Last reviewed: 2026-10-11 ## 1. Program Forms @@ -76,7 +76,7 @@ packages/chaptertool ------> ChapterTool.Node ------> ChapterTool.Core | Standalone CLI | `src/ChapterTool.CommandLine/Program.cs` | `src/ChapterTool.CommandLine/ChapterToolCliHost.cs` | `ChapterToolCliApplication.ImportAsync` | `ChapterToolCliApplication.ConvertAsync` | `tests/ChapterTool.CommandLine.Tests/Cli/ChapterToolCliApplicationTests.cs` | | Avalonia desktop | `src/ChapterTool.Avalonia/Program.cs` | `src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs` | `src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs` | `src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs` | `tests/ChapterTool.Avalonia.Tests` and `tests/ChapterTool.Avalonia.Headless.Tests` | | Blazor WebAssembly browser | `src/ChapterTool.Wasm/Program.cs` | `src/ChapterTool.Wasm/Pages/Home.razor` and `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` | `src/ChapterTool.Wasm/Services/WasmChapterService.cs` | `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` and `wwwroot/js/download.js` | `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs` | -| Node.js package | `src/ChapterTool.Node/Program.cs` | `packages/chaptertool/src/index.ts` | `src/ChapterTool.Node/NodeApi.cs` | `src/ChapterTool.Node/NodeApi.cs` and `NodeCoreApi.cs` | `packages/chaptertool/test/chaptertool.test.ts` and `core-api.test.ts` | +| Node.js package | `src/ChapterTool.Node/Program.cs` | `packages/chaptertool/src/index.ts` | `src/ChapterTool.Node/NodeExactApi.cs` | `src/ChapterTool.Node/NodeApi.cs` and `NodeExactApi.cs` | `packages/chaptertool/test/exact-api.test.ts` | ### 2.1 Host Defaults @@ -146,6 +146,8 @@ They do not describe product priority. | Insert and delete rows | Provides edit operations | Does not provide row commands | Provides insert and delete commands | Provides insert and delete actions | Core editing service plus host command layer `[Host variant]` | | Delete-rows timing preference | Provides `ChapterEditingOptions.DeleteRowsTiming` (`Preserve` or `Normalize`) | Does not expose an interactive preference | Drafts and applies through `SettingsToolViewModel`; reaches Core through `ClipEditingCoordinator.Delete` | Drafts and applies through `Home.razor`; reaches Core through `WasmWorkspace.EditingOptions` | `src/ChapterTool.Core/Editing/ChapterEditingOptions.cs` `[Host variant]` | | Frame display preference | Provides `ChapterEditingOptions.FrameDisplay` (`Round` or `DecimalPlaces`) and `EffectiveFrameDecimalPlaces` | Does not expose an interactive preference | Drafts and applies through `SettingsToolViewModel`; drives frame-update precision in `MainWindowViewModel.Editing.cs` | Drafts and applies through `Home.razor`; drives `WasmWorkspace.EditingOptions` | `src/ChapterTool.Core/Editing/ChapterEditingOptions.cs` `[Host variant]` | +| Repeating-decimal preference | Uses `FramePresentationPolicy` to format exact frame ratios | Does not expose an interactive preference | Drafts and applies through `SettingsToolViewModel`; refreshes frame presentation | Applies valid settings drafts live; formats rows with `FrameValueView` | `FramePresentationPolicy`, `FrameValueFormatter`, and each host's settings view `[Shared]` | +| History inspection and redo branches | Provides retained history and parent-relative field inspection | Does not retain an interactive session | Provides a tree, field details, and deferred redo choices | Provides a responsive tree, root rows, field filters, and deferred redo choices | `SessionState`, `HistoryInspectionViewModel`, `WasmHistoryProjection`, and `HistoryDialog.razor` `[Host variant]` | | Select multiple rows | Provides model collections only | Does not provide row selection | Uses DataGrid selection | Uses `WasmWorkspace.SelectRow` | `MainWindowViewModel` and `WasmWorkspace` `[Host variant]` | | Combine and restore clips | Provides `ChapterSegmentService` | Does not provide interactive clip state | Uses `ClipSession` | Uses `WasmWorkspace` clip state | `src/ChapterTool.Core/Editing/ChapterSegmentService.cs` `[Host variant]` | | Forward frame shift | Provides frame transform services | Provides frame-rate and export options only | Provides `ForwardShiftToolView` | Provides a browser forward-shift action | `src/ChapterTool.Core/Transform/ChapterFpsTransformService.cs` `[Host variant]` | @@ -160,12 +162,12 @@ They do not describe product priority. | Chapter name preservation | Provides projection options | Preserves names by default | Provides a source-name option | Provides a source-name option | `ChapterOutputProjectionService` `[Shared]` | | Automatic chapter names | Provides projection logic | Uses export projection | Provides an automatic-name option | Provides an automatic-name option | `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` `[Shared]` | | Template names | Provides template projection | Does not expose a template file option | Reads a local template file | Reads a browser text file | `ChapterNameTemplateReader` and `WasmWorkspace` `[Host variant]` | -| Lua expressions | Provides Lua execution | Supports `--expression` and `--expression-preset` | Provides expression editing and application | Applies text expressions and Core built-in presets through `WasmWorkspace` | `src/ChapterTool.Core/Transform/Expressions/Lua/` `[Host variant]` | -| Expression authoring | Provides analysis contracts and authoring services | Does not provide completion UI | Provides completion and diagnostics | Surfaces Core expression diagnostics to status/log; no completion editor | `ExpressionEditor` (desktop) and `WasmWorkspace` diagnostics `[Host variant]` | +| Lua expressions | Provides Lua execution | Supports `--expression` and `--expression-preset` | Provides inline expression editing, candidate preview, and application | Provides inline expression editing, candidate preview, and application through `WasmWorkspace` | `src/ChapterTool.Core/Transform/Expressions/Lua/` `[Host variant]` | +| Expression authoring | Provides analysis contracts and authoring services | Does not provide completion UI | Provides completion and diagnostics | Loads Lua files. The main editor has floating diagnostics. The advanced dialog provides highlighting and completion | `Home.razor`, `ExpressionDialog.razor`, and `ExpressionAuthoringService` `[Host variant]` | | Export formats | Provides all Core export formats | Lists and writes all CLI formats | Provides all desktop save formats | Provides all browser save formats | `src/ChapterTool.Core/Exporting/ChapterExportFormats.cs` `[Shared]` | | XML language | Provides the language catalog | Supports `--xml-language` | Provides a localized language selector | Provides a language selector | `XmlChapterLanguageCatalog` `[Host variant]` | | Text encoding | Provides encoding options | Writes UTF-8 output without a BOM | Uses selected encoding and BOM options | Uses selected encoding and browser download bytes | `src/ChapterTool.Core/Exporting/OutputTextEncoding.cs` `[Host variant]` | -| Preview | Provides export projection and serialization | Does not provide a separate preview command | Provides preview before save | Provides preview before download | `ProjectionFacade` and `WasmWorkspace.Preview` `[Host variant]` | +| Preview | Provides pure serialization of supplied content | Does not provide a separate preview command | Serializes committed content before save | Serializes committed content before download | `MainWindowViewModel.BuildPreview` and `WasmWorkspace.Preview` `[Host variant]` | | Save to a file | Provides export content | Writes a local output path or standard output | Writes a selected or configured local path | Starts a browser download | `ChapterToolCliApplication`, `RuntimeChapterSaveService`, and `download.js` `[Host variant]` | | Standard output | Does not own streams | Writes exported content to stdout and diagnostics to stderr | Does not own terminal output | Not applicable | `src/ChapterTool.CommandLine/Cli/ChapterToolCliApplication.cs` `[CLI only]` | @@ -174,18 +176,19 @@ They do not describe product priority. | Capability | Core | Standalone CLI | Avalonia desktop | WASM browser | Owner and entry point | | --- | --- | --- | --- | --- | --- | | Settings model | Provides no host settings store | Reads shared `ChapterToolSettings` for CLI output defaults | Reads and writes shared `ChapterToolSettings` | Uses a browser settings model | `ChapterToolSettings` and `WasmSettings` `[Host variant]` | +| Keyboard shortcuts | Provides no input layer | Uses terminal key handling | Provides editable shortcut mappings and conflict validation | Drafts shared mappings, validates conflicts, and routes commands outside text controls and dialogs | `ShortcutCatalog`, `ShortcutSettings`, `ShortcutRouter`, `WasmShortcuts`, and `ShortcutSettingsEditor.razor` `[Host variant]` | | Settings persistence | Provides no persistence boundary | Uses `settings.json` under the shared ChapterTool settings directory | Uses the same `settings.json` directory | Uses `localStorage` | `ChapterToolRuntimeComposition`, `ChapterToolSettingsStore`, and `Home.razor` `[Host variant]` | -| Settings close confirmation | Provides no UI boundary | Not applicable | Asks whether to keep the settings tool open when unapplied changes exist through `AvaloniaSettingsCloseConfirmationService` | Not applicable | `src/ChapterTool.Avalonia/Services/AvaloniaSettingsCloseConfirmationService.cs` `[Desktop only]` | +| Settings close confirmation | Provides no UI boundary | Not applicable | Confirms unapplied changes through `AvaloniaSettingsCloseConfirmationService` | Offers discard or keep editing for changed drafts | `AvaloniaSettingsCloseConfirmationService` and `Home.razor` `[Host variant]` | | Save directory | Provides no directory policy | Uses `--output`, settings, or the source directory | Uses a picker, settings, or the source directory | Uses the browser download directory | `ChapterToolCliApplication`, `ChapterSaveDirectory`, and `download.js` `[Host variant]` | | External tool configuration | Provides no native tool boundary | Reads configured tool paths and `PATH` search directories | Reads and edits configured tool paths | Does not use native tools | `ChapterToolRuntimeComposition` and `ExternalToolLocator` `[Desktop and CLI]` | | Process execution | Provides no process boundary | Runs external import tools through Infrastructure | Runs external import tools through Infrastructure | Cannot start local processes | `src/ChapterTool.Infrastructure/Processes/ProcessRunner.cs` `[Desktop and CLI]` | | Theme presets | Provides no UI resource layer | Uses terminal text only | Uses Avalonia theme resources | Uses CSS theme resources | `ThemePresetCatalog`, Avalonia theme service, and WASM styles `[Host variant]` | | System fonts | Provides no font enumeration | Uses terminal output only | Enumerates and applies system fonts | Uses CSS font stacks | `AvaloniaFontFamilyCatalog` and WASM CSS `[Desktop only]` | -| Language selection | Provides no UI localization | Uses CLI-owned `.json` resources | Uses Avalonia-owned `.axaml` resources | Uses Web-only `.json` resources | `CliLocalizationManager`, `AvaloniaLocalizationResourceAdapter`, and `WasmLocalizer` `[Host variant]` | +| Language selection | Provides no UI localization | Uses CLI-owned `.json` resources | Uses shared `.axaml` resources | Uses `.json` resources generated from the shared locales | `CliLocalizationManager`, `AvaloniaLocalizationResourceAdapter`, `WasmLocalizer`, and `axaml-to-json.py` `[Host variant]` | | Shell operations | Provides no shell boundary | Does not open paths | Opens paths through `IShellService` | Uses browser links or browser APIs | `src/ChapterTool.Infrastructure/Platform/ShellService.cs` `[Desktop only]` | | Clipboard | Provides no clipboard boundary | Uses standard streams | Uses the desktop clipboard adapter | Uses browser clipboard APIs | `IClipboardService` and browser JavaScript `[Host variant]` | | Application telemetry | Provides no telemetry startup | Does not initialize Sentry | Initializes Sentry for GUI startup when configured | Does not use the desktop Sentry startup | `src/ChapterTool.Avalonia/Program.cs` `[Desktop only]` | -| Logs and diagnostics | Provides structured diagnostics | Writes diagnostics to terminal streams | Uses the application log panel and localized UI messages | Uses in-memory logs and localized UI messages | `ChapterDiagnostic`, `ApplicationLogPanelProvider`, and `WasmWorkspace` `[Host variant]` | +| Logs and diagnostics | Provides structured diagnostics | Writes diagnostics to terminal streams | Uses the application log panel with JSON and CSV export | Filters bounded activity logs, opens details, and exports filtered JSON or CSV | `ChapterDiagnostic`, `ApplicationLogPanelProvider`, `ApplicationLogFileExporter`, `WasmLogView`, and `LogDialog.razor` `[Host variant]` | | Progress | Provides import progress contracts | Does not render interactive progress | Renders import progress in the desktop workflow | Reports browser load and import progress | `ChapterImportProgress` and host workflows `[Host variant]` | | Input size limit | Defines the portable 64 MiB byte budget | Uses local file boundaries | Uses local file boundaries | Enforces the shared budget for load, reload, and append | `src/ChapterTool.Core/Boundaries/PortableInputPolicy.cs`, `WasmWorkspace.cs`, `NodeApi.cs`, and `packages/chaptertool/src/utils/input.ts` `[Shared portable boundary]` | @@ -272,6 +275,16 @@ Start with these paths for browser behavior: - Application startup: `src/ChapterTool.Wasm/Program.cs` - Root components: `src/ChapterTool.Wasm/App.razor` and `src/ChapterTool.Wasm/Layout/MainLayout.razor` - Workspace page: `src/ChapterTool.Wasm/Pages/Home.razor` +- Native dialog lifecycle: `src/ChapterTool.Wasm/Components/WasmDialog.razor` and `src/ChapterTool.Wasm/wwwroot/js/download.js` +- History tree, field inspection, and deferred redo choices: `src/ChapterTool.Wasm/Components/HistoryDialog.razor`, `Services/WasmWorkspace.History.cs`, and `Services/WasmHistoryProjection.cs` +- Combined main-window naming, numbering, and expression candidates: `src/ChapterTool.Wasm/Pages/Home.razor` and `Services/WasmWorkspace.MainOptions.cs` +- Live settings drafts, save, and runtime rollback: `src/ChapterTool.Wasm/Pages/Home.razor` +- Advanced expression editor: `src/ChapterTool.Wasm/Components/ExpressionDialog.razor` +- Advanced export draft: `src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor` +- Reviewed naming, numbering, frame shift, and conversion: `src/ChapterTool.Wasm/Components/ContentOperationDialog.razor` and `Services/WasmWorkspace.Operations.cs` +- Identity-based cell drafts: `src/ChapterTool.Wasm/Components/ChapterCellEditor.razor` +- Shared shortcut settings: `src/ChapterTool.Wasm/Components/ShortcutSettingsEditor.razor` and `Services/WasmShortcuts.cs` +- Filtered log projection and inspector: `src/ChapterTool.Wasm/Services/WasmLogView.cs` and `Components/LogDialog.razor` - Workspace state: `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` - Core import and export adapter: `src/ChapterTool.Wasm/Services/WasmChapterService.cs` - Shared session state: `src/ChapterTool.Core/Session/ChapterWorkspace.cs` @@ -294,7 +307,7 @@ The Node.js package owns JavaScript input conversion, .NET WebAssembly startup, Start with these paths for Node.js package behavior: - WebAssembly host: `src/ChapterTool.Node/NodeApi.cs` -- Core operation exports: `src/ChapterTool.Node/NodeCoreApi.cs` +- Exact Core operation exports: `src/ChapterTool.Node/NodeExactApi.cs` - TypeScript API source and `ChapterTool` class: `packages/chaptertool/src/index.ts` - TypeScript type model: `packages/chaptertool/src/types.ts` - TypeScript initialization helper: `packages/chaptertool/src/api-loader.ts` diff --git a/docs/code-map/testing.md b/docs/code-map/testing.md index 294e8079..cc20b167 100644 --- a/docs/code-map/testing.md +++ b/docs/code-map/testing.md @@ -14,6 +14,8 @@ Use ASD-STE100 principles in this document. Keep each sentence short and direct. - `tests/ChapterTool.Core.Tests` - Browser WebAssembly workspace behavior: - `tests/ChapterTool.Wasm.Tests` +- Browser WebAssembly user workflows: + - `tests/ChapterTool.Wasm.E2E` - Node.js package behavior (Vitest, one worker for the WASM runtime): - `packages/chaptertool/test/chaptertool.test.ts` - `packages/chaptertool/test/core-api.test.ts` @@ -27,21 +29,82 @@ Use ASD-STE100 principles in this document. Keep each sentence short and direct. - Avalonia Headless UI shell/interaction (separate process): - `tests/ChapterTool.Avalonia.Headless.Tests` +DVD presentation coverage uses `tests/ChapterTool.Avalonia.Headless.Tests/Headless/DvdPresentationHeadlessTests.cs`. It verifies title selection, localized labels, diagnostic counts, and single-chapter text in three languages. It verifies the workflow at default, wide, and narrow window sizes. It writes review images under `artifacts/dvd-presentation/`. The images support visual review and do not replace workflow assertions. + +DVD timing coverage uses `tests/ChapterTool.Core.Tests/Importing/DvdNavTimingTests.cs`. Synthetic IFO and NAV packs verify VTS imports, VMG imports, and PTT recovery. Tests cover split VOB files, timestamp wrap, cell clock resets, missing or corrupt navigation data, cancellation, and complete-title fallback. Fractional NAV times must remain unchanged. Long IFO cell sequences compare numeric error against the legacy calculation. `DiscImporterTests` covers legacy timecode compatibility through 99 hours. `IfoImporterTests` checks known chapter frame counts in real IFO fixtures. The Headless DVD test verifies nominal-frame results and the unverified-timing diagnostic. These fixtures do not replace verification against a complete authored DVD. + Desktop composition coverage: - `tests/ChapterTool.Avalonia.Headless.Tests/Composition/AutofacCompositionHeadlessTests.cs` validates missing registrations, test overrides, and repeated disposal. - `tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs` validates shared service identity and shell resolution. - `tests/ChapterTool.Avalonia.Headless.Tests` references Autofac only to test the desktop composition boundary. +## Script Test Map + +`scripts/ci/tests/test_fluent_resource_references.py` verifies Fluent resource extraction and long malformed input. +Run the script suite with `python3 -m unittest discover -s scripts/ci/tests`. + ## Core Test Map +`ChapterExportServiceTests` verifies XML UID stability, positive values, and uniqueness. + +`Editing/ExactFrameRateWorkflowTests.cs` covers real MPLS and IFO imports through fractional frame editing, candidate review, history, conversion, and QPFile export. It checks 500 arithmetic and conversion cycles for each Rational/Approximate rate pair. Integer comparisons must keep the loss below one source frame. The timelines include ticks above the JavaScript safe-integer limit. + +`Headless/ExactFrameRateHeadlessTests.cs` in the Headless project covers the real desktop frame editor, unchanged ticks and history, candidate display refresh, and undo/redo. It captures default, wide, and narrow windows in three languages under `artifacts/exact-frame-rate-domain/`. + +`specs/exact-frame-rate-domain.spec.ts` in the browser E2E project covers real MPLS import, unchanged cell editing, fractional frames, candidate apply, keyboard history, and downloads. It verifies Unknown Auto behavior and explicit-rate editing. Real key events must preserve field undo and cancel browser defaults for document undo. The test captures review images under `artifacts/exact-frame-rate-domain/`. + +`packages/chaptertool/test/exact-api.test.ts` covers exact wire values through the public `ChapterTool` entry point. It checks lossless tick/rate values, malformed rates, and explicit operations. `scripts/ci/consumers/core-frame-rate.cs` covers compiled canonical Core APIs through the NuGet verification entry point. + +`tests/ChapterTool.Wasm.Tests/WasmFrameRateChoiceTests.cs` covers custom rate resolution and known authority under Auto. It checks localized selector labels, computation rates, ticks, and history during language changes. The custom values use the public Core rate service. The workspace language test uses its selectable catalog rate. + Use `tests/ChapterTool.Core.Tests` when changing pure parsing, editing, transform, or export behavior. -Use `tests/ChapterTool.Wasm.Tests` when you change the Blazor browser workspace, bounded byte input, browser settings, or browser export paths. The primary file is `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs`. `tests/ChapterTool.Wasm.Tests/WasmBrowserShortcutGuardTests.cs` covers the browser shortcut guard. +`Models/EditableChapterDocumentTests.cs` covers duration availability through scalar, track, import, and clip metadata adapters. +It distinguishes known zero duration from unknown duration. +`Editing/ChapterSegmentServiceTests.cs` covers combine and append operations with unknown or known zero durations. +The Node exact API tests verify the same boundary through the packaged public API. + +Use `tests/ChapterTool.Core.Tests/Editing/ChapterContentCandidateBuilderTests.cs` and `ChapterClipCandidateBuilderTests.cs` for one-shot content candidates and clip operations. Use `tests/ChapterTool.Core.Tests/Session/ClipSessionTests.cs` and `ChapterWorkspaceTests.cs` for selector metadata, selected-track commits, load/append ownership, ended-session collection, and per-track export baselines. `SessionStateTests.cs` covers mutations and history navigation after session end. + +`tests/ChapterTool.Core.Tests/Session/SessionEditHistoryTests.cs` covers history branches, atomic operation metadata, cancellation, and deep-history allocation measurements. `SessionHistoryInspectionTests.cs` covers parent-relative inspection, stable identities, moves, property-only edits, root details, precise values, and resource failures. These tests verify that inspection does not publish a document or move the cursor. + +Use `tests/ChapterTool.Wasm.Tests` when you change the Blazor browser workspace, bounded byte input, browser settings, or browser export paths. The primary file is `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs`. It covers browser history navigation, independent tab sessions, failed and canceled replacement, and export baselines. `tests/ChapterTool.Wasm.Tests/WasmBrowserShortcutGuardTests.cs` covers the browser shortcut guard. + +Use `tests/ChapterTool.Wasm.E2E` for rendered Blazor workflows and browser boundaries. `specs/repeating-frame-display.spec.ts` verifies exact repetends, plain numeric editing, settings, and responsive frame previews. The default configuration tests the prepared Release site at `/ChapterTool/`. The development configuration supports local diagnosis. Publish the app and run `npm run prepare:site` first. Then run `npm run typecheck` and `npm run test:e2e` from that test directory. + +`WasmContentParityTests` covers read-only candidates, cancellation, stale cell identities, repeated submission, and preference isolation. `WasmToolParityTests` covers shared shortcut defaults and filtered log export. `PortableUiParityTests` compares browser and Avalonia operation sequences through public APIs. `functional-parity.spec.ts` covers rendered cell drafts, shortcut settings, Lua authoring, filtered log actions, persistence failures, 1000-row editing, and five viewport sizes. Browser evidence is stored under `artifacts/wasm-e2e/`. + +`WasmMainOptionsParityTests` covers combined main-window candidates, display refresh, selection, stale drafts, atomic undo, and expression replay prevention on the current branch. `WasmHistoryParityTests` covers localized details, root rows, filters, and deferred direct-child redo choices. `WasmPortableToolParityTests` covers export validation, browser log records, and typed content comparisons. The matching browser workflows are in `main-window-parity.spec.ts`, `history-parity.spec.ts`, and `portable-tools-parity.spec.ts`. They also cover live settings rollback, dialog isolation, and 1000-row candidates. + +`specs/modal-layout.spec.ts` owns dialog geometry, focus, hit targets, draft cancellation, background isolation, and long-body scrolling. It includes viewport boundaries from 320 pixels through desktop widths. `specs/unified-editing.spec.ts` owns modal history branches, expression before/after review, composition, and commits. + +Expression dialog drafts must keep the background grid values and geometry unchanged. History entry selection inspects details. The restore action changes the document. Browser tests verify both boundaries through rendered workflows. + +`WasmDialogTests` covers dialog disposal after JavaScript runtime disconnection. +`WasmWorkspaceTests` verifies canceled expression application. +Cancellation must preserve content, history, and the candidate. +The status must use the expression failure message. +`functional-parity.spec.ts` verifies main preferences, editing, and downloads when browser storage writes fail. +`portable-tools-parity.spec.ts` verifies failed export preference saves. +Failure must preserve the dialog draft and restore the active export options. +Canceling after failure must retain the original options when the dialog reopens. + +`specs/layout-behavior.spec.ts` verifies page and dialog bounds at default, wide, narrow, short, and landscape sizes. It verifies visible actions, downloads, expression diagnostics, and long-content footer access. These assertions run in the browser behavior suite. Browser E2E and Avalonia Headless tests own layout verification. Screenshots support diagnosis and manual review. There is no pixel baseline gate. + +`specs/main-diagnostics-layout.spec.ts` verifies the single main toolbar history action and the expression syntax diagnostic layer. It checks workflow geometry in empty, valid, invalid, pending, and stale states. It checks keyboard access and accessible diagnostic text after language changes. Real keyboard events verify that document Undo and Redo cancel browser defaults while field-level text Undo remains available. + +`specs/expression-preview-screenshots.spec.ts` verifies that changed time values stay above the footer at default, wide, and narrow portrait sizes. It checks English and Chinese count text and expandable property values. It writes review images under `artifacts/expression-preview/` when `E2E_CAPTURE_REVIEW=1`. `ToolViewsHeadlessTests` verifies the desktop comparison reflow in English and Chinese and writes images to the same directory. + +CI runs Chromium and targeted WebKit regressions for affected pull requests. Version tags, weekly checks, and manual CI runs use all three browser engines. Pages deployment reuses the tested site after `CI ready`. Reports and diagnostics are under `artifacts/wasm-e2e/`. Browser emulation does not verify a physical iPhone keyboard. Use `packages/chaptertool/test/chaptertool.test.ts` when you change the Node.js package entry point, TypeScript input conversion, or npm runtime packaging. Use `packages/chaptertool/test/api-loader.test.ts` when you change retryable .NET WebAssembly startup. Use `packages/chaptertool/test/core-api.test.ts` when you change the portable Core API mapping. Run `npm test` from `packages/chaptertool`. The command bundles the TypeScript source, checks its types, and generates `dist/` before Vitest runs the Node.js tests through the package export map. `packages/chaptertool/vitest.config.mjs` keeps the process-wide WebAssembly runtime in one test worker. -The `.NET 10 CI` workflow builds `dist/` once for changes under `packages/chaptertool`. The build and test job runs `npm run typecheck` and `npm run test:built` against this output. The npm pack job downloads the same output. It runs `npm run pack:verify` without lifecycle scripts. The pack check installs the generated tarball into a temporary consumer and calls `ChapterTool.import`. The `Publish to npm` workflow uses npm Trusted Publishing after a successful version tag run. Configure the GitHub Actions trusted publisher for this workflow and the `npm` environment on npmjs.com. +`ChapterExportServiceTests.cs` covers pure serialization. `ChapterOutputProjectionServiceTests.cs` covers explicit content transforms before serialization. `WasmWorkspaceTests.cs` verifies applied content is exported once and failed expressions do not create partial history. `core-api.test.ts` verifies the Node.js package applies a transform explicitly and serialization does not replay it. + +`tests/ChapterTool.Core.Tests/Session/ExpressionPreviewProjectorTests.cs` verifies typed candidate comparison, chapter and property effects, track scope, frame bases, and complete document equality. `tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs` covers expression preview freshness, exact-candidate commit, status, and undo/redo. + +The `.NET 10 CI` workflow builds `dist/` once in the Node.js job. That job runs `npm run typecheck` and `npm run test:built` against this output. It then runs `npm run pack:verify` without lifecycle scripts. The pack check installs the generated tarball into a temporary consumer and calls `ChapterTool.import`. The `Publish to npm` workflow uses npm Trusted Publishing after a successful version tag run. Configure the GitHub Actions trusted publisher for this workflow and the `npm` environment on npmjs.com. High-signal test files: @@ -57,11 +120,14 @@ High-signal test files: - `tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs` - editing - `tests/ChapterTool.Core.Tests/Editing/ChapterEditingServiceTests.cs` (delete-rows timing and frame display options coverage) + - `tests/ChapterTool.Core.Tests/Editing/ChapterContentCandidateBuilderTests.cs` (candidate commit, undo, redo, stale previews, and batch failure) + - `tests/ChapterTool.Core.Tests/Editing/ChapterClipCandidateBuilderTests.cs` (merge, boundary split, append, IDs, ends, frame rates, cancellation, and stale snapshots) - `tests/ChapterTool.Core.Tests/Editing/ChapterSegmentServiceTests.cs` - `tests/ChapterTool.Core.Tests/Editing/SampleChapterNameTemplateTests.cs` - - `tests/ChapterTool.Core.Tests/Editing/ChapterContentServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Importing/ChapterContentServiceTests.cs` - transform - `tests/ChapterTool.Core.Tests/Transform/FrameRateServiceTests.cs` + - `tests/ChapterTool.Core.Tests/Transform/FrameValueFormatterTests.cs` - `tests/ChapterTool.Core.Tests/Transform/ChapterFpsTransformServiceTests.cs` - `tests/ChapterTool.Core.Tests/Transform/ChapterTimeFormatterTests.cs` - `tests/ChapterTool.Core.Tests/Transform/ChapterRoundingTests.cs` @@ -116,6 +182,7 @@ BDMV compatibility tests: - platform services: - `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs` - `tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/ApplicationLogFileExporterTests.cs` - settings persistence: - `tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs` - `tests/ChapterTool.Infrastructure.Tests/CorruptSettingsFileTests.cs` @@ -136,11 +203,22 @@ High-signal test files: - view models - `tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs` + - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs` (preview/apply tool workflows) - `tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsToolViewModelTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/SettingsSnapshotCoordinatorTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolViewModelPortConstructionTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/LogToolViewModelTests.cs` + +Shortcut catalog and routing tests belong in `tests/ChapterTool.Avalonia.Tests`. `MainWindowViewModelTests` covers command-level history navigation and candidate application. `HistoryInspectionViewModelTests` covers native tree topology, inspection selection, asynchronous detail lifetime, filtering, and explicit restoration. `AvaloniaWindowServiceHeadlessTests` covers history dialog reuse, branch navigation, Escape, reopening, and content detachment. `MainWindowHeadlessTests` covers inline before/candidate values, preview lifecycle, property-only frame-rate changes, submillisecond display, selection state, localization, and responsive workspace bounds. It captures default, wide, narrow, dark, enlarged-font, and Chinese review images under `artifacts/compact-content-preview-review/`. `MainWindowInteractionHeadlessTests` verifies selection and scroll retention through apply and discard. `MainWindowEditingUxScreenshotHeadlessTests` captures native history trees, selected-node details, redo choices, root chapter rows, and narrow Details pages under `artifacts/edit-history-tree-details/`. Settings persistence remains in `tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs`. Headless shortcut workflow coverage belongs in `tests/ChapterTool.Avalonia.Headless.Tests/Headless/SettingsToolHeadlessTests.cs`. + +`HistoryInspectionHeadlessTests` covers rendered history details, empty change filters, retry and restore failure feedback, alternate redo choices, and pending preview lifetime. Keep these workflows in the isolated Avalonia Headless test project. + +`MainWindowCloseHeadlessTests` verifies that closing the desktop window ends its document session without a history prompt. It also verifies that a canceled close keeps the session active. + +`EmbeddedHistoryHeadlessTests` covers history inspection through the embedded host. It verifies footer bounds at 520x420 and 520x600. It also verifies content detachment and tool-state disposal on close. + +LogTool coverage is split by boundary. `LogToolViewModelTests` covers list-first projection, severity and text filters, compact summaries, explicit inspector selection, search highlights, flat structured properties, raw values, live updates, eviction handling, localization, and secondary command state. `tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs` covers append-order snapshots, minimum-level filtering, bounded retention, clear notifications, and concurrent access. `tests/ChapterTool.Infrastructure.Tests/ApplicationLogFileExporterTests.cs` covers UTF-8 JSON and CSV output, deterministic ordering, CSV quoting, output paths, and recoverable failures. `tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs` and `UiResourceResolutionHeadlessTests.cs` cover rendered list and inspector workflows, keyboard close behavior, responsive layouts, and locale resource resolution. - commands and services - `tests/ChapterTool.Avalonia.Tests/Commands/UiCommandTests.cs` - `tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs` @@ -165,8 +243,10 @@ High-signal test files: - `tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs` - headless shell/interaction/integration - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/MainWindowHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/MainWindowEditingUxScreenshotHeadlessTests.cs` (optional main, history, and preview screenshots) - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/MainWindowInteractionHeadlessTests.cs` - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/MainWindowStateHeadlessTests.cs` + - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/UiScreenshotCaptureHeadlessTests.cs` (optional default, wide, and narrow screenshot capture) - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/ToolViewsHeadlessTests.cs` - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/SettingsToolHeadlessTests.cs` - `tests/ChapterTool.Avalonia.Headless.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs` @@ -183,15 +263,17 @@ High-signal test files: - `tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs` - `tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootFontTests.cs` -Use `tests/ChapterTool.Avalonia.Tests/PlatformPorts/AuxiliaryToolContractTests.cs` for typed tool identifiers, duplicate catalog validation, custom descriptor selection, embedded reuse, disposal, and unknown-tool results. Use `tests/ChapterTool.Avalonia.Headless.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs` for Native Window close confirmation and content detachment. Keep Native Window and Headless test projects in separate processes. +Use `tests/ChapterTool.Avalonia.Tests/PlatformPorts/AuxiliaryToolContractTests.cs` for typed tool identifiers, duplicate catalog validation, custom descriptor selection, embedded reuse, close callbacks, disposal, and unknown-tool results. Use `tests/ChapterTool.Avalonia.Headless.Tests/Headless/AvaloniaWindowServiceHeadlessTests.cs` for Native Window history and close behavior. Keep Native Window and Headless test projects in separate processes. Set `CHAPTERTOOL_MAIN_WINDOW_UX_SCREENSHOTS=1` to capture the main, history, and preview surfaces under `artifacts/main-window-editing-ux/`. Theme preset coverage is concentrated in `ThemePresetCatalogTests`, `SettingsToolViewModelTests`, `AvaloniaThemeApplicationServiceTests`, and `SettingsToolHeadlessTests`. The Headless workflow switches representative light and dark presets. It verifies the live palette preview, application variant, semantic resources, and DataGrid column-header brushes. Editing-preference coverage for delete-rows timing and frame display is in `ChapterEditingServiceTests` at the Core level and `SettingsToolViewModelTests` for draft/apply lifecycle. +`ChapterGridCaretHeadlessTests` verifies rendered caret geometry in all three editable chapter columns. It covers keyboard navigation, centered text, long-name scrolling, resizing, and edit commits. It checks the input-method cursor rectangle through the public input client. It writes default, wide, and narrow review images under `artifacts/chapter-grid-caret/`. + Imported theme resource coverage is in `AvaloniaThemeApplicationServiceTests`. The tests resolve representative theme brushes and the configured monospace font through the runtime resource tree. They verify every imported `Color.*` token for a dark preset. Headless workflow tests verify visible `Optris.Icons.Avalonia.FontAwesome` icons. -Log projection coverage is in `LogToolViewModelTests` against `LogEntryViewModel`; log orchestration remains in `LogToolViewModel`. Log user-interface behavior is in `AuxiliaryToolHeadlessTests`. These tests verify filtering, selection, copy, clear, live updates, summary, structured data, raw JSON, theme changes, and narrow layout. +Log projection coverage is in `LogToolViewModelTests` against `LogEntryViewModel`; log orchestration remains in `LogToolViewModel`. Log user-interface behavior is in `AuxiliaryToolHeadlessTests`. These tests verify the list-first default, explicit details actions, selection retention, filtering and search, copy and clear actions, live append and bounded eviction, structured and raw disclosures, theme changes, localization, and narrow replacement layout. The settings Headless workflows verify the footer settings-folder action, including its left-side placement, accessible label, and routed shell target. @@ -214,13 +296,13 @@ The diagnosis, timing comparisons, affected tests, and repeatable triage procedu ## Analyzer Report -`scripts/report-analyzers.py` collects compiler and analyzer diagnostics at build time. It does not run tests. It is faster than `scripts/test-coverage.py`, which runs every test assembly. It skips the browser-wasm projects because they add no C# metrics and their Emscripten builds are slow. +`scripts/diagnostics/report-analyzers.py` collects compiler and analyzer diagnostics at build time. It does not run tests. It is faster than `scripts/testing/collect-coverage.py`, which runs every test assembly. It skips the browser-wasm projects because they add no C# metrics and their Emscripten builds are slow. Usage: -- `python3 scripts/report-analyzers.py` prints all diagnostics from every project. -- `python3 scripts/report-analyzers.py -Prefix SA` prints only StyleCop diagnostics. -- `python3 scripts/report-analyzers.py -Rebuild -Prefix CA1502` prints cyclomatic complexity diagnostics above the threshold. +- `python3 scripts/diagnostics/report-analyzers.py` prints all diagnostics from every project. +- `python3 scripts/diagnostics/report-analyzers.py -Prefix SA` prints only StyleCop diagnostics. +- `python3 scripts/diagnostics/report-analyzers.py -Rebuild -Prefix CA1502` prints cyclomatic complexity diagnostics above the threshold. The script requires Python 3 and the .NET SDK. It builds each project in dependency order. It writes one SARIF file per project per target framework under `artifacts/analyzers/raw`. It merges these files into `artifacts/analyzers/analyzers.sarif`. @@ -240,21 +322,32 @@ Per-target-framework reports: `Directory.Build.targets` sets a separate `ErrorLo ## Distribution Verification +Pre-push CI entry point: + +- `python scripts/ci/run.py` runs the shared build, test, browser, npm package, and desktop publish checks on the current host. Browser reports use `artifacts/wasm-e2e/`. Published packages use their existing artifact directories. +- `scripts/ci/run.py` owns build, test, browser, and packaging commands. CI selects its stages and step keys. Each .NET matrix job restores, builds, and tests one solution test project. Each job uses a separate checkout. Local .NET checks run sequentially in separate processes. +- `scripts/ci/plan.py` reads solution membership and project references. It selects affected consumers from changed paths. Shared Core fixtures also select browser and Node checks. It verifies required job results for `CI ready`. `scripts/ci/tests/test_plan_ci.py` verifies dependency routing, renames, documentation changes, and failure handling. +- `scripts/ci/tests/test_check_ci.py` verifies command execution, tool requirements, isolated test selection, browser coverage, and platform publish selection. `scripts/ci/validate-publish.ps1` reports publish script parse errors. +- Infrastructure and Avalonia service test jobs install ffmpeg and mkvtoolnix. Avalonia runtime import tests use the real media readers. +- `scripts/ci/verify-nuget.py` installs and runs packed Core and CLI consumers. `scripts/ci/tests/test_verify_nuget.py` verifies package membership and version checks. +- `scripts/ci/resolve-run.py` requires a successful tag CI run for the exact release SHA. `scripts/ci/tests/test_resolve_ci_run.py` verifies artifact provenance and failure handling. +- `scripts/README.md` documents stage commands, scheduling, distribution, and platform limits. macOS DMG checks require macOS. + Coverage entry point: -- `scripts/test-coverage.py` builds the five test projects and runs their assemblies through VSTest in sequence. This keeps Coverlet collection compatible with the Microsoft.Testing.Platform SDK setting. `scripts/coverage.runsettings` configures Coverlet collection. The script excludes generated `*.g.cs` files. It writes XML and HTML output under `artifacts/coverage`. +- `scripts/testing/collect-coverage.py` builds the five test projects and runs their assemblies through VSTest in sequence. This keeps Coverlet collection compatible with the Microsoft.Testing.Platform SDK setting. `scripts/testing/coverage.runsettings` configures Coverlet collection. The script excludes generated `*.g.cs` files. It writes XML and HTML output under `artifacts/coverage`. - Maintained publish entry points: - - `scripts/publish.sh` - - `scripts/publish.ps1` + - `scripts/packaging/publish-desktop.sh` + - `scripts/packaging/publish-desktop.ps1` - `.github/workflows/dotnet-ci.yml` - `.github/workflows/nuget-publish.yml` - `.github/workflows/npm-publish.yml` - `.github/workflows/release.yml` - The `ChapterTool` NuGet package installs the `chaptertool` command. `src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj` owns its package metadata. -- `.github/workflows/dotnet-ci.yml` packs `ChapterTool.Core` and `ChapterTool` in the build job. It uploads the packages as `ChapterTool-Core-nuget` and `ChapterTool-Cli-nuget`. Each `pack-dotnet` runtime matrix job uploads one `ChapterTool-Avalonia-` artifact. +- `.github/workflows/dotnet-ci.yml` packs `ChapterTool.Core` and `ChapterTool` in the independent NuGet job. It uploads the packages as `ChapterTool-Core-nuget` and `ChapterTool-Cli-nuget`. Each `pack-dotnet` runtime matrix job uploads one `ChapterTool-Avalonia-` artifact. - The Avalonia publish scripts omit symbols, documentation files, and development diagnostics. They reject duplicate top-level assemblies in single-file output. The `pack-dotnet` jobs run this validation before artifact upload. -- `.github/workflows/nuget-publish.yml` applies one release version to both NuGet packages and publishes them. It does not install the CLI package during CI. -- `.github/workflows/release.yml` creates the GitHub Release after a successful `.NET 10 CI` push to a version tag. It publishes the artifacts from that CI run. A manual `workflow_dispatch` may select a tag that already has a successful CI run. +- `.github/workflows/nuget-publish.yml` downloads and publishes the tested tag packages. CI builds both packages with the same tag version and verifies installed consumers. +- `.github/workflows/release.yml` creates the GitHub Release after a successful `.NET 10 CI` push to a version tag. It publishes only the named distribution artifacts from that CI run. A manual `workflow_dispatch` may select a tag that already has a successful CI run. - Use `src/ChapterTool.CommandLine/README.md` for the NuGet Tool installation and external-tool requirements. - 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-map/ui-logging.md b/docs/code-map/ui-logging.md new file mode 100644 index 00000000..655875c0 --- /dev/null +++ b/docs/code-map/ui-logging.md @@ -0,0 +1,101 @@ +# UI 日志审查(Logging Review) + +本文件记录共享 Avalonia UI(`src/ChapterTool.Avalonia.UI`)中所有日志记录场景、触发条件与记录内容。用于排查问题和评估日志覆盖面。 + +## 日志基础设施 + +所有 UI 日志统一经由以下路径写入: + +- 调用入口:`MainWindowViewModel.Log(...)`(`ViewModels/MainWindowViewModel.StatusLog.cs`,薄封装转发)与 `StatusDiagnosticsPresenter.Log / LogImportSummary / LogDiagnostics` +- 实现:`StatusDiagnosticsPresenter.Log(...)`(`Workflows/StatusDiagnosticsPresenter.cs`) +- 输出:结构化 `ILogger`(`Microsoft.Extensions.Logging`),每个条目为一个键值对集合(命名参数) + +日志存储位置(`LoggingModule.cs`): + +| 目标 | 说明 | +|---|---| +| Serilog 文件 | `<设置目录>/logs/chaptertool-YYYYMMDD.log`,按天滚动,保留 14 个文件,单个 10 MB,最低级别 Debug | +| 内存日志面板 | `ApplicationLogPanelProvider`,容量 500 条,最低级别 Information,驱动应用内"日志"工具(`LogToolViewModel`) | +| 日志导出 | 日志工具可将条目导出为 JSON / CSV 到 `<设置目录>/logs/chaptertool-export-*.{json,csv}`(`ApplicationLogFileExporter`) | + +每个条目统一携带的字段: + +- `Message`:**英文原生消息**(调用点直接插值拼装,如 `Loading source: path='...'`),formatter 原样返回,文件日志与面板共用同一文本 —— 文件日志行可直接读懂,不再依赖本地化资源键 +- `Operation`:显式操作分组标签(Load / Save / Edit / Append / Zones / Open / Template / Settings / Lua expression script 等),用于日志面板分组 +- `TechnicalDetail`(可选):技术细节,如异常消息、诊断明细;只进面板检查器,不混入参数列表 +- `ExceptionText`:意外异常的完整 `exception.ToString()`(同时作为 `exception` 传给 `logger.Log`,Serilog 文件 sink 会将其渲染在消息下方) +- 其余命名参数(`(string Name, object? Value)` 元组数组):场景相关数据,供面板检查器与 JSON/CSV 导出(见下表) + +级别约定:默认 `Information`;可预期失败用 `Warning`;意外异常与导入整体失败用 `Error`。诊断(`ChapterDiagnostic`)按严重度映射:Error → `Error`,Warning → `Warning`,其余 → `Information`。 + +## 日志场景一览 + +| # | 场景 | 触发条件 | 级别 | 消息格式(英文原生示例) | 结构化参数 | +|---|---|---|---|---|---| +| 1 | 加载源文件 | 开始加载(拖拽、打开文件、重新加载) | Info | `Loading source: path='/disc/BDMV/INDEX.BDMV'` | `path`(本地源为完整路径 `NormalizedPath`,其余为 `DisplayName`) | +| 2 | 加载结果汇总 | 加载流程结束(成功 / 失败 / 部分成功) | Info 或 Error | `Load completed: groups=2, entries=3, chapters=10, diagnostics=1`(部分成功 → `completed with partial results`,失败 → `failed`;追加时 operation 为 `Append load`) | `operation`、`result`、`success`、`partial`、`groups`、`entries`、`chapters`、`diagnostics` 数量、`details`(逐组逐条目的结构化明细:源路径、媒体轨、时长、FPS 等)、`importOverview`(文本概览) | +| 3 | 加载诊断 | 加载产生诊断(仅 Warning 及以上) | Warn / Error | `Load diagnostic: severity=Warning, code=Import.Partial, message='部分章节缺失'`(有 location 时追加 `, location='...'`) | `severity`、`code`、`location`、`message`、`details`、诊断附加参数;`TechnicalDetail`=诊断 details | +| 4 | 无可选源 | 源显示名为空,或加载状态为 EmptyPath | Info | `Load source skipped: no source selected` | — | +| 5 | 追加 MPLS | 开始追加 | Info | `Appending MPLS: path='/disc/BDMV/PLAYLIST/00042.mpls', currentEntries=3, currentChapters=10` | `path`(完整路径)、`displayName`、`currentEntries`、`currentChapters` | +| 6 | 追加结果汇总 | 追加流程结束 | Info 或 Error | `Append load completed: groups=1, entries=1, chapters=5, diagnostics=0` | 同 #2,`operation`=Append load | +| 7 | 无当前 MPLS 组 | 追加时无活动会话 | Info | `Append MPLS skipped: no active MPLS group` | — | +| 8 | 追加过渡编辑失败 | 追加后合并编辑失败 | Warn / Error | `Append edit diagnostic: severity=Error, code=..., message='...'` | 同 #3,`operation`=Append edit | +| 9 | 保存章节 | 开始保存 | Info | `Saving chapters: format=OGM, directory='...', source='...', chapters=10, applyExpression=False, expression='', xmlLanguage='und', encoding=UTF8, bom=True` | `format`、`directory`、`source`、`chapters`、`applyExpression`、`expression`、`xmlLanguage`、`encoding`、`bom` | +| 10 | 输出投影诊断 | 保存前投影计算产生诊断 | Info / Warn / Error | `Output projection diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Output projection | +| 11 | 保存诊断 | 保存结束后 | Info / Warn / Error | `Save diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Save(含 `Saved` 结果诊断) | +| 12 | 切换源(clip) | 用户选择另一个源选项 | Info | `Selected source option: index=1, label='00015.mpls', source='00007', sourceType=Blu-ray MPLS, chapters=10, fps=23.976` | `index`、`label`、`source`、`sourceType`、`chapters`、`fps` | +| 13 | 单元格编辑 | 编辑时间 / 名称 / 帧单元格 | Info | `Edit time: row=3, value='00:00:05.000', previous='00:00:04.500'` | `action`(含 kind、row、新值、旧值)、`before`、`after`(章节数) | +| 14 | 组合 / 拆分片段 | 组合或还原组合 | Info | `Combine segments: entries=2, sourceType=Blu-ray MPLS: chapters 10 -> 8`(拆分 → `Split combined segments`) | `action`、`before`、`after` | +| 15 | 其他章节编辑 | 删除行、插入行、帧平移等(经 `ApplyEdit` / 端口适配器) | Info | `Delete rows: indexes=1,3: chapters 10 -> 8` / `Insert row: index=2: chapters 10 -> 11` / `Edit chapters: chapters 10 -> 10`(默认) | `action`、`before`、`after` | +| 16 | 编辑诊断 | 编辑产生诊断 | Info / Warn / Error | `Edit diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Edit | +| 17 | 帧信息更新 | 用户触发帧率选项变化后 | Info | `Frame info updated: option=23.976, fps=23.976, round=False, chapters=10`(自动检测时追加 `, autoDetected=true, confidence=...`) | `option`、`fps`、`round`、`chapters`、`autoDetected`、`confidence` | +| 18 | FPS 转换成功 | 转换当前 FPS 成功 | Info | `Convert to current FPS: option='24/1.001 (23.976)', source=23.976, target=23.976, chapters 10 -> 10` | `option`、`sourceFps`、`targetFps`、`before`、`after` | +| 19 | FPS 转换失败 | 转换失败 | Warn / Error | `Change FPS diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Change FPS | +| 20 | 创建 zones | 生成章节区域 | Info | `Create zones: selectedRows=10, chapters=10, zones=9` | `selectedRows`、`chapters`、`zones` | +| 21 | 创建 zones 诊断 | 生成产生诊断 | Info / Warn / Error | `Create zones diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Create zones | +| 22 | 打开关联媒体成功 | 系统成功打开媒体路径 | Info | `Opened related media: path='/disc/BDMV/STREAM/00007.m2ts'` | `status`、`path` | +| 23 | 关联媒体不存在 | 引用路径缺失或解析为空 | Warn | `Related media not found: reference='00007.m2ts', resolved='/disc/BDMV/STREAM/00007.m2ts'` | `status`、`reference`、`resolved` | +| 24 | shell 不可用 | 无法打开本地路径 | Warn | `Open related media skipped: shell unavailable` | `reference` | +| 25 | 加载章节名模板成功 | 模板文件读取成功且非空 | Info | `Loaded chapter name template 'template.txt' from '/path/template.txt'` | `path`、`name` | +| 26 | 加载章节名模板失败 | 文件为空,或 IO / 权限 / 参数异常 | Warn | `Failed to load chapter name template from '/path/template.txt': file is empty`(异常时后缀为异常消息) | `path`、`reason`=empty;异常消息记入 `TechnicalDetail` | +| 27 | 启动加载设置 | 应用启动时加载用户设置 | Info | `Settings loaded: savingPath='...', language='en-US', defaultSaveFormat=OGM, frameDisplay=Milliseconds, frameAccuracy=0.15, xmlLanguage='und'` | `savingPath`、`language`、`defaultSaveFormat`、`frameDisplay`、`frameAccuracy`、`xmlLanguage` | +| 28 | 切换 UI 语言 | 用户更改界面语言 | Info | `Language set to zh-CN` | `language` | +| 29 | Lua 表达式诊断 | 表达式应用 / 校验产生诊断(签名变化时才记录,避免重复刷屏) | Info / Warn / Error | `Lua expression script diagnostic: severity=..., code=..., message='...'` | 同 #3,`operation`=Lua expression script | +| 30 | Lua 脚本加载成功 | 从文件加载表达式脚本成功 | Info | `Lua expression script loaded: path='custom.lua'` | `path` | +| 31 | 意外 UI 异常 | 未处理的 UI 操作异常 | Error | `Unexpected UI operation failure: ...` | `exception`(完整 `exception.ToString()`,面板检查器可展开;`TechnicalDetail` 同文本) | + +## 消息完整性审查(2026-09-01)与修复结果 + +审查时实际检查 `<设置目录>/logs/chaptertool-*.log` 文件,确认当时文件日志每行只有裸 `MessageKey`,不含任何参数: + +``` +2026-08-31 18:21:02.607 +08:00 [INF] Log.LoadingSource +2026-08-31 18:21:02.627 +08:00 [INF] Log.ImportSummary +2026-08-31 22:18:40.741 +08:00 [ERR] Log.Diagnostic +``` + +### 已修复:文件日志丢失全部参数(高优先级) + +根因:`StatusDiagnosticsPresenter.Log` 传给 `logger.Log` 的 formatter 只返回 `MessageKey`,Serilog 文件 sink 的默认输出模板 `{Message:lj}` 不渲染结构化属性,因此路径、格式、数量、异常全部丢失。最严重的是意外异常:堆栈放在 `TechnicalDetail`、formatter 不输出、`exception` 参数又为 `null`,崩溃时文件日志只有一行裸键。 + +修复:**彻底移除 MessageKey 与日志本地化**(en-US / zh-CN / ja-JP 三个资源文件中 29 个 `Log.*`、`EditKind.*`、`Action.*` 键全部删除)。调用点直接写英文插值消息,formatter 原样返回该文本,文件日志与内存面板共用同一完整消息;`exception` 参数如实传入,Serilog 在消息下方渲染完整堆栈。 + +### 已完善:个别场景参数不足(中优先级) + +| 场景 | 审查时的缺失 | 修复结果 | +|---|---|---| +| 加载源 / 追加 MPLS | `path` 仅为 `DisplayName`(`LocalPathChapterSource` 默认 `Path.GetFileName`),同名文件无法区分 | 改为完整路径(`NormalizedPath` / `GetFullPath`),加载与追加消息均携带 | +| 单元格编辑 | 只记新值,before/after 是章节数,值编辑前后不变 | 新增 `OldCellValue` 助手,记录编辑前旧值(时间经 `IChapterTimeFormatter` 格式化),`previous='...'` 直接出现在消息与参数中 | +| 快照类状态日志(无可选源、无会话、shell 不可用、Lua 脚本加载成功) | 仅状态栏本地化文本,无触发上下文 | 改为显式场景消息:`Load source skipped: no source selected`、`Append MPLS skipped: no active MPLS group`、`Open related media skipped: shell unavailable`、`Lua expression script loaded: path='...'` | +| 保存章节 | 缺导出选项 | 补 `xmlLanguage`、`encoding`、`bom`(实际写入文件的编码与 BOM 开关) | +| 启动加载设置 | 仅 savingPath、language | 补 `defaultSaveFormat`、`frameDisplay`、`frameAccuracy`、`xmlLanguage` | +| 创建 zones | 缺生成结果 | 补 `zones`(实际生成的 zone 数) | +| FPS 转换 | 缺所选选项 | 补 `option`(选项显示名) | +| 帧信息更新(自动检测) | 只记 confidence | `autoDetected=true` 时在消息与参数中显式标出检测来源与置信度 | + +### 备注 + +- 程序化(非用户)的源切换会抑制源选项日志(`SelectClip(logSelection: false)`),避免会话恢复时刷屏。 +- 加载 / 追加的逐条诊断只在 Warning 及以上级别才写入日志(`LogDiagnostics` 过滤)。 +- 日志面板条目可筛选严重度、搜索(含嵌套参数与异常文本)、导出 JSON / CSV(`LogToolViewModel`)。 +- 文件日志与面板消息同源:面板检查器展示的消息即文件日志行的消息文本,排障时无需在两边对照。 diff --git a/docs/tasks/expression-preview-interaction-design.md b/docs/tasks/expression-preview-interaction-design.md new file mode 100644 index 00000000..6d0669f5 --- /dev/null +++ b/docs/tasks/expression-preview-interaction-design.md @@ -0,0 +1,447 @@ +# 表达式预览与应用交互设计 + +状态:已选择方案 A。本文不表示功能已实现。 + +实施规划:[unify-expression-preview-interaction](../../openspec/changes/unify-expression-preview-interaction/proposal.md)。该 change 覆盖 Avalonia 与 Web。 + +适用端:Avalonia 桌面端与 Blazor WebAssembly Web 端。 +目标:用户可以看清表达式将修改什么,再一次性应用。一次撤销恢复整次应用。 +推荐:方案 A「独立弹窗 + 章节前后对照」。窄屏使用同一信息结构的纵向条目。 + +## 1. 设计依据与现状 + +本文承接 [统一编辑模型与历史方案](unified-editing-and-unbounded-history.md) 的事务语义。 +Web 容器沿用 [弹窗布局方案](wasm-modal-layout-and-regression-plan.md) 的单弹窗、焦点与关闭规则。 +本文细化表达式任务内部的信息结构和确认行为。 +本文保留方案比较。后续实施以选定方案 A 和对应 OpenSpec change 为准。 + +截图中的预览存在以下问题。 + +| 问题 | 用户受到的影响 | 设计要求 | +| --- | --- | --- | +| 一个章节拆成多个项目符号 | 无法连续比较同一章节的前后值 | 按章节聚合 | +| `FrameAccuracy`、`Neutral`、对象字符串直接显示 | 需要理解内部数据结构 | 使用本地化业务名称和值 | +| 修改前显示为空字符串 | 无法区分未知、未计算和零 | 明确显示缺失状态 | +| 帧数、时间与帧率混排 | 难以看出真正的时间变化 | 时间为主,帧信息为辅,公共属性单独列出 | +| 多行脚本挤在单行输入框 | 难以检查脚本 | 保留多行、缩进与可扩展编辑区域 | +| 缺少范围和影响数量 | 无法判断将修改哪些章节 | 在预览顶部固定显示范围与摘要 | + +以下结论来自当前源码,不从截图推断具体计算结果。 + +- Avalonia 使用 `ExpressionToolViewModel.BuildPreviewSummary` 拼接差异文本。它只取前 8 条。 +- Avalonia 的预览区域使用 `TextBlock`,最大高度为 180。 +- Avalonia 的应用路径先调用 `RefreshPreviewNow`。它会重新计算候选。 +- Web 的 `ExpressionDialog.razor` 使用单行输入与差异列表。 +- Web 的 `FormatExpressionDifference` 只对时间和帧数做专用处理。其他字段回退到内部字段名与字符串。 +- Web 当前以所选轨道准备表达式候选。缺少帧率时,准备路径可能把检测帧率写入候选。 +- Web 应用路径检查候选基准。基准过期时,它重新准备预览并返回失败,不直接提交新结果。 +- Core 的 `ChapterContentPreview` 已包含修改前快照、候选快照、目标身份和版本基准。 +- Core 的应用入口可以直接提交候选。展示前后对照不需要把预览写入主文档。 + +因此,截图中出现帧数变化,不足以证明章节时间发生变化。 +设计必须区分「时间变化」「帧信息更新」和「公共属性变化」。 + +## 2. 三个可选方案 + +三种方案共用同一套计算、确认、取消和撤销规则。 +它们只改变结果的呈现位置和浏览方式。 + +| 方案 | 布局 | 优点 | 代价 | 适合场景 | +| --- | --- | --- | --- | --- | +| A,推荐 | 独立弹窗。上方编辑,下方章节前后对照 | 原值与结果同时可见。流程短。两端容易统一 | 长脚本与长表格需要分配高度 | 日常偏移、修正与批量表达式 | +| B | 独立工作区。左侧脚本,右侧完整候选章节表 | 可以查看结果表的整体形态。适合复杂脚本 | 前后值需要切换或打开行详情。宽度要求更高 | 长时间调试、多行脚本 | +| C | 两步弹窗。「编辑表达式」→「检查并应用」 | 每一步内容少。手机确认更集中 | 输入时不能持续看到完整结果。往返次数更多 | 偶尔使用、重视分步确认 | + +### 2.1 方案 A:编辑与对照同屏 + +```text +时间表达式 × +范围:当前轨道「正片」· 12 个章节 + +预设 [自定义 ▾] [导入脚本] +表达式 [展开] +┌──────────────────────────────────────────────────────┐ +│ t + 5 │ +└──────────────────────────────────────────────────────┘ +输入后自动更新预览。应用前,章节内容保持不变。 + +预览已更新 · 12 个章节时间变化 · 0 个未变化 +显示 [仅变化 ▾] [时间 / 帧数] +章节 修改前 应用后 变化量 +01 片头 00:00:00.000 00:00:05.000 +5.000 秒 +02 正片 00:01:00.000 00:01:05.000 +5.000 秒 +03 片尾 00:02:00.000 00:02:05.000 +5.000 秒 +…其余章节可滚动查看 + +应用后可一次撤销。 [取消] [应用更改] +``` + +上图使用 `t + 5` 的示例数据。它不代表截图中脚本的实际输出。 +应用后的时间必须来自候选快照。 +界面不得根据脚本文本猜测预览值。 + +### 2.2 方案 B:完整候选表为主 + +```text +时间表达式 × +范围:当前轨道「正片」· 12 个章节 +┌──────────────────┬───────────────────────────────────┐ +│ 预设与脚本 │ 结果视图 [应用后] [修改前] │ +│ │ 章节 时间 名称 │ +│ t + 5 │ 01 已修改 00:00:05.000 片头 │ +│ │ 02 已修改 00:01:05.000 正片 │ +│ │ 选中章节:原值 → 结果;变化量 │ +└──────────────────┴───────────────────────────────────┘ +12 个章节时间变化。 [取消] [应用更改] +``` + +候选表只读。标题始终显示「应用后预览,尚未应用」。 +切换前后视图时,保留滚动位置和选中章节。 +主窗口中的章节表仍显示已提交文档。 +窄屏必须改为上下布局,不能压缩两栏。 + +本方案适合完整浏览结果,但同屏对照能力弱于 A。 +若选择本方案,需要为每个变化行提供可展开的前后详情。 + +### 2.3 方案 C:分步检查 + +```text +步骤 1:编辑表达式 +预设 + 多行编辑器 + 实时摘要 / 错误 +3 个章节时间变化。 [取消] [检查结果 →] + +步骤 2:检查并应用 +范围 + 与方案 A 相同的前后对照 + 公共属性变化 + [返回编辑] [取消] [应用更改] +``` + +步骤 1 的摘要随输入更新。 +步骤 2 展示完整候选,不重新执行已完成的同一轮计算。 +返回编辑后,只有实际修改参数才使候选失效。 +步骤 2 仍需处理文档版本过期。 + +## 3. 推荐方案 A 的统一信息结构 + +### 3.1 标题、范围与脚本 + +- 标题统一为「时间表达式」。入口统一为「表达式…」。 +- 范围显示真实轨道名称和参与计算的章节数。 +- 第一阶段沿用当前轨道范围。不新增选中章节或全部轨道选择器。 +- 若存在分隔行,摘要另写「另有 2 个分隔行,不参与计算」。 +- 预设选择器显示名称。选中后在下方显示简短说明。 +- 修改预设脚本后,名称改为「自定义」。 +- 编辑器保留换行和缩进。多行预设不得压成单行。 +- 默认显示约 4–6 行。用户可以展开编辑区域。 +- 简短表达式可以使用较少高度。不得挤占全部结果空间。 +- 两端均提供多行文本编辑。补全和语法着色可以保留宿主差异。 +- 导入脚本按宿主能力显示。未提供文件能力时不显示空按钮。 + +统一的是任务顺序、术语和确认语义。 +两端不需要复制相同的原生控件外观。 + +### 3.2 摘要先回答「改了什么」 + +推荐摘要: + +> 12 个章节中,3 个时间变化,9 个时间未变化。 + +存在额外影响时,增加一行: + +> 另有 12 个章节的帧信息更新;帧率:未设置 → 24 fps。 + +「时间变化」和「帧信息更新」可以包含同一章节。 +界面不得将两项数量相加后称为受影响章节总数。 +受影响章节总数必须按章节身份去重。 +轨道或文档属性变化必须单独计数。 + +如果只有帧信息变化,摘要使用: + +> 章节时间未变化。3 个章节的帧信息将更新。 + +如果只有公共属性变化,摘要使用: + +> 章节时间未变化。帧率将从未设置变为 24 fps。 + +如果整个候选与基准相同,显示「没有可应用的更改」,禁用应用。 +不能只根据时间变化数量判断是否允许应用。 +也不能将帧信息变化伪装成时间变化。 + +### 3.3 章节对照表 + +默认列为「章节」「修改前」「应用后」「变化量」。 +默认单位为时间。用户可以切换到帧数。 +两种视图使用相同的章节顺序和过滤条件。 + +| 元素 | 规则 | +| --- | --- | +| 章节身份 | 显示编号与名称。内部使用稳定章节身份匹配 | +| 行顺序 | 保持当前轨道顺序。不按差值自动排序 | +| 修改前 | 来自本轮计算基准 | +| 应用后 | 来自本轮候选 | +| 时间变化量 | 显示正负号和单位。例如 `+5.000 秒`、`−0.125 秒`、`未变化` | +| 默认过滤 | 仅变化。包含时间变化与仅帧信息变化的章节 | +| 可选过滤 | 全部章节。用于核对相邻章节与上下文 | +| 默认标识 | 时间变化显示「时间已修改」。仅帧信息变化显示「帧信息更新」 | +| 行详情 | 展开显示帧数前后值、帧对齐状态,以及其他实际变化字段 | +| 长名称 | 允许换行或展开查看完整名称。不能只靠鼠标悬停 | +| 大量章节 | 使用虚拟化或分页。显示总数。所有变化都必须可访问 | + +表格不是另一份编辑表。 +预览行不能改值、删除或单独勾选应用。 +首版不提供逐行排除。这样可以保持表达式整体计算与原子提交的语义。 +过滤仅影响显示,不影响本次提交范围。 + +### 3.4 时间、帧与公共属性格式 + +时间默认显示 `HH:mm:ss.fff`,与主表格的时间表达保持一致。 +如果真实差异小于显示精度,增加必要的小数位。 +不得让有变化的行显示两个相同时间和零差值。 + +帧数必须标出使用的帧率。 +前后帧率不同时,分别显示两侧帧率。 +这时不直接显示一个可能误导的帧差值。 +帧率未知时,显示「无法计算:帧率未设置」。 +原帧信息为空时,显示「未计算」,不得显示空白或零。 +只有在业务语义已确认后,才能将 `Neutral` 映射为「未评估」等用户文案。 +`Accurate` 与 `Inexact` 的文案必须根据现有容差定义校核。 +可以使用「对齐到帧」「未对齐到帧」等说明,但不能把容差判断误写为绝对数学结论。 + +公共属性放在摘要下方的独立区域。 +例如: + +| 属性 | 修改前 | 应用后 | +| --- | --- | --- | +| 帧率 | 未设置 | 24 fps | + +分数帧率可以显示 `23.976 fps(24000/1001)`。 +不能显示 `ChapterFrameRate { Numerator = … }`。 +候选若修改了时间以外的持久字段,界面必须明确呈现。 +不改变 Core 对帧信息的当前存储约定,也不在本设计中顺带重构编辑模型。 + +### 3.5 窄屏呈现 + +窄屏仍使用方案 A,不增加另一套确认流程。 +每个章节转换为一个纵向条目。 + +```text +时间表达式 × +当前轨道「正片」· 12 个章节 +预设 [自定义 ▾] +表达式 +┌────────────────────────────────────┐ +│ t + 5 │ +└────────────────────────────────────┘ +预览已更新 · 3 个章节时间变化 +显示 [仅变化 ▾] 单位 [时间 ▾] + +02 正片 时间已修改 +修改前 00:01:00.000 +应用后 00:01:05.000 +变化量 +5.000 秒 +[帧信息详情 ▾] + +03 片尾 时间已修改 +修改前 00:02:00.000 +应用后 00:02:05.000 +变化量 +5.000 秒 + +可一次撤销。 [取消] [应用更改] +``` + +以内容是否容纳作为切换依据。 +建议在约 720 CSS px / DIP 以下使用纵向条目,最终以本地化长文本实测调整。 +桌面端允许缩放工具窗口。Web 手机端使用接近全屏的弹窗。 +标题与底部按钮必须保持可访问。 +Web 优先让内容区统一滚动,避免短视口出现多层结果滚动。 +宽桌面可以为编辑器和结果分别分配滚动区域。 +移动键盘出现后,当前输入与关闭入口必须可见。 +应用按钮不得被键盘或安全区域永久遮挡。 +触控操作目标以至少 44×44 CSS px 为设计目标。 + +## 4. 统一的状态与按钮行为 + +页脚固定显示次要按钮「取消」和主要按钮「应用更改」。 +按钮不得随计算状态出现或消失。 +禁止自动将焦点移动到应用按钮。 + +| 状态 | 结果区域 | 应用按钮 | 用户下一步 | +| --- | --- | --- | --- | +| 没有文档或没有可计算章节 | 显示原因 | 禁用 | 关闭并加载章节 | +| 表达式为空 | 「请输入表达式或选择预设」 | 禁用 | 输入或选择预设 | +| 输入后等待更新 | 「等待更新预览…」 | 禁用 | 继续输入或等待 | +| 正在计算 | 「正在计算预览…」 | 禁用 | 等待或取消 | +| 成功且有变化 | 摘要与完整对照 | 启用 | 检查后应用 | +| 成功且无变化 | 「没有可应用的更改」 | 禁用 | 修改表达式或取消 | +| 语法或运行错误 | 就近错误与失败摘要 | 禁用 | 修正表达式 | +| 文档基准过期 | 「章节已变化,请更新预览」 | 禁用 | 点击「更新预览」后重新检查 | +| 正在应用 | 保留已确认的结果,显示「正在应用…」 | 禁用 | 等待完成 | +| 应用失败 | 明确原因,保留草稿 | 依原因决定 | 修正或重试 | +| 应用成功 | 关闭工具,回到章节表 | 不适用 | 继续编辑或撤销 | + +输入立即使旧候选失去可提交资格。 +推荐两端统一使用约 250 ms 防抖。 +输入法组合期间不启动计算。 +防抖时间属于体验参数,可以根据实际耗时调整。 +旧预览可以暂时保留以避免布局跳动,但必须标记「旧预览,等待更新」。 +旧预览不能保持可应用状态。 +快速输入时,只接受最新一轮结果。 +关闭工具后,迟到的结果不得恢复工具或更新当前文档。 + +有一行失败时,整次表达式不能应用。 +显示示例:「无法应用:第 8 章产生负时间。章节内容未修改。」 +可定位的错误应链接到对应章节或脚本行。 +如果当前诊断没有位置,显示明确原因,不猜测行号。 +不得将部分成功行呈现为完整有效候选。 +警告与阻断错误使用不同标识。 +颜色必须配合文字或图标。 + +空表达式禁用是本方案建议的产品规则。 +它与 Web 当前将空白表达式解释为 `t` 的行为不同。 +显式输入 `t` 时,仍以完整候选判断是否存在帧信息或公共属性变化。 + +## 5. 确认、原子提交与撤销 + +### 5.1 应用用户已检查的那一份结果 + +应用资格同时要求以下条件。 + +1. 预览计算成功。 +2. 存在实际可提交变化。 +3. 草稿与本轮预览参数相同。 +4. 目标章节与范围没有变化。 +5. 当前文档基准与预览基准相同。 +6. 当前没有提交进行中。 + +点击应用后,直接提交已展示的候选。 +应用处理不得先重算,再无提示地提交另一份候选。 +如必须重算,则显示新预览,等待用户再次点击应用。 +重新计算不能沿用先前的确认。 +预览过期时,不自动关闭弹窗。 +两端统一提供「更新预览」操作,并保留脚本。 + +双击、触控重复事件与请求重试只能产生一次事务。 +按钮禁用负责防误触,提交身份负责防重复提交。 +提交成功之前不得清空错误恢复所需的草稿与预览。 + +### 5.2 应用之后 + +应用成功后,两端都关闭表达式工具。 +主表格显示已提交结果,并保持原来的滚动位置与选中章节。 +状态栏显示「已应用表达式:3 个章节时间变化。可撤销」。 +如果只有帧信息或公共属性变化,状态栏使用对应摘要。 +历史显示一条表达式应用记录。 +点击主界面的撤销,必须一次恢复本次全部变化。 +重做恢复同一结果,不重新执行脚本。 + +应用成功后可以保存表达式参数,便于下次使用。 +保存参数不表示表达式持续生效。 +再次打开工具时,从当前已提交文档准备新预览。 +再次应用属于另一次显式编辑。 +例如,连续两次应用 `t + 5` 会累计移动 10 秒,并产生两条历史记录。 + +### 5.3 取消与关闭 + +取消、关闭按钮和 Escape 使用同一条丢弃路径。 +它们丢弃本次候选,并恢复进入工具前的参数基准。 +它们不修改文档,也不增加历史记录。 +点击遮罩不关闭表达式工具,避免误触丢失脚本。 +首次方案不增加二次取消确认弹窗。 + +提交前可以取消计算并关闭。 +短暂的提交阶段禁用关闭和取消,等待确定结果。 +提交结果确定后恢复相应操作。 +不要用「取消」承诺回滚一个可能已经完成的提交。 + +脚本编辑器中的 Ctrl/Cmd+Z 只撤销脚本文本输入。 +表达式工具打开期间,不从模态背景触发文档撤销。 +关闭工具后,主界面撤销操作恢复文档事务。 +Enter 在多行编辑器中换行,不触发应用。 +首版不新增全局应用快捷键。 +Escape 应先关闭补全列表等内部临时界面,再由外层工具处理关闭。 + +## 6. 两端一致性与实现边界 + +| 项目 | 两端必须一致 | 宿主可以不同 | +| --- | --- | --- | +| 信息 | 范围、摘要、前后值、单位、缺失值、错误 | 字体与原生控件外观 | +| 流程 | 编辑 → 自动预览 → 显式应用 → 返回主表格 | 桌面工具窗口与 Web 弹窗容器 | +| 确认 | 提交已展示的候选,过期后重新检查 | 调度与渲染机制 | +| 关闭 | 取消、关闭与 Escape 语义相同 | 系统窗口关闭事件与浏览器焦点管理 | +| 可访问性 | 有标签、键盘可达、变化不只靠颜色 | Avalonia 自动化属性与 Web ARIA | +| 本地化 | 共用术语、数量模板和单位 | 本地化资源加载方式 | + +Web 状态通知使用温和播报。 +不要在每次按键后播报整个表格。 +计算完成只播报摘要,错误出现时播报原因。 +关闭后焦点返回表达式入口。 +切换单位或过滤条件不得抢走编辑焦点。 + +实现时从 `Before` 与 `Candidate` 生成结构化展示数据。 +不要解析 `Differences.Before/After` 中的对象字符串来恢复时间或帧率。 +展示层必须保留所有实际变化的可见入口。 +新增未知变化字段时,不得静默遗漏。 +字段格式化与数量统计应在两端共用的宿主无关层实现。 +具体归属在实施时根据依赖方向决定,不让 Core 引用 UI 控件。 + +主要改动入口如下。 + +| 入口 | 预期调整 | +| --- | --- | +| `src/ChapterTool.Avalonia.UI/Views/Tools/ExpressionToolView.axaml` | 用结构化对照替换摘要文本,调整响应式布局和固定页脚 | +| `src/ChapterTool.Avalonia.UI/ViewModels/Tools/ExpressionToolViewModel.cs` | 引入明确状态,移除点击应用时的隐式重算,提供完整预览集合 | +| `src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/` | 核对工具关闭、候选生命周期与会话通知职责 | +| `src/ChapterTool.Wasm/Components/ExpressionDialog.razor` | 多行编辑、对照表与窄屏条目、统一按钮状态 | +| `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` | 替换字符串差异格式化,明确过期提示与重算路径 | +| `src/ChapterTool.Core/Session/ChapterContentOperationSession.cs` | 复用候选快照与提交语义;仅在展示所需契约不足时扩充结构化信息 | +| `src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/*.axaml` | 增加共用文案,再生成 Web JSON | + +本方案不新增时间轴、不扩展表达式语言、不增加逐行应用。 +本方案不要求迁移主表格的编辑行为。 +若未来需要支持选中章节范围,应单独确定上下文语义和预览统计。 + +## 7. 实施后的验收场景 + +下表是后续实施验收要求,不是本次文档已执行的测试。 + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 3 个章节应用 `t + 5` | 原值、结果和 `+5.000 秒` 同时可见 | +| 截图所示的仅帧信息变化 | 明确说明时间未变。无内部字段名和对象字符串 | +| 未知帧率或原帧值为空 | 分别显示未设置、未计算。不能冒充零 | +| 小于 1 ms 的真实变化 | 能识别前后不同。差值不能被显示为零 | +| 无变化 | 两端都禁用应用。历史不增加 | +| 仅公共属性变化 | 属性前后值可见。允许将有效变化一次提交 | +| 第 8 章计算失败 | 应用禁用。前 7 章也不提交 | +| 快速修改和输入法组合 | 无旧结果覆盖新结果,无未完成输入提交 | +| 预览之后文档版本改变 | 应用禁用或提交被拒绝。更新后再次确认 | +| 预览之后草稿改变 | 旧候选立即不可应用 | +| 双击应用 | 文档只改变一次,历史只增加一次 | +| 取消、Escape、关闭 | 文档与历史保持不变,参数恢复打开前基准 | +| 应用后撤销与重做 | 一次恢复全部变化。重做不重跑表达式 | +| 1000 个章节 | 所有差异可访问,计数完整,不限于前 8 条 | +| 长脚本、长名称和长错误 | 内容可读,按钮不被推出可访问区域 | +| 仅键盘、屏幕阅读器 | 能找到范围、结果、错误和应用;关闭后焦点恢复 | + +布局应覆盖默认、宽、窄窗口,以及 Web 390×844、320px 宽与短横屏。 +应覆盖文字放大、中文和英文文案、深浅主题及移动键盘。 +手工检查截图保存到 `artifacts/`。截图不能代替行为断言。 +Avalonia 交互检查归入独立 Headless 测试进程。 +Web 检查使用现有 E2E 项目。 +Core 检查候选与提交身份、过期拒绝和一次撤销。 +测试项目按仓库规则顺序运行。 +本地化改动后必须运行资源生成与 `--check`。 + +## 8. 待选择项与建议默认值 + +| 决策 | 推荐默认值 | 可选方向 | +| --- | --- | --- | +| 主交互 | A:编辑与章节前后对照同屏 | B:完整候选表;C:分步检查 | +| 默认单位 | 时间,帧数可切换 | 面向逐帧工作时默认帧数 | +| 默认展示范围 | 仅变化,可切换全部 | 默认全部章节 | +| 应用之后 | 两端都关闭工具并回到章节表 | 留在工具,但必须明确开启下一轮应用 | +| 范围能力 | 当前轨道,明确显示 | 后续新增选中章节或全部轨道 | +| 取消草稿 | 恢复进入工具前的参数,不二次确认 | 若用户需要,再单独设计脚本草稿保留 | + +用户已选择 A。 +它直接解决截图中的前后结果难以比较问题,也保留输入时的即时反馈。 +后续按照对应 OpenSpec change 进行两端 UI 实现与行为验证。 diff --git a/docs/tasks/ui-visual-consistency.md b/docs/tasks/ui-visual-consistency.md new file mode 100644 index 00000000..f18d0fbd --- /dev/null +++ b/docs/tasks/ui-visual-consistency.md @@ -0,0 +1,141 @@ +# UI 视觉一致性排查与修复指导 + +## 目的 + +本方案用于排查和修复 Avalonia 界面的视觉不一致问题。适用范围包括工具栏、列表行、详情面板、状态栏、弹窗、Flyout、菜单、标签页和空状态区域。 + +排查目标不是让所有控件完全相同,而是让具有相同语义的控件遵循相同的视觉规则,让不同语义的控件使用独立且可解释的主题资源。 + +## 适用范围 + +在以下情况使用本方案: + +- 新增页面或控件后,界面与现有页面的间距、尺寸或颜色不一致。 +- 同一工作流在摘要、详情、弹窗或窄窗口下出现不同布局。 +- 主题切换后出现固定颜色、错误背景或文字对比度问题。 +- XAML 模板、样式或资源调整后,按钮、边框、圆角或 Padding 发生回退。 +- 行为测试通过,但截图或实际渲染仍存在视觉回归。 + +## 核心原则 + +1. 先定义视觉基准,再判断偏差。基准必须包含布局结构、列定义、行高、Padding、Margin、颜色、边框、圆角、字体和控件状态。 +2. 相同语义复用同一结构或样式。不要在摘要、详情和弹窗中复制近似的列定义和尺寸常量。 +3. 颜色必须来自语义资源。不要用采样得到的近似色值替换主题令牌。 +4. 先检查公共模板,再修改控件实例。优先确认 `ControlTemplate`、`TemplateBinding`、选择器和资源覆盖关系。 +5. 每次聚焦一个视觉问题,并在修改后立即验证。避免把布局、颜色和行为改动混在一次排查中。 +6. 以渲染结果作为证据。源码检查用于定位来源,Headless 和截图用于确认最终效果。 + +## 排查流程 + +### 1. 建立范围和基准 + +记录受影响的页面、控件和状态。为每类控件建立基准表,至少记录: + +| 类别 | 需要记录的属性 | +|---|---| +| 布局 | Grid 列、对齐方式、MinWidth、行高、伸缩规则 | +| 间距 | Padding、Margin、控件之间的间隔 | +| 表面 | Background、Foreground、BorderBrush、BorderThickness、CornerRadius | +| 文字 | 字体、字号、字重、换行和截断规则 | +| 状态 | 默认、悬停、按下、禁用、选中、错误和空状态 | +| 操作 | 可见按钮数量、按钮尺寸、图标、可访问名称和键盘顺序 | + +基准应来自当前主题资源和已确认的稳定页面,而不是来自单张截图中的像素猜测。 + +### 2. 追踪视觉属性来源 + +按以下顺序检查: + +1. 页面和控件实例上的局部属性。 +2. `Styles.axaml` 中的选择器和 `ControlTemplate`。 +3. `Themes.axaml` 及其他主题资源文件中的 `Color.*` 和 `Brush.*`。 +4. 运行时主题服务对资源的覆盖或转换。 +5. 平台默认模板和第三方控件模板。 + +使用 `rg` 搜索资源键、模板和属性绑定。对每个异常属性记录“定义位置、覆盖位置、最终运行时值”三项证据。特别检查同一令牌是否被错误地用于不同语义,例如把工具栏背景用于状态栏或弹窗。 + +### 3. 建立同类控件清单 + +不要只修复发现问题的第一个实例。按语义列出全部同类控件,例如: + +- 所有 Flyout 及其 `FlyoutPresenter`。 +- 所有详情标题、摘要行和列表头。 +- 所有关闭、返回、复制和更多操作按钮。 +- 所有空状态、错误状态和进度状态区域。 +- 所有主题和语言下的对应资源。 + +逐项比较结构、资源键和运行时属性。若多个实例需要相同修复,应抽取共享样式或模板;若语义不同,应定义独立的资源键。 + +### 4. 实施最小修复 + +- 先统一结构,再处理颜色和细节。 +- 让详情或扩展视图复用基准布局,仅追加必要操作列。 +- 为状态栏、内容区、工具栏和弹窗使用明确的语义资源。 +- 删除重复边框层,确认外层和内容层的边界职责只有一个。 +- 为固定格式区域设置稳定尺寸或 `MinWidth`,防止按钮、旋钮和文字相互覆盖。 +- 确认文本替换命中了唯一节点,再修改 XAML。 + +### 5. 分层验证 + +每个修复至少完成以下验证: + +1. 编译相关项目,确认 XAML、资源和绑定无错误。 +2. 运行对应 Avalonia 单元测试和 Headless 测试,验证打开、关闭、筛选、命令和状态转换。 +3. 在默认、宽窗口和窄窗口下生成截图,检查布局是否稳定。 +4. 在支持的主题和语言下重复检查。 +5. 对关键属性增加行为断言,包括 `Bounds`、高度、`Padding`、`CornerRadius`、`BorderThickness`、背景、前景和可见按钮数量。 + +截图用于发现和复核视觉差异。不要把截图文件存在作为唯一自动化断言;应同时提供运行时属性或像素检查的证据。 + +### 6. 资源和本地化检查 + +修改共享 Avalonia 本地化资源后,运行: + +```bash +uv sync --project scripts +uv run --project scripts scripts/localization/axaml-to-json.py +uv run --project scripts scripts/localization/axaml-to-json.py --check +``` + +不要手工编辑生成的 Wasm JSON 文件。检查所有支持的语言是否具有相同的资源键,并确认长文本不会破坏按钮、标题和窄窗口布局。 + +## 问题记录模板 + +每个问题都应记录以下内容: + +| 项目 | 内容 | +|---|---| +| 位置 | 页面、控件、文件和行号 | +| 现象 | 可观察的视觉或交互差异 | +| 期望 | 对应的视觉基准或设计规则 | +| 来源 | 局部属性、样式、模板或运行时资源 | +| 影响 | 主题、窗口尺寸、语言和用户操作 | +| 修复 | 最小改动及其共享范围 | +| 验证 | 测试命令、运行时属性、截图或像素证据 | + +不要把“看起来不对”作为唯一证据。应给出可定位的属性、资源键、Bounds 或渲染差异。 + +## 验收标准 + +- 相同语义的区域具有一致的结构、间距、尺寸、颜色、圆角和状态表现。 +- 不同语义的区域不复用会造成误解的主题令牌。 +- 默认、宽窗口和窄窗口均无文字、按钮、表格列或旋钮重叠。 +- 弹窗和 Flyout 具有一致且可解释的 Presenter、背景、边框和内部 Padding。 +- 详情、关闭、返回和菜单操作符合预期,且只显示必要的操作按钮。 +- 主题切换和所有支持的语言不会引入固定颜色、资源缺失或布局溢出。 +- Avalonia 单元测试、Headless 测试和本地化资源漂移检查均通过。 +- 发生视觉回归时,可以根据问题记录复现来源并定位到具体修复。 + +## 推荐验证命令 + +```bash +dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore +dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore +dotnet test ChapterTool.slnx --no-restore +``` + +涉及应用项目文件时,再运行: + +```bash +dotnet build src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj --no-restore +``` diff --git a/docs/tasks/unified-editing-and-unbounded-history.md b/docs/tasks/unified-editing-and-unbounded-history.md new file mode 100644 index 00000000..eecae63a --- /dev/null +++ b/docs/tasks/unified-editing-and-unbounded-history.md @@ -0,0 +1,846 @@ +# ChapterTool 统一编辑模型与不限步数历史方案 + +状态:实施方案。实现范围与验收要求由本文完整定义。 + +设计决策:采用唯一当前文档、不可变状态、内存历史树和统一事务入口。 +本文中的“反撤销”统一称为“重做”。 +撤销和重做恢复已提交状态,不重新执行业务操作。 + +## 1. 目标与核心决策 + +本方案替代此前“原始章节编辑 + 持续输出投影”的方案。 + +ChapterTool 只保留一份当前可编辑文档。 +所有编辑操作都修改这份文档。 +表格、格式预览和导出都读取这份文档。 +模板和表达式是一次性编辑操作。 +它们不是保存时再次执行的输出规则。 + +历史不设置操作条数上限。 +文档会话内的全部已提交历史保留在内存中。 +撤销后产生新编辑时,旧分支仍保留。 +重做可以恢复当前分支,也可以选择其他已保留分支。 +关闭或替换文档会话时释放其历史。 +应用退出、进程崩溃或浏览器页面刷新后,历史不保留。 +本功能不增加历史持久化、自动恢复或历史包导入导出能力。 + +本方案中的 `must` 表示要求。 +`may` 表示许可。 +`can` 表示能力。 + +“不限步数”表示产品不主动截断已提交历史。 +它不表示设备具有无限内存。 +历史占用随编辑内容和分支数量增长。 +实现 must 不按条数、时间或内存阈值自动删除历史。 +可检测且可恢复的内存分配失败 must 拒绝本次操作,并保留原会话状态。 +操作系统终止进程或运行时无法恢复的内存耗尽不在可恢复保证内。 +界面 must 明确说明历史仅在当前文档会话中有效。 + +## 2. 当前实现与需要替换的行为 + +当前主要入口如下。 + +| 路径 | 当前职责 | 设计中的变化 | +| --- | --- | --- | +| `src/ChapterTool.Core/Session/ChapterWorkspace.cs` | 当前章节、片段会话、投影和导出偏好 | 持有唯一文档、事务入口和历史游标 | +| `src/ChapterTool.Core/Session/ProjectionState.cs` | 持续命名与表达式规则 | 从交互文档状态中移除;工具参数转为草稿 | +| `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` | 保存前修改名称、编号和时间 | 转换为显式编辑操作的计算能力 | +| `src/ChapterTool.Core/Exporting/ChapterExportOptions.cs` | 混合格式选项与内容变换选项 | 分离纯序列化选项与编辑操作参数 | +| `src/ChapterTool.Core/Exporting/ChapterExportService.cs` | 生成各格式内容 | 只序列化传入文档快照 | +| `src/ChapterTool.Core/Session/ClipSession.cs` | 保留原片段组与合并结果 | 以当前结构转换替代两套可编辑内容 | +| `src/ChapterTool.Core/Editing/ChapterSegmentService.cs` | 合并时重新生成章节名称 | 默认保留当前名称和章节身份 | +| `src/ChapterTool.Avalonia.UI/Workflows/ClipEditingCoordinator.cs` | 编辑、片段与帧更新 | 将所有修改提交到 Core 事务入口 | +| `src/ChapterTool.Avalonia.UI/Workflows/ProjectionFacade.cs` | 生成投影后的表格行 | 表格直接呈现当前文档;候选预览单独呈现 | +| `src/ChapterTool.Avalonia.UI/Views/MainView.axaml.cs` | 按表格位置提交单元格修改 | 使用稳定章节身份和编辑会话 | +| `src/ChapterTool.Wasm/Services/WasmWorkspace.cs` | 浏览器编辑与导出编排 | 使用相同的文档与历史内核 | + +当前表达式服务可能产生逐行成功和失败混合的结果。 +新事务适配器 must 将整次操作判定为成功或失败。 +它 must 不提交部分变换结果。 + +当前 JSON 导出分支直接读取传入章节,而其他分支可能读取投影结果。 +迁移后的所有格式 must 读取同一个已捕获快照。 + +## 3. 唯一文档状态 + +### 3.1 概念结构 + +以下结构用于描述所有权。 +它不要求立即替换全部公共 Core 数据类型。 +`Workspace` 的当前文档、状态身份、修改版本和游标由同一个 `SessionState` 提供。 +这些属性是只读访问入口,must 不保存可分别修改的副本。 + +```text +Workspace + DocumentId + CurrentDocument + CurrentStateId + MutationRevision + HistoryCursor + ViewState + ActiveDraft + ExportPreferences + +ChapterDocument + DocumentMetadata + OrderedTracks + +ChapterTrack + TrackId + Metadata + DurationTicks? + EffectiveFrameRate? + NumberingPolicy + OrderedChapters + SegmentBoundaries + +EditableChapter + ChapterId + Name + StartTicks + EndTicks? + Kind + Provenance? +``` + +`CurrentDocument` 是唯一权威的当前章节状态。 +已发布的文档及其集合 must 不可变。 +编辑通过替换整个状态引用生效。 +宿主 must 不获得可修改内部集合的引用。 +候选、导出快照和历史记录 can 共享不可变数据。 +这些快照不是另一份可独立提交的工作文档。 +`ChapterSet` can 作为现有纯业务服务的输入和输出契约。 +适配器 must 从当前文档生成 `ChapterSet`。 +适配器 must 将服务结果转换为一个候选状态。 +适配器 must 不长期保留第二份可编辑 `ChapterSet`。 + +导入原稿属于历史根节点。 +原稿不是与当前文档并行运行的编辑缓冲区。 +撤销到根节点可以恢复原稿。 +当前文档 can 保存源路径、媒体引用和导入格式。 +这些来源信息不具有另一份可编辑章节集合。 + +### 3.2 时间、帧率与编号 + +时间以有符号 64 位整数 ticks 保存。 +一个 tick 等于 100 ns,与 `TimeSpan` 一致。 +显示文本不是时间的权威值。 +已知帧率以约分后的正有理数保存。 +未知帧率使用空值,must 不用零参与计算。 +帧与 ticks 转换 must 使用统一舍入规则:取最近整数,中点远离零。 +计算 must 检测中间值和最终值的溢出。 +浮点兼容输入 must 在适配边界转换一次。 +实现 must 不因浮点显示误差产生新编辑。 + +未知总时长使用空值,must 不与真实零时长混用。 +只有已知总时长才执行章节时间上界校验。 +依赖总时长的合并和按边界拆分 must 要求明确时长。 +适配旧 `ChapterSet.Duration` 时,must 按导入格式区分未知时长与零时长。 +实现 must 不把最后一个章节起点自动当作媒体终点。 + +章节顺序由 `OrderedChapters` 决定。 +编号起点属于 `NumberingPolicy`。 +显示编号由章节顺序和编号起点推导。 +修改编号起点是一条文档事务。 +派生编号不另存为可独立修改的状态。 + +有效帧率属于轨道内容。 +帧显示精度和准确度容差属于视图或应用偏好。 +帧文本和准确度标记在刷新时计算。 +显示刷新 must 不修改有效帧率或章节时间。 + +### 3.3 稳定身份 + +| 标识 | 规则 | +| --- | --- | +| `DocumentId` | 标识当前文档会话及其内存历史 | +| `TrackId` | 标识当前可编辑章节轨道 | +| `ChapterId` | 修改名称、时间或位置时保持不变 | +| `StateId` | 标识一个已提交的历史状态 | +| `TransactionId` | 标识一次操作;用于重试去重 | +| `MutationRevision` | 每次成功编辑、撤销、重做或分支切换后递增 | + +新增章节在候选计算时分配身份。 +重做 must 复用首次提交的身份。 +删除后撤销 must 恢复原身份。 +合并和拆分 must 保持已有章节身份。 +章节身份在整份文档内唯一,不以轨道为作用域。 +复制和重复导入 must 分配新身份。 +合并生成新轨道身份,拆分为每条结果轨道生成新身份。 +撤销恢复被替换轨道的原身份,重做复用首次结果身份。 +行号和数组索引 must 不作为稳定身份。 +编辑身份 must 不泄漏到不需要它的现有章节导出格式。 + +`StateId` can 随撤销返回旧值。 +`MutationRevision` must 不随撤销返回旧值。 +关闭或替换会话时,宿主 must 取消该会话的未完成任务。 +运行实例令牌与文档身份 must 阻止旧会话任务提交到新会话。 + +### 3.4 文档不变量与适配边界 + +每个章节在当前状态中 must 只属于一条轨道。 +章节移动 must 不隐式按时间排序。 +默认允许相同起点和时间不递增的行序。 +要求时间有序的工具或格式 must 报告诊断,不能静默排序。 +显式排序属于一次内容事务。 + +`Kind = Separator` 表示可编辑的结构行。 +结构行具有身份和名称,但不参与章节编号、时间计算和默认模板目标计数。 +结构行 must 不同时充当片段边界的权威值。 +按时间拆分时,结构行跟随原行序中的下一个标记;末尾结构行跟随前一个标记。 +不存在标记时,结构行归入第一个结果片段。 +工具目标按轨道顺序和行序枚举,must 不依赖选择集合的迭代顺序。 + +适配旧服务时,must 显式映射输入身份与输出身份。 +位置映射只适用于已证明不增删、不重排的操作。 +结构操作 must 返回身份映射或直接生成领域变更集。 +导入、候选提交和历史恢复 must 使用相同的文档不变量。 +历史恢复 must 不自动修正旧值。 + +## 4. 一致的编辑语义 + +### 4.1 所见值就是可编辑值 + +主表格 must 显示当前文档值。 +单元格提交 must 修改用户看到的那个值。 +主表格 must 不显示持续表达式变换后的替代值。 +主表格 must 不保留“源值”和“输出值”两种编辑模式。 + +例如,当前章节时间为 `00:10:00`。 +应用 `t + 5` 后,文档时间变为 `00:10:05`。 +随后将单元格改为 `00:10:06`,文档时间就是 `00:10:06`。 +导出时 must 不再次执行 `t + 5`。 +撤销单元格编辑恢复 `00:10:05`。 +继续撤销表达式应用恢复 `00:10:00`。 + +### 4.2 模板、表达式和批量工具 + +模板命名、自动命名、正则替换、整体偏移和表达式都使用同一流程。 + +```text +Capture current state and explicit target IDs + | + v +Compute candidate document + | + v +Validate complete result + | + v +Show optional before/after preview + | + v +Apply one atomic in-memory transaction +``` + +候选预览是临时只读数据。 +它不是第二份工作文档。 +取消预览 must 不修改文档或历史。 +应用成功后,预览草稿结束。 +工具参数 can 留在控件中,便于下一次使用。 +参数保留 must 不表示规则仍在生效。 + +模板名称数不足时,默认只修改对应的前 N 个目标章节。 +模板名称数超过目标数时,工具 must 报告未使用名称数。 +空模板项默认跳过对应章节。 +预览 must 明确标示跳过项。 +无匹配结果不生成事务。 + +表达式上下文 must 来自操作开始时的完整快照。 +后续章节的计算 must 不读取前面章节已修改的候选值。 +候选计算中的单行错误、超时和取消 must 使整次应用失败。 +负时间和数值溢出 must 拒绝提交。 +截断或丢弃章节只有在用户显式选择对应操作策略时 may 执行。 + +再次点击“应用表达式”会基于当前文档再次计算。 +这会形成新的事务。 +界面 must 显示预计修改数量和关键差异。 +界面 must 防止同一次按钮事件或请求重试被提交两次。 +这通过 `TransactionId` 去重实现。 + +### 4.3 操作范围 + +工具 must 显式显示操作范围。 +范围可以是当前轨道、选中章节或全部轨道。 +默认范围是当前轨道。 +工具 must 不把“无选择”隐式解释为“全部文档”。 +目标身份在计算开始时固定。 +应用时 must 校验文档身份和修改版本。 +预览过期时,工具 must 要求重新计算。 + +时间字段编辑只修改指定章节字段。 +若结果使 `EndTime < StartTime`,提交 must 失败。 +整体平移 must 同时平移存在的开始时间和结束时间。 +平移默认保留轨道总时长和片段边界。 +越界结果 must 失败。 +时基缩放 must 同时缩放开始时间、结束时间、总时长和片段边界。 +缩放因子 must 为正数。 +相同原始时间 must 映射到相同结果 ticks。 +若舍入使片段长度变为零,提交 must 失败。 +源媒体的原始入点和出点属于来源信息,must 不被时基缩放改写。 +FPS 工具 must 区分“设置解释帧率”和“保持帧号转换时间”。 +两种操作 must 使用不同名称和差异预览。 + +### 4.4 单元格编辑 + +开始编辑时,宿主 must 捕获章节身份、字段、原值和修改版本。 +输入期间只更新草稿。 +Enter、Tab 和有效的失焦提交形成一次事务。 +Esc 丢弃草稿。 +输入法组合期间 must 不提交。 +无效输入 must 保留错误提示。 +无效输入 must 不被静默替换为零。 +旧编辑服务的警告、时间归零和负值过滤 must 不被直接视为成功候选。 +删除后的时间归一化 must 同时更新保留章节的开始时间与显式结束时间。 +它 must 使用第 4.3 节的平移校验规则。 +提交相同规范值 must 不生成事务。 + +文本编辑状态下,撤销快捷键操作控件草稿。 +离开编辑状态后,撤销快捷键操作文档历史。 +文档撤销按钮 must 先取消未提交草稿,再撤销一条文档事务。 +执行保存或其他内容命令前,宿主 must 提交有效草稿。 +无效草稿 must 阻止依赖当前编辑结果的命令。 +宿主 must 不静默导出与屏幕草稿不同的旧内容。 + +## 5. 合并、拆分与追加 + +### 5.1 合并修改当前结构 + +合并是一条内容事务。 +合并将选定的当前轨道替换为一条合并轨道。 +它 must 使用当前已编辑的章节。 +它 must 保留章节名称、身份、结束时间和必要来源信息。 +它 must 不默认重命名为 `Chapter 01` 等名称。 +自动命名是另一条显式操作。 + +合并时,章节时间加上累计片段时长。 +合并轨道保存片段边界与媒体放置信息。 +边界数据不包含另一份章节内容。 +每个片段 must 保存当前区间、片段身份、轨道元数据和媒体放置信息。 +该数据是可编辑结构,must 随事务保存。 +片段区间 must 连续覆盖合并轨道,且各区间长度为正。 +合并前 must 校验各输入轨道时长与章节范围。 +嵌套合并 must 将已有片段区间平移后展开,不能丢失内部边界。 +默认编号起点继承首个轨道。 +差异预览 must 显示编号变化。 + +具有不同有效帧率的轨道 must 先统一帧率策略再合并。 +实现 must 不直接采用首个轨道帧率解释全部章节。 + +### 5.2 拆分使用当前内容 + +“恢复原片段”按钮改为“按片段边界拆分”。 +拆分 must 使用合并轨道的当前章节。 +它 must 不恢复合并前的隐藏备份。 +拆分将章节开始时间减去所属片段的当前起点。 +存在的结束时间 must 减去同一个起点。 +拆分结果使用片段区间长度作为时长。 +结果继承当前有效帧率和编号策略,并使用片段元数据恢复标题与媒体引用。 +预览 must 显示这些元数据与编号变化。 +章节归属按当前开始时间确定。 +边界采用左闭右开区间。 +恰好位于边界的章节属于后一个片段。 +位于轨道总时长的标记属于最后一个片段。 + +章节跨越边界后,拆分会将它分配到新的片段。 +名称和章节身份保持不变。 +显式结束时间跨越片段边界时,拆分 must 报告冲突。 +默认 must 不自动截断或复制章节。 +用户 can 先调整结束时间再拆分。 + +直接修改章节时间不会自动移动片段边界。 +修改边界属于独立事务。 +时基缩放则同时缩放边界。 +拆分后重新合并 must 基于拆分后的当前内容。 + +“撤销合并”与“按边界拆分”是不同操作。 +前者恢复合并之前的完整状态。 +后者保留合并之后的有效编辑。 +两者都可撤销。 + +### 5.3 追加 + +追加先读取源,再计算候选状态。 +已有合并轨道 must 保留当前编辑内容。 +追加 must 不从导入原稿重新构造已有部分。 +追加的媒体引用和边界 must 一并进入事务。 +重做追加 must 使用历史中保留的结果。 +重做 must 不重新读取文件。 + +## 6. 事务与可逆变更 + +### 6.1 提交入口 + +Core 提供一个逻辑提交入口。 +每份文档会话拥有一个串行命令队列。 +编辑、撤销、重做和历史切换 must 通过该队列提交。 +候选计算 can 在队列外执行。 +宿主 must 不直接修改当前文档集合。 + +接口职责如下。 + +```text +Prepare(operation, document, explicit targets) + -> PreparedEdit(candidateId, baseToken, candidate, diagnostics, changeSet) + +BaseToken = (documentId, instanceToken, mutationRevision, stateId) + +Commit(transactionId, preparedEdit) +Undo(transactionId, baseToken) +Redo(transactionId, baseToken, optionalChildStateId) +Checkout(transactionId, baseToken, targetStateId) + -> Applied | NoChange | Invalid | Stale | Cancelled | ResourceFailure +``` + +`Prepare` 只计算。 +`PreparedEdit` must 绑定捕获的基准状态和不可变候选。 +Core must 验证变更集应用到基准状态后等于候选。 +宿主 must 不分别提交可互相矛盾的候选和变更集。 +`Commit` 负责校验并原子替换内存会话状态。 +`Checkout` 用于历史面板定位任意保留状态。 +所有内容命令 must 按第 4.4 节处理活动草稿。 +历史面板切换 must 先取消草稿,再切换状态。 + +### 6.2 历史记录与会话状态 + +```text +HistoryNode + StateId + ParentStateId + TransactionId + OperationKind + DescriptionArguments + CommitSequence + Timestamp + ChangeSet + BeforeBookmark + AfterBookmark + +SessionState + CurrentDocument + CurrentStateId + MutationRevision + HistoryNodes + ChildStateIds + PreferredChildStateIds + CompletedCommands +``` + +`Workspace` 持有一个当前 `SessionState` 引用。 +文档、游标、历史索引、首选路径与已完成命令结果 must 作为同一状态发布。 +根节点保存导入时的完整不可变文档。 +根节点没有父节点和编辑变更集。 +其他节点保存领域变更集。 +实现 must 不使用任意 JSON 路径反射修改。 + +基本变更包括字段替换、章节插入、章节删除、顺序替换和轨道结构替换。 +每种变更 must 保存恢复所需的旧值和新值。 +删除 must 保存完整被删除条目及原位置。 +结构变更 must 保存受影响轨道的前后结构。 +大型批量变更 can 使用受影响轨道的完整前后快照。 +正向和反向应用 must 使用同一份双向数据,避免重复保存相同负载。 +反向应用 must 按相反顺序执行子变更。 + +每一步应用 must 验证目标身份、原位置或旧值等前置条件。 +恢复结果 must 满足文档不变量。 +事务内的全部子变更只形成一条历史节点。 +失败操作和无变化操作 must 不创建节点。 +描述使用稳定操作类型和本地化参数。 +历史 must 不引用 UI 控件、事件处理器、取消令牌、任务或进程对象。 + +撤销和重做 must 不运行原始表达式或重新解析原文件。 +它们只应用内存中保留的变更。 +恢复校验失败 must 返回 `Invalid` 并保留当前游标和内容。 +实现 must 报告历史状态不一致,不能静默跳过损坏节点。 +本模型无需历史序列化格式、模式迁移、字节校验和或存储端口。 + +### 6.3 原子提交 + +1. 在隔离候选状态上计算全部结果。 +2. 进入文档会话的串行命令队列。 +3. 查询同一事务 ID 的已完成结果,再校验基准令牌与取消状态。 +4. 校验身份、字段范围、变更前置条件和候选一致性。 +5. 构建完整的新 `SessionState`,包括节点、索引、首选路径、游标、递增版本和命令结果。 +6. 一次性替换当前 `SessionState` 引用。 +7. 在提交完成后通知视图,并恢复选择书签。 + +步骤 6 是提交生效点。 +步骤 6 之前 must 完成全部可能失败的计算、分配和校验。 +实现 must 不先修改当前文档,再补写历史。 +实现 must 不通过逐个修改共享字典来拼接已发布状态。 +不可变集合 can 共享未变部分,避免每次复制整棵历史树。 +读取方 must 捕获同一个会话快照,不能混用不同版本的文档和游标。 + +步骤 6 之前失败或取消时,旧状态保持不变。 +步骤 6 不执行用户回调、异步等待或可取消工作。 +步骤 6 之后到达的取消请求不能撤回提交。 +视图通知失败不改变提交结果。 +宿主 must 从当前会话快照重新刷新视图,不能重新执行业务操作。 +其他内容命令 must 在当前提交结束后执行。 +事件回调 must 不重入提交临界区。 + +### 6.4 命令去重与生命周期 + +`TransactionId` 同时适用于编辑提交和游标移动。 +同一次事件分发或请求重试 must 复用原 ID。 +一次新的用户操作 must 使用新 ID。 +重新计算候选后提交也 must 使用新 ID。 + +`CompletedCommands` 在内存中记录成功操作的请求身份和结果。 +请求身份包含命令种类、基准令牌及候选或目标状态身份。 +候选准备时 must 分配稳定候选身份。 +相同 ID 与相同请求返回原结果。 +相同 ID 与不同请求 must 返回 `Invalid`。 +原结果查询 must 先于过期版本拒绝。 +结果记录 must 与文档和游标一起原子发布。 + +返回原结果不代表当前游标仍停留在该结果状态。 +宿主 must 读取当前会话快照,不能因迟到通知回退显示。 +结果记录只保存去重所需的身份与结果标识。 +它 must 不额外保留整份候选、原始文件或操作闭包。 +记录在会话内保留,随会话一起释放。 +`NoChange` 不修改会话;相同基准上的重复请求仍返回 `NoChange`。 +基准已变化时,无成功记录的旧请求返回 `Stale`。 +本地内存提交具有明确生效点,无需持久化回执或通信结果确认协议。 + +## 7. 不限步数的撤销与重做 + +### 7.1 历史树与游标 + +每个新编辑节点只有一个父节点。 +同一节点 can 有多个子节点。 +当前状态由内存会话中的游标指向。 + +```text +S0 --> S1 --> S2 --> S3 + | + +---> S4 --> S5 +``` + +在 `S3` 撤销一次,游标到 `S2`。 +在 `S2` 提交新编辑,创建 `S4`。 +`S3` must 继续保留。 +新操作 must 不删除旧重做分支。 + +撤销移动到父节点。 +普通重做沿当前节点的首选子节点前进。 +撤销时,刚离开的子节点成为首选重做节点。 +新提交的子节点也成为首选重做节点。 +显式重做某个子节点时,该子节点成为首选。 +用户 can 从历史分支入口选择其他子节点。 +所有首选路径保留在当前会话中。 +历史排序使用单调提交序号,时间戳仅用于显示。 +序号 must 不作为历史条数限制。 + +失败操作、草稿修改、选择行和切换轨道 must 不改变历史分支。 +撤销和重做只移动游标,不产生新的内容节点。 +游标、当前文档及首选路径 must 一起原子更新。 +`Redo` 指定的状态 must 是当前节点的直接子节点。 +根节点撤销、无子节点重做、定位当前状态均返回 `NoChange`。 +这些操作 must 不增加版本或改变首选路径。 + +### 7.2 跨分支切换 + +`Checkout` must 验证目标属于同一文档会话。 +跨分支切换先计算当前节点与目标节点的最近公共祖先。 +实现 must 在隔离状态中反向应用变更到祖先,再正向应用到目标。 +路径上的每步 must 验证变更前置条件。 +路径遍历 must 使用迭代算法,不能依赖随历史深度增长的递归调用栈。 + +宿主只在全部恢复成功后发布目标状态。 +中间状态 must 不对外发布。 +跨越任意多个节点只增加一次修改版本。 +离开路径按撤销规则更新首选子节点。 +到达路径再按重做规则更新首选子节点。 +到达路径优先决定公共祖先处的首选分支。 +不在本次路径上的首选项保持不变。 +目标状态已有的首选子节点保持不变。 + +### 7.3 操作分组 + +一次单元格编辑会话形成一条事务。 +一次批量操作形成一条事务。 +同一数值拖动手势形成一条事务。 +显式确认、保存、切换工具或更换编辑目标 must 结束当前分组。 +已提交节点 must 不被后续输入原地修改。 +分组在草稿阶段完成。 +历史记录保留全部已提交事务。 +产品不承诺保存每次未提交的键入或鼠标采样。 + +### 7.4 内存所有权与资源处理 + +Core 的文档会话拥有历史树及其全部负载。 +桌面和浏览器 must 使用相同的 Core 内存历史实现。 +宿主只负责命令绑定、通知与会话生命周期。 +CLI 和 Node.js 的普通转换调用直接使用纯业务操作,无需创建历史会话。 + +根快照、领域变更集和历史索引 must 在会话有效期内保持可达。 +未变化的轨道、章节和集合节点 must 尽可能共享不可变引用。 +单字段修改 must 不复制全部轨道或全部历史节点。 +批量结构替换只保留受影响结构的前后状态。 +实现 must 不为每个历史节点额外保存整份文档快照。 +当前文档与历史负载 can 共享对象,不能修改共享对象。 + +历史面板 must 使用虚拟化视图,并按需构造显示项。 +这只限制 UI 对象的数量,不截断内存中的历史。 +不引入历史落盘、压缩文件、数据库或浏览器存储适配器。 +不引入独立检查点系统;跨分支恢复使用根快照与领域变更集。 +性能验收 must 同时衡量变更集内存与恢复路径耗时。 + +内存占用 can 随历史长度增长。 +可恢复的资源不足 must 返回 `ResourceFailure`。 +宿主 must 保留原文档、游标和历史。 +宿主 must 保留已存在的输入草稿,便于用户调整操作或导出当前内容。 +失败候选与临时重建状态 must 释放,不能为了重试继续占用其全部内存。 +实现 must 不自动删除旧历史来完成新操作。 + +### 7.5 会话释放 + +执行应用内关闭、替换或重新加载命令前,宿主 must 说明该操作会结束当前撤销历史。 +会话存在编辑历史、活动草稿或未导出内容时,宿主 must 提供继续与取消选择。 +关闭确认 can 与现有未保存内容确认合并。 +没有编辑历史且内容未修改时无需额外确认。 +章节导出不包含历史;确认界面 must 不将导出描述为保留撤销能力。 +浏览器刷新、强制退出和进程终止可能绕过应用内确认。 +宿主 must 在历史面板标明会话有效期,不能承诺这些情况下可拦截退出或恢复历史。 + +会话结束后,宿主 must 取消任务并解除事件订阅。 +历史树、命令结果、草稿、预览及 UI 显示项 must 解除引用。 +已开始的只读导出 can 持有捕获的内容快照直至完成。 +它 must 不持有整份历史会话。 +本功能不增加单独的清除历史命令或后台会话恢复列表。 + +## 8. 多轨道、异步操作与会话隔离 + +一份文档拥有一棵历史树。 +全部轨道共享这棵树。 +先修改 A,再修改 B,撤销顺序为 B、A。 +切换轨道 must 不创建或清空历史。 +撤销时,宿主 must 定位到受影响轨道和章节。 +选择书签不属于内容状态。 +它保存在事务上下文中。 +界面恢复书签失败时,内容恢复仍然成功。 +界面 can 选择最近的有效行。 + +异步任务捕获完整 `BaseToken` 和目标身份。 +提交时 must 校验全部令牌。 +编辑到其他状态后又撤销回相同内容,版本仍然增加。 +旧任务因此不能提交。 +过期的批量结果 must 重新计算,不能自动重定位或合并。 + +打开其他源或重新加载源会创建新文档会话与新历史根。 +宿主 must 先完成导入与新会话构建,再按第 7.5 节确认替换。 +等待确认期间旧会话有新修改时,宿主 must 重新检查替换条件。 +导入失败、构建失败或用户取消 must 保留原会话。 +替换成功后,旧历史释放。 +重新打开同一路径也会创建新 `DocumentId`,不恢复旧历史。 +追加属于当前会话中的编辑事务,不创建新根。 + +多个窗口或标签页独立打开同一文件时,各自拥有独立文档会话。 +它们 must 不共享历史、游标或版本。 +本方案不增加跨进程锁、标签页协调或实时协作。 +同一会话的多个视图 must 共享一个 Core 会话实例与命令队列。 +异步导出结果只能更新所属会话的导出基线,不能更新后来打开的新会话。 + +## 9. 预览、导出与保存状态 + +格式预览和导出使用同一纯序列化入口。 +二者 must 捕获相同类型的文档快照和格式选项。 +格式选项只包含格式、编码、BOM、语言和格式必需的元数据。 +序列化 must 不改名、偏移时间或执行表达式。 +序列化 can 按目标格式处理分隔行、精度和不支持的字段。 +格式能力损失 must 通过诊断报告。 +这些格式适配 must 不回写当前文档。 + +批量工具的候选预览必须使用独立标识。 +它 must 不冒充主表格或格式预览的当前内容。 +保存前,界面 must 处理活动草稿。 +它 must 不自动把未确认的工具候选应用到文档。 + +保存动作在本方案中指章节导出。 +单轨道导出成功只能确认对应内容已导出。 +它 must 不把其他轨道标记为已导出。 +成功导出记录轨道内容摘要、格式选项和对应 `StateId`。 +摘要 must 排除选择、日志、缓存和帧显示文本。 +摘要 must 包含实际参与该格式导出的规范内容。 +内容恢复到相同摘要时,可以恢复“与上次导出一致”的状态。 + +异步导出 must 绑定开始时捕获的快照。 +完成时 must 不把期间新增编辑标记为已导出。 +导出失败 must 不更新基线。 +导出 must 不清空撤销历史。 +导出基线仅存在于当前会话中。 +“已应用”表示内存事务已完成。 +“已导出”表示捕获的内容已成功输出。 +界面 must 分别报告两种状态,不能将内存编辑描述为已保存到文件。 +本功能不增加工程文件格式或历史保存点。 + +## 10. 用户界面 + +删除“持续应用表达式”和“持续模板命名”的开关语义。 +保留表达式编辑器和模板选择器。 +工具提供“预览”和“应用”动作。 +主表格始终展示当前文档。 + +主工具栏提供撤销与重做。 +按钮名称包含下一次操作描述。 +示例为“撤销:删除 3 个章节”。 +多个重做分支通过重做按钮的分支入口选择。 +历史面板按需构造树节点的显示项。 +历史面板 must 标明当前状态和首选路径。 + +Windows 和 Linux 默认使用 `Ctrl+Z` 撤销。 +默认提供 `Ctrl+Y` 和 `Ctrl+Shift+Z` 重做。 +macOS 默认使用 `Cmd+Z` 和 `Cmd+Shift+Z`。 +快捷键 must 接入现有快捷键目录和冲突检查。 +文本控件 must 保留自己的草稿撤销作用域。 + +历史恢复期间,内容修改命令暂时不可执行。 +日志追加、状态刷新和选择恢复 must 不创建历史记录。 +状态栏 must 区分处理中、提交失败和提交成功。 +“已应用”只能在历史事务完成后显示。 + +## 11. 迁移与兼容边界 + +迁移 must 不保留交互宿主中的双重内容模型。 +完成切换后,旧投影路径不得继续参与表格、预览或导出。 + +CLI 的 `--expression` 和 `--expression-preset` 保留用户语义。 +CLI must 先执行一次内容变换,再调用纯序列化入口。 +这些参数不是序列化选项。 +CLI 参数仍通过 `DotMake.CommandLine` 定义与绑定。 + +公开 Core 和 Node.js 接口的现有行为必须有迁移路径。 +兼容入口 can 把旧选项转换为一次性操作,再调用新序列化入口。 +兼容入口 must 不在交互文档中保存持续规则。 +每次兼容调用 must 只执行一次变换,并将结果传给纯序列化入口。 +无状态调用无法识别调用方传入的内容是否已经变换。 +调用方复用变换结果时 must 使用纯序列化入口,不能再传入变换选项。 +旧接口的弃用与删除 must 遵循明确的版本策略。 + +设置中的旧自动命名或表达式开关 must 不被恢复为活动规则。 +设置迁移 can 保留工具文本和默认参数。 +首次打开时,工具显示“待应用”。 +编号偏移迁移为文档编号策略的显式修改。 +现有设置保存 can 保持原行为,但 must 不写入文档历史。 + +## 12. 实施顺序与完成条件 + +以下步骤组成同一项完整实施。 +每一步都 must 完成,不能以暂时禁用已有编辑功能作为最终交付。 + +| 步骤 | 实施内容 | 完成条件 | +| --- | --- | --- | +| 1 | 定义不可变文档、稳定身份、时间规则和纯序列化边界 | 表格、预览与导出读取同一当前内容;旧数据类型适配不保留第二份编辑状态 | +| 2 | 实现领域变更集、内存会话状态和统一命令队列 | 原子提交、前置校验、取消与命令去重通过 Core 测试 | +| 3 | 实现历史树、撤销、重做和跨分支切换 | 所有保留节点可达;首选路径符合第 7 节;无自动截断 | +| 4 | 将全部内容操作接入候选计算 | 单元格、插入、删除、排序、编号、模板、表达式、偏移、FPS 与元数据修改都形成可逆事务 | +| 5 | 重写合并、拆分和追加 | 当前编辑、身份、结束时间、片段边界和来源信息完整保留 | +| 6 | 接入 Avalonia 与浏览器交互 | 两个宿主均使用 Core 内存会话;草稿、预览、快捷键、分支入口与历史面板行为一致 | +| 7 | 完成会话生命周期与导出基线 | 打开、重新加载、关闭、取消和异步导出遵守会话隔离;结束会话后释放历史 | +| 8 | 完成 Core、CLI 和 Node.js 兼容入口 | 旧参数转换为一次性操作;内容不重复变换;普通转换调用不创建历史 | +| 9 | 移除旧交互投影与直接修改路径,更新代码地图 | 全部已有编辑入口完成迁移;所有相关测试与性能验收完成 | + +实施中 must 逐项盘点宿主的内容修改入口,并记录对应事务和行为测试。 +同一可编辑文档 must 不混用旧直接修改入口与新历史入口。 +旧模型的切换 must 在完整事务链可用后进行。 +迁移完成后,内容命令的可用性只由文档条件与运行状态决定。 +不能因未完成迁移而留下永久禁用的命令。 +本功能的交付不依赖历史数据库、存储适配器或后续补充阶段。 + +## 13. 验收要求 + +### 13.1 行为验收 + +| 场景 | 必须结果 | +| --- | --- | +| 应用表达式后改时间,再保存 | 保存单元格中的当前时间;表达式不重复执行 | +| 应用模板后手动改名 | 表格、TXT、XML、JSON 和其他支持名称的格式读取当前名称 | +| 操作失败、提交前取消或结果不变 | 内容、历史节点和首选重做路径不变 | +| 修改全部章节时一行无效 | 全部修改拒绝提交 | +| 插入、删除、移动后撤销与重做 | 值、顺序、身份和有效选择恢复 | +| 改 A,再改 B,然后连续撤销 | 先恢复 B,再恢复 A | +| 合并后改名,再按边界拆分 | 名称保留;不读取合并前章节备份 | +| 合并后追加 | 已有编辑保留,新增内容形成一次事务 | +| 时间跨边界或结束时间跨片段 | 执行确定的归属规则或报告明确冲突 | +| 撤销后产生新分支 | 旧分支仍能选择并重做 | +| 大量编辑后撤销到根,再沿各分支重做 | 所有已提交状态仍可恢复,没有步数截断 | +| 根节点撤销、叶节点重做或定位当前状态 | 返回 NoChange,不增加版本 | +| 提交前注入可恢复分配失败 | 原文档、游标与历史保持一致,释放临时候选 | +| 提交成功但视图通知失败后重试 | 同一事务只生效一次,视图从当前会话刷新 | +| 撤销、重做或切换重复提交 | 返回原结果,不再次移动游标 | +| 相同事务 ID 携带不同请求 | 拒绝请求,内容与游标不变 | +| 旧命令通知在后续编辑完成后到达 | 不回退显示,不重复发布旧状态 | +| 跨分支重建在提交前失败或取消 | 不发布中间状态,不移动游标 | +| 取消发生在原子替换之后 | 返回已应用结果,不将成功提交报告为取消 | +| 旧任务遇到编辑后又撤销 | 因修改版本不同而拒绝提交 | +| 未知时长的纯文本章节编辑 | 不将未知时长当作零上界 | +| 复制、重复导入、合并和拆分后重做 | 身份唯一,重做复用首次结果身份 | +| 分隔行参与模板、编号和拆分 | 遵守结构行规则,不改变标记目标计数 | +| 删除并归一化包含结束时间的章节 | 起止时间同步平移,可完整撤销 | +| 会话内源文件已移动或删除 | 撤销、重做和分支切换仍使用内存负载完成 | +| 导出期间继续编辑 | 新编辑仍被识别为与上次导出不同 | +| 导出期间打开另一个文档 | 旧导出完成不能更新新文档的导出基线 | +| 导出成功后继续撤销 | 当前会话全部历史保留 | +| 导入或新会话构建失败 | 原工作区和历史保持可用 | +| 取消关闭、替换或重新加载 | 原会话、草稿和历史保留 | +| 确认关闭或替换会话 | 旧任务失效,历史和相关 UI 对象解除引用 | +| 应用重启或浏览器页面刷新 | 不恢复历史,重新导入创建新根 | +| 不同窗口或标签页打开同一文件 | 会话与历史相互独立 | +| 同一会话的多个视图操作 | 经同一命令队列提交,观察一致快照 | + +### 13.2 内核与资源验证 + +Core 测试 must 验证正向变更与反向变更的组合恒等性。 +属性测试 must 将随机编辑、撤销、重做、重试和分支切换与完整快照参考模型比较。 +比较 must 包含全部规范内容与身份,不能只比较章节数量。 +操作序列测试 must 覆盖多次分支切换及过期任务。 +原子提交测试 must 在候选构建、历史索引构建和视图通知处注入故障。 +可恢复分配失败 must 通过故障注入验证,不能依赖耗尽测试进程内存。 + +大历史测试 must 逐级增加提交次数与分支数。 +测试 must 覆盖单字段修改、大批量修改和结构变更。 +测试 must 记录托管内存、每次编辑分配量、撤销耗时与远距离切换耗时。 +单字段修改的历史负载 must 随实际变化增长,不能为每步复制整份文档。 +深历史遍历 must 不发生调用栈溢出。 +历史面板创建的显示项数量 must 受可见区域与展开范围约束。 +性能报告 must 记录测试数据规模与运行环境。 +固定条数的测试不能证明物理上的无限内存。 +实现审核 must 确认没有按条数、时间或内存占用自动截断历史的路径。 + +会话释放测试 must 验证历史、草稿和视图订阅不再被宿主持有。 +测试 must 单独验证进行中的导出只保留内容快照。 +测试 can 使用弱引用与受控垃圾回收检查对象可回收性。 +测试 must 不将进程工作集立即下降作为释放成功条件。 + +### 13.3 宿主验证与命令 + +Avalonia 非 Headless 测试覆盖会话编排、导出与异步任务隔离。 +Headless 测试覆盖编辑提交、Esc、焦点、快捷键作用域和分支选择。 +浏览器测试覆盖相同的编辑流程,以及标签页独立和刷新后新会话行为。 +CLI 与 Node.js 验证一次性变换及纯序列化兼容行为。 +所有测试 must 使用运行时行为或公共 API,不能读取源码文本代替验证。 +Headless 测试与非 Headless 测试 must 保持进程隔离。 + +实现完成后,按仓库要求先恢复依赖,再顺序运行测试。 +跨项目的统一模型变更 must 执行: + +```text +dotnet test ChapterTool.slnx --no-restore +``` + +Node.js 与浏览器专项验证 must 按对应代码地图和项目脚本执行。 +相关 UI 布局变更 must 提供默认、宽和窄尺寸截图。 +实施报告 must 列明实际执行命令、性能数据和未完成检查。 +本文档修改本身不要求 .NET 构建或测试。 + +## 14. 参考设计与适用范围 + +- [Qt Undo Framework](https://doc.qt.io/qt-6/qundostack.html):借鉴事务分组、操作描述和保存点。 +- [CodeMirror history](https://codemirror.net/docs/ref/#commands.history):借鉴编辑分组与显式历史边界。 +- [ProseMirror history 实现](https://github.com/ProseMirror/prosemirror-history/blob/master/src/history.ts):借鉴事务标记和选择书签。 +- [Immer patches](https://immerjs.github.io/immer/patches/):借鉴可逆变更与不可变状态。 + +本方案不要求引入上述框架。 +本方案不声称这些参考实现默认保留无限历史或全部分支。 +会话内保留全部分支、不主动截断历史和纯内存所有权是本项目的明确设计决策。 diff --git a/docs/tasks/wasm-modal-layout-and-regression-plan.md b/docs/tasks/wasm-modal-layout-and-regression-plan.md new file mode 100644 index 00000000..0e15296a --- /dev/null +++ b/docs/tasks/wasm-modal-layout-and-regression-plan.md @@ -0,0 +1,283 @@ +# 网页弹窗布局调整与回归测试方案 + +实施 change:[`fix-wasm-modal-layout`](../../openspec/changes/fix-wasm-modal-layout/proposal.md)。本文保留变更前的分析基线。当前实现和验证结果见[弹窗布局验收记录](../testing/wasm-modal-layout-acceptance.md)。 + +## 1. 结论和范围 + +历史导航和表达式编辑必须改为独立弹窗。表达式预览、错误、差异列表及 Apply/Cancel 必须放在表达式弹窗内。本次导出的高级选项应放入单独的选项弹窗。主界面保留加载、保存、撤销、重做、片段选择、章节表格、紧凑选项和状态栏。 + +所有视口使用相同的弹窗归属。不得在桌面显示内嵌面板、在手机显示另一套面板。弹窗打开后,主界面的网格行和章节区尺寸必须保持稳定。 + +必须复用现有 Playwright 套件。布局行为断言必须进入常规 PR 测试。截图比较必须覆盖工具打开、错误和长内容状态。 + +初次分析只运行了临时布局采集脚本。采集成功不表示布局通过验收。后续实现必须使用正式 E2E 与 Headless 行为断言验证。 + +分析日期:2026-10-04。源码基线:`90bb423`。运行环境:Windows、本地当前源码、Blazor 开发服务器、Playwright 1.63.0、Chromium 和 WebKit。公开部署站点和真实 iPhone Safari 尚未验证。 + +## 2. 截图现象和证据边界 + +用户截图显示以下问题: + +- Edit history 插在章节表格和导出选项之间。 +- Expression preview、错误列表和 Apply/Cancel 出现在底部选项区。 +- 表达式预设和输入框变成窄条。 +- 在手机上,辅助功能占用了大量章节编辑空间。 + +当前源码确认了前两项的结构来源。本地浏览器还确认了表格挤压和短视口遮挡。 + +当前 390px 宽的本地测试中,表达式输入框宽约 273px,没有复现截图中的窄条。因此,不能仅凭源码将窄条归因于某个 Safari 缺陷。后续必须核对部署提交、实际 CSS 响应、缓存、CSS 视口尺寸、文字缩放和真实设备行为。 + +不得将截图物理像素直接当成 CSS 视口尺寸。不得把桌面 WebKit 或设备模拟测试当成真实 iPhone 验收。[Playwright 设备模拟](https://playwright.dev/docs/emulation)。 + +## 3. 当前结构的根因 + +| 问题 | 当前证据 | 影响 | +| --- | --- | --- | +| 主布局行数随工具状态变化 | [`Home.razor`](../../src/ChapterTool.Wasm/Pages/Home.razor) 在 `.main` 内插入 `.history-panel`;[`app.css`](../../src/ChapterTool.Wasm/wwwroot/css/app.css) 的 `.main` 只有三条显式行:`auto minmax(0, 1fr) auto` | 历史打开后出现第四条隐式行。导出区移动到隐式行。章节区被压缩 | +| 子项最小高度超过分配空间 | `.grid-zone` 和 `.grid-wrap` 都有最小高度;手机规则仅降低 `.grid-wrap` 的最小高度 | 在短视口中,网格行可以接近 0,但表格仍保留最小高度。表格越过行边界并遮挡后续内容 | +| 表达式任务嵌入选项单元格 | `.option-expression` 同时承载输入、异步计算状态、诊断、差异列表和操作按钮 | 一项输入变成完整任务面板。错误出现后,底部区域继续增长 | +| 表单列与内容面板耦合 | `.option-expression` 使用标签/控件双列;`.expression-preview` 固定在 `grid-column: 2 / -1` | 诊断受标签宽度和窄屏规则影响。增加 CSS 特例不能解决任务归属问题 | +| 弹窗已有样式,行为不完整 | 设置、日志、导出预览已有 fixed 遮罩和 `role="dialog"`;多个布尔值分别控制它们 | 现有视觉样式可复用,但角色属性本身不负责焦点约束或背景交互隔离 | +| Escape 分发顺序存在缺口 | `OnKeyDownAsync` 先检查可编辑目标并返回,再检查模态窗口 | 输入框聚焦时,现有页面级 Escape 关闭逻辑不会执行。新弹窗必须统一处理取消和关闭 | + +### 3.1 本地测量结果 + +复现步骤:加载 OGM sample,打开 Edit history,输入 `t + 1`,等待超出已知时长的错误。随后测量区域尺寸,并检查历史关闭按钮是否能接收点击。 + +| 环境 | 普通状态的章节网格行高度 | 历史和错误同时出现后的行高度 | 结果 | +| --- | ---: | ---: | --- | +| Chromium,390×844 | 431.89px | 232.16px | 章节表格实际高度从 413.00px 降至 213.27px | +| WebKit,390×844 | 421.08px | 221.34px | 同样压缩主章节区 | +| Chromium,390×640 | 227.89px | 28.16px | 表格实际底边约 358.02px,历史顶边约 211.86px。历史被覆盖,关闭按钮无法接收点击 | +| WebKit,390×640 | 217.08px | 17.34px | 表格实际底边约 367.42px,历史顶边约 210.45px。关闭按钮同样被覆盖 | +| Chromium,844×390 | 187.88px | 3.63px | 横屏关闭按钮被覆盖 | +| WebKit,844×390 | 179.88px | 0px | 网格行归零,表格仍有最小高度。横屏关闭按钮被覆盖 | + +390×640 表示用于验证布局的短视口。它不是对用户截图视口尺寸的反推。两个浏览器的这些状态均没有触发 `pageerror`。页面没有 JavaScript 异常,仍然可以出现无法操作的布局。 + +点击检查日志明确记录:关闭按钮可见、已启用且稳定,但 `.grid-zone` 内的章节输入框或单元格拦截了指针事件。这是实际遮挡证据,不能用 `toBeVisible()` 的成功结果替代。 + +证据保存在 `artifacts/wasm-layout-analysis/`: + +- [默认尺寸截图](../../artifacts/wasm-layout-analysis/chromium-default-history-expression-error.png) +- [宽尺寸截图](../../artifacts/wasm-layout-analysis/chromium-wide-history-expression-error.png) +- [390×844 截图](../../artifacts/wasm-layout-analysis/chromium-narrow-history-expression-error.png) +- [390×640 遮挡截图](../../artifacts/wasm-layout-analysis/chromium-narrow-short-history-expression-error.png) +- [WebKit 横屏截图](../../artifacts/wasm-layout-analysis/webkit-landscape-history-expression-error.png) +- [Chromium 短视口测量](../../artifacts/wasm-layout-analysis/chromium-narrow-short-measurements.json) +- [WebKit 短视口测量](../../artifacts/wasm-layout-analysis/webkit-narrow-short-measurements.json) + +这些本地产物不会进入版本控制。重新分析时可以用下述复现步骤重新采集。测量使用的是当前源码开发服务器;修复验收还必须使用准备后的 Release 静态产物。 + +## 4. 为什么现有测试没有检出问题 + +当前仓库已有真实浏览器测试。缺口是状态覆盖和门禁范围,而不是缺少测试工具。 + +| 当前测试或配置 | 已验证内容 | 本次遗漏 | +| --- | --- | --- | +| [`unified-editing.spec.ts`](../../tests/ChapterTool.Wasm.E2E/specs/unified-editing.spec.ts) B19 | 历史分支导航和 Redo | 默认宽视口;没有区域尺寸、遮挡或弹窗归属断言 | +| 同文件 B20–B23 | 表达式只读预览、提交、取消、错误和最新草稿 | 没有短视口及历史同时打开的布局状态。内嵌面板本身满足这些用例 | +| [`layout.spec.ts`](../../tests/ChapterTool.Wasm.E2E/specs/layout.spec.ts) B18 | 普通页面、设置、导出预览和下载;默认、宽、390×844 截图 | 没有历史弹窗、表达式有效/无效预览、长差异、短视口或横屏 | +| [`playwright.config.ts`](../../tests/ChapterTool.Wasm.E2E/playwright.config.ts) | 常规行为测试 | `testIgnore: '**/layout.spec.ts'` 排除了现有布局用例 | +| [`wasm-browser-e2e.yml`](../../.github/workflows/wasm-browser-e2e.yml) | PR 的 Chromium 行为测试和 WebKit 回归;版本标签、定时和手动的三引擎验收 | 布局由 E2E 和 Headless 行为断言验证 | +| [`WasmWorkspaceTests.cs`](../../tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs) | 工作区、候选、历史与导出契约 | 不渲染 DOM,不能检出 CSS 覆盖 | + +`toBeVisible()` 不能证明控件有足够宽度、未被遮挡或在可见视口内。无参数的 `toBeInViewport()` 也不能保证控件完全可见。测试必须结合区域关系、完整可见性和真实点击。[可操作性检查](https://playwright.dev/docs/actionability)、[视口断言](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-in-viewport)。 + +`fullPage: true` 的截图可以记录整个文档。它不能证明操作按钮位于用户当前可见区域。仅检查外框横向溢出,也不能发现纵向覆盖。 + +## 5. 功能归属与主界面调整 + +| 功能 | 调整后的归属 | 关键行为 | +| --- | --- | --- | +| Load、Save、Undo、Redo | 顶部工具栏 | 保持主操作直接可用。窄屏可以使用有可访问名称的图标按钮 | +| Clip、当前 FPS、Round frames | 顶部紧凑区域 | 保留当前选择与读数。变换操作通过明确入口执行 | +| 章节编辑 | 中央表格 | 作为主要空间占用者;表格内部滚动 | +| 格式、章节名称模式 | 底部紧凑选项 | 保留常用快速选择 | +| Order +、XML lang、模板选择、本次编码/BOM | “导出选项”弹窗 | 编辑本次导出参数;Apply 后生效,Cancel 恢复打开前的值 | +| 表达式预设、自定义脚本、计算状态、错误、差异、Apply/Cancel | “时间表达式”弹窗 | 在同一个弹窗完成草稿与候选确认。主界面只保留入口及一行简短摘要 | +| Edit history | “编辑历史”弹窗 | 保留分支列表、当前节点、导航与内部滚动 | +| 导出文本预览 | 现有“导出预览”弹窗 | 读取已提交的文档;继续提供复制、下载和关闭 | +| 设置、日志、平移、关联媒体 | 现有弹窗 | 接入相同的弹窗生命周期和尺寸规则 | +| 成功状态与一般进度 | 底部状态栏 | 保持短文本;不在主布局新增详情面板 | + +“本次导出选项”与“设置中的默认输出值”必须在入口和标签中区分。Cancel 不能写入持久化设置。Apply 必须保留当前底部选项的应用和持久化契约。若要进一步改变本次参数与默认值的保存规则,必须作为独立行为变更处理。 + +主布局的目标结构如下: + +```text +主界面 +┌────────────────────────────────────┐ +│ 加载 保存 撤销 重做 更多 │ +│ 片段选择 / 帧率 / 帧显示 │ +├────────────────────────────────────┤ +│ │ +│ 章节表格 │ +│ │ +├────────────────────────────────────┤ +│ 格式 名称模式 导出选项… 表达式… │ +├────────────────────────────────────┤ +│ 状态 / 进度 日志 │ +└────────────────────────────────────┘ + +弹窗层,位于主布局之外 +┌────────────────────────────────────┐ +│ 标题 × │ +├────────────────────────────────────┤ +│ 输入、诊断、差异或历史列表 │ +│ 内容过长时仅此区域滚动 │ +├────────────────────────────────────┤ +│ Cancel Apply │ +└────────────────────────────────────┘ +``` + +手机上的“更多”入口可以收纳历史等辅助操作。不得隐藏加载、保存或编辑能力。触控按钮以至少 44×44 CSS px 作为本方案设计目标。标签必须允许合理换行。表达式输入必须占用独立完整行,不能挤在长标签剩余的列内。 + +## 6. 弹窗实现与交互契约 + +### 6.1 最小实现结构 + +建议新增 `src/ChapterTool.Wasm/Components/WasmDialog.razor`,负责公共标题、内容、页脚、关闭和焦点生命周期。建议分别提取 `HistoryDialog.razor`、`ExpressionDialog.razor` 和 `ExportOptionsDialog.razor`,避免继续扩大 `Home.razor`。 + +`Home.razor` 保留工具入口和页面协调。使用一个页面内 `ActiveDialog` 状态表示当前弹窗。不得继续为新增弹窗叠加独立布尔值。无需建立全局窗口管理服务。 + +公共弹窗优先使用原生 `` 和 `showModal()`。它提供顶层渲染和背景交互隔离。不能用 `` 代替模态打开。视觉样式可沿用当前标题、内容和页脚结构。[HTML dialog 标准](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element)。 + +若保留自定义遮罩实现,必须补齐与原生弹窗相同的焦点约束、背景隔离和关闭行为。仅添加 `aria-modal="true"` 不满足这些要求。 + +### 6.2 通用契约 + +- 同一时刻只打开一个弹窗。主菜单、行菜单和拖放提示在弹窗打开时关闭或暂停。 +- 打开后将焦点移入弹窗。Tab/Shift+Tab 必须留在弹窗内。 +- Escape 从输入框内也能执行该弹窗的取消或关闭逻辑。 +- 关闭后将焦点返回触发按钮。触发按钮失效时,返回明确的页面入口。 +- 遮罩后的 Save、Undo、表格编辑、拖入替换和全局应用快捷键不能修改文档。 +- 只读弹窗允许点遮罩关闭。表达式、设置和导出选项不因误触遮罩丢失草稿。 +- 表达式的 Cancel、Escape 和关闭按钮采用相同的候选取消路径。 +- 关闭和组件销毁必须取消待执行预览、移除事件监听并释放相关引用。 + +焦点与关闭要求依据 [WAI-ARIA 模态弹窗模式](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)。必须通过浏览器行为验收,不能只检查角色属性。 + +### 6.3 表达式弹窗 + +必须保留工作区已有的 Prepare/Apply/Cancel 候选机制。输入不能直接提交章节数据。有效候选显示变更前后值。非法候选显示完整诊断并禁用 Apply。无变化候选不创建无意义历史记录。 + +打开时建立草稿。Cancel 必须恢复打开前的表达式选择和草稿基准,并丢弃候选。Apply 成功后产生一个事务,关闭弹窗,返回章节表格。Undo 必须恢复原内容。预览和保存只读取已提交内容。 + +表达式防抖、取消和修订号由表达式组件负责。快速输入和关闭后到达的异步结果不得重新打开弹窗。候选必须继续验证工作区版本。失效候选不得强行提交。 + +导出预览不能叠在表达式弹窗之上。现有 B20 应改成“取消草稿后导出仍为原内容,再重新打开并 Apply 后导出新内容”。用例仍验证原有文档契约,但不能继续点击模态背景上的按钮。 + +### 6.4 历史弹窗 + +保留当前点击节点即导航的行为。点击后更新当前节点指示,并保留列表滚动位置。关闭不撤销已完成的历史导航。因此,历史页脚使用 Close,不使用含糊的 Cancel。 + +Undo/Redo 可以在历史弹窗中提供同语义操作。主界面按钮在模态期间不可操作。现有 B19 必须限定查询范围,或关闭弹窗后再使用主界面 Redo。 + +保留 `Virtualize`,为节点使用稳定 ID。长分支列表只在内容区滚动。标题、当前状态和关闭按钮不能滚出视口。 + +### 6.5 响应式尺寸和滚动 + +主界面必须给工具栏、章节区、底部选项和状态栏明确的布局归属。禁止以新增隐式网格行承载任务面板。 + +必须同时调整 `.grid-zone` 和 `.grid-wrap` 的最小尺寸规则。不能只修改其中一个。空间充足时由章节区伸展。空间不足时使用明确的滚动容器,不能让子项越过分配区域盖住下一行。 + +弹窗使用 `header / minmax(0, 1fr) body / footer` 或等效 Flex 结构。内容区设置 `min-height: 0` 和内部滚动。标题与页脚不收缩。诊断列表不与 Apply/Cancel 共用一个高度有限的滚动容器。 + +尺寸上限必须考虑动态视口和安全区域。CSS 可使用 `100dvh`,并保留必要回退。[动态视口单位](https://www.w3.org/TR/css-values-4/#viewport-relative-lengths)。不能假定 `100dvh` 自动解决所有软键盘覆盖情况。真实 iPhone 键盘测试若发现遮挡,再用 `visualViewport` 提供可见高度约束。 + +长脚本、长名称和差异文本必须有内部换行或滚动策略。窄屏标签与输入采用单列。普通页面允许表格内部横向滚动,禁止整个页面意外横向溢出。 + +## 7. 必须补齐的测试 + +### 7.1 验证布局行为 + +`modal-layout.spec.ts` 与 `layout-behavior.spec.ts` 必须进入默认 `test:e2e` 范围。它们验证弹窗行为、区域关系和可操作性。Windows 本地和 Linux PR 必须运行相同的行为断言。 + +E2E 必须覆盖弹窗打开状态和诊断状态。桌面布局必须通过 Avalonia Headless 验证。截图用于失败诊断或人工复核。 + +行为布局断言必须属于日常 CI。流水线不生成或比较像素基线。 + +### 7.2 回归用例 + +| ID | 场景 | 必须断言 | +| --- | --- | --- | +| L01 | 打开/关闭历史弹窗 | 存在唯一命名 dialog;章节区、选项区、状态栏的几何位置不改变;关闭可真实点击;焦点返回入口 | +| L02 | 长历史和分支导航 | 列表内部滚动;当前节点标记正确;头部和 Close 完整可见;关闭后表格与导出反映所选节点 | +| L03 | 表达式 pending → 有效差异 | 输入框有可用宽度;结果只在弹窗内;Apply 完整可见、可点击;提交一次;Undo 和导出正确 | +| L04 | `t + 1` 时长错误、Lua 错误和长诊断 | 错误完整呈现;Apply 禁用;Cancel 可点击;章节、历史和下载内容不变 | +| L05 | 无变化、快速输入、计算期间关闭 | 只显示最新草稿结果;关闭后不复活候选;不产生额外事务 | +| L06 | 弹窗输入聚焦,Tab/Shift+Tab、Escape | 焦点不离开弹窗;Escape 正确取消;后台 Save/Undo/编辑不会执行 | +| L07 | 历史、表达式、设置、导出预览依次开关 | 始终只有一个模态面;不残留遮罩或焦点约束;已关闭任务不再占据主布局 | +| L08 | 导出选项 Apply/Cancel 与默认设置 | 本次参数正确生效;取消恢复原值且不写设置;Apply 的持久化结果符合当前契约 | +| L09 | 390×640、844×390 和尺寸动态切换 | 各弹窗的关闭与提交按钮在可见区域;内部滚动可以到达内容;表格不遮挡其他区域 | +| L10 | en-US、zh-CN、ja-JP,长字段和明暗主题 | 标签、输入、诊断与按钮不重叠;主流程可完成;E2E 与 Headless 行为断言通过 | +| L11 | 真机 iPhone Safari:工具栏变化、键盘、文字缩放、旋转 | 输入与页脚可达;关闭不会误触背景;无截图中的控件窄条;主页面状态和滚动恢复 | + +L01、L03、L04、L06、L07、L09 是本次 P0。必须在 Chromium 和 WebKit 的目标视口执行。首次完整验收再执行 Firefox。L11 属于人工补充验收,必须记录设备、系统、浏览器、实际 CSS 视口、部署 SHA 和截图。 + +最低视口集:1280×800、1920×1080、390×844、390×640、844×390。补充 320px 宽及 519/520/521、759/760/761px 断点邻域检查。不得只用“手机宽度 + 大高度”代替短视口测试。 + +原来的“历史与错误同时内嵌”作为修复前反例保留截图。修复后的用例应验证单弹窗约束,并依次打开历史和表达式;不能要求新设计继续允许两个内嵌任务同时出现。 + +### 7.3 断言要求 + +- 在固定视口和相同滚动位置测量工具栏、章节区、选项区和状态栏。弹窗开关前后允许约 2 CSS px 的舍入差异。 +- 验证相邻主区域边界不重叠。对滚动容器验证视口边界,不将滚动内容超出容器误判为溢出。 +- 对页脚的 Close、Cancel 和有效 Apply 使用 `toBeInViewport({ ratio: 1 })`,并完成真实点击。必要时使用 `click({ trial: true })` 辅助定位遮挡。 +- 输入框要有显式可读宽度目标。手机表达式输入独占完整行。不能仅断言宽度大于 0。 +- 点击不得使用 `force: true`。不得调用工作区方法、直接触发 DOM handler 或修改 CSS 来绕过遮挡。 +- 禁用 Apply 必须断言禁用状态,不对它要求点击成功。 +- 视觉用例同时保存 viewport 截图和必要的完整内容截图。等待字体和实际预览状态,不使用固定延迟。 + +像素比较必须在相同浏览器、系统和字体环境执行。新增基线之前,必须先证明布局契约通过,再人工审阅图片。[Playwright 视觉比较](https://playwright.dev/docs/test-snapshots)。 + +## 8. CI 和实施顺序 + +1. 先加入 P0 回归断言,记录当前源码失败。尤其记录短视口遮挡和没有 dialog 的失败。不得先接受当前错误布局作为新基线。 +2. 实现历史、表达式和公共弹窗生命周期。调整主网格最小尺寸与滚动。保留现有 Core 候选和历史契约。 +3. 接入导出选项弹窗,并统一已有模态窗口。更新 B19–B23 的操作路径和查询范围。 +4. 将 `modal-layout.spec.ts` 放入常规 Chromium PR 门禁。为相关 UI 变化增加 WebKit 的目标回归集。 +5. 在 `.github/workflows/wasm-browser-e2e.yml` 增加 PR 的固定 Linux 视觉检查。使用该 workflow 的既有环境;不要把像素比较只留在定时运行。 +6. 行为测试和视觉测试分别使用 results、report 和 JUnit 目录。当前视觉配置继承默认输出目录,实施时必须避免覆盖前一轮报告。 +7. Pages 部署 smoke 至少加入一次打开/关闭辅助弹窗的短视口流程。测试必须继续使用准备后即将上传的 Release 产物。 +8. 完成真机验收,更新代码地图、WASM README 和实际执行记录。 + +实施时的主要命令如下。`modal-layout.spec.ts` 尚未创建;对应命令必须在实施后运行。 + +```text +dotnet test tests/ChapterTool.Wasm.Tests/ChapterTool.Wasm.Tests.csproj --no-restore +dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --configuration Release --no-restore --output artifacts/wasm-e2e/publish +npm --prefix tests/ChapterTool.Wasm.E2E run prepare:site +npm --prefix tests/ChapterTool.Wasm.E2E run typecheck +npm --prefix tests/ChapterTool.Wasm.E2E run test:e2e -- modal-layout.spec.ts --project=chromium --project=webkit +npm --prefix tests/ChapterTool.Wasm.E2E run test:e2e +``` + +布局回归必须通过 E2E 和 Headless 行为断言验证。依赖或项目资产变化时先恢复依赖。修改共享翻译时,必须修改 AXAML 源并运行生成和检查命令,不得手工修改生成的 WASM JSON。 + +## 9. 本次分析验证与最终验收 + +本次临时采集覆盖两种引擎、五种视口和四类状态。普通、宽和 390×844 的 6 个采集场景完成。短视口与横屏的 4 个采集场景也完成,并记录了历史关闭按钮的点击检查失败。 + +首次短视口尝试正常关闭历史时发生超时。后续采集将该遮挡记录为诊断,再通过顶部历史开关继续采集。没有使用强制点击。最终采集脚本的通过结果仅表示证据已保存。 + +上述结果来自修复前的临时采集。当前回归使用正式测试入口: + +```text +npm --prefix tests/ChapterTool.Wasm.E2E run test:e2e -- modal-layout.spec.ts --project=chromium --project=webkit +``` + +临时采集脚本不作为仓库维护工具保留。该回归命令检查当前弹窗流程。`layout-behavior.spec.ts` 验证响应式边界与操作可达性。截图只用于失败诊断或人工复核。 + +调整完成后,必须同时满足以下条件: + +- 历史和表达式任务不再插入主网格或底部选项单元格。 +- 所有目标视口中,弹窗开关不压缩章节区。主区域不互相遮挡。 +- 长内容仅在弹窗内容区滚动。关闭、取消和提交按钮始终可达。 +- 键盘焦点、背景隔离、快捷键和候选取消契约通过真实浏览器测试。 +- 章节编辑、历史分支、表达式单次提交和下载结果继续正确。 +- 状态布局回归在 PR 必须执行。视觉基线不能只覆盖默认状态。 +- 真机 Safari 的未验证项必须明确记录;不得用模拟器结果替代。 + +相关基础方案:[浏览器 E2E 测试方案](../testing/wasm-browser-e2e-plan.md)。本方案补充其布局状态、模态行为和 PR 门禁要求。 diff --git a/docs/testing/wasm-browser-e2e-acceptance.md b/docs/testing/wasm-browser-e2e-acceptance.md new file mode 100644 index 00000000..8068382d --- /dev/null +++ b/docs/testing/wasm-browser-e2e-acceptance.md @@ -0,0 +1,63 @@ +# WASM browser E2E acceptance + +This document records the first acceptance run. Its Linux visual commands are historical. Current verification uses browser E2E and Avalonia Headless behavior assertions. See `scripts/README.md`. + +## First acceptance run + +- Date: 2026-10-04 (Asia/Shanghai). +- Source revision: `b1126a189ad569a5a2698b4612634f32efb96efa`. +- The working tree contained the OpenSpec change under test. +- Playwright: `@playwright/test` 1.63.0. +- Locked development dependencies: TypeScript 7.0.2 and `@types/node` 26.6.4. +- Node.js: 22.16.0 on Windows; 22.22.3 in the visual container. +- Windows: Windows 11 Pro Workstation, build 26200, x64. +- Visual container: Debian 12, x64, `node:22-slim` image digest `sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732`. +- Browsers: Chromium 153.0.8010.12, Firefox 155.0, and WebKit 26.6. + +## Results + +| Check | Result | Duration | +| --- | ---: | ---: | +| WASM workspace tests | 38 passed, 0 failed, 0 skipped | 1.7 seconds | +| Chromium smoke repeated three times | 6 passed, 0 failed, 0 skipped | 11.9 seconds | +| Chromium E2E suite | 18 passed, 0 failed, 0 skipped | Included in the three-engine run | +| Firefox E2E suite | 18 passed, 0 failed, 0 skipped | Included in the three-engine run | +| WebKit E2E suite | 18 passed, 0 failed, 0 skipped | Included in the three-engine run | +| Three-engine E2E suite, no retries | 54 passed, 0 failed, 0 skipped | 1.9 minutes | +| B18 Linux visual suite | 1 passed, 0 failed, 0 skipped | 2.1 seconds | + +The B18 run exercised 1280×800, 1920×1080, and 390×844 viewports. It checked modal bounds, key interactions, horizontal overflow, and screenshot baselines. The narrow viewport check found and verified a responsive settings-dialog fix. + +## Commands + +The Windows checks ran from `tests/ChapterTool.Wasm.E2E`: + +```powershell +npm run typecheck +node node_modules/playwright/cli.js test --project=chromium --grep='@smoke' --repeat-each=3 --retries=0 +$env:E2E_NO_RETRY='1'; node node_modules/playwright/cli.js test +``` + +The WASM test ran from the repository root: + +```powershell +dotnet test tests/ChapterTool.Wasm.Tests/ChapterTool.Wasm.Tests.csproj --configuration Release --no-restore +``` + +The Linux visual run prepared the same Pages-shaped site, then ran: + +```bash +npm run test:visual -- --update-snapshots +npm run test:visual +``` + +The first command created the reviewed baselines. The second command passed without updating them. + +## Coverage + +- B01–B09 cover startup, import, edit history, preview, download, export formats, replacement confirmation, malformed XML, settings persistence, cancellation, and English, Chinese, and Japanese localization. +- B10–B15 cover row selection and deletion, keyboard behavior, frame-rate and expression changes, file drop and size limits, tab isolation, and unavailable settings storage. +- B16–B17 cover MPLS and XPL input, MPLS append, and UTF-8/UTF-16 downloads with BOM byte checks. +- B18 covers layout interactions and geometry at the three recorded viewport sizes. + +The HTML report, JUnit report, traces, screenshots, videos, browser logs, and downloaded files are under `artifacts/wasm-e2e/`. The recorded screenshot baselines have been removed. `specs/layout-behavior.spec.ts` retains their behavior assertions. diff --git a/docs/testing/wasm-browser-e2e-plan.md b/docs/testing/wasm-browser-e2e-plan.md new file mode 100644 index 00000000..9acf2eb3 --- /dev/null +++ b/docs/testing/wasm-browser-e2e-plan.md @@ -0,0 +1,351 @@ +# 网页版浏览器 E2E 测试补齐方案 + +## 1. 结论与交付范围 + +选择 **Playwright Test + TypeScript**。在 `tests/ChapterTool.Wasm.E2E/` 建立独立 npm 测试项目。测试必须启动真实浏览器,操作 Blazor WebAssembly 页面,并验证用户可见结果和实际下载文件。 + +默认测试目标是经过 GitHub Pages 子路径处理的 Release 静态产物。开发服务器模式用于本地排错。PR 首先以 Chromium 阻断回归。完整验收覆盖 Chromium、Firefox 和 WebKit。 + +本文初版记录 2026-10-03 的实施方案和当时未执行 E2E 的交付范围。截至 2026-10-04,仓库已加入 `tests/ChapterTool.Wasm.E2E/` 和浏览器 CI。本文的目录与代码示例保留原始设计;当前执行入口以测试项目 README 和实际配置为准。 + +初次核查发现套件缺少历史、表达式错误及短视口的布局门禁。`fix-wasm-modal-layout` 按[网页弹窗布局与回归测试方案](../tasks/wasm-modal-layout-and-regression-plan.md)补齐了常规布局断言和视觉状态。验证结果见[弹窗布局验收记录](wasm-modal-layout-acceptance.md)。 + +## 2. 仓库现状与缺口 + +以下结论来自当前仓库代码,核查日期为 2026-10-03。 + +| 现有入口 | 当前职责 | 浏览器 E2E 缺口 | +| --- | --- | --- | +| [`ChapterTool.Wasm.csproj`](../../src/ChapterTool.Wasm/ChapterTool.Wasm.csproj)、[`Program.cs`](../../src/ChapterTool.Wasm/Program.cs) | .NET 10 Blazor WebAssembly 宿主 | 没有真实浏览器启动与断言 | +| [`Home.razor`](../../src/ChapterTool.Wasm/Pages/Home.razor) | HTML 按钮、文件输入、章节表格、选项、状态栏和模态窗口 | Razor 事件、焦点和浏览器行为没有端到端覆盖 | +| [`download.js`](../../src/ChapterTool.Wasm/wwwroot/js/download.js) | Blob 下载、文本编码、剪贴板、localStorage、拖放和快捷键拦截 | JavaScript 与 .NET 互操作没有真实浏览器验证 | +| [`WasmWorkspaceTests.cs`](../../tests/ChapterTool.Wasm.Tests/WasmWorkspaceTests.cs) | 导入、编辑、历史、预览、导出和字节限制的 xUnit 测试 | 直接调用工作区,没有操作页面 | +| [`WasmBrowserShortcutGuardTests.cs`](../../tests/ChapterTool.Wasm.Tests/WasmBrowserShortcutGuardTests.cs) | 快捷键规则测试 | 没有覆盖 DOM 事件和输入框焦点 | +| `tests/ChapterTool.Avalonia.Headless.Tests/` | Avalonia UI 交互 | 不运行 Blazor 或浏览器 | +| [`packages/chaptertool/package.json`](../../packages/chaptertool/package.json) | Node.js 包构建与 Vitest 测试 | 不覆盖网页版图形界面 | +| [`.github/workflows/dotnet-ci.yml`](../../.github/workflows/dotnet-ci.yml) | .NET、Node.js 测试与打包;已有 Node.js 22.x | 没有浏览器 E2E 作业 | +| [`.github/workflows/github-pages.yml`](../../.github/workflows/github-pages.yml) | Release 发布、base href 改写、静态文件检查与部署 | 没有在部署前运行页面交互测试 | + +网页版是 Blazor HTML 页面。它不是 Avalonia Browser 的 Canvas 渲染页面。测试可以直接使用 DOM、角色和标签定位。 + +现有静态文件检查可以发现缺失文件。它不能证明 WASM 启动成功、按钮可用、下载正确或设置在刷新后恢复。 + +## 3. 工具选择 + +| 候选 | 适用性 | 本方案决策 | +| --- | --- | --- | +| Playwright Test + TypeScript | 内置浏览器项目、隔离上下文、断言、服务启动、追踪和报告;可沿用现有 Node.js 工具链 | 采用 | +| Playwright .NET | 可用 C# 编写浏览器测试,并接入 .NET 测试运行器 | 保留为替代方案;本次不增加 .NET 浏览器测试项目与宿主生命周期管理 | +| Cypress | 适合交互式网页测试;多浏览器能力需要按其当前支持范围评估 | 本次不采用;Playwright 的项目配置更直接满足本方案的三引擎测试目标 | +| Selenium WebDriver | 适合已有 WebDriver/Grid 基础设施的团队 | 本仓库没有该基础设施,本次不引入 | +| bUnit、现有 xUnit、Vitest | 适合组件、工作区和包级测试 | 保留原有测试;不能替代真实浏览器验收 | + +Playwright 支持通过 projects 配置 Chromium、Firefox 和 WebKit。它提供文件选择和下载事件 API。以上功能符合本仓库的主要浏览器边界。[浏览器项目](https://playwright.dev/docs/test-projects)、[文件上传](https://playwright.dev/docs/input)、[下载](https://playwright.dev/docs/downloads)。 + +候选能力依据官方资料核查:[Playwright .NET 运行器](https://playwright.dev/dotnet/docs/test-runners)、[Cypress 浏览器支持](https://docs.cypress.io/app/guides/cross-browser-testing)、[Selenium WebDriver](https://www.selenium.dev/documentation/webdriver/)。表中的采用结论是针对本仓库的工程判断。 + +实施时必须选择支持 Node.js 22 的稳定版本。必须固定 `@playwright/test`、TypeScript 和类型依赖版本,并提交 `package-lock.json`。CI 必须使用 `npm ci` 和锁定版本对应的浏览器二进制。版本升级时必须重新安装浏览器,并执行三引擎验收。[浏览器安装与版本关系](https://playwright.dev/docs/browsers)。 + +## 4. 目录与改动范围 + +```text +tests/ChapterTool.Wasm.E2E/ # 新建;不加入 .NET solution + package.json + package-lock.json + tsconfig.json + playwright.config.ts # 默认测试 Release 静态产物 + playwright.dev.config.ts # 本地 Blazor DevServer + README.md # 安装、执行和故障定位 + scripts/ + prepare-site.mjs # 处理发布目录;不重新编译 + serve-published.mjs # 仅提供本地静态 HTTP 服务 + support/ + fixtures.ts # 自动诊断与页面就绪检查 + chapter-workspace.ts # 用户操作和语义定位 + fixtures/ + minimal-ogm.txt + unicode-ogm.txt + invalid.xml + expected/ # 人工核对的预期下载内容 + specs/ + smoke.spec.ts + import-export.spec.ts + editing-history.spec.ts + settings-localization.spec.ts + browser-boundaries.spec.ts + disc-workflows.spec.ts + layout.spec.ts +``` + +必须在 `Home.razor` 补充必要的可访问标签和少量稳定定位属性。必须在 `.github/workflows/dotnet-ci.yml` 接入独立 E2E 作业。必须在 `.github/workflows/github-pages.yml` 增加部署前验收。 + +实施后必须更新 `docs/code-map/testing.md` 和 `src/ChapterTool.Wasm/README.md`,说明测试入口和命令。测试产物放在已忽略的 `artifacts/wasm-e2e/`。截图只用于失败诊断或人工复核。 + +不把 E2E 依赖加入 `packages/chaptertool`。该目录仍只负责 Node.js 包。业务解析和转换算法继续由现有 .NET 测试负责。 + +## 5. 被测站点与运行配置 + +### 5.1 默认:Release 静态产物 + +1. 在仓库根目录恢复 WASM 项目依赖。发布一次 Release 产物到 `artifacts/wasm-e2e/publish/`。 +2. `prepare-site.mjs` 将其中的 `wwwroot/` 复制到 `artifacts/wasm-e2e/site/ChapterTool/`。 +3. 准备脚本按 Pages 工作流生成 `.nojekyll`、`404.html` 和部署信息。它将两个 HTML 入口的 base href 改为 `/ChapterTool/`。 +4. 静态服务器从 `artifacts/wasm-e2e/site/` 提供 HTTP 服务。浏览器访问 `http://127.0.0.1:5261/ChapterTool/`。 +5. 同一份准备后的静态目录必须用于测试和后续 Pages artifact 上传。测试通过后不得再次发布或改写入口。 + +静态服务器必须提供正确的 JavaScript、JSON、CSS 和 `application/wasm` MIME 类型。资源缺失必须返回 404。服务器不得用 `index.html` 吞掉 `_framework` 或脚本的资源错误。先直接提供未压缩资源;若后续模拟预压缩资源,必须同时验证 `Content-Encoding`。 + +服务只能监听本机地址。路径解析必须限制在明确的静态根目录内。准备脚本必须验证目标目录位于 `artifacts/wasm-e2e/`,然后才能清理旧产物。 + +Pages 作业可以继续使用其现有发布目录。它应直接服务准备后的目录,并挂载到 `/${repositoryName}/`。准备规则应收敛为同一个脚本,避免测试与部署各维护一套不同规则。 + +### 5.2 本地排错:开发服务器 + +开发配置使用下面的服务启动命令。相对路径以仓库根目录为基准。 + +```text +dotnet run --project src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --no-launch-profile --no-restore --urls http://127.0.0.1:5261 +``` + +开发配置必须设置 `ASPNETCORE_ENVIRONMENT=Development`。它使用根路径 `/`,并保持与默认配置相同的用例和断言。`--no-launch-profile` 避免 launch profile 自动打开额外浏览器。 + +两个配置均由 Playwright `webServer` 管理服务启动与退出。默认禁止复用占用端口的服务,避免误测旧进程。[服务生命周期配置](https://playwright.dev/docs/test-webserver)。 + +### 5.3 默认配置示例 + +以下配置是待实施模板。`serve-published.mjs` 必须先完成,才能执行它。 + +```typescript +import { defineConfig } from '@playwright/test'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const suiteDir = dirname(fileURLToPath(import.meta.url)); +const baseURL = 'http://127.0.0.1:5261/ChapterTool/'; + +export default defineConfig({ + testDir: './specs', + testIgnore: '**/layout.spec.ts', + outputDir: '../../artifacts/wasm-e2e/results', + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + failOnFlakyTests: Boolean(process.env.CI), + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [ + ['list'], + ['html', { outputFolder: '../../artifacts/wasm-e2e/report', open: 'never' }], + ['junit', { outputFile: '../../artifacts/wasm-e2e/junit.xml' }], + ], + use: { + baseURL, + locale: 'en-US', + viewport: { width: 1280, height: 800 }, + acceptDownloads: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: 'node scripts/serve-published.mjs --root ../../artifacts/wasm-e2e/site --port 5261', + cwd: suiteDir, + url: baseURL, + reuseExistingServer: false, + timeout: 30_000, + stdout: 'pipe', + stderr: 'pipe', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], +}); +``` + +90 秒是 WASM 冷启动用例的初始预算。实施后必须根据 CI 实测调整。不得用增加固定等待来修复失败。初期使用一个 worker,控制多个 WASM 实例的内存开销;这不是 Avalonia Headless 的进程隔离要求。[配置](https://playwright.dev/docs/test-configuration)、[不稳定测试门禁](https://playwright.dev/docs/api/class-testconfig#test-config-fail-on-flaky-tests)。 + +## 6. 定位、就绪与浏览器边界 + +### 6.1 最小可测性改动 + +| 页面对象 | 当前可用入口 | 计划补齐 | +| --- | --- | --- | +| 页面根 | `#chaptertool-shell` | `data-app-ready` 和 `aria-busy` | +| Load、Save、Undo、Redo | 按钮可访问名称 | 沿用 `getByRole`;在模态窗口内限定查询范围 | +| 导入与附加 | `#loadFileInput`、`#appendMplsInput` | 沿用;主流程通过按钮与 `filechooser` 验证 | +| 保存格式与语言 | `#saveFormat`、`#xmlLang`,已有 label | 优先 `getByLabel` | +| 章节表格 | 原生 table 和各列 | 表格补充可访问名称;时间、名称输入框补充“行号 + 字段”的可访问标签 | +| 设置与预览 | `role="dialog"`、`aria-labelledby` | 沿用 dialog 范围查询;为预览正文增加稳定定位属性 | +| 状态栏 | `.status-text` | 增加 `role="status"` 或等效稳定语义 | + +必须先使用角色和标签。必要时使用 `data-testid`。不得依赖长 CSS 路径、屏幕坐标或跨窗口的模糊文本查询。[定位策略](https://playwright.dev/docs/locators)。 + +`data-app-ready="true"` 必须在首次 `OnAfterRenderAsync` 完成快捷键和拖放注册、设置读取及恢复后设置。它只表示初始化完成。烟测仍必须实际导入文件,以证明 JavaScript 互操作可用。 + +操作完成必须等待具体结果,例如行数、输入值、按钮状态、dialog 关闭或下载事件。页面编辑使用 `@onchange`。测试执行 `fill()` 后必须通过 Tab 或其他真实失焦动作提交编辑,再验证 Undo 和导出结果。不得只验证输入框刚填入的值。 + +### 6.2 状态隔离与诊断 + +每个测试使用 Playwright 默认的新 BrowserContext。设置恢复测试在同一 context 内刷新。标签页隔离测试在同一 context 内打开两个 page,验证章节会话互不影响;共享 localStorage 不应被误判为章节会话泄漏。[上下文隔离](https://playwright.dev/docs/browser-contexts)。 + +必须在导航前监听 `pageerror`、`console.error`、`requestfailed` 和同源关键资源的非成功响应。自动 fixture 必须在测试结束时附加日志,并断言没有意外错误。主动注入故障的测试必须逐项声明预期错误。不得全局忽略资源加载错误。[网络事件](https://playwright.dev/docs/network)。 + +不得调用 `window.chapterToolWasm.downloadText` 或直接调用工作区方法来替代用户操作。不得 mock WASM/Core 返回值。JavaScript 注入仅用于浏览器边界,例如构造拖放 `DataTransfer`、预置损坏设置或模拟存储异常。 + +文件选择和下载监听必须先于按钮点击。拖放必须将 `File` 放入真实 DOM `DataTransfer` 并发送事件,随后断言表格结果。取消替换与确认替换必须分别处理真实 `window.confirm`。不得对所有 dialog 自动确认。 + +跨浏览器测试不得假定剪贴板权限一致。主要验收通过预览和下载验证内容。剪贴板可在明确支持的项目中单独验证。WebKit 测试验证引擎行为,不等同于已验证所有 macOS/iOS Safari 环境。 + +## 7. 用例矩阵 + +P0 是第一阶段必须完成的门禁。P1 在第二阶段补齐。每个用例必须能单独运行。 + +| ID | 优先级 | 用户操作 | 必须断言的结果 | +| --- | --- | --- | --- | +| B01 | P0 | 在 `/ChapterTool/` 冷启动;点击 Load OGM sample | 就绪标记完成;空表时 Save 禁用;样例产生 3 行;Save 可用;无意外启动错误 | +| B02 | P0 | 点击 Load;选择 2 行 OGM UTF-8 文件 | 两行时间、名称与已知输入一致;来源名称正确;选择事件真正接入 .NET | +| B03 | P0 | 修改名称和时间;失焦;Undo;Redo | 表格恢复和重做正确;导出反映提交后的内容;历史按钮状态正确 | +| B04 | P0 | 打开 Preview;关闭后 Save | 预览正文与实际下载内容一致;下载名称和扩展名正确;下载未失败 | +| B05 | P0 | 导出 TXT、XML、QPFile、TimeCodes 的代表样例 | 每种格式的独立预期内容正确;XML 语言生效;重复保存不重复应用表达式 | +| B06 | P0 | 修改章节后取消替换;再确认替换 | 取消保留章节和历史;确认结束旧会话并显示新文档 | +| B07 | P0 | 导入损坏 XML;再导入正常文件 | 失败有可见诊断;原文档和历史保留;后续操作恢复可用 | +| B08 | P0 | 设置语言、主题、默认格式后 Save;刷新;修改后 Cancel | 保存值在刷新后恢复;取消草稿不改变已应用值;新增 context 保持默认设置 | +| B09 | P0 | 用 en-US、zh-CN、ja-JP 分别导入中文和日文章节并保存 | 按钮、表头和 dialog 显示对应语言;已应用语言下 `html.lang` 正确;UTF-8 下载可正确解码名称 | +| B10 | P1 | Ctrl/Shift 选中多行;右键删除;Undo | 仅删除目标行;撤销恢复内容;选择状态符合当前工作流 | +| B11 | P1 | 使用 Ctrl/Meta+Z、Ctrl+S、F5;在文本框内编辑 | 非编辑焦点触发应用操作;输入框内不触发工作区撤销;应用 Reload 不造成页面重新导航 | +| B12 | P1 | 选择固定 FPS;切换帧显示;应用合法和非法表达式 | 指定时间和帧数符合已知结果;合法表达式只提交一次;非法表达式不产生部分修改 | +| B13 | P1 | 拖入正常文件、空拖放和大于 64 MiB 的文件 | 正常导入;空输入和超限有诊断;原文档保留;控件可继续操作 | +| B14 | P1 | 在同一 context 打开两个标签页并分别编辑 | 两个页面的章节和历史独立;刷新清除章节会话但保留已保存设置 | +| B15 | P1 | 预置损坏/旧版设置;模拟 localStorage 拒绝访问 | 不阻止启动;损坏设置回退;旧键按当前迁移契约处理;仍可导入和下载 | +| B16 | P1 | 导入多片段 MPLS;切换、合并、恢复、Append;导入代表 IFO/XPL/CUE/FLAC/TAK | selector、章节数量和已知关键时间正确;附加仅接受 MPLS;不依赖桌面外部工具 | +| B17 | P1 | 设置 UTF-8/UTF-16LE/UTF-16BE、BOM 后下载 | 直接核对字节序、BOM 和中文/日文内容;不能只比较解码后的字符串 | +| B18 | P1 | 在默认、宽、窄视口编辑、打开设置、预览并保存 | 控件可点击且无遮挡;章节表允许内部滚动;页面外框无意外水平溢出;弹窗操作可达 | + +B11 的快捷键验证必须观察下载、表格和导航事件。仅测试快捷键判断函数不足以完成验收。模态窗口 Escape 测试必须设置明确的非编辑焦点,再验证关闭和焦点行为。 + +必须将 B01 和包含导入、编辑、下载的核心流程标记为 `@smoke`。Pages 门禁使用 `--grep @smoke`。P0/P1 表示实施优先级,不代表自动过滤规则。 + +64 MiB 上限适用于 Load、Reload 和 MPLS Append。模板上传目前另有 2 MiB 上限。模板超限和字节临界值必须在后续边界测试中分别覆盖。大文件用例按需生成临时文件,不提交大二进制 fixture。跨浏览器 DOM 拖放不能证明操作系统级文件拖拽,后者可保留少量人工验收。 + +复杂光盘数据可复用 `tests/ChapterTool.Core.Tests/Fixtures/Importing/Disc/`。例如 `Mpls/00001_Hidan_no_Aria_AA.mpls` 已用于工作区多片段测试。不得复制整个大型 fixture 目录或依赖本地未跟踪数据。简单 OGM、错误 XML 和 Unicode 输入应使用小型专用 fixture。 + +预期导出内容必须是预先核对的固定结果。不得通过调用同一 Core 导出实现动态生成“期望值”。允许统一平台换行;编码测试必须保留原始字节。 + +## 8. 核心流程示例 + +下面示例展示 B02、B03 和下载验证。它依赖第 6 节计划新增的可访问标签和 fixture。`support/fixtures.ts` 必须先完成就绪检查和自动诊断。此处的测试代码尚未落地。 + +```typescript +import { expect, test } from '../support/fixtures'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +test('导入后编辑、撤销、重做并下载 @smoke', async ({ page }, testInfo) => { + // './' 保留 baseURL 中的 /ChapterTool/;使用 '/' 会跳到站点根目录。 + await page.goto('./'); + await expect(page.locator('#chaptertool-shell')) + .toHaveAttribute('data-app-ready', 'true'); + + const chooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + const chooser = await chooserPromise; + await chooser.setFiles(fileURLToPath(new URL('../fixtures/minimal-ogm.txt', import.meta.url))); + + const grid = page.getByRole('table', { name: 'Chapters', exact: true }); + await expect(grid.locator('tbody tr')).toHaveCount(2); + const name = grid.getByRole('textbox', { name: 'Chapter 1 name', exact: true }); + await expect(name).toHaveValue('Opening'); + await name.fill('开场'); + await name.press('Tab'); + await expect(page.getByRole('button', { name: 'Undo', exact: true })).toBeEnabled(); + + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(name).toHaveValue('Opening'); + await page.getByRole('button', { name: 'Redo', exact: true }).click(); + await expect(name).toHaveValue('开场'); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const download = await downloadPromise; + expect(await download.failure()).toBeNull(); + expect(download.suggestedFilename()).toMatch(/\.txt$/); + const output = testInfo.outputPath('actual.txt'); + await download.saveAs(output); + const actual = await readFile(output, 'utf8'); + const expected = await readFile( + new URL('../fixtures/expected/edited-ogm.txt', import.meta.url), 'utf8'); + expect(actual.replace(/\r\n/g, '\n')) + .toBe(expected.replace(/\r\n/g, '\n')); +}); +``` + +此测试必须从默认英文的新 context 开始。多语言测试必须通过设置界面切换应用语言。Playwright `locale` 只设置浏览器环境,不能代替应用设置操作。 + +## 9. 本地执行与 CI 接入 + +### 9.1 待实施的执行入口 + +新增 npm scripts 必须包含 `prepare:site`、`typecheck`、`test:e2e`、`test:e2e:dev`、`test:e2e:headed`、`test:e2e:ui`和 `report`。开发命令使用开发配置。有界面命令使用 `--headed`。交互排错命令使用 `--ui`。报告命令使用 `playwright show-report`。脚本必须直接调用固定版本工具,不使用全局 Playwright。 + +默认配置运行 `layout-behavior.spec.ts`。该文件验证页面边界、真实点击和长内容滚动。E2E 与 Avalonia Headless 必须通过行为断言验证布局。截图用于失败诊断或人工复核。流水线不生成或比较像素基线。 + +实施完成后,在仓库根目录依次执行: + +```text +dotnet restore src/ChapterTool.Wasm/ChapterTool.Wasm.csproj +dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --configuration Release --no-restore --output artifacts/wasm-e2e/publish +npm --prefix tests/ChapterTool.Wasm.E2E ci +npm --prefix tests/ChapterTool.Wasm.E2E exec -- playwright install chromium +npm --prefix tests/ChapterTool.Wasm.E2E run prepare:site +npm --prefix tests/ChapterTool.Wasm.E2E run typecheck +npm --prefix tests/ChapterTool.Wasm.E2E run test:e2e -- --project=chromium +``` + +三引擎验收时,安装命令不指定单个引擎,并运行不带 `--project` 的 `test:e2e`。Windows 本地不要求安装 Linux 系统依赖。Linux CI 使用 `playwright install --with-deps` 安装对应引擎和系统依赖。[CI 安装](https://playwright.dev/docs/ci-intro)。 + +Playwright 转译 TypeScript 不代替类型检查。`typecheck` 必须执行 `tsc --noEmit`。修改共享本地化资源时,必须遵循仓库 AXAML 到 JSON 的生成与检查流程,不能手工修改生成的 WASM JSON。 + +### 9.2 自动运行矩阵 + +| 触发 | 测试目标 | 浏览器与用例 | 门禁 | +| --- | --- | --- | --- | +| PR / push:WASM、Core、Contracts、测试或构建配置变化 | 本次提交的 Release 静态产物,挂载 `/ChapterTool/` | Chromium 全部已实现行为用例 | 必须通过 | +| 定时运行或手动运行 | 同样的静态产物 | Chromium、Firefox、WebKit 完整行为集 | 记录兼容性回归;发布前必须通过 | +| Pages 部署前 | 已通过浏览器验收的 Pages 目录 | 复用 CI 站点制品 | `CI ready` 失败必须停止部署 | +| 布局回归 | 本次提交的 Release 静态产物 | Chromium 与 WebKit 的布局行为断言;桌面使用 Headless | 必须通过 | + +E2E 作业使用 `ubuntu-24.04`、.NET 10 和 Node.js 22.x。运行顺序是 checkout、工具安装、依赖恢复、Release 发布、站点准备、类型检查、浏览器安装、测试、报告上传。 + +必须将新 lockfile 加入 Node 缓存键。缓存 npm 和 NuGet 下载即可。首期不缓存 Playwright 浏览器,减少版本不匹配的排错成本。只在需要 AOT 时安装 `wasm-tools`,不得把 Node.js 包的构建要求直接套到普通 Blazor 发布上。 + +调度必须读取项目引用关系。WASM、Core、Contracts、E2E、共享构建配置及本地化源变化必须触发相关消费者。`CI ready` 必须检查本次所需节点的结果。 + +`actions/upload-artifact` 必须使用 `if: always()`,上传 `artifacts/wasm-e2e/` 下的报告、trace、失败截图、日志和下载文件。保留期建议 14 天。部署 smoke 失败必须阻断发布。不得使用 `continue-on-error` 把门禁变成提示。 + +E2E 作业与现有 .NET 测试保持独立进程和工作目录。不得并行启动多个 `dotnet publish` 写入同一目录。浏览器项目可顺序运行;后续增加 worker 必须先证明状态与产物隔离。 + +## 10. 分阶段实施与验收 + +| 阶段 | 实施内容 | 完成条件 | +| --- | --- | --- | +| A:可运行基础 | 新建独立项目、锁依赖、静态服务、Pages 处理、就绪与定位契约、自动诊断 | 一条命令执行 B01;Windows 本地和 Linux CI 均可运行 | +| B:核心门禁 | 完成 B02–B09;加入 PR 和部署前 smoke | 所有 P0 在 Chromium 无重试通过;实际下载与预期内容一致 | +| C:浏览器边界 | 完成 B10–B17;扩展 Firefox、WebKit | 完整行为集三引擎通过;不把兼容性失败长期标记为 skip | +| D:布局回归 | 完成 B18 的工作流和几何断言 | 默认 1280×800、宽 1920×1080、窄 390×844 均可完成主流程;E2E 与 Headless 行为断言通过 | + +布局测试必须验证点击和工作流结果。几何断言可检查选项区与状态栏位置、关键控件可见范围和外框溢出。章节表内部水平滚动可以保留。截图保存本身不构成自动断言。 + +布局检查必须验证控件边界、操作可达性、焦点和滚动结果。截图可以辅助诊断。流水线不生成或比较像素基线。 + +实施者必须在首次验收报告中记录提交 SHA、依赖锁版本、浏览器版本、操作系统、完整命令、各项目通过/失败/跳过数量、耗时及 artifacts 路径。必须区分计划覆盖与已通过覆盖。 + +首次稳定性验收必须执行 Chromium P0 用例 `--repeat-each=3 --retries=0`。三引擎至少完成一次无重试验收。重复运行只用于首次稳定性验证或调查失败。不得在常规修改中反复运行已成功且不受影响的测试。 + +CI 必须禁用重试。失败时先看 trace、控制台和资源请求,再修复代码或同步条件。不得通过固定延迟或删除断言消除失败。[Trace Viewer](https://playwright.dev/docs/trace-viewer)。 + +可测性改动涉及 Razor 或浏览器互操作时,必须顺序运行受影响的 .NET 测试项目,再运行 E2E。已有业务测试不能因新增 E2E 被删除。 + +最终完成标准:新环境可按 README 安装并运行;P0 和三引擎验收通过;部署测试使用实际上传产物;失败报告可定位问题;测试入口写入代码地图;所有未完成项均在验收报告中明确列出。 diff --git a/docs/testing/wasm-modal-layout-acceptance.md b/docs/testing/wasm-modal-layout-acceptance.md new file mode 100644 index 00000000..3ece97bf --- /dev/null +++ b/docs/testing/wasm-modal-layout-acceptance.md @@ -0,0 +1,95 @@ +# 网页弹窗布局验收记录 + +日期:2026-10-04。变更:[`fix-wasm-modal-layout`](../../openspec/changes/fix-wasm-modal-layout/proposal.md)。变更前基线:`90bb423`。本记录验证本地工作树的 Release 发布产物。 + +本文保留首次验收证据。固定 Linux 像素基线与比较命令已移除。当前布局由 E2E 与 Avalonia Headless 行为断言验证。 + +## 实现结果 + +- 历史、表达式编辑及预览、高级导出选项使用独立弹窗。 +- 主页面保留工具栏、章节表格、紧凑导出控件和状态栏。 +- 所有工具共用原生 `dialog` 和 `showModal()`。页面同时只打开一个弹窗。 +- 弹窗正文独立滚动。标题和操作按钮保持可达。 +- Escape 在输入框中生效。关闭后焦点返回触发控件。WebKit 的指针点击触发目标单独记录。 +- 弹窗打开时,背景快捷键和文件拖放不能修改文档。 +- 表达式取消会停止待完成验证,丢弃候选结果,并恢复先前表达式和预设。 +- 表达式应用保留一次提交、撤销、重做和导出语义。 +- 高级选项暂存序号偏移、XML 语言、编码、BOM 和模板。应用使用现有投影和设置持久化路径。 +- 新增中文、英文和日文标题。WASM JSON 由共享 AXAML 资源生成。 + +## 自动化覆盖 + +采用仓库现有的 Playwright Test + TypeScript。没有新增浏览器测试框架依赖。 + +| 用例 | 验证结果 | +| --- | --- | +| M01 | 390×640 历史弹窗打开、真实点击关闭、原生模态状态和焦点返回。此用例属于 Pages `@smoke`。 | +| M02 | 12 个视口的独立弹窗、主表格几何稳定、错误输入宽度、操作可达、44px 目标和外层水平溢出。 | +| M03 | 正反向 Tab、输入框 Escape、快速取消与重开、背景撤销和拖放隔离、取消后导出字节不变。 | +| M04 | 模板和高级值暂存、取消后设置及字节不变、应用后模板生效、编码/BOM 保存和刷新恢复。 | +| M05 | 80 个章节的长差异列表滚动。页脚始终可达。取消不修改章节数量。 | +| M06 | 设置、预览、日志和帧平移共用关闭行为。草稿不接受遮罩点击关闭。预设取消恢复。 | +| M07 | 虚拟化历史滚动、当前项、导航时滚动保持和关闭后的重做。 | +| M08 | 取消恢复已应用的预设。无变化候选不能再次应用或增加历史。 | +| B19–B23 | 兄弟历史分支、表达式应用、导出及撤销、无效表达式、后续编辑和快速输入。 | + +视口为 1280×800、1920×1080、390×844、390×640、844×390,以及宽度 320、519、520、521、759、760、761,高度 640。 + +Firefox 的 DOMRect 可能将 44 CSS 像素表示为 43.999969。目标断言只允许 0.001 像素的舍入误差。 + +## 视觉证据 + +固定 Linux 环境使用 `node:22-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732` 和锁定的 Playwright 1.63.0。 + +共检查 32 张基线。五种主要尺寸分别覆盖闲置、历史、表达式有效结果、表达式错误、高级导出选项和设置。短屏和横屏另覆盖长表达式结果。 + +记录中的 32 张像素基线已移除。`layout-behavior.spec.ts` 保留布局行为断言。供人工复核的副本和六张汇总图在 `artifacts/wasm-modal-layout/screenshots/`。逐组复核确认标题、输入框、错误列表和页脚没有相互覆盖。长正文需要滚动。截图本身不替代 M01–M08 的行为断言。 + +代表性图片: + +- [`390×640 历史`](../../artifacts/wasm-modal-layout/screenshots/wasm-narrow-short-history-chromium-linux.png) +- [`390×844 表达式错误`](../../artifacts/wasm-modal-layout/screenshots/wasm-narrow-expression-error-chromium-linux.png) +- [`844×390 长表达式结果`](../../artifacts/wasm-modal-layout/screenshots/wasm-landscape-expression-long-chromium-linux.png) + +## 执行记录 + +| 检查 | 结果 | +| --- | --- | +| 变更前回归 | 旧源码的 M01 因缺少历史 dialog 失败。旧发布产物的 M02 因缺少表达式入口失败。 | +| WASM 单元测试 | 42/42 通过。 | +| Release 发布 | 成功。准备为 `/ChapterTool/` 子路径静态站点。 | +| TypeScript 类型检查 | 通过。 | +| 弹窗专项三引擎检查 | 24/24 通过。没有重试。 | +| 最终三引擎完整套件 | 93/93 通过,耗时 4.8 分钟。Chromium、Firefox、WebKit 各 31 项。禁止重试。 | +| Linux 视觉比较 | 2/2 通过。比较运行不使用 `--update-snapshots`。32 张基线已复核。 | +| 翻译生成检查 | 通过。 | +| OpenSpec 严格校验 | 通过。 | +| `git diff --check` | 通过。 | +| 文档链接及 CI YAML | 32 个本地链接和工作流 YAML 校验通过。 | + +主验证命令: + +```text +dotnet test tests/ChapterTool.Wasm.Tests/ChapterTool.Wasm.Tests.csproj --no-restore +dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --configuration Release --no-restore --output artifacts/wasm-e2e/publish +uv run --project scripts scripts/localization/axaml-to-json.py --check +``` + +在 `tests/ChapterTool.Wasm.E2E/` 执行: + +```text +npm run prepare:site +npm run typecheck +npm run test:e2e +npm run test:e2e -- modal-layout.spec.ts +``` + +设置 `CI=1`、`E2E_NO_RETRY=1`、`E2E_RUN_NAME=acceptance` 后执行最终完整套件。报告为 `artifacts/wasm-e2e/report-acceptance/` 和 `junit-acceptance.xml`。专项报告使用后缀 `modal-verification`。Linux 视觉报告为 `artifacts/wasm-e2e/modal-visual-context/artifacts/wasm-e2e/report-visual/`。 + +## CI 与未验证项 + +PR 配置加入了 WebKit 弹窗和编辑回归,以及固定 Linux 视觉检查。Chromium 继续运行完整行为套件。每周和手动验收继续使用三种引擎。视觉、Chromium 和 WebKit 报告使用独立路径。 + +本次没有在 GitHub Actions 上运行工作流。真实 iPhone Safari 的地址栏、软键盘、文字缩放和已部署缓存仍需设备验证。本地 WebKit 和视口模拟不能替代该验证。原始截图中的极窄输入框也不能据此归因于特定 Safari 缺陷。 + +本次没有部署网页,也没有归档或改写已有的完成 change。 diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 00000000..18264e64 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.81.0", + "commands": [ + "docfx" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/legacy/timeshift.cc b/legacy/timeshift.cc new file mode 100644 index 00000000..fc5e357f --- /dev/null +++ b/legacy/timeshift.cc @@ -0,0 +1,191 @@ +#include "windows.h" +#include "cstdio" +#include "iostream" +#include "sstream" +#include "string" +#include "fstream" +#include "iomanip" +#include "conio.h" +using namespace std; + +struct Segment +{ + string ChapterOrder; + int hour; + int minute; + int second_int; + int second_decimal; + int second_total; + string line2; + string ModefiedName; + int cal() + { + second_total = hour * 3600 + minute * 60 + second_int; + return second_total; + } +}; + +inline int GetSegment_line1(string &in, Segment &out); +inline int GetSegment_line2(string &in, Segment &out); +inline int OffsetCal(int offset, int offset_decimal, Segment &output); +inline int GetData(ifstream &in, Segment &output); + + + + +int main(int argc, char *argv[]) +{ + string title1 = "TimeShift"; + SetConsoleTitleA(LPCSTR("TimeShift ver.1.7 TC")); + char choice = 'n'; + if (argv[1] == '\0') + { + cout << "Please drag chapter file to this program" << endl; + cout << "Press any key to continue." << endl; + _getch(); + return 0; + } + Segment output, print; + int offset_time = 0; + int offset_decimal = 0; + for (int i = 1; i < argc; ++i) + { + cout << argv[i] << endl; + + ifstream in(argv[i], ios::binary); + if (!in.is_open()) + { + cout << "Error opening file"; + exit(1); + } + string path = argv[i]; + int pos = path.find_last_of('.'); + if (pos < 0) + { + path = path + "_.txt"; + } + else + { + path.erase(path.begin() + pos, path.end()); + path = path + "_.txt"; + } + ofstream out(path, ios::binary); + if (!out.is_open()) + { + cout << "Error saving file"; + exit(1); + } + + GetData(in, output); + offset_time = output.cal(); + offset_decimal = output.second_decimal; + out << "CHAPTER01=00:00:00.000\r\n"; + if (choice == 'y') + { + out << "CHAPTER01NAME=" << output.ModefiedName << "\n"; + } + else + { + out << "CHAPTER01NAME=Chapter 01\r\n"; + } + int counter = 2; + while (GetData(in, output)) + { + OffsetCal(offset_time, offset_decimal, output); + out << "CHAPTER" << setw(2) << setfill('0') << counter << "=" << setw(2) << setfill('0') << output.hour << ":"; + out << setw(2) << setfill('0') << output.minute << ":" << setw(2) << setfill('0') << output.second_int << "." << setw(3) << setfill('0') << output.second_decimal << "\r\n"; + if (choice == 'y') + { + out << "CHAPTER" << setw(2) << setfill('0') << counter << "NAME=" << output.ModefiedName << "\n"; + } + else + { + out << "CHAPTER" << setw(2) << setfill('0') << counter << "NAME=Chapter" << " " << setw(2) << setfill('0') << counter << "\r\n"; + } + ++counter; + } + out.close(); + } + + return 0; +} + +inline int GetData(ifstream &in, Segment &output) +{ + int flag; + string buffer; + getline(in, buffer); + while(buffer[0]=='\r') + { + cout << "Blank" << endl; + getline(in, buffer); + } + flag = GetSegment_line1(buffer, output); + if (flag == 0) + { + return 0; + } + else + { + getline(in, buffer); + flag = GetSegment_line2(buffer, output); + } + output.cal(); + return flag; +} + + +inline int GetSegment_line1(string &in, Segment &out) +{ + if (in == "") + { + return 0; + } + int EqualSign = in.find_first_of('='); + out.ChapterOrder = in.substr(0, EqualSign); + stringstream buffer(in.substr(EqualSign + 1, in.length())); + char colon; + buffer >> out.hour >> colon >> out.minute >> colon >> out.second_int >> colon >> out.second_decimal; + return 1; +} + + +inline int GetSegment_line2(string &in, Segment &out) +{ + out.line2 = in; + if (in == "") + { + return 0; + } + int EqualSign = in.find_first_of('='); + out.ModefiedName = out.line2.substr(EqualSign + 1, in.length()); + return 1; +} + +inline int OffsetCal(int offset, int offset_decimal, Segment &output) +{ + output.second_total = output.second_total - offset; + output.hour = output.second_total / 3600; + output.minute = (output.second_total / 60) % 60; + output.second_int = output.second_total % 60; + if (output.second_decimal >= offset_decimal) + { + output.second_decimal = output.second_decimal - offset_decimal; + } + else + { + output.second_decimal = 1000 - (offset_decimal - output.second_decimal); + --output.second_int; + if (output.second_int < 0) + { + output.second_int = 59; + --output.minute; + if (output.minute < 0) + { + output.minute = 59; + --output.hour; + } + } + } + return 1; +} diff --git a/openspec/changes/archive/2026-06-07-rewrite-avalonia-dotnet10/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-06-07-rewrite-avalonia-dotnet10/specs/avalonia-ui-shell/spec.md index d9409b96..17d17dd1 100644 --- a/openspec/changes/archive/2026-06-07-rewrite-avalonia-dotnet10/specs/avalonia-ui-shell/spec.md +++ b/openspec/changes/archive/2026-06-07-rewrite-avalonia-dotnet10/specs/avalonia-ui-shell/spec.md @@ -60,10 +60,6 @@ The Avalonia shell SHALL preserve documented shortcuts and context menu actions. - **WHEN** the main window has focus - **THEN** `Ctrl+O`, `Ctrl+S`, `Alt+S`, `Ctrl+R`, `F5`, `Ctrl+L`, and `F11` SHALL invoke the corresponding ViewModel commands -#### Scenario: Clip shortcuts preserve legacy mapping -- **WHEN** `Ctrl+1` through `Ctrl+9` or `Ctrl+0` are pressed -- **THEN** they SHALL select clips 1 through 9 and clip 10 respectively when in range - #### Scenario: Context menus use capability flags - **WHEN** load, clip, or chapter-row context menus open - **THEN** entries such as append MPLS, merge chapters, related media, zones, forward translation, and insert SHALL be enabled only when the ViewModel capability flags allow them diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml b/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml new file mode 100644 index 00000000..6dde4668 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-30 +goal: Make log viewing more intuitive and powerful diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md new file mode 100644 index 00000000..af84405d --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md @@ -0,0 +1,8 @@ +# log-tool-enhancements + +Rebuild the Avalonia log tool around progressive disclosure. + +The tool must show a compact, searchable list first. Users must open a focused +inspector when they need technical data. Copy, clear, and export actions remain +available in a secondary overflow surface. Grouping, time-range filters, and +history pagination are deferred from this change. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md new file mode 100644 index 00000000..039cae16 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md @@ -0,0 +1,194 @@ +## Context + +`LogToolViewModel` reads a bounded snapshot from `IApplicationLogService` and +subscribes to `EntryAdded` and `Cleared`. `ApplicationLogPanelProvider` already +owns the bounded in-memory history. `LogEntryViewModel` already formats the +localized summary, technical detail, exception text, raw JSON, and structured +values. The desktop composition already writes rolling Serilog files below +`settings/logs/`. + +The current revision added several independent concerns at once. The rendered +surface still auto-selects the first entry, reserves a large hidden detail +column, exposes empty detail tabs, and does not bind grouping, export-format, +status, or search-highlight state. The new design fixes the information +hierarchy first and keeps the provider contract stable. + +## Goals / Non-Goals + +**Goals:** + +- Make the list the dominant surface when the tool opens. +- Let users inspect one event without losing the list context on wide windows. +- Use an explicit inspector action and predictable selection semantics. +- Show concise rows, useful search, severity filtering, and visible match + feedback. +- Show only populated inspector sections and keep structured expansion state. +- Keep wide and narrow layouts usable, keyboard navigable, localized, and + screen-reader friendly. +- Keep copy, clear, and JSON/CSV export available as secondary actions. +- Preserve bounded memory, live updates, and existing Serilog archive behavior. + +**Non-Goals:** + +- Do not add grouping, time-range filtering, or cursor-based history loading. +- Do not change `IApplicationLogService.Entries`, `EntryAdded`, `Cleared`, or + the provider capacity contract for this visual change. +- Do not create a log database, remote sink, or archive-management workflow. +- Do not change Core APIs, CLI arguments, browser logging behavior, or settings + persistence. + +## Interaction Model + +The inspector has its own state. A selected row and an open inspector are not +the same thing. + +```text +Open tool + | + v +List only <----- close / Escape <----- Inspector + | ^ + | explicit Details action | + +--------------------------------------+ + wide: split view + narrow: inspector replaces list +``` + +- Opening the tool starts in `List only` with no selected entry. +- Clicking a row selects it and keeps the inspector closed. +- A row details action, Enter, or Space selects and opens that entry. +- Closing the inspector keeps the selected row and list scroll position. +- The same row can open the inspector again through the explicit action. +- A filter change clears selection only when the selected entry is no longer + visible. It must not select a replacement entry. +- A live entry must not steal focus or open the inspector. If the selected + entry is evicted, the selection and inspector close together. + +## Decisions + +### 1. Use a list-first responsive shell + +The root layout must give the list all available width when the inspector is +closed. The inspector column and splitter must have zero effective width in +that state. A wide split view may reserve a fixed inspector width between 400 +and 480 pixels while the list uses the remaining star-sized width. The exact +width must come from stable layout resources rather than content measurement. + +When the list and inspector minimum widths cannot coexist, the view must switch +to an inspector-only state with a localized back action. The view must not rely +on horizontal clipping or a `WrapPanel` for primary toolbar layout. + +### 2. Keep rows concise and information-dense + +Each row must have a stable height and one primary summary line. The row must +show a severity marker, localized severity accessible text, local time, and a +truncated summary. Operation, category, and event identity may appear in a +muted secondary line only when they are not already represented by the summary. +The row must expose an explicit details action with a localized automation +name. Empty fields must not reserve layout space. + +The row template must render the highlight runs produced by the projection. A +search match that exists only in hidden technical data must still retain the +row and expose an accessible indication of the match. + +### 3. Make the inspector progressive + +The inspector must use a compact context line for level, time, and identity. It +must not repeat the summary in a large header block. It must show the message, +technical detail, properties, exception, and structured data only when those +values exist. Empty sections and placeholder tabs must not occupy space. + +Structured values must use cached recursive nodes. A node with children must +bind its disclosure control two-way to `IsExpanded`. The projection must enforce +a finite depth and display a localized or stable depth-limit marker. Rebinding +the selected entry must not reset expansion state. + +Raw JSON and copy actions belong to the inspector's secondary action menu. A +user can still select and copy complete technical data without making raw JSON +the default visual surface. + +### 4. Keep filtering discoverable but quiet + +The primary toolbar must contain search, the visible result count, and one +severity filter affordance. The filter surface must label each control. An +active-filter indicator must be visible without opening the menu. Search must +be case-insensitive across the summary, category, operation, event identity, +technical detail, exception text, and structured/raw values. + +This change has no time-range or grouping control. Those controls must not be +reintroduced as disabled or unbound placeholders. + +### 5. Put destructive and infrequent actions in overflow + +Copy summary, copy details, clear, and export must be available through one +labeled overflow menu or an equivalent secondary action surface. The primary +toolbar must not give all four actions equal visual weight with search. JSON and +CSV choices must be inside the export action surface. Export uses the current +visible membership and must preserve selection and inspector state. + +The ViewModel must use `IApplicationLogExporter` when the host provides it. It +must not access the filesystem. Export status and recoverable failures must be +shown in a compact localized status region that is hidden when empty. + +### 6. Preserve boundaries and localization + +The existing bounded provider and Serilog rolling sink remain the source of +log data. This change must not add provider cursors or a persistent history +index. All user-facing labels, automation names, empty states, and status text +must come from the shared locale AXAML resources. Generated Wasm JSON must be +updated only through the repository conversion script. + +### 7. Test visible behavior + +Unit tests must cover projection and state transitions. Headless tests must +drive row selection, explicit inspection, closing, reopening, filtering, +search, keyboard focus, and secondary actions. Screenshot captures must cover +default, wide, narrow, and localized layouts. Tests must assert bounds and +visibility outcomes, not only control existence. + +## Risks / Trade-offs + +- A list-first default changes the previous auto-selection behavior. Tests must + prove that explicit inspection remains fast and that selection is preserved. +- A narrow inspector-only mode adds a visual state. Headless tests must verify + the back action and focus restoration at the tool minimum width. +- Rendering highlight runs and cached structured nodes adds projection state. + The ViewModel must reuse entry projections and update collections once per + filter or live-entry change. +- Moving actions into overflow can make them less obvious. The overflow button + must have a stable accessible name, and each menu item must have a tooltip or + localized label. +- Export can fail for host or file-system reasons. The exporter boundary must + return a failure result and leave the list and inspector unchanged. + +## Migration Plan + +1. Remove the previous draft-only grouping, time-range, and cursor-history + contracts and bindings. Keep the existing entries and live-event contract. +2. Add explicit selection and inspector state. Stop automatic selection and + automatic inspector opening. Add a row details command and focus rules. +3. Rework the row projection and inspector templates. Cache structured nodes, + bind expansion state, and render search matches. +4. Replace the fixed split layout and crowded footer with the responsive shell, + filter affordance, and secondary overflow actions. +5. Keep or finish the existing exporter behind the overflow action. Do not + modify the automatic Serilog archive. +6. Update locale resources and generated Wasm JSON. Update code-map ownership + and test guidance. +7. Run focused Avalonia unit tests, Headless tests in a separate process, + infrastructure export tests, the application build, and the full solution + test sequence. + +Rollback is file-level. Removing the inspector bindings and commands restores +the existing bounded list and logging sink. No provider data migration is +required. + +## Resolved Decisions + +- The list starts without a selected entry. Reuse of an existing tool window + may retain state only after the user has explicitly opened that inspector. +- The inspector is a split surface on wide windows and a replacement surface + on narrow windows. +- The default inspector view is conditional sections, not four permanently + visible tabs. +- Export defaults to the visible filtered membership and remains secondary. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md new file mode 100644 index 00000000..8aca8858 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md @@ -0,0 +1,93 @@ +## Why + +The current LogTool opens a technical panel as soon as it creates the first +selection. The panel repeats the message and identity, keeps several empty +tabs visible, and leaves large unused areas for sparse entries. The fixed +master-detail columns also clip content at the tool minimum width. The footer +places diagnostic actions beside the primary search workflow and makes the +common view harder to scan. + +Users need a quick answer to two different questions: + +1. Which recent operation needs attention? +2. What technical evidence belongs to one selected event? + +The first question must be answered by the list. The second question must be +available on demand. The existing bounded log provider, live-entry events, and +Serilog rolling files already supply the required data. A visual redesign must +not expand that provider contract with speculative history or grouping APIs. + +## What Changes + +- Open the tool in a list-first state with no automatic selection or inspector. +- Render each row as a stable one-line summary with a severity marker and local + time. Show operation, category, and event identity only as compact secondary + context when they add information. +- Add an explicit details action for each row. Selecting a row must not open the + inspector by itself. Enter, Space, or the accessible row action can open it. +- Show a compact inspector only after an explicit request. Use a narrow header + for the selected event and show only non-empty technical sections. +- Keep the list available beside the inspector at a wide width. Replace the + list with the inspector and a back action when the two surfaces cannot fit. +- Keep search and severity filtering in the primary toolbar. Put copy, clear, + and JSON/CSV export in a labeled secondary overflow surface. +- Render search matches in the row instead of exposing highlight metadata that + the view does not consume. +- Keep structured values expandable with stable, two-way expansion state and a + finite depth limit. +- Preserve bounded live updates, selection state, keyboard focus, and localized + resource boundaries. +- Keep the existing automatic Serilog archive unchanged. Manual export, when + available on the host, must use the existing exporter boundary. + +## Deferred From This Change + +- Level grouping and grouped virtualized sections. +- Time-range filters. +- Cursor-based history pagination and older-page loading. +- A new persistent log database or a change to the bounded provider capacity. + +These features may be proposed separately after the list and inspector +workflow has evidence from real use. + +## Capabilities + +### New Capabilities + +- `log-tool-viewing`: List-first presentation, concise summaries, explicit + inspection, responsive behavior, search, severity filtering, and accessible + structured details. +- `log-export-archive`: Secondary JSON/CSV export and preservation of the + existing automatic archive behavior. + +### Modified Capabilities + +None. + +## Impact + +- Avalonia UI: `src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml`, + `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs`, and + `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs`. +- Host composition: `src/ChapterTool.Avalonia/Services/StandardToolCatalogFactory.cs` + only when the inspector needs a host capability already present in the + catalog. +- Optional export adapter: `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs`. +- Localization: `src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/*.axaml` + and generated Wasm resources through `python3 scripts/axaml-to-json.py`. +- Tests: `tests/ChapterTool.Avalonia.Tests`, + `tests/ChapterTool.Avalonia.Headless.Tests`, and the focused infrastructure + export tests when export remains enabled. +- Documentation: `docs/code-map/avalonia.md` and `docs/code-map/testing.md`. +- Core, CLI, browser log contracts, settings schema, and automatic archive + retention are outside the change. + +## Success Criteria + +- A default screenshot shows the list as the dominant surface and no inspector. +- An explicit details action opens the inspector without losing list context. +- Closing and reopening details does not change the selected row or scroll + position. +- Default, wide, narrow, and all three supported locales render without + clipping or overlapping controls. +- Unit and Headless tests prove the state transitions and visible outcomes. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md new file mode 100644 index 00000000..33cc817e --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Export is a secondary action + +The LogTool MUST expose an explicit export action from a labeled secondary +overflow surface. JSON and CSV choices must be available within that surface. +The primary list and search toolbar must remain usable without opening export. + +#### Scenario: Export choices stay out of the primary toolbar + +- **WHEN** the log tool opens with no menu open +- **THEN** search, count, and the severity filter remain visible while export + format choices are hidden in the secondary action surface + +#### Scenario: Export a selected format + +- **WHEN** the user chooses JSON or CSV and activates export +- **THEN** the host exporter receives that format and a snapshot of the current + visible log membership + +### Requirement: Export preserves the visible state + +Export MUST use the current severity-filtered and search-filtered membership by +default. It must not change selection, inspector visibility, filter values, or +the provider's bounded history. + +#### Scenario: Export after filtering + +- **WHEN** the user applies a severity filter or search query and exports +- **THEN** the output request contains only entries visible under those filters + in deterministic timestamp order + +#### Scenario: Export succeeds or fails + +- **WHEN** the exporter returns success or a recoverable failure +- **THEN** the list, selected row, inspector state, and filter values remain + unchanged and a compact localized status is shown + +### Requirement: Export failures are recoverable + +The export boundary MUST convert expected path, permission, encoding, and file +write failures into a failure result. The UI command must remain usable after a +failure and must not clear retained entries. + +#### Scenario: Export target cannot be written + +- **WHEN** the host cannot create or write the export target +- **THEN** the command completes without an uncaught file-system exception, + shows localized failure feedback, and leaves the log view usable + +### Requirement: Existing automatic archives remain unchanged + +The desktop logging composition MUST keep its existing rolling Serilog files +below `settings/logs/`. This change must not add a second archive workflow or +change retention settings. + +#### Scenario: Desktop logging starts + +- **WHEN** the desktop composition starts with a settings directory +- **THEN** the existing log directory and rolling sink behavior remain intact diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md new file mode 100644 index 00000000..b9437ed7 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md @@ -0,0 +1,159 @@ +## ADDED Requirements + +### Requirement: The log tool opens as a list-first surface + +The LogTool MUST open with no selected entry and no inspector. The retained log +list must occupy the available content width until the user requests details. + +#### Scenario: Open an empty or populated log tool + +- **WHEN** the log tool is created with zero or more retained entries +- **THEN** the inspector is hidden, no entry is selected automatically, and the + list or localized empty state is visible + +#### Scenario: A live entry arrives + +- **WHEN** a new accepted entry arrives while the tool is open +- **THEN** the list updates without selecting the new entry, moving keyboard + focus, or opening the inspector + +### Requirement: Rows provide concise summaries + +Each retained entry MUST render as a stable compact row with a severity marker, +localized severity accessible text, local time, and a truncated summary. Optional +operation, category, or event identity may appear as muted context when it adds +information. Empty values must not reserve space. + +#### Scenario: Routine entry is rendered + +- **WHEN** an entry has a summary and timestamp but no extra identity +- **THEN** the row shows one readable summary line, a severity marker, and the + local time without empty badges or columns + +#### Scenario: Diagnostic identity is useful + +- **WHEN** an entry has category, operation, or event identity that is not in + the summary +- **THEN** the row exposes that identity as compact secondary context and the + inspector remains closed until explicitly requested + +### Requirement: Details require an explicit action + +Each row MUST expose an accessible details action. Selecting a row alone MUST +not open the inspector. Activating the details action, Enter, or Space must +select the entry and open the inspector. + +#### Scenario: Select without inspecting + +- **WHEN** the user clicks a row body +- **THEN** the row becomes selected and the list remains the only visible data + surface + +#### Scenario: Open and close the inspector + +- **WHEN** the user activates the selected row details action +- **THEN** the inspector displays that entry without removing the list on a + wide window +- **WHEN** the user closes the inspector or presses Escape +- **THEN** the list remains at its current filter and scroll state, the selected + row remains selected, and focus returns to the row or its details action + +### Requirement: The inspector uses progressive disclosure + +The inspector MUST show a compact context line and only non-empty message, +technical, property, exception, or structured sections. It must not show a +large repeated summary header or permanently visible empty tabs. + +#### Scenario: Sparse entry is inspected + +- **WHEN** the selected entry has no technical, property, exception, or nested + structured data +- **THEN** the inspector shows compact context and one localized empty state + without blank tab bodies + +#### Scenario: Technical entry is inspected + +- **WHEN** the selected entry has technical detail, an exception, or structured + values +- **THEN** the corresponding populated sections are available, while raw data + and copy actions remain secondary + +### Requirement: Structured values remain expandable + +Nested dictionaries and enumerable values MUST render as recursive nodes. A node +with children must expose an accessible disclosure control bound two-way to its +mutable expansion state. The projection must enforce a finite depth limit. + +#### Scenario: Expand a nested value + +- **WHEN** the selected entry contains nested structured state +- **THEN** the inspector shows a disclosure control and preserves the user's + expansion choice while that entry remains selected + +#### Scenario: Depth limit is reached + +- **WHEN** structured data exceeds the supported nesting depth +- **THEN** the terminal node shows a stable depth-limit marker and the view + remains responsive + +### Requirement: Search and severity filtering stay primary + +The LogTool MUST provide case-insensitive search and severity filtering without +changing retained provider history. Search must cover the summary, category, +operation, event identity, technical detail, exception text, and structured/raw +values. The filter surface must label its controls and expose an active state. + +#### Scenario: Search finds hidden diagnostic text + +- **WHEN** the query occurs only in exception text or structured state +- **THEN** the entry remains visible and the row exposes a localized indication + or highlight that the match is in technical data + +#### Scenario: Search highlights a summary + +- **WHEN** the query occurs in the visible summary +- **THEN** the matching segment is rendered with the log highlight style and + clearing the query removes stale highlights + +#### Scenario: Severity filter changes membership + +- **WHEN** the user selects a severity filter +- **THEN** only matching retained entries are shown, the count updates, and no + replacement entry is selected automatically + +### Requirement: Wide and narrow layouts remain usable + +The inspector MUST use a bounded width beside a star-sized list when the tool +is wide. When the list and inspector minimum widths cannot coexist, the +inspector must replace the list with a localized back action. Hidden columns, +fixed-width overflow, and clipped text are not valid responsive behavior. + +#### Scenario: Inspector opens at the wide size + +- **WHEN** details are opened in a wide tool window +- **THEN** the list remains visible, the inspector is bounded, and both surfaces + have independent scrolling where needed + +#### Scenario: Inspector opens at the narrow size + +- **WHEN** details are opened at the supported narrow width +- **THEN** the inspector fits within the window, exposes a back or close action, + and no control or text is clipped horizontally + +### Requirement: Localization and accessibility are preserved + +User-facing labels, empty states, status text, tooltips, and automation names +MUST resolve from the shared locale resources. Icon-only actions MUST have +accessible names. Keyboard navigation MUST reach the row details action, the +inspector close/back action, structured disclosures, and the filter surface. +#### Scenario: Locale changes while the tool is open + +- **WHEN** the active UI locale changes +- **THEN** list labels, inspector labels, filter labels, and accessibility names + refresh without losing the selected entry or expansion state + +#### Scenario: Keyboard inspection workflow + +- **WHEN** a keyboard user moves to a row and presses Enter or Space +- **THEN** the selected row opens its inspector and Escape returns focus to the + list diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md new file mode 100644 index 00000000..9978db64 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md @@ -0,0 +1,75 @@ +## 1. Scope And Boundary Cleanup + +- [x] 1.1 Record the current list, provider, localization, and host composition + behavior before changing the view. +- [x] 1.2 Remove the previous draft-only grouping, time-range, and cursor-history + contracts, commands, bindings, and tests from this change. +- [x] 1.3 Keep `IApplicationLogService.Entries`, `EntryAdded`, `Cleared`, and the + bounded provider capacity contract unchanged. +- [x] 1.4 Keep `IApplicationLogExporter` optional and host-injected when manual + export is enabled. Keep automatic Serilog archive settings unchanged. + +## 2. Projection And ViewModel State + +- [x] 2.1 Make the initial state list-first with a null selection and a closed + inspector. Remove automatic selection on load, filtering, and live updates. +- [x] 2.2 Add explicit open, close, and narrow back commands with selection, + focus, and scroll-preservation rules. +- [x] 2.3 Build concise summary and optional context projections. Omit empty or + duplicated operation, category, and event identity fields. +- [x] 2.4 Cache structured nodes per entry, bind mutable expansion state two-way, + and keep the finite depth-limit behavior. +- [x] 2.5 Implement case-insensitive search across visible and technical fields, + severity filtering, visible counts, and rendered highlight runs. +- [x] 2.6 Keep live additions and capacity eviction incremental. Do not steal + focus or open the inspector when a new entry arrives. +- [x] 2.7 Keep copy, clear, and export commands secondary. Preserve all view state + after export success or recoverable failure and expose localized status. + +## 3. Responsive Avalonia View + +- [x] 3.1 Make the list fill the content width when the inspector is closed. Do + not reserve a hidden detail column or splitter. +- [x] 3.2 Render stable one-line rows with severity marker, local time, concise + summary, optional context, and an accessible explicit details action. +- [x] 3.3 Render the inspector with a compact context line and populated sections + only. Keep raw data and copy actions in the inspector or action overflow. +- [x] 3.4 Bind structured disclosure controls to `IsExpanded` and provide + keyboard-accessible names and focus behavior. +- [x] 3.5 Add a wide split state and a narrow inspector-only state with a back or + close action. Verify no horizontal clipping at supported widths. +- [x] 3.6 Replace the crowded footer with search, count, severity filter, and a + labeled secondary overflow surface. Use stable Grid sizing for the toolbar. + +## 4. Localization And Host Wiring + +- [x] 4.1 Add or update concise row, inspector, filter, overflow, status, and + accessibility strings in `en-US`, `zh-CN`, and `ja-JP` locale AXAML files. +- [x] 4.2 Run `python3 scripts/axaml-to-json.py` and + `python3 scripts/axaml-to-json.py --check`; do not edit generated Wasm JSON + by hand. +- [x] 4.3 Register the optional exporter through the existing desktop + composition without adding filesystem access to the ViewModel. +- [x] 4.4 Verify that `LoggingModule` still creates `settings/logs/` and keeps + the existing rolling Serilog sink and retention behavior. + +## 5. Tests And Documentation + +- [x] 5.1 Add Avalonia unit tests for list-first state, explicit inspection, + close/reopen, selection preservation, filtering, search highlights, live + updates, eviction, structured expansion, and secondary command state. +- [x] 5.2 Add infrastructure exporter tests for UTF-8 JSON/CSV output, quoting, + visible scope, and recoverable failures when export remains enabled. +- [x] 5.3 Add Headless workflow tests for row selection, details action, keyboard + and Escape behavior, populated/empty inspector sections, overflow actions, + localization, and narrow replacement layout. +- [x] 5.4 Capture default, wide, narrow, and localized screenshots under + `artifacts/` and review bounds, clipping, focus surfaces, and information + hierarchy. +- [x] 5.5 Update `docs/code-map/avalonia.md` and `docs/code-map/testing.md` with + the final ownership and test boundaries. +- [x] 5.6 Run the focused Avalonia unit project, the Headless project in a + separate process, focused infrastructure tests, the Avalonia build, and the + full solution tests sequentially. +- [x] 5.7 Run `openspec validate "log-tool-enhancements" --strict` and review all + changed artifacts before archive. diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/.openspec.yaml b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/.openspec.yaml new file mode 100644 index 00000000..7a8e2be6 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/design.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/design.md new file mode 100644 index 00000000..826155f8 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/design.md @@ -0,0 +1,60 @@ +## Context + +快捷键目前由 `ShortcutRouter` 的字符串 switch、主窗口 XAML 的 `InputGesture` 和本地化提示分别维护。设置文档使用统一的 `ChapterToolSettings` 聚合存储,设置工具已经具备草稿、实时应用、保存、重置和放弃生命周期。详见 `proposal.md` 及本变更的 capability specs。 + +## Goals / Non-Goals + +**Goals:** + +- 建立单一快捷键动作目录,供路由、菜单显示、设置编辑和测试共享。 +- 将快捷键覆盖值纳入现有版本化设置聚合,并保持旧文档默认行为。 +- 在设置 Tab 中提供键盘录入、冲突校验、逐项重置、全部恢复和保存/放弃反馈。 +- 通过 Avalonia Headless 驱动真实视图和命令,验证用户可观察的完整流程。 + +**Non-Goals:** + +- 不改变命令本身的业务语义或增加新的主窗口操作。 +- 不支持宏、序列快捷键、鼠标手势或跨设备同步。 +- 不要求 Wasm 浏览器端提供同等的可编辑快捷键 UI;浏览器默认快捷键拦截继续使用其现有保护逻辑。 + +## Decisions + +1. **稳定动作标识符 + 独立手势值** + - 为每个动作定义不可本地化的 ID,显示名称和默认 `KeyGesture` 分开存储。运行时使用 ID 查找命令,避免语言变化或菜单文本变化破坏映射。 + - 备选方案:直接以菜单标题作为键。放弃原因是本地化和重命名会造成设置迁移问题。 + +2. **共享运行时映射服务** + - 由一个无 UI 依赖的目录/映射组件负责解析、规范化、冲突检测和按手势查找动作。`ShortcutRouter` 只消费该组件并调用现有 ViewModel 命令。 + - 备选方案:继续在 View 中注册多个 `KeyBinding`。放弃原因是无法统一处理动态更新,也难以测试冲突和文本输入焦点边界。 + +3. **设置文档新增 shortcuts section** + - 在 `ChapterToolSettings` 增加可选的快捷键覆盖集合;规范化时补齐缺省动作并忽略未知 ID。保存仍由现有聚合 store 一次完成,schema 版本按连续升级规则处理缺少 section 的旧文档。 + - 备选方案:单独 shortcuts.json。放弃原因是违反统一设置文档和一次提交约束。 + +4. **设置编辑采用录入控件而非自由文本** + - 快捷键行使用专门的 gesture capture 控件或等效事件适配,将按键事件转换为规范字符串;文本框仅用于显示,不作为解析入口。冲突和保留键校验在草稿层完成。 + - 备选方案:允许用户手写 `Ctrl+...` 文本。放弃原因是格式错误率高,且无法可靠表达平台键名。 + +5. **Headless 测试覆盖完整工作流** + - 在现有 Headless 测试集合中打开 `SettingsToolView`,通过 UI 线程录入手势、保存设置,再在主窗口发送该手势并观察目标命令状态/调用记录;同时覆盖冲突、重置和放弃。 + - 备选方案:只测试 ViewModel 属性。放弃原因是无法验证 Tab 可见性、绑定和菜单手势更新。 + +## Risks / Trade-offs + +- [平台键名差异] → 使用 Avalonia `KeyGesture` 的规范解析和显示,不持久化平台特定扫描码;对无法跨平台表示的组合显示校验错误。 +- [旧设置兼容] → 缺少 shortcuts section 时注入默认映射,未知动作 ID 在加载时忽略并在保存时不重新生成。 +- [动态菜单绑定复杂度] → 将菜单 InputGesture 绑定到可观察的活动映射,并以 Headless 测试验证自定义后即时刷新。 +- [命令冲突导致不可达] → 保存前进行全量唯一性检查;冲突时保留上一次有效运行时映射,阻止提交。 + +## Migration Plan + +1. 增加快捷键模型、目录和 `ChapterToolSettings` section,提供从当前默认映射的规范化升级。 +2. 改造 `ShortcutRouter`、主窗口菜单/提示和设置 ViewModel/View,接入共享映射和草稿生命周期。 +3. 添加非 Headless 的目录/校验测试,再添加 Headless 设置与主窗口工作流测试。 +4. 发布后首次加载旧设置时自动补齐 shortcuts 默认值;若保存失败,继续使用此前有效映射。 + +回滚时可移除快捷键 section 的读取覆盖并恢复目录默认路由;未知 section 字段会被忽略,因此旧版本不会破坏已有设置文件。 + +## Open Questions + +无。动作集合、冲突策略、持久化位置和测试边界已由本变更规格确定。 diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/proposal.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/proposal.md new file mode 100644 index 00000000..f0af8f53 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/proposal.md @@ -0,0 +1,27 @@ +## Why + +当前 Avalonia 快捷键分散在视图代码和 `ShortcutRouter` 中,存在硬编码、重复映射和无法自定义的问题。用户需要在不修改配置文件或重新编译应用的情况下调整常用操作,并且设置变更必须可持久化、可验证且不破坏现有工作流。 + +## What Changes + +- 抽取统一的快捷键动作目录、默认手势和运行时路由,消除视图中的散落硬编码。 +- 在设置工具中新增“快捷键”Tab,按动作显示当前手势,支持录入、重置和保存。 +- 对非法手势、重复手势和平台不可用手势提供即时校验,并阻止保存冲突配置。 +- 将快捷键映射加入版本化 `settings.json`,旧配置自动使用默认映射,保留未知字段兼容性。 +- 让主窗口菜单、提示文本和 Headless 测试使用同一份运行时映射。 +- 增加 ViewModel、设置持久化和 Avalonia Headless 工作流测试,覆盖自定义、冲突、重置、保存及重新加载。 + +## Capabilities + +### New Capabilities + +- `keyboard-shortcut-customization`: 定义动作目录、手势校验、用户自定义生命周期及运行时路由契约。 + +### Modified Capabilities + +- `avalonia-ui-shell`: 主窗口快捷键和菜单手势改为读取可配置映射,同时保留默认行为。 +- `versioned-settings-document`: 版本化设置文档新增快捷键 section,并定义缺省与升级行为。 + +## Impact + +影响 `ShortcutRouter`、主窗口菜单/提示绑定、`SettingsToolViewModel` 与 `SettingsToolView`,以及 `ChapterToolSettings` 的序列化和规范化逻辑。需要新增快捷键领域模型与本地化资源,并扩展 `ChapterTool.Avalonia.Tests`、`ChapterTool.Avalonia.Headless.Tests`;不引入外部依赖。 diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..495907b6 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: Legacy-inspired cross-platform UX +The Avalonia main window SHALL preserve the original ChapterTool workflow density while using cross-platform Avalonia controls and services. + +#### Scenario: Main window uses modern responsive layout +- **WHEN** the main window is opened at its default size +- **THEN** it SHALL use responsive Avalonia layout panels rather than absolute coordinates, preserving the original workflow zones without attempting a 1:1 WinForms geometry clone + +#### Scenario: Main window avoids absolute layout +- **WHEN** the main window XAML is inspected +- **THEN** the primary layout SHALL NOT use `Canvas`, `Canvas.Left`, or `Canvas.Top` to position normal workflow controls + +#### Scenario: Main window text is readable +- **WHEN** the main window XAML is inspected or rendered +- **THEN** visible Chinese labels SHALL be stored as valid UTF-8 text and SHALL NOT appear as mojibake strings such as `杞藉叆` or `淇濆瓨` + +#### Scenario: Main surface matches legacy workflow zones +- **WHEN** the main window is rendered +- **THEN** it SHALL present an intuitive light tool-style surface with Load and Save actions, frame rounding controls, a central editable chapter grid, and a bottom options panel for save format, XML language, naming, order shift, expression, and log/status controls + +#### Scenario: Auxiliary actions remain discoverable without visual clutter +- **WHEN** optional actions such as preview, refresh, color, language, template, zones, forward shift, related media, or append MPLS are available +- **THEN** they SHALL be reachable from compact buttons or context menus on the relevant workflow area rather than from an always-visible marketing-style navigation strip + +#### Scenario: Load variants are reachable from a visible control +- **WHEN** the load action offers the Reload and Append MPLS variants +- **THEN** the variants SHALL be reachable from a visible split-style control on the Load action +- **AND** the variants SHALL NOT be reachable only through a right-click context menu on a button + +#### Scenario: Frame-rate change action has a visible entry point +- **WHEN** the Change FPS action is available for the frame-rate selector +- **THEN** it SHALL be reachable from a visible control next to the selector + +#### Scenario: Keyboard shortcuts are displayed +- **WHEN** a menu item or primary action has a keyboard shortcut +- **THEN** the menu item SHALL display the active shortcut from the shared shortcut catalog as an input gesture, or the control tooltip SHALL include that shortcut text + +#### Scenario: Platform-specific integration is gated +- **WHEN** a workflow needs file picking, directory picking, clipboard, shell-open, settings, or file association +- **THEN** the UI SHALL use platform service abstractions and SHALL NOT require direct Windows registry access for normal cross-platform operation + +#### Scenario: Registry-dependent actions are not primary controls +- **WHEN** the normal cross-platform main window is rendered +- **THEN** registry-dependent integrations such as `.mpls` file association SHALL NOT be exposed as always-visible primary controls + +### Requirement: Keyboard and menu routing +The Avalonia shell SHALL route the active, user-configurable shortcut mapping and preserve context menu actions. + +#### Scenario: Global shortcuts route to commands +- **WHEN** the main window has focus and an active mapped gesture is pressed +- **THEN** the command associated with that gesture SHALL execute + +#### Scenario: Default mapping preserves legacy behavior +- **WHEN** no shortcut overrides exist +- **THEN** `Ctrl+O`, `Ctrl+S`, `Alt+S`, `Ctrl+R`, `F5`, `Ctrl+L`, and `F11` SHALL invoke the corresponding ViewModel commands + +#### Scenario: Context menus use capability flags +- **WHEN** load, clip, or chapter-row context menus open +- **THEN** entries such as append MPLS, merge chapters, related media, zones, forward translation, and insert SHALL be enabled only when the ViewModel capability flags allow them + +#### Scenario: MPLS clip merge is a checked toggle +- **WHEN** an MPLS source exposes multiple clip options and the user invokes merge chapters from the clip or chapter-row context menu +- **THEN** the menu item SHALL show a checked state and the current chapter rows SHALL represent all clips combined into one chapter set +- **AND** invoking the same checked menu item again SHALL clear the checked state and restore the individual clip options and selected clip rows diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/keyboard-shortcut-customization/spec.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/keyboard-shortcut-customization/spec.md new file mode 100644 index 00000000..41f8d3ee --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/keyboard-shortcut-customization/spec.md @@ -0,0 +1,67 @@ +## Purpose + +Provide a unified, testable, and persistent shortcut catalog for the main window and settings tool so users can safely customize gestures and use them in the current session. + +## ADDED Requirements + +### Requirement: Shortcut catalog exposes stable actions and defaults +The system SHALL expose a shortcut action catalog with stable identifiers, display names, default gestures, and availability descriptions. + +#### Scenario: Catalog contains existing actions +- **WHEN** the shortcut catalog is initialized +- **THEN** it SHALL contain save、reload、log、preview and clip-selection actions with their current default gestures + +#### Scenario: Defaults are deterministic +- **WHEN** no user shortcut overrides exist +- **THEN** runtime routing and visible menu gestures SHALL use the catalog defaults + +### Requirement: Users can edit shortcuts in settings +The settings tool SHALL provide a dedicated "Shortcuts" tab that allows users to record, clear, reset, and save gestures for configurable actions. + +#### Scenario: Record a new gesture +- **WHEN** the user presses a valid key combination in a shortcut row and saves +- **THEN** the draft gesture for that action SHALL update and appear in the current window + +#### Scenario: Reset one shortcut +- **WHEN** the user selects reset for an action +- **THEN** that action SHALL restore the catalog default gesture and other drafts SHALL remain unchanged + +### Requirement: Invalid and conflicting gestures are rejected +The system SHALL reject gestures that cannot be parsed, reserved key combinations without modifiers, and duplicate gestures assigned to multiple actions, and SHALL provide localizable error states. + +#### Scenario: Duplicate gesture +- **WHEN** two actions use the same non-empty gesture +- **THEN** the settings tool SHALL mark the conflict and prevent saving + +#### Scenario: Invalid gesture input +- **WHEN** the user enters text that Avalonia KeyGesture cannot parse +- **THEN** the row SHALL show a validation error and SHALL NOT change the saved mapping + +### Requirement: Runtime routing uses the active mapping +The main window SHALL route gestures to the corresponding commands according to the current session shortcut mapping; it SHALL NOT intercept editing shortcuts when a text input control has focus. + +#### Scenario: Customized gesture invokes command +- **WHEN** the user presses a customized gesture in the main window after saving it +- **THEN** the corresponding command SHALL execute, and the old default gesture SHALL no longer trigger that action unless it remains assigned to another action + +#### Scenario: Text editing preserves control shortcuts +- **WHEN** an editable control has focus and the user presses an editing shortcut +- **THEN** the shortcut router SHALL NOT execute an application command + +### Requirement: Shortcut changes apply and persist safely +Shortcut drafts SHALL follow the settings tool's existing load, live-apply, save, reset, and discard lifecycle, and SHALL preserve the last valid mapping when saving fails. + +#### Scenario: Save and reload +- **WHEN** the user saves valid shortcuts and reopens the settings tool +- **THEN** the saved gestures SHALL be restored one-to-one with the catalog action identifiers + +#### Scenario: Discard unsaved edits +- **WHEN** the user changes shortcuts and discards the settings +- **THEN** the current session SHALL restore the most recently saved mapping + +### Requirement: Headless workflow verifies shortcut customization +The system SHALL provide Avalonia Headless tests that drive the real settings view and main-window commands to verify shortcut customization behavior. + +#### Scenario: Headless customize workflow +- **WHEN** a Headless test records and saves a gesture in the Shortcuts tab and triggers the customized gesture +- **THEN** the test SHALL observe settings persistence, an updated menu gesture, and execution of the target command diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/versioned-settings-document/spec.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/versioned-settings-document/spec.md new file mode 100644 index 00000000..d6490e04 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/specs/versioned-settings-document/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Settings use one sectioned document +The system SHALL persist current user configuration in one `settings.json` document containing a top-level schema version and typed application, theme, font, and shortcut sections. + +#### Scenario: Saving the aggregate creates the unified shape +- **WHEN** aggregate settings are saved and no active configuration exists +- **THEN** `settings.json` SHALL be written with the current `schemaVersion` +- **AND** it SHALL contain `application`, `theme`, `font`, and `shortcuts` child content + +#### Scenario: Settings workflow loads and saves once +- **WHEN** a settings workflow loads application, theme, font, and shortcut values and then saves changes across one or more child sections +- **THEN** it SHALL call the aggregate store load exactly once +- **AND** it SHALL commit the updated aggregate exactly once +- **AND** the store SHALL replace `settings.json` exactly once for that commit + +#### Scenario: Unchanged document is parsed once +- **WHEN** multiple consumers load settings through the shared aggregate store and `settings.json` has not changed +- **THEN** the first load SHALL parse the document +- **AND** subsequent loads SHALL reuse the cached normalized aggregate without reopening or reparsing the file + +#### Scenario: Isolated update preserves other child content +- **WHEN** a consumer updates one child value through the aggregate store update operation +- **THEN** the store SHALL read the latest document once under the canonical-path lock +- **AND** it SHALL write the transformed aggregate once while preserving other child content + +#### Scenario: No section persistence stores exist +- **WHEN** application services, ViewModels, CLI workflows, and tool discovery access persisted settings +- **THEN** they SHALL depend on `ISettingsStore` +- **AND** the system SHALL NOT expose separate application, theme, font, or shortcut persistence stores diff --git a/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/tasks.md b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/tasks.md new file mode 100644 index 00000000..f3396112 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-customize-keyboard-shortcuts/tasks.md @@ -0,0 +1,33 @@ +## 1. Shortcut Domain Model + +- [x] 1.1 Add stable shortcut action identifiers, default gesture metadata, display-resource keys, and an immutable active-mapping model in the shared contracts/UI-independent layer; verify the catalog contains save, reload, log, preview, and clip-selection defaults with deterministic ordering. +- [x] 1.2 Implement gesture parsing, normalization, reserved-key validation, duplicate detection, unknown-action filtering, and draft-to-active conversion; verify focused unit tests cover valid gestures, empty bindings, malformed gestures, reserved keys, and conflicts. +- [x] 1.3 Add shortcut overrides to `ChapterToolSettings`, normalization, JSON serialization, and schema-upgrade handling while preserving unrelated sections; verify settings contract/store tests load old documents with catalog defaults and round-trip customized mappings without losing theme or font data. + +## 2. Runtime Routing + +- [x] 2.1 Refactor `ShortcutRouter` to consume the active shortcut mapping and dispatch by stable action identifier instead of a hard-coded gesture switch; verify existing default shortcut tests still invoke the same commands. +- [x] 2.2 Wire active-mapping updates from the preference/settings session into the main-window ViewModel and router, including removal of replaced gestures; verify a changed gesture invokes only its newly assigned command and the old gesture no longer does so. + +## 3. Settings Shortcut Tab + +- [x] 3.1 Extend `SettingsToolViewModel` with observable shortcut rows, draft values, validation messages, per-row reset, reset-all, dirty-state tracking, save, and discard integration with `SettingsSnapshotCoordinator`; verify ViewModel tests cover valid edits, reset isolation, conflict blocking, save failure rollback, and discard. +- [x] 3.2 Add a dedicated shortcuts Tab to `SettingsToolView.axaml` using responsive row layout, accessible names, gesture capture controls, validation visuals, and reset actions; verify compiled bindings build and Headless view construction finds the Tab and all action rows. +- [x] 3.3 Bind main-window menu `InputGesture` and shortcut hints to the active mapping rather than literals while preserving visible defaults; verify the rendered menu updates after a saved customization and still exposes all primary actions. + +## 4. Localization and Resources + +- [x] 4.1 Add localized labels, action names, validation errors, reset commands, and shortcut-tab text to every supported Avalonia locale; verify resource keys resolve in English, Chinese, and Japanese without mojibake. +- [x] 4.2 Regenerate locale JSON artifacts with `uv run --project scripts scripts/axaml-to-json.py` and run `uv run --project scripts scripts/axaml-to-json.py --check`; verify no generated locale diff is left uncommitted by the change. + +## 5. Automated Verification + +- [x] 5.1 Add non-Headless catalog, parser, conflict, settings-normalization, and router tests in the owning Avalonia or contracts test projects; verify with the affected `dotnet test ... --no-restore` commands. +- [x] 5.2 Add Avalonia Headless workflow coverage that opens the real settings view, records a custom gesture, saves it, reopens settings, checks the updated menu gesture, and triggers the target command; verify with `dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore`. +- [x] 5.3 Add Headless scenarios for duplicate/invalid gesture rejection and discard/reset behavior, using the runner UI thread and deterministic `RunJobs` state; verify failed saves leave the prior active mapping unchanged. +- [x] 5.4 Run the affected non-Headless and Headless test projects sequentially, then run `openspec validate "customize-keyboard-shortcuts" --strict`; verify all tests and artifact validation pass without disabling assembly parallelization. + +## 6. Documentation and Delivery + +- [x] 6.1 Update `docs/code-map/avalonia.md` and `docs/code-map/testing.md` with shortcut ownership, settings-tab entry points, persistence boundaries, and Headless test locations; verify the documentation uses short active ASD-STE100 sentences and real repository paths. +- [x] 6.2 Review the change against every scenario in the three delta specs and record any intentional platform limitations in implementation notes or tests; verify the final OpenSpec status reports proposal, specs, design, and tasks as complete. diff --git a/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/.openspec.yaml b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/design.md b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/design.md new file mode 100644 index 00000000..b01ee667 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/design.md @@ -0,0 +1,78 @@ +## Context + +The WASM application is a Blazor WebAssembly site. Existing .NET tests exercise its workspace and browser shortcut rules, but they do not launch the published site or a real browser. GitHub Pages publishes a static site below a repository subpath, so the browser suite must exercise the prepared deployment shape as well as support local development diagnosis. + +The source plan is `docs/testing/wasm-browser-e2e-plan.md`. It defines the proposed project layout, scenarios, CI gates, and acceptance evidence. This design keeps that plan as the detailed implementation reference while the capability spec defines required outcomes. + +## Goals / Non-Goals + +**Goals:** +- Exercise the actual Blazor page and JavaScript interop through Playwright browser interaction. +- Test the same prepared Release static directory that is uploaded for GitHub Pages. +- Make browser diagnostics actionable and retain user-visible behavior as the assertion boundary. +- Stage implementation from Chromium smoke coverage through cross-browser and visual acceptance. + +**Non-Goals:** +- Replace Core, workspace, Avalonia, or package-level tests. +- Re-test parser and conversion algorithms by calling internal APIs from browser tests. +- Claim operating-system file drag/drop coverage or full Safari platform coverage from Playwright WebKit. +- Add browser automation dependencies to `packages/chaptertool` or the .NET solution. + +## Decisions + +### Use a standalone Playwright Test and TypeScript project + +Place the suite under `tests/ChapterTool.Wasm.E2E/`, with its own pinned dependencies and lockfile. Playwright Test provides browser projects, isolated contexts, download/file chooser events, managed web servers, traces, and screenshot assertions. Do not add a .NET browser-test host or reuse the Node package project. + +Alternatives considered: Playwright .NET would keep tests in C# but adds browser-host lifecycle and package concerns to the .NET test setup. Cypress and Selenium do not fit the plan's direct three-engine project model and the repository's lack of WebDriver infrastructure. + +### Make the Pages-shaped Release artifact the default target + +Publish WASM once, prepare its `wwwroot` into `artifacts/wasm-e2e/site/ChapterTool/`, and serve that directory at `/ChapterTool/`. The preparation logic must match Pages base-path behavior and produce the static deployment files. The Pages workflow must test and upload the same prepared directory so the tested bytes are the deployed bytes. + +The local development configuration uses the Blazor development server at `/` for diagnosis and keeps the same tests and assertions. Both modes use Playwright `webServer` lifecycle management and refuse to reuse an existing process. + +Alternative considered: testing only the development server is faster to set up but misses release output, static MIME handling, and repository subpath regressions. + +### Use semantic interaction and explicit readiness + +Prefer roles, labels, and scoped dialog locators. Add only the accessible names and stable attributes needed to identify the table, fields, preview, status, and application readiness. Set readiness after initialization, settings restoration, and JavaScript event registration. Treat readiness as a synchronization signal, not proof of working interop; smoke coverage must import a real fixture. + +Wait for observable outcomes such as table values, row counts, button states, dialogs, downloads, and navigation behavior. For `@onchange` editing, blur after filling before asserting history or exported output. + +Alternative considered: CSS selectors and direct calls into the WASM bridge are more coupled to implementation and bypass the user's actual path. + +### Isolate browser state and collect diagnostics by default + +Use a fresh BrowserContext per test. Use two pages in one context only for tests that distinguish per-tab workspace state from shared localStorage settings. Install listeners before navigation for page errors, console errors, failed requests, and failed same-origin resource responses. Attach their output on failure and fail on unexpected errors; tests that intentionally inject failures must declare the expected errors. + +Use browser-side JavaScript only to create boundary conditions that users can trigger, such as DOM `DataTransfer`, malformed persisted settings, or storage denial. Do not mock application results, parsers, or downloads. + +### Gate in stages and preserve evidence + +Start with Chromium P0 behavior in pull requests. Add a Pages pre-deployment Chromium `@smoke` run. Run the complete suite in Chromium, Firefox, and WebKit on a scheduled or manual workflow. Run screenshot comparisons only in a fixed Linux environment with reviewed baselines. Upload reports and failure diagnostics even when the job fails. + +Use one worker initially to constrain WASM memory use. A CI retry may collect diagnostics, but flaky retries remain failures. Do not fix timing failures with fixed sleeps or erase assertions. + +## Risks / Trade-offs + +- [Three browser engines increase CI time and maintenance] → Keep pull request coverage on Chromium and run the full matrix on scheduled/manual and release acceptance paths. +- [WASM cold start may exceed the initial timeout] → Measure CI duration and adjust the timeout from evidence; synchronize on readiness and observable results. +- [Visual baselines vary with fonts and runtime images] → Pin the Linux runner, browser version, locale, viewport, fonts, and theme; review baseline updates manually. +- [Static preparation can diverge from Pages deployment] → Share the preparation script and make Pages test and upload one unchanged prepared artifact. +- [DOM drag/drop does not cover shell-level file dragging] → Record that limitation and retain focused manual platform acceptance where needed. + +## Migration Plan + +1. Add the standalone test project, preparation and static-serving scripts, fixtures, readiness contract, and Chromium smoke workflow. +2. Add P0 scenarios and the Pages pre-deployment smoke gate; run and record real acceptance results. +3. Add P1 browser-boundary and disc workflows, then enable the three-engine acceptance workflow. +4. Add fixed-environment visual coverage and reviewed baselines. +5. Update testing ownership and WASM usage documentation as each command becomes available. + +Rollback consists of disabling the new workflow gates and removing the standalone test project. It does not require changes to persisted application data or user-facing APIs. + +## Open Questions + +- Which scheduled cadence best balances cross-browser feedback and CI cost? The implementation can begin with a weekly schedule and adjust from observed duration and regressions. +- Does the first implementation need all P1 import formats in the initial cross-browser run, or can they enter incrementally by stage? The task list stages them without changing the final coverage goal. diff --git a/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/proposal.md b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/proposal.md new file mode 100644 index 00000000..b8ecaf1f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/proposal.md @@ -0,0 +1,29 @@ +## Why + +The Blazor WebAssembly application has unit and workspace coverage, but no automated check that starts the published site in a real browser. Add browser coverage now so pull requests and GitHub Pages deployments can catch failures in startup, user interaction, JavaScript interop, and downloaded files. + +## What Changes + +- Add a standalone Playwright Test and TypeScript project for the WASM application. +- Test the Release static site mounted under the GitHub Pages `/ChapterTool/` path, and provide a development-server mode for local diagnosis. +- Cover core import, editing, history, settings, localization, export, keyboard, drag-and-drop, disc workflows, and responsive layout in staged browser suites. +- Add minimal accessible names and stable readiness semantics to the page where browser tests need them. +- Run Chromium E2E checks in CI and a smoke suite against the Pages artifact before deployment; define scheduled or manual cross-browser acceptance for Chromium, Firefox, and WebKit. +- Document setup, commands, ownership, and the first real acceptance results after implementation. + +## Capabilities + +### New Capabilities +- `wasm-browser-e2e`: Real-browser verification of the published WASM application, including user-visible workflows, browser boundaries, downloadable output, and CI release gates. + +### Modified Capabilities + +None. + +## Impact + +- Adds `tests/ChapterTool.Wasm.E2E/` with its locked Node.js dependencies, Playwright configuration, fixtures, support helpers, and browser specs. +- Changes the WASM Razor page only where required for accessible browser interaction and app readiness. +- Updates `.github/workflows/dotnet-ci.yml`, `.github/workflows/github-pages.yml`, `docs/code-map/testing.md`, and `src/ChapterTool.Wasm/README.md`. +- Produces ignored diagnostics under `artifacts/wasm-e2e/` and maintains reviewed visual baselines in the E2E project. +- Keeps existing .NET and package tests in place; browser E2E supplements rather than replaces them. diff --git a/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/specs/wasm-browser-e2e/spec.md b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/specs/wasm-browser-e2e/spec.md new file mode 100644 index 00000000..c36802d6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/specs/wasm-browser-e2e/spec.md @@ -0,0 +1,105 @@ +## ADDED Requirements + +### Requirement: Browser suite runs against deployment-shaped WASM output +The project SHALL provide a standalone Playwright Test and TypeScript suite that exercises the Blazor WebAssembly application in real browsers. The default target SHALL be the Release static output prepared for the GitHub Pages `/ChapterTool/` path. A local development-server configuration SHALL run the same scenarios at the root path for diagnosis. + +#### Scenario: Test the prepared Release site under the repository path +- **WHEN** a developer prepares and runs the default browser suite +- **THEN** the suite serves the prepared Release site and navigates to `http://127.0.0.1:5261/ChapterTool/` +- **AND** framework assets load from the repository subpath without falling back to the page for missing resources + +#### Scenario: Diagnose the application with the development server +- **WHEN** a developer runs the development browser configuration +- **THEN** Playwright starts the Blazor development server on the loopback address at the root path +- **AND** the same workflow assertions run without changing their expected behavior + +### Requirement: Static site preparation is safe and deployment-consistent +The preparation step SHALL transform a single Release publish into a static site directory with the required Pages base path and deployment files. It SHALL validate that any directory it clears is within `artifacts/wasm-e2e/`. The static server SHALL bind only to loopback, serve correct MIME types including `application/wasm`, return 404 for missing resources, and prevent paths from escaping its configured root. GitHub Pages SHALL test and upload the same prepared directory without rebuilding or rewriting it after tests. + +#### Scenario: Prepare static output for local browser testing +- **WHEN** the preparation script receives a valid publish directory +- **THEN** it creates the Pages-shaped site with the expected base path and static deployment files +- **AND** it does not delete files outside the validated E2E artifact directory + +#### Scenario: Serve an asset and reject missing or unsafe paths +- **WHEN** the static server receives requests for a WASM asset, a missing framework asset, and a path outside its root +- **THEN** it serves the asset with `application/wasm`, returns 404 for the missing asset, and refuses the path traversal request + +#### Scenario: Deploy the artifact that passed smoke tests +- **WHEN** the Pages workflow runs its pre-deployment smoke suite successfully +- **THEN** it uploads the same prepared site directory that the suite tested +- **AND** a failed smoke suite stops artifact upload and deployment + +### Requirement: Browser tests use accessible user interactions and observable outcomes +The application SHALL expose accessible names and stable readiness semantics needed to operate and observe its browser workflows. The browser suite MUST prefer roles, labels, and scoped dialog locators over brittle CSS paths or coordinates. Readiness MUST be set after initialization, settings restoration, and JavaScript event registration, and MUST NOT substitute for an actual interop smoke action. + +#### Scenario: Wait for application initialization and verify interop +- **WHEN** a browser test opens the application +- **THEN** it waits for the application readiness marker +- **AND** the smoke test imports a real file through the page before passing + +#### Scenario: Commit and verify an edited chapter +- **WHEN** a test changes a chapter field and performs a real blur action +- **THEN** it verifies the committed table value and the corresponding undo/redo or exported result +- **AND** it does not pass based only on the value still present in the focused input + +### Requirement: Browser workflows cover user-visible behavior and downloaded bytes +The suite SHALL cover the planned staged workflows: startup and import, chapter editing and history, preview and export, replacement confirmation, invalid input recovery, settings persistence and cancellation, localization, keyboard behavior, file boundaries, tab isolation, disc workflows, and responsive layout. Download tests MUST inspect the downloaded file's name and contents; encoding tests MUST inspect bytes. Expected exports MUST be fixed, reviewed fixtures and MUST NOT be generated by the implementation under test. + +#### Scenario: Complete a core import, edit, history, and download workflow +- **WHEN** a test imports a known OGM fixture, edits a chapter, undoes and redoes the change, then saves +- **THEN** the table and history controls show the expected state +- **AND** the real downloaded file matches a fixed expected result + +#### Scenario: Recover from invalid input without losing the current document +- **WHEN** a test imports malformed input while a document is open +- **THEN** the page shows a visible diagnostic and preserves the current document and history +- **AND** a subsequent valid import remains usable + +#### Scenario: Verify localization and encoded output +- **WHEN** a test selects English, Chinese, or Japanese in the application and saves representative chapter names +- **THEN** visible labels and the applied document language match the selected locale +- **AND** the downloaded text decodes correctly, with byte-level checks for BOM and UTF-16 formats + +### Requirement: Browser state boundaries and diagnostics are explicit +Each browser test SHALL start with an isolated BrowserContext unless it explicitly tests multiple pages in one context. Tests SHALL collect page errors, console errors, failed requests, and failed same-origin resource responses from before navigation, attach diagnostics, and fail on unexpected errors. Tests MUST interact through the page and MUST NOT call application internals or mock Core/WASM results. + +#### Scenario: Detect unexpected startup and resource errors +- **WHEN** a test navigates to the application +- **THEN** its diagnostics capture page, console, request, and same-origin resource failures +- **AND** unexpected failures cause the test to fail with attached diagnostic output + +#### Scenario: Verify independent chapter sessions across tabs +- **WHEN** two pages in the same BrowserContext edit separate documents +- **THEN** each page retains its own chapter and history state +- **AND** shared settings storage is not treated as chapter-session leakage + +### Requirement: CI gates browser regressions and retains artifacts +Pull requests and relevant pushes SHALL run the implemented Chromium behavior suite. The Pages workflow SHALL run Chromium `@smoke` against the prepared deployment artifact before deployment. A scheduled or manually triggered acceptance workflow SHALL run the full suite in Chromium, Firefox, and WebKit. CI SHALL retain reports, traces, failure screenshots, logs, and downloaded files when a run fails or succeeds. Retried flaky tests SHALL remain gate failures. + +#### Scenario: Run the Chromium pull request gate +- **WHEN** a relevant pull request or push changes the WASM application, its dependencies, browser suite, or build configuration +- **THEN** CI publishes and prepares the Release output and runs the implemented Chromium suite +- **AND** a failed test makes the job fail + +#### Scenario: Run cross-browser acceptance +- **WHEN** the scheduled or manually triggered acceptance workflow runs +- **THEN** it executes the full browser suite in Chromium, Firefox, and WebKit +- **AND** it reports pass, failure, and skip counts for each engine + +#### Scenario: Preserve diagnostics for failed CI runs +- **WHEN** an E2E job fails +- **THEN** CI uploads its reports, traces, screenshots, logs, and downloaded test files +- **AND** a test that passes only after retry remains a failure + +### Requirement: Test setup and acceptance evidence are documented +The E2E README and repository testing ownership documentation SHALL explain prerequisites, install and run commands, browser installation, development mode, report viewing, and failure diagnosis. The WASM README and testing code map SHALL identify the suite and its ownership. The first acceptance report SHALL distinguish planned coverage from executed coverage and record the commit, locked dependency and browser versions, operating system, commands, per-engine outcomes, duration, and artifact paths. + +#### Scenario: Set up the suite in a clean environment +- **WHEN** a developer follows the E2E README in a clean checkout +- **THEN** the developer can install locked dependencies, install the selected browsers, prepare the site, run type checking and tests, and open the report + +#### Scenario: Record first acceptance results +- **WHEN** implementation reaches the first acceptance run +- **THEN** the report records the required environment and command evidence +- **AND** it labels unexecuted coverage as planned instead of passed diff --git a/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/tasks.md b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/tasks.md new file mode 100644 index 00000000..e33ba918 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-add-wasm-browser-e2e/tasks.md @@ -0,0 +1,37 @@ +## 1. Browser project and deployment-shaped site + +- [x] 1.1 Create `tests/ChapterTool.Wasm.E2E/` as an independent Node.js 22 Playwright Test and TypeScript project; pin dependencies and commit `package-lock.json`. +- [x] 1.2 Add the package scripts, TypeScript configuration, default Release config, development-server config, and fixed-environment visual config. +- [x] 1.3 Implement `prepare-site.mjs` to copy the Release `wwwroot`, apply the Pages base path, create deployment files, and validate cleanup targets before deleting output. +- [x] 1.4 Implement the loopback static server with MIME handling, 404 behavior for missing assets, and root-confined path resolution. +- [x] 1.5 Configure Playwright `webServer` lifecycle for both modes, one worker, no stale-server reuse, HTML/JUnit reporting, and failure trace/screenshot/video retention. +- [x] 1.6 Add small OGM, Unicode, malformed XML, and fixed expected-output fixtures; reuse tracked disc fixtures instead of copying the full fixture tree. +- [x] 1.7 Add an E2E README with clean-checkout installation, browser installation, site preparation, type check, run modes, report viewing, and troubleshooting commands. + +## 2. Readiness, diagnostics, and P0 workflows + +- [x] 2.1 Add the minimum accessible labels and table, preview, status, and application readiness semantics to the WASM page; set readiness after settings restore and JavaScript event registration. +- [x] 2.2 Add Playwright fixtures that wait for readiness and collect page errors, console errors, failed requests, same-origin resource failures, and attach diagnostics while failing on unexpected errors. +- [x] 2.3 Add semantic workspace helpers that operate through accessible roles and labels, scope modal queries, and wait for observable workflow outcomes. +- [x] 2.4 Implement B01–B04 Chromium smoke coverage for cold startup, import, edit/undo/redo, preview, and an actual download checked against fixed expected output. +- [x] 2.5 Implement B05–B09 P0 coverage for representative formats, replacement confirmation, malformed-input recovery, persisted settings, cancellation, and English/Chinese/Japanese localization. +- [x] 2.6 Ensure edits submitted through `@onchange` use a real blur action before history and export assertions; ensure file chooser, download, and confirm handlers are registered before user actions. +- [x] 2.7 Add `@smoke` tags to startup and core import/edit/download flows; keep tests independently runnable and do not generate expected exports through Core code. + +## 3. CI and GitHub Pages release gates + +- [x] 3.1 Add the Chromium E2E job to `.github/workflows/dotnet-ci.yml` with .NET 10, Node.js 22, Release publish, site preparation, type checking, browser installation, and report upload using `if: always()`. +- [x] 3.2 Include the E2E lockfile in the Node cache key and update relevant path filters for WASM, Core, Contracts, E2E files, workflows, shared build configuration, and localization sources. +- [x] 3.3 Update `.github/workflows/github-pages.yml` to prepare the deployment directory once, run Chromium `@smoke` against it, and upload that same unchanged directory only after the smoke gate passes. +- [x] 3.4 Verify CI retains reports, traces, screenshots, logs, JUnit output, and downloaded files; configure retry behavior so tests passing only after retry still fail the gate. +- [x] 3.5 Update `docs/code-map/testing.md` and `src/ChapterTool.Wasm/README.md` with E2E ownership, local commands, CI coverage, and diagnostic locations. + +## 4. P1 boundaries, cross-browser, and visual acceptance + +- [x] 4.1 Implement B10–B15 coverage for selection/deletion, keyboard boundaries, FPS and expression behavior, DOM file drop and size boundaries, per-tab session isolation, and malformed or inaccessible settings storage. +- [x] 4.2 Implement B16–B17 coverage for representative disc imports and MPLS append plus UTF-8/UTF-16 and BOM byte-level downloads; keep large boundary files temporary. +- [x] 4.3 Add a scheduled or manually triggered acceptance workflow for the full Chromium, Firefox, and WebKit suite and record pass/fail/skip counts per engine. +- [x] 4.4 Implement B18 layout interaction and geometry checks at 1280×800, 1920×1080, and 390×844; generate and review Chromium baselines in a fixed Linux environment. +- [x] 4.5 Run the first Chromium P0 suite three times with `--repeat-each=3 --retries=0`, then complete a no-retry three-engine acceptance run; investigate failures through traces and diagnostics rather than fixed delays or removed assertions. +- [x] 4.6 Record the first acceptance report with commit SHA, locked dependency and browser versions, OS, exact commands, per-engine pass/fail/skip counts, duration, artifact paths, and planned-versus-executed coverage. +- [x] 4.7 Run affected .NET test projects sequentially before the E2E suite when Razor or browser interop changes, then run the relevant browser acceptance checks and fix regressions caused by the change. diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/.openspec.yaml b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/.openspec.yaml new file mode 100644 index 00000000..a26662f7 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/design.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/design.md new file mode 100644 index 00000000..4a99fb7a --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/design.md @@ -0,0 +1,169 @@ +## Approved shared corrections + +The continuation request approves corrections found by matching host fixtures. +Both hosts must derive frame text without committing display preferences. +Frame edits and signed shifts must use the displayed FPS. +Expression preparation may supply detected FPS to the selected track when source FPS is absent. +It must preserve other tracks. +Frame conversion must quantize each absolute segment boundary through the chapter conversion calculation. +The converted duration must equal the converted end minus the converted start. +Source frame-rate metadata must remain unchanged. + +## Context + +`ChapterTool.Wasm` uses Blazor components. `ChapterTool.Avalonia.UI` uses Avalonia views and ViewModels. Both hosts use the Core `ChapterWorkspace` and content history. Web already references Core and Contracts. It does not reference Avalonia.UI or Infrastructure. + +The current Web expression dialog already reviews a candidate before application. Other Web content operations do not use that interaction. `Home.razor` calls `WasmWorkspace.ApplyOptionsAndRefresh` for naming changes, rounding changes, advanced options, and settings application. That method commits naming and numbering candidates. Its `applyExpression` argument is false. It does not rerun Lua. + +Web frame-rate conversion and frame shift also commit immediately. The frame column is read-only. Web uses fixed shortcut routing and a basic retained log list. Its settings modal discards changes on close. + +The completed changes `unified-editing-04-content-operations`, `unified-editing-06-host-workflows`, `unified-editing-07-session-lifecycle`, `fix-wasm-modal-layout`, and `unify-expression-preview-interaction` remain unarchived. This design uses their implemented contracts. It must not edit their artifacts or assume their specs are already present in `openspec/specs/`. + +### Difference and acceptance checklist + +| ID | Priority | Current difference | Planned outcome | Primary evidence | +| --- | --- | --- | --- | --- | +| D01 | P0 | Web naming and numbering commit from option changes | Draft, reviewed candidate, explicit apply/cancel | Workspace and browser workflow tests | +| D02 | P0 | Settings can reapply content drafts | Preferences never commit chapter content | Rename a generated chapter, save appearance, compare history and export | +| D03 | P0 | Web frame-rate conversion commits immediately | Review source/target rates and candidate before commit | Cross-host conversion, cancel, stale candidate, undo | +| D04 | P1 | Web shift permits only 1..100000 | Signed -1000000..1000000 draft with validation and review | Valid negative shift, invalid negative time, positive limit | +| D05 | P1 | Web frames are read-only | Frame edit changes time through Core | Time/frame/export equality and one-step undo | +| D06 | P1 | Avalonia accepts ineffective negative numbering input | Both hosts expose 0..1000 offsets | Core result plus rendered bounds and validation | +| D07 | P1 | Web lacks Lua file and editor assistance | Script load, highlighting, completion, positioned diagnostics | Actual editor input and script-loading workflows | +| D08 | P1 | Web shortcuts are fixed and incomplete | Catalog-based settings and routing | Customized mapping, conflicts, row actions, clip navigation | +| D09 | P1 | Web settings close discards drafts | Save, explicit discard, and keep-editing lifecycle | Close button, Escape, validation, persistence failure | +| D10 | P2 | Web logs expose only summaries and clear | Search, severity, explicit inspector, copy, filtered export | Membership and rendered inspection workflows | +| D11 | P2 | Web advanced options use codes and obscure content intent | Localized labels and separate preference/content surfaces | Locale changes, enabled states, accessible labels | +| D12 | P2 | Web input lifecycle and table behavior are incomplete | Explicit commit/cancel, stable identity, reachable large tables | Enter/Tab/Escape, focus, selection, 1000-row workflow | + +## Goals / Non-Goals + +**Goals:** + +- Match committed editing outcomes and explicit confirmation behavior across hosts. +- Preserve one document owner, one history tree, and Core validation. +- Make settings, output preferences, display preferences, and content operations distinct. +- Close the portable editor, shortcut, and log gaps. +- Preserve existing Web modal focus, IME, and responsive behavior. + +**Non-Goals:** + +- Resolve native tools, media import, BDMV directories, local save paths, shell access, system fonts, or telemetry. +- Change browser download acknowledgement, document export baselines, or page-leave handling. +- Replace the Web UI framework or require identical layouts. +- Add a persistent chapter history, a new script language, or negative numbering semantics. +- Implement speculative grid virtualization before a measured workflow requires it. + +## Decisions + +### 1. Reuse the Core candidate boundary for every reviewed operation + +Core keeps ownership of naming, numbering, frame shifts, frame conversion, validation, and transactions. Web must expose narrow prepare/apply/cancel operations over the existing candidate APIs. It must retain the exact reviewed candidate and its base identity. Application must not recompute a candidate. + +Reuse the expression dialog state rules for unavailable, draft, computing, invalid, unchanged, ready, stale, applying, and failed states. Extract only presentation that has a real second consumer. Naming/numbering and timing operations can use a common review component. Operation-specific parameters and summaries must remain explicit. + +The default scope is the current track. A combined clip uses its current combined track. Separator rows do not participate as ordinary chapters. Scope and excluded rows must be visible. A scope or document change invalidates the old candidate. A rejected application must preserve the draft. + +Alternative: Retain immediate commits and rely on Undo. This does not provide the desktop review/cancel workflow or prevent settings from committing content. + +### 2. Separate preference changes from one-shot content drafts + +`Home.razor` and `WasmWorkspace` must stop calling content operations from settings and display handlers. Format, XML language, encoding, and BOM update export preferences. Rounding, frame precision, and tolerance update derived display. Language, theme, fonts, and shortcuts update presentation or input preferences. + +Move naming, templates, and numbering to an explicit content operation surface. The compact naming control can remain as a draft selector. It must not imply a persistent export rule. Advanced export options must contain output preferences. A template picker can remain near naming controls, but loading a template must only prepare a draft. + +After a successful naming/numbering application, clear the active one-shot intent. Retained template text can remain available for deliberate reuse. Saving preferences must not replay it. Expression parameters keep their existing deliberate-reapply behavior. + +Startup applies saved default format. Saving settings during an active session must preserve the selected session format, as required by `chapter-workspace-session`. Setting changes can alter derived frames or serialized encoding without changing document content or its history cursor. + +Alternative: Add guards to each existing `ApplyOptionsAndRefresh` caller. This leaves a method that combines unrelated effects and makes future accidental commits likely. + +### 3. Use existing Core numeric semantics + +Both hosts must expose numbering offsets from 0 to 1000. Zero is the neutral draft value. This change does not add negative chapter numbering. Avalonia must reject ineffective negative input instead of advertising it as supported. Core callers keep the existing normalization policy. + +Frame shift accepts signed integers from -1000000 to 1000000. Zero produces an unchanged preview. Core validates each result, including negative times and overflow. UI range checks do not replace Core validation. Both hosts use the same FPS basis and current-track scope. + +Alternative: Support negative numbering because Avalonia accepts it today. Core clamps it to zero and the current model requires valid chapter numbers. That would require a separate semantic change. + +### 4. Make browser cell edits identity-based drafts + +Capture chapter ID, field, original value, and base version at edit start. Reuse Core `EditCell` with `ChapterCellField.Frame` for frame input. Reuse the same parsing and timing conversion as Avalonia. Enter, Tab, and valid focus loss commit once. Escape restores the original draft without a history node. Invalid input stays visible with an accessible error. + +Text-control Undo must remain local to the draft. Document Undo uses committed content when the table owns focus. Commit handlers must not use a row index after history or clip changes. Preserve selected chapter identities and focus where those chapters still exist. + +Use a 1000-row rendered workflow to assess scrolling and edit reachability. Add paging or virtualization only if the workflow fails or measured rendering is unusable. Do not add desktop column resizing as a prerequisite for functional parity. + +### 5. Reuse Core Lua authoring services through browser presentation + +Use `LuaExpressionScriptService` for script rules and `ExpressionAuthoringService` for diagnostics and completion. Read a selected `.lua` file through the existing bounded browser file-input pattern. A failed read or parse must preserve the current script, preset, source metadata, document, and history. + +Add a browser editor adapter for caret, selection, completion, diagnostic ranges, and highlighting. Keep editor state inside the expression component. Preserve multiline text, accessibility, composition handling, debounce, and local text Undo. Completion consumes its keyboard events before the dialog does. + +Use a small browser presentation over existing authoring results. A new large editor framework is not required by this plan. If implementation proves one necessary, record its size and accessibility impact before adding it. + +Alternative: Implement a separate browser Lua parser and completion catalog. This duplicates Core semantics and creates different diagnostics across hosts. + +### 6. Extend browser settings with shared shortcut data + +Reuse `ShortcutCatalog`, `ShortcutSettings`, `ShortcutGestureText`, and `ShortcutConflictValidator`. Keep canonical action IDs and gesture text in Contracts. A browser adapter maps keyboard events to those values. Web must not depend on Avalonia `KeyGesture`. + +Add a `shortcuts` child to the browser settings model. Treat missing shortcut content in existing version-one documents as defaults. Preserve existing application, theme, and font content. Do not introduce another storage key or write settings during load. Invalid stored overrides must retain usable defaults. Invalid drafts must remain visible and block Save. + +Use one active mapping for dispatch, visible hints, and browser-default suppression. Preserve the existing fixed aliases. Route Insert/Delete and PageUp/PageDown through their existing catalog actions. A modal or editable target prevents background commands. Browser-reserved and undeliverable gestures need localized validation. Existing supported browser-intercepted defaults remain compatible. This work does not change native browser APIs or page reload policy. + +Alternative: Extend the current independent C# and JavaScript hard-coded key lists. They can diverge from saved mappings and displayed hints. + +### 7. Define settings draft close and save behavior + +Compare normalized draft values with the entry settings snapshot. Save validates all settings, persists once, then activates the saved snapshot and closes. Failure keeps the dialog, draft, and previous valid runtime settings. Intentional Cancel discards and closes. Close or Escape with changed values asks whether to discard or keep editing. Reset changes the draft only. + +Shortcut edits and output defaults must use this lifecycle. A nested discard confirmation must restore focus to the settings surface. Settings actions must not create document history entries. + +Alternative: Automatically save or silently discard all close paths. Neither matches a reviewable settings draft lifecycle. + +### 8. Extend the Web log projection without importing desktop infrastructure + +Keep the existing bounded browser log source. Add stable entry identity and a browser-owned filtered projection. Search must include every field the browser entry actually contains, including details. The inspector must not invent desktop exception or structured fields that the browser did not record. + +Use list-first presentation, explicit details, severity filters, copy, and a secondary JSON/CSV export action. Export captures the visible filtered membership before reporting success or failure. Use UTF-8 and the existing browser download adapter. CSV must quote separators, quotes, and newlines correctly. This action does not change the chapter export baseline or desktop log archives. + +Keep the inspector beside the list at wide widths. Replace the list with a back action at narrow widths. Eviction or filtering must not silently select another entry. Clear closes an unavailable inspector and empties the retained source. + +Alternative: Reference Infrastructure or Avalonia log ViewModels from Web. That adds host dependencies without sharing the browser boundary. + +### 9. Verify behavior across hosts and keep layout adaptations + +Use the same fixtures and expected content for naming, numbering, frame edit, signed shift, frame conversion, and expression application. Verify full relevant document fields and history outcomes through public APIs. Compare exported bytes for the same preferences. Do not test implementation source text. + +Use Web workspace tests for state, Playwright for browser interaction, Avalonia unit tests for reference outcomes, and Headless tests for the numbering control and existing reference workflows. Run test projects sequentially. Headless remains in its own process. + +Keep history in a Web dialog and content review in modal surfaces. Use shared locale AXAML sources and generated Web JSON. XML language selectors must expose localized names while storing stable language codes. Default, wide, narrow, narrow-short, and landscape browser checks must verify actual reachable controls. Screenshots supplement behavior checks. + +## Risks / Trade-offs + +- Existing users expect naming options to commit immediately. Mitigation: Label the draft action and show a clear preview/apply path. +- New cell handlers can commit twice on Enter followed by blur. Mitigation: Bind one edit identity and test exactly one history node. +- An operation can become stale during review. Mitigation: Check session, track, draft revision, and base version at application. +- Shortcut capture can interfere with text editing or browser defaults. Mitigation: Use one mapping and test editable and modal contexts in each supported engine. +- Lua assistance can interfere with IME or Escape. Mitigation: Preserve composition tests and give completion first ownership of Escape. +- Browser storage can reject a settings save. Mitigation: Keep the last valid runtime snapshot and offer retry without content changes. +- Negative numbering looked supported on desktop. Mitigation: Document the existing Core policy and verify explicit UI rejection. +- Log retention can remove an inspected entry. Mitigation: Use stable identity and a deterministic unavailable-entry transition. +- Completed upstream specs are not synchronized yet. Mitigation: Do not duplicate or rewrite them; validate the new change independently and preserve their behavior tests. + +## Migration Plan + +1. Add behavior regressions for preference isolation and candidate review. +2. Implement content-operation parity before exposing more editing actions. +3. Add table edits and Lua authoring without changing the expression review contract. +4. Add shortcut settings and draft lifecycle with additive version-one compatibility. +5. Add the browser log tool and localized option presentation. +6. Run the affected checks and capture supported layouts under `artifacts/`. +7. Update code-map owners and capability boundaries. Review the completed implementation before any spec sync or archive. + +Rollback can revert the implementation without migrating chapter documents. Older browser versions ignore the new shortcut child. Existing version-one output, theme, and font settings remain readable. This change does not archive itself or any prerequisite change. + +## Open Questions + +No product-scope decision blocks implementation. The design uses the existing Core nonnegative numbering policy. Platform differences are deferred by the user. Implementation must measure the browser table and editor before choosing additional rendering dependencies. diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/implementation-evidence.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/implementation-evidence.md new file mode 100644 index 00000000..dc2dcaf4 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/implementation-evidence.md @@ -0,0 +1,61 @@ +# Implementation evidence + +The portable changes and approved shared corrections are implemented. +Final solution and browser acceptance checks pass. +Platform differences remain outside this change. + +## D01-D12 coverage + +| Difference | Evidence | +| --- | --- | +| D01 | Naming, template, and numbering use draft, preview, Apply, and Cancel. Workspace tests cover cancellation, stale candidates, repeated submission, and one-step history. P01 and P08 cover the rendered workflow. | +| D02 | Settings and display changes preserve committed names, format, and history. Frame information is a derived projection in both hosts. P01 and P07 verify preference isolation. | +| D03 | FPS review shows the selected track's source and target rates, scope, and typed differences. Apply commits the captured candidate. Rational conversion uses consistent boundary quantization. P10 covers single-track and multi-track sources. Matching host fixtures verify Apply, Undo, and Redo. | +| D04 | Signed shifts use displayed FPS. Core rejects the whole candidate for negative times or duration violations. Zero preserves existing numbering and does not commit. Out-of-range values do not commit. Core, workspace, and matching host tests cover these cases. | +| D05 | Frame cells use Core parsing and captured chapter identity. Source FPS zero uses the detected display rate. Invalid, unchanged, and stale edits do not commit. P02 and matching host tests verify accepted edits. | +| D06 | Both numbering controls use 0..1000. Web rejects negative and fractional drafts. The rendered Avalonia integer-input regression passes. | +| D07 | Lua files use bounded UTF-8 reads. Shared authoring services provide highlighting, completion, and positioned diagnostics. Revision guards reject obsolete results. P04 and B20-B25 cover editing, cancellation, rapid input, and composition. | +| D08 | Settings use the shared shortcut catalog. Routing and hints use one active mapping. Validation includes browser restrictions, fixed aliases, synthesized Meta aliases, and F9. P03 and P12 cover configuration, command prerequisites, row commands, and clip bounds. | +| D09 | Settings compare normalized drafts. Save validates the aggregate before persistence and runtime activation. Failure retains active settings and the draft. P03, P07, and P11 cover discard, reset, storage failure, integer bounds, and runtime rollback. | +| D10 | Logs support filtering, explicit details, copy, clear, and captured JSON/CSV export. Workspace tests cover Unicode, multiline quoting, live append, and bounded eviction. P05 and P09 cover rendered actions and recovery. | +| D11 | Shared locale resources contain English, Chinese, and Japanese labels. Generated Web resources pass validation. XML choices show translated names and stable codes. P01, B09, and resource tests cover the labels. | +| D12 | Cell drafts support Enter, Tab, blur, Escape, and local Undo. Surviving identities retain selection. P02 verifies the edit lifecycle. P06 edits and exports the last of 1000 rows and checks five viewport sizes. | + +## Approved shared corrections + +- Avalonia frame refresh must not commit display preferences to content history. +- Frame cells and signed shifts must use displayed FPS in both hosts. +- Expression preparation can supply detected FPS to the selected track when source FPS is absent. +- Track duration must cover all retained segments. Reviewed conversion must update document bounds and preserve other tracks. +- Document and segment boundaries use the same frame quantization. Segment duration equals converted end minus converted start. +- Conversion preserves segment identities and source FPS metadata. +- CUE output uses the current track's media source in both hosts. + +`PortableUiParityTests` runs matching naming, Unicode rename, frame edit, signed shift, expression, conversion, and history sequences. +It checks content fields, segment metadata, scope, transaction counts, and all nine export formats. +Eight formats use exact UTF-8 byte comparison. +XML comparison excludes generated `ChapterUID` and `EditionUID` values. It compares all remaining XML content. +The exporter already generates new random UIDs for each serialization. This change preserves that behavior. + +## Checks + +- `dotnet test ChapterTool.slnx --no-restore --max-parallel-test-modules 1`: 1405 pass, 2 expected skips, 0 failures. +- Web unit tests: 64 pass. +- Avalonia Headless tests: 106 pass in a separate process. Screenshot capture also passes. +- Release Web publish: passes with no warnings or errors. +- Browser TypeScript checking: passes. +- Single-track and multi-track FPS review: 6 pass across Chromium, Firefox, and WebKit. Source comparison uses the importer's six-decimal precision. Target comparison requires `25/1`. +- `npm run test:e2e` against the prepared Release site: 139 pass, 2 expected screenshot-only skips, 0 failures across Chromium, Firefox, and WebKit. +- Browser report: `artifacts/wasm-e2e/report-functional-parity-final/index.html`. JUnit results: `artifacts/wasm-e2e/junit-functional-parity-final.xml`. +- Locale generation and `--check`: pass. +- OpenSpec strict validation and `git diff --check`: pass. +- Linux visual baseline comparison is not run on this Windows host. +- Physical mobile keyboards are not verified by browser emulation. +- No change is archived or pushed. Deferred platform work and unrelated changes remain untouched. + +## Screenshots + +- Browser tools at 1280x800, 1920x1080, 390x844, 390x640, and 844x390: `artifacts/wasm-functional-parity/screenshots/chromium/`, `firefox/`, and `webkit/`. Each engine has 20 images. +- Avalonia numbering at default, wide, and narrow sizes: `artifacts/unify-sourcegit-design-system/wasm-functional-parity/main-default.png`, `main-wide.png`, and `main-narrow.png`. + +Screenshots supplement behavior checks. They are not automated assertions. diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/proposal.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/proposal.md new file mode 100644 index 00000000..89c01973 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/proposal.md @@ -0,0 +1,50 @@ +## Why + +The Web and Avalonia hosts share the editing kernel, but their controls do not provide the same editing behavior. Web settings can reapply naming or numbering operations, while several desktop actions are missing or commit without review. + +## What Changes + +- Make Web naming, numbering, frame-shift, and frame-rate operations use draft, preview, apply, and cancel states. +- Prevent settings and display changes from committing chapter content or reapplying operation drafts. +- Add Web frame-cell editing and align signed frame-shift limits. +- Align numbering controls with the existing nonnegative Core policy. Remove ineffective negative input from Avalonia. +- Add Web Lua file loading, syntax highlighting, completion, and positioned authoring diagnostics. +- Add configurable Web shortcuts, row commands, and previous/next clip navigation. +- Add settings draft close protection and preserve valid settings when validation or persistence fails. +- Add Web log search, severity filters, explicit details, copy, and JSON/CSV export. +- Align localized option labels, command availability, keyboard workflows, and responsive acceptance checks. +- Verify equivalent committed content, history, and exports through both hosts. + +## Capabilities + +### New Capabilities + +- `wasm-content-operation-parity`: Reviewed content operations, preference isolation, signed shifts, and shared numbering policy. +- `wasm-grid-expression-authoring`: Frame-cell editing, table edit lifecycle, Lua file loading, and expression editor assistance. +- `wasm-settings-shortcuts`: Browser shortcut configuration, command routing, settings validation, persistence compatibility, and close protection. +- `wasm-log-tool-parity`: Browser log filtering, inspection, copy, export, localization, and responsive interaction. + +### Modified Capabilities + +None. The new browser contracts extend existing shared behavior. They do not replace the desktop settings store, shortcut parser, or log provider contracts. + +## Impact + +- Web `Home.razor`, dialog components, workspace services, settings models, styling, and existing JavaScript adapters. +- Core candidate APIs where an existing operation needs a reusable entry point. Core remains the owner of edit semantics. +- Host-neutral shortcut catalog and gesture validation surfaces in Contracts. +- Avalonia numbering control and targeted parity regressions. +- Derived frame displays in both hosts. Display preferences must not create content transactions. Frame edits, shifts, and expression candidates must use the same effective FPS. +- Core frame conversion must quantize document and segment boundaries through the same frame calculation. It must preserve source metadata and known duration invariants. +- Shared AXAML locale resources and generated Web JSON resources. +- Core, Web, Avalonia unit, Avalonia Headless, and browser E2E tests. +- Relevant capability, ownership, and testing code-map pages. + +## Non-goals + +- Native media import, external tools, BDMV directory access, local save directories, or local file overwrite. +- Desktop shell operations, related-media access, system font enumeration, or telemetry. +- Changes to the browser download bridge, export-success acknowledgement, or page-leave handling. +- Replacing Blazor with Avalonia, copying desktop window layouts, or adding durable document history. +- Changing Lua syntax, export formats, or the Core numbering policy to support negative offsets. +- Archiving or rewriting completed OpenSpec changes. diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-content-operation-parity/spec.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-content-operation-parity/spec.md new file mode 100644 index 00000000..b928a274 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-content-operation-parity/spec.md @@ -0,0 +1,150 @@ +## ADDED Requirements + +### Requirement: Shared frame display and conversion bounds remain consistent + +Both hosts MUST derive frame text and accuracy from display preferences without content transactions. +Frame edits and signed shifts MUST use the displayed FPS. +Expression preparation MAY supply detected FPS to the selected track when source FPS is absent. +Frame conversion MUST quantize absolute segment boundaries through the chapter conversion calculation. +It MUST preserve segment identities, source FPS metadata, and known document bounds. +Selected-track operations MUST preserve other tracks. + +#### Scenario: Source FPS is absent +- **WHEN** either host detects a usable display FPS and the user edits a frame cell +- **THEN** both hosts produce the same chapter time +- **AND** refreshing the display creates no content transaction + +#### Scenario: Convert rational FPS +- **WHEN** a reviewed rational-FPS conversion changes a segment timeline +- **THEN** each converted segment duration equals its converted end minus its converted start +- **AND** the known document duration contains every retained segment +- **AND** the operation preserves source FPS metadata and other tracks + +### Requirement: Content parameters remain drafts until explicit application + +The Web host MUST keep naming, template, numbering, frame-shift, and frame-rate parameters as operation drafts. Parameter changes and candidate preparation MUST NOT change committed chapters, history, format preview, or export. The operation surface MUST identify the current-track scope and excluded separator rows. + +#### Scenario: Choose automatic names or load a template +- **WHEN** the user changes the naming draft or loads a valid template +- **THEN** the operation surface shows the draft and scope +- **AND** committed names, preview, export, and history remain unchanged + +#### Scenario: Change timing parameters +- **WHEN** the user selects a target frame rate or edits a frame-shift draft +- **THEN** the host prepares a read-only candidate for the current track +- **AND** timing changes require a separate Apply action + +### Requirement: Application commits the exact reviewed candidate + +The Web host MUST show relevant before/after values and complete change counts. Apply MUST be enabled only for a valid, changed, current candidate. Apply MUST commit the reviewed candidate once without recalculation. Repeated submission MUST create at most one transaction. Cancel, Close, and Escape before submission MUST discard the draft and candidate without content changes. + +#### Scenario: Apply and undo a naming candidate +- **WHEN** the user applies reviewed generated or template names +- **THEN** the committed values match the displayed candidate +- **AND** one transaction contains all changes and one Undo restores them +- **AND** Redo and export use stored content without replaying the operation + +#### Scenario: Candidate is invalid or unchanged +- **WHEN** a candidate fails validation or equals its base document +- **THEN** Apply remains disabled and the surface explains the reason +- **AND** the document and history cursor remain unchanged + +#### Scenario: Cancel a prepared conversion +- **WHEN** the user cancels, closes, or presses Escape before applying a frame-rate candidate +- **THEN** no chapter time, frame rate, segment metadata, or history node changes + +### Requirement: Candidate identity protects document and track scope + +Candidate application MUST validate the document session, target track, draft revision, and base version. A stale candidate MUST require explicit refresh and another review. Failed application MUST preserve the draft and report the failure. Applying MUST prevent duplicate edits or dismissal until the outcome is known. + +#### Scenario: History or track changes during review +- **WHEN** the committed version or selected track changes after candidate preparation +- **THEN** the old candidate cannot commit +- **AND** explicit refresh prepares a new candidate without applying it + +#### Scenario: Base changes during submission +- **WHEN** the document changes between the readiness check and transaction commit +- **THEN** the transaction rejects the candidate without partial changes +- **AND** the surface retains parameters for correction or refresh + +### Requirement: Preferences never replay content operations + +Saving settings or changing output, appearance, or frame-display preferences MUST NOT apply naming, numbering, expression, shift, or conversion drafts. Display refresh MUST read committed content. A successful naming or numbering operation MUST clear its active one-shot intent. Retained template text MUST require a new explicit operation before reuse. + +#### Scenario: Save appearance after manually renaming a chapter +- **WHEN** the user applies automatic names, manually renames one chapter, and saves theme or font settings +- **THEN** the manually edited name remains unchanged in the table and export +- **AND** settings save creates no content history node + +#### Scenario: Change rounding or output encoding +- **WHEN** the user changes rounding, frame precision, tolerance, format, XML language, encoding, or BOM +- **THEN** the corresponding presentation or output preference changes +- **AND** chapter content and content history remain unchanged +- **AND** a pending content draft is not applied + +#### Scenario: Save default format during an active session +- **WHEN** the user saves a new default format while a document has a selected session format +- **THEN** the active session format remains selected +- **AND** the saved default applies at the next application startup + +### Requirement: Signed frame shifts use shared timing validation + +Both hosts MUST accept integer frame-shift drafts from -1000000 through 1000000. The Web host MUST review shifts using the same current-track scope and FPS basis as Avalonia. Zero MUST produce an unchanged candidate. Invalid times, overflow, or missing required FPS MUST reject the whole candidate. + +#### Scenario: Review a valid negative shift +- **WHEN** a shift of minus one frame leaves every participating chapter time valid +- **THEN** both hosts show the same candidate times and related timing fields +- **AND** the Web document changes only after Apply + +#### Scenario: Shift would make a chapter time negative +- **WHEN** a signed shift would violate a chapter timing invariant +- **THEN** the entire candidate is invalid +- **AND** neither host commits partial chapter changes + +#### Scenario: Zero or out-of-range shift +- **WHEN** the user enters zero or a value outside the supported integer range +- **THEN** zero is reported as unchanged and an out-of-range value has a validation error +- **AND** neither value creates a history node + +### Requirement: Numbering controls reflect the existing Core policy + +Both hosts MUST expose numbering offsets from 0 through 1000. Zero MUST remain the neutral draft value. Negative, fractional, and out-of-range UI input MUST NOT be advertised as a valid numbering operation. Applying a nonzero offset MUST use the Core numbering result and separator handling. + +#### Scenario: Apply a positive offset +- **WHEN** the user previews and applies an offset of two to a track with ordinary chapters and separators +- **THEN** ordinary chapter numbers match the Core candidate in both hosts +- **AND** separator numbering follows the existing Core policy + +#### Scenario: Enter a negative offset in either host +- **WHEN** the user attempts to enter a negative numbering offset +- **THEN** the control prevents acceptance or reports a validation error +- **AND** no operation silently treats that input as a supported negative offset + +### Requirement: Portable operations produce equivalent host outcomes + +Verification MUST use matching inputs, target scopes, operations, and output preferences in both hosts. It MUST compare relevant committed document fields, one-step Undo/Redo outcomes, and serialized output. Web command enabled states and option labels MUST expose the same portable prerequisites. + +#### Scenario: Run the same operation sequence +- **WHEN** tests apply naming, numbering, frame edit, signed shift, conversion, and expression fixtures through both hosts +- **THEN** relevant committed values and exported bytes match for the same preferences +- **AND** each accepted operation has the expected atomic history behavior + +#### Scenario: Present localized XML choices +- **WHEN** the user changes UI language with XML output selected +- **THEN** Web XML choices expose localized language names and stable language codes +- **AND** the selected code remains unchanged and the selector is disabled for non-XML output + +### Requirement: Portable tool surfaces remain localized and accessible + +All new or changed Web operation, editor, settings, shortcut, and log controls MUST use shared locale resources. Icon actions MUST have accessible names. Modal focus containment and restoration MUST remain intact. Default, wide, narrow, narrow-short, and landscape layouts MUST keep primary actions reachable. Behavior assertions MUST accompany screenshot evidence. + +#### Scenario: Switch among supported locales +- **WHEN** the user selects English, Chinese, or Japanese and opens a changed tool +- **THEN** labels, statuses, validation, and accessible names resolve for that locale +- **AND** chapter text and operation identity remain unchanged + +#### Scenario: Use the changed tools at supported viewport sizes +- **WHEN** browser checks run at 1280x800, 1920x1080, 390x844, 390x640, and 844x390 +- **THEN** changed tools and primary actions can be reached and activated without page-level horizontal overflow +- **AND** closing each modal returns focus to a valid opener or workspace target +- **AND** screenshots record layouts in addition to those interaction assertions diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-grid-expression-authoring/spec.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-grid-expression-authoring/spec.md new file mode 100644 index 00000000..f28ef177 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-grid-expression-authoring/spec.md @@ -0,0 +1,108 @@ +## ADDED Requirements + +### Requirement: Browser frame cells use Core edit semantics + +The Web chapter grid MUST permit edits to time, name, and frame fields through Core cell-edit semantics. Frame edits MUST use the same FPS basis, parsing, related-field updates, and validation as Avalonia. Separator and missing-FPS restrictions MUST match the applicable Core operation. + +#### Scenario: Edit a frame value +- **WHEN** the user commits a valid frame value for an ordinary chapter with valid FPS +- **THEN** the chapter time and derived frame display match the Avalonia result +- **AND** export uses the committed timing and one Undo restores the prior document + +#### Scenario: Edit a protected or invalid field +- **WHEN** the user attempts an unsupported separator edit or frame edit without required FPS +- **THEN** the host prevents or rejects that edit with an accessible explanation +- **AND** no partial content or history change occurs + +### Requirement: Browser cells have explicit draft and commit states + +A Web cell edit MUST capture chapter identity, field, original value, and base version. Enter, Tab, or valid focus loss MUST commit once. Escape MUST discard the draft. Invalid input MUST remain visible with a localized error and MUST NOT be coerced to zero. Text-control Undo MUST affect the draft while the input owns focus. + +#### Scenario: Enter is followed by focus loss +- **WHEN** the user presses Enter on a valid edit and the input then loses focus +- **THEN** at most one content transaction is created +- **AND** an unchanged canonical value creates no history node + +#### Scenario: Escape or local Undo +- **WHEN** the user presses Escape during an edit +- **THEN** the original value is restored without a document transaction +- **WHEN** the focused input receives a text Undo gesture +- **THEN** document history does not move + +#### Scenario: Invalid input stays available for correction +- **WHEN** a time or frame draft cannot be parsed or violates timing invariants +- **THEN** the draft remains visible with an accessible error +- **AND** committed rows, history, and export retain the preceding valid values + +#### Scenario: A row position becomes stale +- **WHEN** history navigation or clip replacement changes the row projection during an edit +- **THEN** the old edit cannot commit against a different chapter at that row index +- **AND** the host resolves the stale draft without changing unrelated chapters + +### Requirement: Table workflows remain reachable by keyboard and scrolling + +The Web grid MUST preserve surviving chapter selection identities and usable focus after accepted edits. Keyboard users MUST reach editable fields, selection actions, and portable row commands. A rendered table with 1000 chapters MUST make every row reachable without losing committed edits or blocking the primary workflow controls. + +#### Scenario: Edit a late row in a large table +- **WHEN** the user scrolls to and edits the last chapter in a 1000-row table +- **THEN** that chapter commits correctly and remains reachable +- **AND** preview, history, and output actions remain usable + +#### Scenario: Undo an edit on a selected chapter +- **WHEN** the user commits an edit and invokes document Undo with table focus +- **THEN** the previous content is restored +- **AND** the surviving chapter remains selected with a usable focus target + +### Requirement: Browser expression tools load Lua files as drafts + +The Web expression tool MUST provide a `.lua` file input. Loading MUST use the shared script-content rules and bounded input behavior. Success MUST populate the script draft and source metadata without committing chapters. Cancel, failed read, or invalid script content MUST preserve the entry expression state and document. + +#### Scenario: Load a multiline script +- **WHEN** the user loads an accepted Lua file containing multiline UTF-8 text +- **THEN** line breaks and script content appear in the editor +- **AND** the source filename is inspectable and a read-only preview starts +- **AND** document history remains unchanged until explicit Apply + +#### Scenario: File read fails or exceeds its limit +- **WHEN** Lua file input fails shared validation or bounded reading +- **THEN** the tool reports a localized error +- **AND** the preceding script, preset, source metadata, document, and history are preserved + +#### Scenario: Cancel a loaded script draft +- **WHEN** the user loads a script and closes the tool without applying it +- **THEN** the entry script, preset, and source metadata are restored on reopening +- **AND** no chapter content changes + +### Requirement: Browser authoring uses shared diagnostics and completion + +The Web editor MUST present Lua syntax highlighting, completion, and positioned diagnostics from the shared authoring service. Assistance MUST operate on the current draft and caret. Diagnostic positions MUST be shown only when the service supplies them. Completion MUST preserve surrounding text and multiline formatting. + +#### Scenario: Accept a completion +- **WHEN** the user requests and accepts a valid completion at the caret +- **THEN** the selected completion replaces only its intended draft range +- **AND** surrounding script text remains intact and no chapter transaction occurs + +#### Scenario: Rapid edits replace old diagnostics +- **WHEN** a later draft replaces an earlier draft before authoring results arrive +- **THEN** only diagnostics for the latest draft are displayed +- **AND** supplied positions map to that draft without invented locations + +### Requirement: Editor assistance preserves expression review and accessibility + +The enhanced editor MUST preserve the existing expression candidate review, debounce, composition, cancellation, and exact-candidate application contract. Highlighting MUST NOT duplicate text for assistive technology. Enter MUST insert a newline when completion is inactive. Completion MUST consume Escape before the outer dialog. Ctrl/Cmd+Z MUST remain local to the editor. + +#### Scenario: Type with an input method +- **WHEN** IME composition is active +- **THEN** incomplete composition does not trigger candidate evaluation or destructive completion +- **AND** composition completion schedules only the current draft + +#### Scenario: Dismiss completion before the dialog +- **WHEN** completion is open and the user presses Escape +- **THEN** completion closes and the expression tool remains open +- **WHEN** the user presses Escape again with no inner surface active +- **THEN** the expression tool follows its existing cancel and focus-restoration behavior + +#### Scenario: Use the editor at narrow sizes +- **WHEN** the expression tool is used at default, wide, narrow, or short supported sizes +- **THEN** editor text, diagnostics, completion, Cancel, and Apply remain reachable +- **AND** editor assistance does not create page-level horizontal overflow diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-log-tool-parity/spec.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-log-tool-parity/spec.md new file mode 100644 index 00000000..1bade64a --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-log-tool-parity/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Browser logs open as a bounded list-first surface + +The Web log tool MUST open without automatic selection or an inspector. It MUST retain the existing bounded browser log source. Rows MUST provide a concise summary, local time, and localized severity text. Live append MUST not steal focus or open details. Entries MUST have stable identity for selection and inspection. + +#### Scenario: Open populated logs and append an entry +- **WHEN** the user opens logs and a new retained entry arrives +- **THEN** the list updates without selecting the entry or opening an inspector +- **AND** keyboard focus remains at its current target + +### Requirement: Search and severity filter the browser projection + +The Web log tool MUST provide case-insensitive text search and severity filtering. Search MUST include message, details, and any recorded identity or structured fields. Filtering MUST preserve the retained source and show a visible count. It MUST not invent data absent from browser entries. + +#### Scenario: Search hidden details +- **WHEN** the query occurs only in a retained entry's details +- **THEN** the entry remains visible with a localized indication of the detail match +- **AND** opening details exposes that text + +#### Scenario: Change or clear filters +- **WHEN** the user changes severity or clears search +- **THEN** visible membership and count update without altering retained entries +- **AND** stale search highlights disappear + +### Requirement: Browser inspection requires an explicit action + +A row details action, Enter, or Space MUST open the inspector for that entry. Row selection alone MUST not open it. The inspector MUST show populated browser data and secondary raw/copy actions. Closing inspection MUST preserve filters and surviving selection. Filter exclusion or retention eviction MUST close an unavailable inspector without selecting a replacement. + +#### Scenario: Select and inspect an entry +- **WHEN** the user selects a row body +- **THEN** the list remains the only data surface +- **WHEN** the user activates its details action +- **THEN** the inspector displays that entry's recorded data + +#### Scenario: Entry leaves visible membership +- **WHEN** filtering or bounded retention removes the inspected entry from visible membership +- **THEN** the inspector closes or presents an explicit unavailable state +- **AND** no different entry is selected automatically + +#### Scenario: Return from details +- **WHEN** the user closes details or invokes its Escape action +- **THEN** the list retains its filters, scroll state, and surviving selection +- **AND** focus returns to the row or its details action + +### Requirement: Browser log copy and clear preserve defined state + +The tool MUST provide explicit copy for selected entry data and explicit clear for retained logs. Copy MUST not change retained entries or filters. Copy failure MUST be recoverable. Clear MUST empty the retained source and resolve selection and inspector state without stale data. + +#### Scenario: Copy details +- **WHEN** the user copies the inspected entry +- **THEN** the clipboard request contains the selected recorded data +- **AND** the list, filters, and inspector remain usable after success or failure + +#### Scenario: Clear retained entries +- **WHEN** the user activates Clear +- **THEN** retained logs, visible membership, selection, and inspector data are cleared +- **AND** a localized empty state is visible + +### Requirement: Log export captures the filtered membership + +The Web log tool MUST expose JSON and CSV through a secondary export surface. Export MUST capture current filtered membership and serialize it in deterministic timestamp order. Output MUST use UTF-8 and preserve Unicode, quotes, delimiters, and multiline details. The existing browser download adapter MUST deliver the result. Export MUST not change chapter export baselines, log retention, filters, selection, or inspector state. + +#### Scenario: Export filtered Unicode entries +- **WHEN** the user filters logs and exports JSON or CSV containing Unicode and multiline details +- **THEN** output contains exactly the captured visible entries in deterministic order +- **AND** JSON parses and CSV round-trips its quoted fields correctly +- **AND** no invisible retained entry is included + +#### Scenario: Export fails or a new entry arrives +- **WHEN** download fails or logs change after the export snapshot was captured +- **THEN** the tool reports the applicable outcome without changing the captured membership +- **AND** filters, surviving selection, inspector state, and retained entries remain usable + +### Requirement: Browser log presentation is responsive and localized + +Wide layouts MUST place a bounded inspector beside the list. Narrow layouts MUST show the inspector with a back action when both surfaces cannot fit. Search, severity, count, details, close/back, copy, clear, and export MUST have localized labels and accessible keyboard targets. Meaning MUST not depend on color alone. + +#### Scenario: Inspect at narrow and short sizes +- **WHEN** the user opens details at supported narrow or short sizes +- **THEN** details and back/close actions remain reachable without horizontal clipping +- **AND** returning restores the filtered list + +#### Scenario: Change locale while inspecting +- **WHEN** the UI language changes with a surviving entry inspected +- **THEN** controls and accessible labels update from shared locale resources +- **AND** the selected entry and recorded data remain stable diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-settings-shortcuts/spec.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-settings-shortcuts/spec.md new file mode 100644 index 00000000..8a6665f1 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/specs/wasm-settings-shortcuts/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Browser shortcuts use the shared action catalog + +The Web host MUST use stable action identifiers from the shared shortcut catalog. It MUST expose load, save, reload, preview, log, previous clip, next clip, insert, delete, Undo, and Redo actions with their catalog defaults and applicable fixed aliases. A configurable Shortcuts settings page MUST support recording, clearing, per-action reset, and saving editable mappings. + +#### Scenario: Load default shortcuts +- **WHEN** no browser shortcut overrides exist +- **THEN** Web routes catalog defaults and applicable fixed aliases to available commands +- **AND** visible hints use that same mapping + +#### Scenario: Customize or clear an editable action +- **WHEN** the user records or clears an editable action and saves valid settings +- **THEN** runtime routing and visible hints use the saved value +- **AND** a replaced default no longer triggers the action unless it is a fixed alias or remains assigned + +#### Scenario: Reset one action +- **WHEN** the user resets one shortcut draft +- **THEN** that action returns to its catalog default +- **AND** other shortcut drafts remain unchanged + +### Requirement: Shortcut validation protects usable mappings + +The Web host MUST validate canonical gestures without depending on Avalonia key types. It MUST reject invalid, duplicate, fixed-alias-conflicting, or unsupported captured gestures with localized errors. It MUST keep the preceding active mapping until valid settings are saved. Browser-specific restrictions MUST be explicit and MUST preserve existing supported intercepted defaults. + +#### Scenario: Duplicate or invalid draft +- **WHEN** two commands share a conflicting gesture or a row contains an invalid gesture +- **THEN** settings marks the invalid rows and prevents Save +- **AND** the active mapping remains usable + +#### Scenario: Browser gesture cannot be delivered +- **WHEN** a user attempts to capture an unsupported browser-reserved gesture +- **THEN** the row explains the restriction and does not save it as a functioning shortcut + +### Requirement: Routing and default suppression use one active mapping + +Dispatch, hints, and application shortcut default suppression MUST derive from the same active mapping. Editable controls MUST keep text-editing gestures. Active dialogs MUST prevent background commands. Unavailable commands MUST not mutate the document. + +#### Scenario: Invoke row commands with table focus +- **WHEN** the user presses Insert or Delete with an eligible table selection and no active cell draft +- **THEN** the corresponding Core edit runs once +- **AND** text input focused inside a cell retains its own editing behavior + +#### Scenario: Navigate clips +- **WHEN** the user presses the configured previous/next clip gesture with eligible workspace focus +- **THEN** the active clip changes within the valid clip range +- **AND** reaching a range boundary does not wrap or select an invalid index + +#### Scenario: Use a customized gesture +- **WHEN** an eligible workspace target receives an active customized application gesture +- **THEN** the mapped command executes and its conflicting browser default is suppressed +- **AND** unrelated gestures are not suppressed as application actions + +#### Scenario: Modal or editor owns focus +- **WHEN** an expression editor, settings control, or active dialog owns input +- **THEN** background row, clip, load, and document-history commands do not run +- **AND** local editing and inner dismissal remain available + +### Requirement: Shortcut persistence is additive and compatible + +The Web settings document MUST retain its current storage key and existing application, theme, and font content. An optional shortcuts child MUST store canonical action mappings. Existing version-one settings without shortcuts MUST load defaults without a rewrite. Unknown actions and malformed stored mappings MUST normalize to a usable mapping without blocking other valid settings. + +#### Scenario: Load existing version-one settings +- **WHEN** the stored document contains valid application, theme, and font settings but no shortcuts child +- **THEN** those values are restored and shortcut defaults are active +- **AND** loading alone does not rewrite storage + +#### Scenario: Save shortcuts and reopen +- **WHEN** valid shortcut settings are saved and the application is reopened +- **THEN** action IDs and canonical mappings are restored +- **AND** unrelated output and appearance preferences are preserved + +### Requirement: Settings drafts have explicit save and discard behavior + +The Web settings tool MUST compare normalized drafts with its entry snapshot. Save MUST validate and persist one aggregate before activating saved settings and closing. A validation or persistence failure MUST preserve the tool, draft, and last valid active settings. Reset MUST modify only the draft. Intentional Cancel MUST discard and close. + +#### Scenario: Save valid settings +- **WHEN** the user saves a valid settings draft +- **THEN** one aggregate is persisted and the saved runtime preferences become active +- **AND** the settings tool closes without changing chapter content or history + +#### Scenario: Save fails +- **WHEN** storage rejects settings persistence +- **THEN** the tool remains open with a recoverable localized error and the draft +- **AND** active preferences and shortcuts retain their last valid values +- **AND** chapter content and history remain unchanged + +#### Scenario: Reset then cancel +- **WHEN** the user resets settings and invokes Cancel +- **THEN** saved values and active settings remain unchanged +- **AND** reopening restores the entry saved values + +### Requirement: Changed settings require a close decision + +Closing the Web settings tool by Close or Escape with changed drafts MUST offer Discard and Keep editing. Unchanged settings MUST close directly. Keep editing MUST preserve draft values and focus. Discard MUST restore the entry active snapshot and close without persistence. This requirement concerns the settings modal, not page-leave handling. + +#### Scenario: Keep a changed draft +- **WHEN** the user closes changed settings and selects Keep editing +- **THEN** the settings tool remains open with all draft values +- **AND** focus returns to the settings surface + +#### Scenario: Discard through close confirmation +- **WHEN** the user closes changed settings and selects Discard +- **THEN** the entry active settings remain in effect and no storage write occurs +- **AND** the modal closes and restores focus to its opener + +#### Scenario: Close unchanged settings +- **WHEN** normalized draft values equal the entry snapshot and the user presses Escape +- **THEN** the tool closes without an extra confirmation diff --git a/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/tasks.md b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/tasks.md new file mode 100644 index 00000000..a1db8fd4 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-align-wasm-ui-functional-parity/tasks.md @@ -0,0 +1,69 @@ +## 1. Establish regression fixtures and scope + +- [x] 1.1 Add matching Core, Web, and Avalonia operation fixtures for ordinary chapters, separators, rational FPS, names, and numbering. Compare relevant content through public APIs. Covers D01-D06. +- [x] 1.2 Add a Web regression that applies automatic names, manually renames a chapter, and saves appearance settings. Assert the name, committed document, history cursor, and export remain unchanged. Covers D02. +- [x] 1.3 Add Web regressions for prepared naming, frame shift, and frame conversion. Assert preview and cancellation leave the document and history unchanged. Covers D01, D03, D04. + +## 2. Align reviewed content operations + +- [x] 2.1 Add narrow Web prepare/apply/cancel entry points over Core candidates. Retain candidate identity, session, current-track scope, draft revision, and base version. Covers D01, D03, D04. +- [x] 2.2 Separate `Home.razor` output/display/settings handlers from content-operation handlers. Remove content commits from settings and rounding changes. Preserve the active session format during settings save. Covers D02. +- [x] 2.3 Add naming, template, and numbering draft controls with an explicit review surface. Move content parameters out of advanced output preferences. Loading templates must not commit. Covers D01, D11. +- [x] 2.4 Clear active naming/numbering intent after success. Preserve template text only for deliberate reuse. Verify later preference changes do not overwrite manual edits. Covers D01, D02. +- [x] 2.5 Add frame-rate review with source/target FPS, typed differences, explicit Apply, and Cancel. Commit the exact reviewed candidate. Covers D03. +- [x] 2.6 Add signed frame-shift drafts from -1000000 to 1000000. Verify FPS basis, zero/no-change behavior, negative-time rejection, and limits through Core. Covers D04. +- [x] 2.7 Align Web and Avalonia numbering controls with 0..1000. Add rendered rejection of negative/fractional input and preserve Core normalization for API consumers. Covers D06. +- [x] 2.8 Add candidate race and repeat-submission tests. Verify stale rejection, explicit refresh, no partial commit, and one-step Undo/Redo. Covers D01, D03, D04. + +## 3. Complete browser table editing + +- [x] 3.1 Add identity-based Web cell drafts for time, name, and frame fields. Use Core parsing and frame-to-time conversion. Covers D05, D12. +- [x] 3.2 Implement Enter, Tab, valid blur, Escape, and local text Undo. Preserve invalid drafts with accessible errors. Prevent Enter-plus-blur duplicate commits. Covers D12. +- [x] 3.3 Preserve surviving chapter selection and usable focus after edit, history navigation, and clip changes. Reject edits whose captured identity or base is stale. Covers D12. +- [x] 3.4 Add workspace and Playwright tests for frame edits, invalid time/frame input, unchanged values, separators, and missing FPS. Compare accepted results with Avalonia. Covers D05, D12. +- [x] 3.5 Run a 1000-row browser workflow that scrolls to and edits the last row. Record interaction and rendering evidence. Add paging or virtualization only if that evidence requires it. Covers D12. + +## 4. Add Lua file loading and authoring assistance + +- [x] 4.1 Add `.lua` file input to the expression tool. Reuse shared script rules and bounded reads. Keep source metadata and restore entry state on Cancel or failed input. Covers D07. +- [x] 4.2 Connect the browser editor to Core authoring diagnostics and completion for the current draft and caret. Reject late results from obsolete revisions. Covers D07. +- [x] 4.3 Add theme-aware syntax highlighting and positioned diagnostics without duplicating accessible text or changing script content. Covers D07. +- [x] 4.4 Add keyboard completion, range replacement, and completion-first Escape behavior. Preserve multiline input, local Undo, IME composition, and debounce. Covers D07. +- [x] 4.5 Add script-loading, completion, diagnostic-position, rapid-input, cancellation, and composition browser tests. Retain existing exact-candidate expression and one-step Undo coverage. Covers D07. + +## 5. Align settings and shortcuts + +- [x] 5.1 Add an optional canonical shortcuts child to `WasmSettings`. Preserve the current storage key and version-one application/theme/font values. Verify missing and malformed overrides load usable defaults without rewriting on load. Covers D08. +- [x] 5.2 Add a Shortcuts settings page with record, clear, per-action reset, and shared conflict validation. Show localized invalid or unsupported gesture errors. Covers D08. +- [x] 5.3 Derive Web dispatch, displayed hints, and application default suppression from one active mapping. Preserve editable-target and modal input isolation. Covers D08. +- [x] 5.4 Route existing Insert/Delete and previous/next clip actions. Verify command prerequisites, range boundaries, and fixed Undo/Redo aliases. Covers D08. +- [x] 5.5 Add normalized settings draft comparison. Validate and persist once before activating saved runtime settings. Preserve the previous active snapshot and draft after failure. Covers D09. +- [x] 5.6 Add changed-draft Close/Escape confirmation with Discard and Keep editing. Preserve intentional Cancel and draft-only Reset. Verify nested focus restoration. Covers D09. +- [x] 5.7 Add browser workflows for shortcut recording, save/reopen, conflict rejection, text-input isolation, and modal isolation. Covers D08. +- [x] 5.8 Add settings workflows for unchanged close, keep editing, discard, reset/cancel, and injected storage failure. Assert no content history changes. Covers D02, D09. + +## 6. Complete the browser log tool + +- [x] 6.1 Add stable log-entry identity and a filtered projection over the existing bounded source. Add case-insensitive search, severity filters, counts, and detail-match indication. Covers D10. +- [x] 6.2 Add list-first presentation and an explicit inspector. Preserve surviving selection and scroll state. Resolve filtered or evicted entries without selecting replacements. Covers D10. +- [x] 6.3 Add selected-entry copy and clear workflows with recoverable failure feedback. Preserve filters and avoid stale inspector data. Covers D10. +- [x] 6.4 Add secondary JSON/CSV export of a captured filtered snapshot through the existing download adapter. Verify UTF-8, quoting, multiline fields, deterministic ordering, and chapter-baseline isolation. Covers D10. +- [x] 6.5 Add wide side-by-side and narrow replacement inspector layouts with keyboard details/back actions. Covers D10. +- [x] 6.6 Add behavior tests for hidden-detail search, filters, live append, retention eviction, explicit inspection, copy/clear, filtered export, and export failure. Covers D10. + +## 7. Localize and verify cross-host parity + +- [x] 7.1 Add localized labels, scope/status text, errors, shortcut hints, log actions, and XML language display names in shared locale AXAML files. Preserve stable values and codes. Covers D01-D12. +- [x] 7.2 Generate Web locale JSON with `uv run --project scripts scripts/axaml-to-json.py`. Run the same command with `--check`. Install the scripts environment once if absent. Covers D11. +- [x] 7.3 Compare matching operation sequences in Web and Avalonia through public APIs. Verify committed fields, selected scope, transaction counts, Undo/Redo, and serialized bytes for matching preferences. Covers D01-D07. +- [x] 7.4 Extend Playwright workflows at 1280x800, 1920x1080, 390x844, 390x640, and 844x390. Verify action reachability, focus restoration, modal isolation, and lack of outer horizontal overflow. Covers D01-D12. +- [x] 7.5 Verify English, Chinese, and Japanese labels and accessible names through resource or rendered behavior. Preserve Unicode chapter text and existing expression regressions. Covers D07-D11. +- [x] 7.6 Capture default, wide, and narrow Avalonia evidence for the numbering control change under `artifacts/`. Record browser screenshots for changed surfaces. Use behavior checks alongside screenshots. Covers D06, D11, D12. + +## 8. Complete checks and documentation + +- [x] 8.1 Run affected Core, Web, Avalonia unit, and Avalonia Headless test projects sequentially with `--no-restore`. Restore once only if dependencies or generated assets require it. Keep Headless in its own process. +- [x] 8.2 Run `dotnet test ChapterTool.slnx --no-restore` after shared Core or Contracts changes. Fix failures caused by this change. +- [x] 8.3 Run the Web build and browser typecheck/E2E commands selected from `scripts/check-ci.py`. Use supported browser engines. Run visual comparison only in its matching Linux baseline environment. +- [x] 8.4 Update `docs/code-map/program-form-capability-map.md`, `avalonia.md`, and `testing.md` for changed owners, entry points, behavior, and primary tests. Update the Web README for newly supported portable features and retained platform boundaries. +- [x] 8.5 Review D01-D12 against implementation evidence. Run `openspec validate align-wasm-ui-functional-parity --strict` and `git diff --check`. Report unsupported-host checks and screenshot paths. Leave deferred platform items and existing changes untouched. diff --git a/openspec/changes/archive/2026-10-09-compact-content-preview-review/.openspec.yaml b/openspec/changes/archive/2026-10-09-compact-content-preview-review/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-compact-content-preview-review/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-compact-content-preview-review/design.md b/openspec/changes/archive/2026-10-09-compact-content-preview-review/design.md new file mode 100644 index 00000000..965ee6d8 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-compact-content-preview-review/design.md @@ -0,0 +1,149 @@ +## Context + +第一版以独立结果区域展示候选,解决了重叠问题,但仍降低章节可视区域高度。 +用户进一步明确要求在原章节表内比对。 +本设计采用同一章节行、同一单元格中的原值与预览值。 +原内容仍是已提交文档,预览是只读候选投影。 +帧的原值必须从候选出现之前同一行可见的 `FramesInfo` 捕获。 +候选投影不能用 Core 中的缺失标记覆盖显示帧率已计算出的值。 + +## Goals / Non-Goals + +**Goals:** + +- 不增加底部结果区域或独立逐章审阅弹框。 +- 原章节位置、选择和滚动必须在预览过程中保持。 +- 时间、名称、编号及帧值必须在其对应单元格中比较。 +- 图标和语义颜色必须区分已提交值与尚未应用的值,且保留无障碍名称和工具提示。 +- 应用和放弃必须使用同一候选及既有原子事务。 + +**Non-Goals:** + +- 不修改历史弹框、Core 事务或独立 Expression 工具的既有编辑流程。 +- 不默认隐藏未变化章节,不引入排序或第二套章节行。 +- 不把预览值直接写入现有可编辑字段。 +- 不修改 Blazor 或 CLI。 + +## Decisions + +### 1. 同格比较是默认表格模式 + +宽窗口中,变化单元格显示原值、箭头图标和预览值。 +时间单元格同时显示带符号的变化量。 +原值用次要文本,目标值使用语义强调。 +表头保持简短的时间、名称、帧和编号。 +单元格不得显示“原/预览”或 `Original/Preview` 文本标签。 +未变化单元格仍只显示原值。 +箭头图标、语义颜色及 accessible name 和 tooltip 必须说明原值与预览值,不能只依赖颜色。 + +窄窗口使用两行单元格,每行显示一个值及紧凑箭头提示。 +该模式可以增加行高,但必须保留表格可视区域的分配高度。 +不能通过缩小字体或覆盖文字挤入两份时间。 +合理设置列最小宽度。 +确有必要时,仅在表格内提供横向滚动。 + +时间、帧、名称和编号必须按实际变化分别比较。 +帧原值使用准备候选之前行中可见的 `FramesInfo` 快照。 +原帧的准确、近似或中性颜色也必须在同一时刻从可见行捕获。 +候选帧使用候选的 `FrameAccuracy`,不能统一覆盖为预览强调色。 +准确和近似值复用普通帧列的语义颜色,中性值保持普通中性颜色。 +如果当时没有可见计算值,才显示本地化缺失值。 +快照按稳定轨道和章节 ID 绑定,并在候选准备、输入变化和投影刷新期间保持。 +表格主视图不能输出长帧率分数或原始对象描述。 + +交互原型同时展示同格比较和追加预览列。 +同格比较保持原有列结构,更适合当前小窗口。 +追加预览列在宽屏下有更好的跨行扫描效果,但多字段变化会扩张表宽。 +当前实施选择同格比较;追加列仅为设计对照。 + +### 2. 原表格复用同一套稳定章节行 + +预览数据必须按轨道 ID 和章节 ID 映射到原章节行。 +不能按当前行索引、显示编号或名称匹配。 +只使用当前候选的基础快照和目标快照。 +不得从技术字符串解析对比值。 + +展示模型可以为每个行对象附加只读比较投影。 +不得修改正常编辑使用的 `TimeText`、名称或帧字段来模拟预览。 +不创建第二个内容会话或第二套结果列表。 +原有排序、选择、滚动及虚拟化必须保持。 +全部未变化章节继续留在原位置。 + +正常内容编辑必须先结束预览。 +有效候选展示期间表格只读。 +滚动、选择和编辑表达式继续可用。 +准备候选前,现有单元格草稿必须按已有提交或取消规则结束。 +表格锁定必须同步禁用插入、删除及其他直接内容变更入口。 +历史导航、换轨或文档替换仍可能使候选过期,必须走既有失效机制。 +表达式编辑器的撤销继续作用于草稿。 + +### 3. 确认操作保留在现有操作槽 + +表达式所在行不显示章节摘要、摘要范围工具提示或重复章节详情按钮。 +表达式所在行显示应用、放弃和可用的更新操作。 +工具提示或现有诊断反馈只呈现无法由章节单元格表示的属性变化和错误。 +其他内容操作复用这一状态,不能误标为表达式。 +计算中显示简短状态,旧结果立即失效。 +错误和过期状态禁用应用,并清除可被误认为当前结果的目标值。 + +文档或轨道属性变化无法放入章节单元格。 +现有应用操作的本地化工具提示展示这些属性的范围和差异。 +属性独立变化也必须可审阅和提交。 +技术错误使用现有错误反馈,不增加重复详情浮层。 + +### 4. 应用与放弃恢复普通表格 + +应用必须提交当前表格展示的同一有效候选。 +不能重新执行表达式。 +成功后目标值成为普通表格的已提交值,原值和变化标识消失。 +一次撤销必须恢复全部内容效果。 +表格恢复编辑,并尽可能保留章节选择和滚动。 + +放弃必须取消候选,移除所有目标值并恢复普通表格。 +原章节内容必须保持不变。 +主窗口表达式草稿保留。 +被放弃的同一草稿版本不能立即被定时器重新准备。 +下一次输入修改或显式准备才恢复预览。 + +准备、响应式切换及保存不能提交候选。 +保存继续使用已提交内容。 +无变化时不能创建空事务。 +提交中必须禁止重复应用及候选替换。 +失败必须保留草稿并说明恢复动作。 + +### 5. 验收测量可视区域,而不是要求所有行同高 + +预览前后章节表格的可视区域边界必须相同。 +宽窗口同格比较尽可能保持正常行高。 +窄窗口允许两行布局增加行高。 +两者都不能重新引入底部大面板。 +测试必须检查有差异的单元格与未变化单元格。 +必须检查滚动到末行后的身份对应。 +截图必须覆盖中文、英文、760×600、1280×800 和 760×520。 + +## Risks / Trade-offs + +- [双值增加单元格密度] → 宽窗横向比较,窄窗上下比较。采用合理列宽及可展开完整值。 +- [预览与实际编辑混淆] → 箭头图标、语义颜色、可访问名称和工具提示标记预览值。只在应用后写入已提交字段。 +- [用户想直接修改某行] → 明确提供放弃操作。结束预览后恢复原编辑入口。 +- [按行索引映射产生错位] → 使用稳定轨道与章节身份。验证名称重复、编号变化及虚拟化。 +- [元数据变化没有行位置] → 在应用操作的本地化工具提示中显示文档、轨道或分段差异,并保留属性独立变化的应用条件。 +- [前一版规格要求独立区域] → 按先第一版后本 change 的顺序同步。以本次原表格审阅要求替换冲突条款。 + +## Migration Plan + +1. 加入表格可视区域及原值未被预览写入的回归用例。 +2. 增加稳定身份映射和只读行比较投影。 +3. 增加同格比较模板及响应式列宽,并保留预览前可见帧值。 +4. 将底部结果区替换为原操作槽中的应用、放弃、更新和必要错误反馈。 +5. 移除重复章节摘要及逐章详情浮层,并验证应用工具提示中的非章节属性差异。 +6. 对齐底部选项输入,更新翻译、代码地图和尺寸截图。 + +不需要数据或设置迁移。 +第一版未提交实现必须保持。 +第一版实现和测试已经存在于工作区。本次更新扩展同一 change,不新增平行 change。 + +## Open Questions + +无阻塞问题。 +具体列宽与窄窗口行高必须通过生产资源的渲染检查确定。 diff --git a/openspec/changes/archive/2026-10-09-compact-content-preview-review/proposal.md b/openspec/changes/archive/2026-10-09-compact-content-preview-review/proposal.md new file mode 100644 index 00000000..f585fb19 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-compact-content-preview-review/proposal.md @@ -0,0 +1,34 @@ +## Why + +第一版的详细预览面板持续挤占章节表格。用户要求将预览直接显示在原章节表中,以便沿原行位置比较原值和目标值。 + +## What Changes + +- 移除主窗口底部的详细结果区域。预览准备、错误和有效结果不能增加底部布局行。 +- 在原章节表的对应单元格中显示原值、预览值和变化量。宽窗口使用同格横向对比,窄窗口使用两行对比。通过图标、语义颜色、无障碍名称和工具提示区分原值与预览值,不显示“原/预览”文本标签。 +- 保留全部章节和原行顺序。只突出实际发生变化的单元格,不默认过滤、排序或插入第二套结果行。 +- 表达式所在行只保留必要的应用、放弃、更新操作和错误状态。移除重复的章节摘要及逐章详情浮层。用户可以检查表格后直接确认,无需审阅弹框。 +- 帧和名称变化也显示在对应原单元格中。帧原值必须保留进入预览前行中可见的计算结果,包括依据当前显示帧率产生的值。 +- 帧比较必须保留章节精确度的颜色提示。原帧使用预览前可见行的精确度,候选帧使用候选计算的精确度;中性值保持中性颜色。 +- 无法在章节行中表示的文档或轨道属性变化,通过相关操作的本地化工具提示检查。有效预览期间章节表只读,但滚动、选择和继续编辑表达式可用;继续编辑表达式会使旧候选立即失效。 +- 底部选项表单中的文本输入使用响应式共享标签列,表达式编辑器与同列输入保持左边界对齐。 +- 应用必须提交实际展示的同一候选。放弃必须恢复正常表格而不修改内容。 +- 验证表格可视区域高度稳定、滚动与选择保留、按稳定章节身份映射及原子提交。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `avalonia-ui-shell`: 增加原章节表中的只读候选比较、同格响应式布局、行内确认和空间占用验收。 + +## Impact + +- 影响 `MainView.axaml`、章节行展示模型、响应式列模板及 `MainWindowViewModel` 的候选展示和操作命令。 +- 复用既有 typed candidate 和 `ExpressionPreviewProjector`。无需引入用于正常逐章审阅的模态工具或独立结果表格。 +- 影响 Avalonia ViewModel 与 Headless 测试、共享翻译及相关 code map。 +- 本 change 延续 `repair-main-window-editing-ux`。历史弹框和完整章节工作区保持第一版行为。 +- 本次更新同一 change 的规划制品和交互原型。Core、CLI、Blazor 页面及第一版未提交实现不在编辑范围内。 diff --git a/openspec/changes/archive/2026-10-09-compact-content-preview-review/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-10-09-compact-content-preview-review/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..d789d957 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-compact-content-preview-review/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,140 @@ +## MODIFIED Requirements + +### Requirement: Global styles preserve workflow usability +The ported style layer SHALL keep ChapterTool workflows usable at supported window sizes. Preview readiness, diagnostics, and inline comparison SHALL NOT add rows to the bottom options form or change the allocated chapter viewport bounds at a fixed window size and input layout. Narrow comparison cells MAY use two labeled lines without reducing the allocated viewport. + +#### Scenario: Main window uses the global styles +- **WHEN** the main window opens +- **THEN** the load and save area, chapter grid, options area, and status strip SHALL remain available +- **AND** controls SHALL not overlap + +#### Scenario: Tool window uses a narrow supported size +- **WHEN** a tool window is resized to its minimum width +- **THEN** primary actions SHALL remain visible +- **AND** text SHALL remain inside its control bounds + +#### Scenario: Inline comparison preserves chapter space +- **WHEN** a fixed-size main window moves through empty, computing, ready, invalid, unchanged, and stale preview states +- **THEN** its allocated chapter viewport bounds SHALL remain equal within layout rounding tolerance +- **AND** no detailed result panel SHALL appear below the options form +- **AND** responsive comparison SHALL remain readable without overlapping values + +## ADDED Requirements + +### Requirement: Candidate comparisons appear in original chapter cells +候选预览 MUST 显示在原章节表的对应单元格内。原值和预览值 MUST 明确区分。时间变化 MUST 显示带符号的变化量。宽窗口 MUST 使用可读的同格横向比较;窄窗口 MUST 使用紧凑两行比较或表格内滚动。单元格 MUST NOT 显示“原/预览”或 `Original/Preview` 文本标签。图标、语义颜色、无障碍名称及工具提示 MUST 区分已提交值和预览值。普通逐章比较 MUST NOT 要求打开独立审阅弹框。 + +#### Scenario: Review a time shift in place +- **WHEN** 用户准备 `t + 1` 候选 +- **THEN** 对应章节的时间单元格显示原时间、预览时间和正一秒变化量 +- **AND** 章节名称和其他未变化字段继续显示已提交值 +- **AND** 短表头保持时间、名称、帧和编号 +- **AND** 图标和语义颜色明确表示尚未应用的预览,辅助功能名称或工具提示解释值的角色 +- **AND** 用户沿原章节位置即可比对结果 + +#### Scenario: Compare other fields +- **WHEN** 候选改变名称、编号或帧信息 +- **THEN** 对应原单元格显示实际原值和目标值 +- **AND** 未变化单元格不出现虚假的变化标记 +- **AND** 显示的原帧值与进入预览前同一行的可见帧值一致 +- **AND** 只有进入预览前没有可见帧计算值时才显示本地化缺失值 +- **AND** 原帧保留进入预览前可见行的准确、近似或中性颜色 +- **AND** 候选帧按候选准确度显示对应语义颜色,不使用统一的预览强调色 +- **AND** 文档或轨道属性差异可从相关现有控件的本地化工具提示检查 +- **AND** 缺失值与零值使用不同业务表示 + +### Requirement: Inline review preserves row identity and committed values +比较 MUST 通过稳定轨道及章节身份映射。原行顺序、未变化章节、选择、滚动和虚拟化 MUST 保持。准备和展示 MUST NOT 修改已提交字段或产生内容事务。 + +#### Scenario: Compare repeated names and shifted numbering +- **WHEN** 多个章节有相同名称且候选改变编号 +- **THEN** 对比通过稳定身份显示在正确原行 +- **AND** 不按名称、显示编号或行索引推断对应关系 + +#### Scenario: Review a large candidate +- **WHEN** 候选涉及 1,000 个章节并且用户滚动到末行 +- **THEN** 所有原章节仍然可达且比较值正确 +- **AND** 表格不默认只显示变化行或插入重复章节行 +- **AND** 可视区域分配高度不因候选大小变化 + +#### Scenario: Save while preview is visible +- **WHEN** 用户尚未确认候选而保存已提交内容 +- **THEN** 保存结果不包含未应用的预览值 +- **AND** 展示投影不改写正常编辑字段或导出源 + +### Requirement: Grid preview is read-only while candidate input remains editable +有效候选展示期间章节表及直接内容变更入口 MUST 只读。滚动和选择 MUST 继续可用。表达式输入 MUST 保持可编辑。输入或基础内容变化 MUST 立即使旧候选不可应用。 + +#### Scenario: Edit an expression during review +- **WHEN** 用户在预览状态修改表达式 +- **THEN** 旧候选立即失效且应用禁用 +- **AND** 旧目标值清除或明确标为过期,不能冒充当前结果 +- **AND** 自动准备完成后仅展示最新草稿对应候选 +- **AND** 输入编辑的撤销不改变文档历史 + +#### Scenario: Attempt a chapter edit during preview +- **WHEN** 用户尝试编辑单元格、插入或删除章节 +- **THEN** 直接内容编辑保持禁用,并明确提供放弃预览的动作 +- **AND** 放弃后普通表格编辑恢复 +- **AND** 预览准备前的单元格草稿按既有规则结束 + +### Requirement: Inline preview actions share the existing options row +主窗口 MUST 在原操作槽显示应用及放弃操作,并保留需要的更新和错误反馈。它 MUST NOT 新增确认行或结果面板。章节摘要和重复逐章详情入口 MUST NOT 显示。无法放入章节单元格的属性差异 MUST 通过相关现有操作的本地化工具提示检查。错误 MUST 使用现有错误反馈显示。 + +#### Scenario: Confirm an inline result +- **WHEN** 有效当前候选显示在原章节表中 +- **THEN** 原操作槽提供应用和放弃按钮 +- **AND** 原操作槽不显示章节摘要、完整范围摘要工具提示或重复逐章详情按钮 +- **AND** 无需先打开模态审阅才能应用 +- **AND** 入口标明实际操作而不把所有候选都误标为表达式 + +#### Scenario: Review property-only effects +- **WHEN** 候选只有文档或轨道属性变化 +- **THEN** 相关现有操作的本地化工具提示显示属性差异 +- **AND** 用户可查看原值、目标值和所属范围 +- **AND** 即使章节时间不变,完整候选有变化时仍可应用 +- **AND** 检查工具提示不增加底部布局行或修改候选 + +#### Scenario: Invalid, unchanged, or stale preview +- **WHEN** 候选无效、无变化、计算中或过期 +- **THEN** 应用禁用且原操作槽显示对应状态 +- **AND** 错误通过现有错误反馈查看 +- **AND** 更新过期结果需要显式动作,不能替换后自动提交 + +### Requirement: Apply and discard restore the normal table +应用 MUST 提交原表格实际展示的有效当前候选,不得重新计算表达式。放弃 MUST 移除比较并保持原文档。成功或放弃后 MUST 恢复普通编辑且尽可能保留选择和滚动。 + +#### Scenario: Apply and undo +- **WHEN** 用户点击应用 +- **THEN** 目标值成为普通表格的已提交值且比较标记消失 +- **AND** 只创建一次原子事务,一次撤销恢复全部变化 +- **AND** 重复点击不重复提交 + +#### Scenario: Discard without erasing input +- **WHEN** 用户点击放弃 +- **THEN** 原值恢复为普通表格,文档和历史保持不变 +- **AND** 表达式草稿保留 +- **AND** 同一草稿版本不立即自动重建被放弃候选 +- **AND** 下一次修改或显式准备可以创建新候选 + +### Requirement: Bottom option inputs share responsive label alignment +底部选项中的文本输入 MUST 使用响应式共享标签列,且不能按某个固定语言文本设置像素宽度。表达式编辑器 MUST 与同列输入左边界对齐,并在中、英、日文、字号变化及宽窄布局中保持一致。 + +#### Scenario: Align bottom text inputs +- **WHEN** 主窗口以支持语言和字体大小渲染 +- **THEN** 保存格式、XML 语言及表达式输入的同列控件左边界对齐 +- **AND** 断言实际 TextBox 或 ExpressionEditor 的边界,而不是只检查容器 + +### Requirement: Inline review requires rendered workflow evidence +验收 MUST 覆盖中文及英文、760×600、1280×800、760×520 和布局阈值两侧。测试 MUST 检查原值保持、身份映射、单元格可读性及表格可视区域。截图 MUST 补充行为断言。 + +#### Scenario: Validate the inline design +- **WHEN** 对本 change 执行验收 +- **THEN** 验证预览前后可视区域边界相同,允许窄布局两行单元格增加行高 +- **AND** 验证显示帧率 24000/1001 下原 0 和 8357 等可见帧值在预览中保持,且候选目标帧正确 +- **AND** 验证无候选时没有可见原/预标签、章节摘要或重复详情按钮 +- **AND** 验证箭头图标资源、颜色、accessible name 及工具提示 +- **AND** 验证稳定身份、未变化行、末行、名称及帧变化、属性独立变化和亚毫秒时间差 +- **AND** 验证应用、放弃、候选失效和一次撤销 +- **AND** 代表性字体放大及深浅主题保持可用 +- **AND** 截图保存在 `artifacts/compact-content-preview-review/` diff --git a/openspec/changes/archive/2026-10-09-compact-content-preview-review/tasks.md b/openspec/changes/archive/2026-10-09-compact-content-preview-review/tasks.md new file mode 100644 index 00000000..ccea9398 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-compact-content-preview-review/tasks.md @@ -0,0 +1,52 @@ +## 1. 建立回归证据 + +- [x] 1.1 发现并严格验证本 change。复核第一版未提交实现和活动规格。保留历史弹框及完整章节工作区。 +- [x] 1.2 增加候选展示不修改已提交行字段、导出源或历史的 ViewModel 用例。 +- [x] 1.3 增加预览前后表格可视区域边界相同的 Headless 回归。覆盖空、计算中、有效、无变化、错误及过期状态。 + +## 2. 原章节行的只读比较投影 + +- [x] 2.1 按稳定轨道和章节 ID 映射 typed candidate。复用已有业务格式和 ExpressionPreviewProjector,不按行索引或字符串推断。 +- [x] 2.2 为现有行增加只读的时间、名称、编号和帧比较投影。正常编辑字段保持原值。 +- [x] 2.3 验证重复名称、编号变化、未变化章节、1,000 章节及末行虚拟化。保留顺序、选择和滚动。 +- [x] 2.4 处理属性独立变化、缺失值、不同帧率依据及亚毫秒差异。 + +## 3. 同格比较和行内确认 + +- [x] 3.1 实现宽窗口原值、箭头、预览值及时间差的同格模板。变化字段才突出显示,表头及标记明确表示预览。 +- [x] 3.2 实现窄窗口带原/预标签的上下模板、合理列宽和必要的表格内滚动。行高可增加,但表格分配高度不变。 +- [x] 3.3 移除 ContentPreviewRegion 及专用布局行。原操作槽保留应用、放弃和必要的更新操作。 +- [x] 3.4 使用表格单元格展示逐章差异。通过相关现有操作的工具提示检查非章节属性差异和诊断。 +- [x] 3.5 有效预览期间禁用单元格及插入、删除等直接内容编辑,继续允许滚动、选择及表达式编辑。候选准备前按现有规则结束单元格草稿。 + +## 4. 候选生命周期 + +- [x] 4.1 输入、轨道、历史或会话变化立即使旧候选失效。清除或明确标记旧目标值,并禁用应用。 +- [x] 4.2 应用提交实际展示的同一候选,不重新计算表达式。成功后清除比较并恢复编辑。验证一次撤销及重复提交防护。 +- [x] 4.3 放弃取消候选并恢复原表,保留输入。防止同一草稿版本被定时器立即重建。 +- [x] 4.4 验证错误、过期、失败及计算状态,属性独立变化仍可检查和应用。保存始终读取已提交内容。 + +## 5. 验收和文档 + +- [x] 5.1 补充共享中、英、日文资源。执行翻译生成与检查,通过资源或渲染验证语言。 +- [x] 5.2 Headless 覆盖 760×600、1280×800、760×520 和宽窄阈值两侧,断言同格可读、可视区域稳定和选择保留。 +- [x] 5.3 验证实际滚动、检查行内差异、继续编辑表达式、应用、放弃、恢复章节编辑及一次撤销。覆盖长值、深浅主题和字体放大。 +- [x] 5.4 顺序运行受影响的 Avalonia.Tests 和 Avalonia.Headless.Tests 项目,使用 `dotnet test --no-restore`。修复本 change 引起的失败。 +- [x] 5.5 将主表格正常、预览及应用后的默认、宽、窄截图保存到 `artifacts/compact-content-preview-review/` 并复核。 +- [x] 5.6 更新 code map 的行展示和测试归属。严格验证本 change。同步时用原表格审阅要求替换第一版常驻结果区域条款。推送前执行 `python scripts/check-ci.py`。 + +## 6. 修正行内比较来源并收敛预览交互 + +- [x] 6.1 从候选投影前同一稳定章节行捕获可见 `FramesInfo` 和显示依据。刷新候选时不得用 typed 缺失值覆盖可见帧值,也不得将目标帧倒灌为原值。 +- [x] 6.2 用紧凑箭头图标、语义颜色、accessible name 和工具提示替代可见原/预览标签。恢复短表头,移除章节摘要、完整范围摘要工具提示和重复逐章详情浮层。保留属性工具提示及现有错误、过期和超时反馈。 +- [x] 6.3 使用响应式共享标签测量对齐底部保存格式、XML 语言和表达式输入,验证中英日、不同字号及宽窄布局中的实际输入控件边界。 +- [x] 6.4 增加有行为意义的回归:显示帧率 24000/1001 下原 0 和 8357 与预览目标保持正确;区分源缺失和可见计算帧;验证图标及可访问说明、无多余控件、属性提示、无效/过期、应用、放弃及保存。 +- [x] 6.5 为至少 20 章的中文 fixture 生成并复核默认、宽和窄预览截图;验证默认和最小尺寸的行高与输入对齐。 +- [x] 6.6 更新共享翻译和相关 code map。按仓库流程生成并检查 Wasm 翻译 JSON。 +- [x] 6.7 顺序运行受影响的 Avalonia.Tests 和 Headless.Tests,修复本 change 引起的失败,并通过 OpenSpec strict validation。 + +## 7. 保留行内帧精确度颜色 + +- [x] 7.1 原帧捕获预览前可见行的 `FrameAccuracy`,候选帧使用 typed candidate 的准确度。宽窄布局均复用准确、近似和中性语义颜色,预览强调色只用于箭头;覆盖源精确度元数据缺失但显示帧及其颜色可计算的情况。 +- [x] 7.2 添加可见行为和资源断言,覆盖 Accurate、Inexact、Neutral、主题变化、应用和放弃。重新生成并复核中文默认、宽、窄截图。 +- [x] 7.3 顺序运行受影响的 Avalonia 测试并通过 OpenSpec strict validation。 diff --git a/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/.openspec.yaml b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/.openspec.yaml new file mode 100644 index 00000000..f98e139f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/design.md b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/design.md new file mode 100644 index 00000000..066b30e9 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/design.md @@ -0,0 +1,34 @@ +## Context + +Home.razor places history and expression diagnostics inside a three-row main grid. Browser probes show that the chapter grid covers the history close button at 390×640 and 844×390. Existing Playwright tests cover workflows and idle screenshots. They do not assert modal focus, hit targets, or these occupied layout states. + +## Goals / Non-Goals + +Goals are independent tool dialogs, responsive editing zones, isolated drafts, and automated browser regression gates. Preserve committed chapter data, candidate validation, history branches, export bytes, localization, and settings persistence. + +This change does not replace Playwright, change the core history model, or redesign the desktop UI. Browser emulation does not establish physical iPhone keyboard behavior. + +## Decisions + +- Use a shared native dialog component with showModal, cancel handling, scrollable body, and focus restoration. Native top-layer placement prevents the chapter grid from covering controls. Reuse it for existing tools to avoid divergent modal behavior. +- Use one active-dialog enum in Home. Close context menus on entry. Block background shortcuts and file drops while a dialog is active. +- Put history, expression editing, and advanced export options outside the main grid. Keep format and naming mode in compact bottom controls. Give the grid its own scrolling region. Let short pages scroll when toolbar and bottom controls need more space. +- Keep expression draft and debounce ownership in a focused component. Cancel cancels pending work and restores the previous expression and preset. Apply commits one valid candidate and closes the dialog. History navigation remains immediate. +- Stage advanced export values and template bytes until Apply. Cancel must not update settings or chapter projections. Apply uses the existing persistence and projection paths. +- Extend default Playwright tests with geometry, focus, actual clicks, and state assertions. Keep pixel snapshots in a separate fixed-Linux visual suite. Add dialog states to that suite and run visual and WebKit regression gates on PRs. + +## Risks / Trade-offs + +- Native dialogs require event cleanup and focus handling. Cover reopen, Escape from inputs, and removed components in browser tests. +- Virtualized history can lose scroll after a render. Retain the dialog while navigating and verify branch navigation. +- Moving inputs changes browser interaction paths. Update all affected workflow tests and accessible labels together. +- Pixel snapshots vary across hosts. Generate and review them in the existing pinned Linux environment. +- Physical mobile browser chrome and keyboard behavior require a separate device check. Record this limitation in the validation report. + +## Migration Plan + +Add failing browser regressions. Implement the shared lifecycle and move the tools. Update translations and generate JSON. Run WASM unit tests, all browser engines, and fixed-Linux visual checks. Update ownership and validation documentation. No data migration is required. Rollback consists of reverting this change. + +## Open Questions + +None for implementation. Physical iPhone validation remains a documented follow-up when a device is available. diff --git a/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/proposal.md b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/proposal.md new file mode 100644 index 00000000..b4cc7526 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/proposal.md @@ -0,0 +1,25 @@ +## Why + +The browser history panel and expression diagnostics occupy the main editing grid. In short and landscape viewports, the chapter table covers the history close button. Current browser tests verify data workflows but omit these rendered states from PR layout gates. + +## What Changes + +- Move history, expression authoring and diagnostics, and advanced export options into dialogs outside the main grid. +- Use one active dialog and a shared native dialog lifecycle for new and existing tools. +- Preserve the chapter editing, expression candidate, history navigation, export, and settings persistence contracts. +- Keep compact format and naming options in the main workflow. Correct short-viewport sizing and scrolling. +- Add browser layout, focus, cancellation, and hit-target checks to the default suite. Add dialog visual states and PR visual gates. + +## Capabilities + +### New Capabilities + +- `wasm-modal-workflows`: Browser tool dialogs, responsive workflow zones, draft isolation, focus lifecycle, and state-based browser regression gates. + +### Modified Capabilities + +None. Core candidate and history requirements remain unchanged. This change builds on the implemented `live-expression-preview` and `add-wasm-browser-e2e` changes without archiving or rewriting them. + +## Impact + +Affected areas are `src/ChapterTool.Wasm`, shared localization sources, `tests/ChapterTool.Wasm.E2E`, browser CI, and current browser ownership documentation. No new runtime or test dependency is required. The modal workflow changes browser interaction paths while preserving committed chapter data behavior. diff --git a/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/specs/wasm-modal-workflows/spec.md b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/specs/wasm-modal-workflows/spec.md new file mode 100644 index 00000000..73488f5f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/specs/wasm-modal-workflows/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Independent tool dialogs +The browser UI SHALL display history, expression authoring, advanced export options, settings, logs, preview, frame shift, and related media through one shared modal lifecycle. Only one dialog SHALL be active. Tool content SHALL remain outside the main editing grid. + +#### Scenario: Open history on a short viewport +- **WHEN** the user opens history at 390×640 or 844×390 +- **THEN** the dialog close button is inside the viewport and accepts an actual click +- **AND** the chapter grid does not change size or overlap the dialog + +### Requirement: Accessible modal lifecycle +Dialogs SHALL have an accessible title, contain keyboard focus, make the background inert, close on Escape from focused inputs, and restore focus to the opener. Draft dialogs SHALL require explicit Apply or cancellation. Background shortcuts and file drops SHALL not change workspace data while a dialog is active. + +#### Scenario: Cancel an input-focused dialog +- **WHEN** the user edits a dialog input and presses Escape +- **THEN** the dialog closes without committing the draft +- **AND** focus returns to its opener + +### Requirement: Expression draft isolation +Expression authoring SHALL retain live debounced validation. Apply SHALL commit a valid candidate once. Cancel SHALL discard the candidate, cancel pending validation, and restore the previous expression and preset without changing chapters or history. + +#### Scenario: Cancel an invalid expression +- **WHEN** the user enters an invalid expression and cancels +- **THEN** committed chapters, history, and exported content remain unchanged +- **AND** reopening displays the previous expression + +#### Scenario: Apply a valid expression +- **WHEN** the user applies a valid candidate +- **THEN** the dialog closes and the chapter table reflects one committed operation +- **AND** undo and redo retain their existing behavior + +### Requirement: Advanced export draft isolation +Order shift, XML language, template loading, encoding, and BOM SHALL be available in advanced export options. Apply SHALL use the existing projection and settings persistence paths. Cancel SHALL leave those values and projections unchanged. + +#### Scenario: Cancel advanced options +- **WHEN** the user changes advanced export values or selects a template and cancels +- **THEN** exported bytes, persisted settings, and chapter projections remain unchanged + +### Requirement: Responsive workflow zones +The main UI SHALL retain toolbar, chapter grid, compact export controls, and status zones. Dialog bodies SHALL scroll independently. At widths of 320 pixels and above, dialog controls SHALL remain readable without page horizontal overflow. Mobile dialog action targets SHALL be at least 44 pixels high. + +#### Scenario: Long diagnostics in landscape +- **WHEN** expression diagnostics exceed the body height at 844×390 +- **THEN** the body scrolls and the header and footer remain reachable +- **AND** Apply, Cancel, and Close do not overlap + +### Requirement: Browser regression gates +The default browser suite SHALL assert modal geometry, focus, real hit targets, cancellation, history navigation, and background isolation. Visual checks SHALL cover idle and modal states in the fixed Linux environment. Pull requests SHALL run Chromium, targeted WebKit regression checks, and visual checks. + +#### Scenario: Detect a layout regression +- **WHEN** a change puts history back into the main grid or covers its close action +- **THEN** a default browser test fails without relying only on screenshot generation + +#### Scenario: Validate a published site +- **WHEN** CI checks the published browser artifact +- **THEN** a short-viewport smoke test opens and closes a native dialog diff --git a/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/tasks.md b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/tasks.md new file mode 100644 index 00000000..db9fb348 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-fix-wasm-modal-layout/tasks.md @@ -0,0 +1,23 @@ +## 1. Regression baseline + +- [x] 1.1 Add failing browser assertions for independent history and expression dialogs. + +## 2. Dialog implementation + +- [x] 2.1 Add the shared native dialog lifecycle and one active-dialog state. Migrate existing dialogs and block background actions. +- [x] 2.2 Move history into a dialog and preserve branch navigation. +- [x] 2.3 Extract expression draft and debounce ownership. Preserve cancellation, validation, and one-step apply. +- [x] 2.4 Add staged advanced export options and template loading. Preserve persistence on Apply. +- [x] 2.5 Correct responsive main and dialog sizing. Add shared translations and regenerate locale JSON. + +## 3. Browser gates + +- [x] 3.1 Update affected workflows and add geometry, focus, hit-target, cancellation, and background-isolation checks. +- [x] 3.2 Add dialog visual states, separate visual outputs, and published-site modal smoke coverage. +- [x] 3.3 Add PR WebKit and fixed-Linux visual gates. + +## 4. Verification and documentation + +- [x] 4.1 Run affected WASM unit tests and the full browser suite across three engines. Fix regressions. +- [x] 4.2 Generate and inspect fixed-Linux snapshots. Run visual checks and capture default, wide, narrow, short, and landscape evidence. +- [x] 4.3 Update browser ownership and usage documentation. Record validation and physical-device limitations. Validate the OpenSpec change. diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/.openspec.yaml b/openspec/changes/archive/2026-10-09-live-expression-preview/.openspec.yaml new file mode 100644 index 00000000..f98e139f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/design.md b/openspec/changes/archive/2026-10-09-live-expression-preview/design.md new file mode 100644 index 00000000..cdede9b8 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/design.md @@ -0,0 +1,53 @@ +## Context + +The Core already builds immutable `ChapterContentPreview` candidates and applies a valid candidate through one history transaction. Avalonia uses this path after a manual Preview action. Its expression tool also has preview, apply, and cancel commands. The WASM page still executes expression transforms when the checkbox changes or the input commits. Neither expression surface refreshes a candidate while the user edits. + +The main chapter grid and exports must show committed document values. Expression text is an operation draft. Preview data must remain separate from the editable document. + +## Goals / Non-Goals + +**Goals:** + +- Refresh a read-only expression candidate preview while the user edits text or selects a preset. +- Show changed chapter values and current validation errors in Avalonia and WASM. +- Apply only the latest valid candidate through the existing atomic history transaction path. +- Keep the main chapter grid, format preview, and export on committed document values until apply succeeds. +- Make expression application explicit. Remove the persistent apply checkbox from expression flows. +- Preserve syntax highlighting, completion, presets, script loading, diagnostics, undo, and redo. + +**Non-Goals:** + +- Execute an expression on every chapter edit or during export. +- Add a second editable chapter document or mutate the main grid during preview. +- Change Lua evaluation policy, expression context, or Core candidate validation. +- Add a WASM syntax colorizer or completion system in this change. + +## Decisions + +1. **Use the shared candidate preview contract.** Avalonia will call its content-operation port to prepare expression candidates. WASM will prepare against the active content-session snapshot and use the existing candidate builder and transaction APIs. Both hosts will show differences from `Before` and `Candidate`. + +2. **Refresh after a short input debounce.** Text edits and preset changes schedule a preview refresh. A newer edit cancels the prior scheduled refresh and replaces its candidate. The operation reads one immutable snapshot. It does not update rows, history, or export state. + +3. **Keep the preview separate from the grid.** The preview region will list changed values and validation messages. The main grid remains the committed document. Invalid candidates show diagnostics and disable Apply. A no-change candidate reports that no values will change. + +4. **Apply commits the latest candidate.** Apply uses the preview's captured base token and preview ID. If the document changed after preview, the host discards the stale candidate and prepares a new preview. A successful apply creates one undoable transaction. Undo and redo operate on the committed candidate and do not rerun Lua. + +5. **Cancel discards only preview state.** Cancel clears the candidate and its displayed differences. It does not change the document or history. The expression text remains available for further editing; the next text or preset change prepares a new preview. + +6. **Remove persistent expression toggles.** A non-empty expression is an unapplied draft until the user applies it. Preset selection populates the draft and starts preview. Editing preset text clears the selected preset identity while retaining the edited script. + +7. **Keep export bound to committed content.** Format preview and save use the current document. They never evaluate the expression draft. Once applied, the resulting chapter values are exported as stored. + +## Risks / Trade-offs + +- **Lua evaluation may be expensive for large documents** → Debounce editor changes and show the most recent completed preview only. +- **A concurrent edit may stale a preview** → Check the captured base token during apply and refresh instead of committing stale data. +- **The two hosts may drift in wording or state transitions** → Cover the same edit, invalid, cancel, apply, undo, and export scenarios in Avalonia and Playwright tests. + +## Migration Plan + +No data migration is required. Remove the UI checkbox behavior and stop routing expression drafts through output projection. Existing expression text and presets remain available. Roll back by restoring the prior controls and handlers; Core history and document formats remain compatible. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/proposal.md b/openspec/changes/archive/2026-10-09-live-expression-preview/proposal.md new file mode 100644 index 00000000..b9d69b2d --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/proposal.md @@ -0,0 +1,26 @@ +## Why + +The unified editing model treats Lua expressions as one-time document operations. The Avalonia shell and WASM page still expose option-style controls, and the browser applies expression changes on input commit. Users need to see the candidate result while editing without changing committed chapters or history. + +## What Changes + +- Update expression input flows to refresh a read-only candidate preview as the user edits. +- Keep the main chapter grid, export preview, and save output on committed document values until the user applies the candidate. +- Replace persistent expression-enable behavior with explicit apply and cancel actions. +- Apply a valid expression candidate as one atomic, undoable document transaction in Avalonia and WASM. +- Keep expression diagnostics and preset selection connected to the live preview. + +## Capabilities + +### New Capabilities +- `wasm-expression-editing`: Defines the browser expression editor, live candidate preview, and explicit commit behavior. + +### Modified Capabilities +- `avalonia-ui-shell`: Defines consistent live preview and explicit apply behavior for Avalonia expression inputs. +- `chapter-workspace-session`: Defines how expression drafts produce read-only previews and committed transactions. + +## Impact + +- Avalonia expression controls, main-window content-option preview, expression tool ViewModel, and Headless/unit tests. +- WASM expression controls, workspace candidate lifecycle, browser localization, and Playwright coverage. +- Existing expression presets, Lua engine, document candidate builder, transaction history, and export pipeline remain the shared policy and services. diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..36d9d7ca --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Avalonia expression editing provides a live read-only preview +Avalonia expression inputs SHALL refresh a read-only candidate preview while the user edits the script or selects a preset. The preview SHALL remain separate from the editable chapter grid and SHALL require an explicit Apply action to change the document. + +#### Scenario: Main expression input refreshes the candidate preview +- **WHEN** the user edits the expression in the main window or selects an expression preset +- **THEN** the UI SHALL refresh the candidate from the current committed document +- **AND** it SHALL display changed chapter values or a no-change result +- **AND** the main chapter grid SHALL continue to display committed values + +#### Scenario: Expression tool shows live differences and diagnostics +- **WHEN** the expression tool is open and the user edits its Lua script +- **THEN** the UI SHALL refresh a read-only before-and-after preview +- **AND** it SHALL show current Lua and document validation errors +- **AND** it SHALL disable Apply while the candidate is invalid + +#### Scenario: User applies or cancels the live candidate +- **WHEN** the user applies a valid current expression candidate +- **THEN** the UI SHALL commit one history transaction and refresh the grid from the committed document +- **AND** format preview and save SHALL use those committed values without rerunning the expression +- **WHEN** the user cancels the candidate +- **THEN** the UI SHALL clear preview data without changing document content or history + +#### Scenario: Expression text is a draft without a persistent enable toggle +- **WHEN** the user edits expression text or selects a preset +- **THEN** the UI SHALL treat that value as an unapplied operation draft +- **AND** it SHALL require an explicit Apply action to commit it +- **AND** it SHALL NOT expose a persistent checkbox that silently enables expression projection during preview or save + +## MODIFIED Requirements + +### Requirement: Lua expression script authoring +The Avalonia shell SHALL allow users to author and apply Lua expression transforms with built-in presets and external script selection. Expression text SHALL remain an operation draft until the user explicitly applies its candidate. + +#### Scenario: Expression tool exposes Lua script editing +- **WHEN** the expression tool window is opened +- **THEN** it SHALL present Lua script editing as the expression authoring surface +- **AND** it SHALL NOT require users to choose or understand the previous formula/postfix grammar + +#### Scenario: Built-in Lua preset can populate script text +- **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor +- **THEN** the tool SHALL show or insert the preset script text +- **AND** it SHALL prepare a live candidate preview without changing the document +- **AND** it SHALL apply that script to the document only after the user confirms Apply + +#### Scenario: External Lua script can be selected +- **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool +- **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input +- **AND** it SHALL use the file picker service abstraction to select and read the script text +- **AND** loading the script SHALL prepare a preview without changing the document +- **AND** applying the loaded script SHALL pass its text into the candidate operation without requiring Core to read the file path + +#### Scenario: Lua expression or script is applied as a document operation +- **WHEN** the user edits or loads a Lua expression script +- **THEN** the UI SHALL refresh a read-only candidate preview and surface Lua diagnostics through the status/log diagnostic path +- **AND** format preview and save SHALL use committed chapter values and SHALL NOT apply the expression draft +- **WHEN** the user applies a valid candidate +- **THEN** the UI SHALL commit the candidate as one undoable transaction + +#### Scenario: Simple arithmetic remains approachable through Lua +- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` +- **THEN** the live candidate preview SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/specs/chapter-workspace-session/spec.md b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/chapter-workspace-session/spec.md new file mode 100644 index 00000000..5d08a914 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/chapter-workspace-session/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Expression draft previews remain read-only until applied +Expression drafts SHALL produce candidate previews from one current session snapshot. Preparing or refreshing a preview SHALL NOT change the committed document, table rows, export output, or history. Applying a valid current candidate SHALL create one atomic history transaction. + +#### Scenario: Expression draft refreshes its candidate +- **WHEN** the user changes expression text or selects a preset +- **THEN** the workspace SHALL compute a candidate from the current committed document snapshot +- **AND** the preview SHALL report before-and-after values and validation errors +- **AND** the committed document, rows, exports, and history SHALL remain unchanged + +#### Scenario: Invalid expression candidate is shown without partial changes +- **WHEN** expression evaluation or document validation fails +- **THEN** the workspace SHALL expose the current diagnostic in the preview +- **AND** it SHALL NOT change the committed document or history +- **AND** it SHALL NOT allow the candidate to be applied + +#### Scenario: Current valid expression candidate is applied once +- **WHEN** the user applies a valid candidate whose base snapshot is still current +- **THEN** the workspace SHALL commit the candidate as one transaction +- **AND** rows, format preview, and export SHALL use the committed candidate values +- **AND** undo SHALL restore the values before the expression operation without rerunning Lua + +#### Scenario: Stale expression candidate is not committed +- **WHEN** the document changes after the expression preview was prepared +- **THEN** applying that preview SHALL NOT overwrite the newer document +- **AND** the host SHALL prepare a new candidate from the latest snapshot + +#### Scenario: Expression preview is canceled +- **WHEN** the user cancels an expression preview +- **THEN** the preview SHALL be discarded +- **AND** the committed document, rows, export output, and history SHALL remain unchanged diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/specs/wasm-expression-editing/spec.md b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/wasm-expression-editing/spec.md new file mode 100644 index 00000000..6ed3bc3e --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/specs/wasm-expression-editing/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: WASM expression editing provides a live read-only preview +The WASM application SHALL let users edit Lua expression drafts and inspect a live read-only candidate preview. The preview SHALL NOT mutate the committed document, grid rows, export output, or history. + +#### Scenario: Typing refreshes the browser candidate preview +- **WHEN** the user enters or edits expression text +- **THEN** the browser SHALL refresh a candidate preview from the current committed document +- **AND** it SHALL show changed chapter values, no-change state, or validation errors +- **AND** it SHALL keep the expression input usable without a persistent enable checkbox + +#### Scenario: Selecting a preset refreshes the browser preview +- **WHEN** the user selects a built-in expression preset +- **THEN** the editor SHALL populate the preset script and refresh the read-only preview +- **AND** selecting the preset SHALL NOT change chapter values or history + +#### Scenario: Invalid candidate cannot be applied +- **WHEN** Lua evaluation or document validation fails +- **THEN** the page SHALL display the current diagnostic +- **AND** the Apply action SHALL be disabled +- **AND** the committed grid and history SHALL remain unchanged + +#### Scenario: Applying a valid candidate updates committed chapters +- **WHEN** the user applies a valid current candidate +- **THEN** the page SHALL commit one atomic history transaction +- **AND** the chapter grid, format preview, and download SHALL use the committed values +- **AND** Undo SHALL restore the values before the expression operation without rerunning Lua + +#### Scenario: Canceling a candidate leaves the document unchanged +- **WHEN** the user cancels a live candidate preview +- **THEN** the page SHALL discard that preview +- **AND** the document, grid, export output, and history SHALL remain unchanged + +#### Scenario: Editing an applied row does not rerun the expression +- **WHEN** the user edits a chapter after applying an expression +- **THEN** the page SHALL retain the user's chapter value +- **AND** preview and download SHALL NOT run the previous expression again diff --git a/openspec/changes/archive/2026-10-09-live-expression-preview/tasks.md b/openspec/changes/archive/2026-10-09-live-expression-preview/tasks.md new file mode 100644 index 00000000..c1f78432 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-live-expression-preview/tasks.md @@ -0,0 +1,21 @@ +## 1. WASM candidate workflow + +- [x] 1.1 Add read-only expression candidate preparation, apply, cancel, and stale-base handling to the WASM workspace without executing drafts from export or row refresh. +- [x] 1.2 Add WASM workspace tests for live candidate preparation, invalid input, cancellation, one-transaction apply, stale candidates, and no expression reapplication after row edits or export. + +## 2. WASM expression experience + +- [x] 2.1 Replace the expression enable checkbox with a localized live-preview region, explicit Apply and Cancel actions, and preset-driven preview refresh. +- [x] 2.2 Debounce expression edits and ensure only the latest candidate and diagnostics are displayed. +- [x] 2.3 Add Playwright coverage for input preview without mutation, invalid scripts, cancel, apply, undo, committed export, and row edits after apply. + +## 3. Avalonia expression experience + +- [x] 3.1 Refresh the main-window expression candidate while editing and show readable differences and diagnostics without changing committed rows. +- [x] 3.2 Refresh the expression tool candidate while editing or selecting presets, remove the persistent apply checkbox, and apply or cancel the latest preview. +- [x] 3.3 Add unit and Headless tests for live preview, invalid scripts, cancellation, stale candidates, atomic apply, undo, and export from committed content. + +## 4. Verification + +- [x] 4.1 Run the focused WASM unit and browser E2E checks, then the focused Avalonia unit and Headless checks sequentially. +- [x] 4.2 Validate the OpenSpec change with `openspec validate "live-expression-preview" --strict` and report any remaining mismatch. diff --git a/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/.openspec.yaml b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/design.md b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/design.md new file mode 100644 index 00000000..ec0c041b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/design.md @@ -0,0 +1,166 @@ +## Context + +本 change 依据用户提供的两张桌面截图。 +第一张显示右侧常驻历史和章节列表下方的空白。 +第二张显示表达式差异覆盖 XML 语言和表达式输入。 +这些现象与当前代码中的布局和展示路径一致。 + +| 证据位置 | 当前行为 | 修正方向 | +| --- | --- | --- | +| `MainView.axaml` 中央 Grid | 两个默认 star 行。表格位于第一行,空状态位于第二行 | 表格和空状态使用同一个完整工作区 | +| `MainView.axaml` 历史 Border;`MainWindowViewModel.cs` | 固定 220 宽侧栏。`isHistoryPanelExpanded` 默认开启 | 历史按钮打开独立弹框 | +| `MainView.axaml.cs` 的 `ApplyAdvancedOptionsLayout` | 宽布局重建两行,窄布局重建三行。预览仍留在 `Grid.Row=3` | 每个布局状态必须为预览分配有效独立区域 | +| `MainWindowViewModel.Editing.cs` 的 `BuildExpressionPreviewText` | 直接输出差异字段和值,并取前六条 | 使用 typed snapshot 和业务格式 | +| `MainWindowViewModel.Commands.cs` 的选项预览 | 差异摘要仅写入状态文本 | 所有待确认内容操作必须具有可检查的预览入口 | + +`MainWindow` 当前默认尺寸为 760×600,最小尺寸为 760×520。 +底部选项宽窄切换阈值为 860。 +尺寸均指 Avalonia 逻辑单位。 + +既有 `unified-editing-06-host-workflows` 和 `unify-expression-preview-interaction` 已完成任务,但仍在活动 change 目录中。 +其规格包含历史分支和 typed candidate 语义。 +本 change 必须保留这些语义。 +实现前必须重新检查活动 change 与主规格。 +本 change 不同步或归档其他 change。 +`modernize-main-window-ux` 当前没有可供实现的完整制品。 +本 change 独立定义截图所示回归的边界。 + +## Goals / Non-Goals + +**Goals:** + +- 历史只在按钮打开的弹框中显示。 +- 章节表格使用中央工作区的完整宽度和高度。 +- 表格、空状态、底部选项、预览和状态条必须有明确布局边界。 +- 表达式、命名和其他内容操作的预览必须可读、可滚动和可确认。 +- 尺寸切换必须保留草稿、候选、选择和历史状态。 +- 验收必须包含交互结果和实际控件边界。 + +**Non-Goals:** + +- 不进行完整主题或导航重设计。 +- 不修改 Blazor 页面布局或 CLI。 +- 不新增持久化历史,不改变 Core 历史树。 +- 不改写 Lua 执行、内容事务或导出算法。 +- 不扩展为全部辅助窗口的 UX 整改。 + +## Decisions + +### 1. 历史使用现有辅助工具宿主 + +历史按钮必须执行打开命令。 +主窗口必须移除历史侧栏及其占位列。 +增加稳定 `ToolIds.History`、工具描述和历史视图。 +桌面描述必须设置 `IsModal`。 +已有 Embedded 宿主必须通过同一协议提供次级展示,不能重新引入常驻侧栏。 + +历史视图必须使用窄会话端口读取条目和命令状态。 +它必须调用现有撤销、重做和按节点 ID 导航路径。 +它不能持有第二个历史树或接收完整 `MainWindowViewModel`。 +弹框打开期间的命令结果必须刷新当前节点和分支。 +关闭必须释放订阅并归还焦点。 +重复打开必须激活已有弹框。 +无可用会话时历史按钮必须禁用。 + +曾考虑默认折叠侧栏和按钮 Flyout。 +折叠侧栏仍会在打开后改变章节区域。 +Flyout 不适合长历史列表和分支导航。 +模态工具沿用现有生命周期和键盘隔离机制。 + +### 2. 中央区域只分配一个章节工作区 + +中央 Grid 必须保留一个可伸展工作区。 +章节表格必须填充该区域。 +空状态必须覆盖同一区域,并保持 `IsHitTestVisible=False`。 +已加载章节必须隐藏空状态。 +表格的滚动和虚拟化必须继续工作。 +列必须保留稳定身份和合理最小宽度。 + +曾考虑只给表格增加 `RowSpan`。 +这能遮住空白,但保留了不必要的双行布局和不同的空状态位置。 +共享工作区能直接表达加载与空状态的关系。 + +### 3. 预览独立于选项表单的行重排 + +底部区域必须由选项表单和独立预览容器组成。 +`ApplyAdvancedOptionsLayout` 只管理表单的宽窄重排。 +预览容器必须在两种布局中具有明确的高度和滚动边界。 +长结果必须在结果区域滚动。 +确认和取消操作必须位于结果滚动区之外。 +短窗口必须限制预览占用,并保留可用章节区域和状态条。 +无预览时容器必须折叠,不能留下空白。 + +保留主窗口输入后自动准备候选的入口。 +保留独立 Expression 工具的完整编辑流程。 +主窗口预览可以显示有限数量的紧凑比较行。 +它必须同时显示完整计数、截取说明和完整差异入口。 +完整差异视图必须展示同一个候选,不能通过重新计算替换待确认内容。 +命名、编号偏移和帧率操作也必须使用可检查的预览容器。 + +曾考虑继续增加 `ExpressionPreviewText.MaxHeight`,或将全部表达式输入强制移入弹框。 +增加高度不能修复无效行和缺失完整差异。 +强制弹框会改变截图中现有的快速编辑流程。 +独立结果容器可以保留入口并修复空间分配。 + +### 4. 预览使用已有 typed comparison + +表达式比较必须复用 `ExpressionPreviewProjector` 和既有行展示模型的格式规则。 +其他操作必须从 typed candidate 的原值和目标值生成业务字段。 +不能解析技术摘要字符串来生成 UI。 +时间必须使用能分辨实际变化的格式。 +帧值必须标明帧率依据。 +时间变化、帧信息更新和属性变化必须分组计数。 +缺失值必须区别于零。 +错误必须与有效结果分开显示。 +状态条只显示简短本地化状态。 + +候选所有权必须保持单一。 +输入变化必须立即使原候选不可应用。 +应用必须提交当前已展示候选。 +取消必须终止待处理工作。 +失败或过期必须保留草稿并显示可理解的恢复入口。 +布局切换不能重新计算或提交候选。 + +### 5. 验收覆盖整个窗口和真实操作 + +验收尺寸必须包括 760×600、1280×800 和 760×520。 +还必须包括 860 与 861 的阈值两侧,以及宽→窄→宽的连续切换。 +每个主要状态必须包含中文和英文。 +代表性状态必须检查放大字体和浅色、深色主题。 + +Headless 必须加载真实窗口、生产资源和确定性数据。 +必须验证同坐标系下的控件边界、滚动可达性和实际按钮操作。 +章节表格边界必须覆盖中央分配区,不能只验证控件存在。 +预览内容和选项控件边界必须不相交。 +截图必须写入 `artifacts/main-window-editing-ux/` 并人工复核。 +截图不能替代行为断言。 +Headless 与非 Headless 测试必须在独立进程中顺序执行。 + +## Risks / Trade-offs + +- [历史弹框增加点击] → 保留主窗口撤销和重做快捷键。弹框必须保留全部导航操作。 +- [小窗口被长预览挤占] → 限制结果区域高度。提供滚动和完整差异入口。检查最小窗口的控件边界。 +- [两种表达式入口显示不一致] → 复用 typed projector 和格式规则。用同一候选 fixture 比较展示结果。 +- [多种候选共用操作区导致错误提交] → 展示明确的操作名称和作用范围。应用绑定当前候选身份和版本。 +- [弹框留下会话订阅] → 关闭后释放订阅。覆盖反复开关和内容树分离。 +- [布局断言过于固定] → 按分配区、包含关系和不相交关系验收。只对最低可用边界采用数值约束。 + +## Migration Plan + +1. 先添加失败的布局和交互回归用例。 +2. 引入历史工具,移除侧栏展示状态。 +3. 合并章节工作区,分离表单和预览容器。 +4. 统一预览业务格式和完整差异入口。 +5. 运行受影响测试,复核三种尺寸截图。 +6. 更新 code map 和翻译投影。 + +无数据或设置迁移。 +回滚使用普通代码回滚。 +现有文档会话和输出文件格式不变。 + +## Open Questions + +无阻塞问题。 +本提案将“左侧列表”解释为第一张截图中的主章节表格。 +将“表达式等”解释为主窗口已经存在的内容操作预览。 +历史弹框的具体尺寸和结果容器的高度由实现时的最小窗口验收确定。 diff --git a/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/proposal.md b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/proposal.md new file mode 100644 index 00000000..aa4802b0 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/proposal.md @@ -0,0 +1,30 @@ +## Why + +近期功能修改引入了主窗口 UX 回归。用户截图显示编辑历史常驻占用右侧空间、章节列表下方出现大块空白,以及表达式预览与底部选项重叠,影响章节编辑和操作确认。 + +## What Changes + +- 将编辑历史收进历史按钮打开的模态弹框。保留撤销、重做、分支导航和当前会话提示。 +- 让章节列表使用顶部工具区与底部选项之间的完整可用区域。让空状态与列表共用同一区域。 +- 为表达式和其他内容操作预览提供独立布局区域。修复宽窄布局切换后的行定位、滚动和操作按钮布局。 +- 用本地化的业务值展示变更。提供修改前、修改后和变化量,避免直接展示 `StartTicks`、`FramesInfo` 和枚举名称。 +- 保留预览、应用、取消和一步撤销的事务语义。防止重排布局或打开历史创建内容变更。 +- 增加实际渲染、键盘交互、控件边界和窗口尺寸切换的回归验收。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `avalonia-ui-shell`: 增加历史弹框行为、章节区域空间分配和主窗口内容预览的布局及可读性要求。补充响应式布局的行为验收。 + +## Impact + +- 主要影响 `src/ChapterTool.Avalonia.UI/Views/MainView.axaml`、其代码后置和 `MainWindowViewModel` 的历史与内容预览展示。 +- 历史弹框使用现有辅助工具协议、桌面工具目录和 `AvaloniaWindowService`。按需增加窄会话端口及工具视图。 +- 预览展示复用已有 typed candidate 和表达式比较投影。Core 历史、表达式计算和导出规则保持既有契约。 +- 相关验证位于 `tests/ChapterTool.Avalonia.Tests` 和 `tests/ChapterTool.Avalonia.Headless.Tests`。实现时更新相关 `docs/code-map/` 页面和本地化资源投影。 +- 本 change 聚焦截图所示 Avalonia UX 回归。它不包含完整视觉重设计、Blazor 页面改版、CLI 修改或新编辑功能。 diff --git a/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..d478d3c5 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,176 @@ +## MODIFIED Requirements + +### Requirement: Global styles preserve workflow usability +The ported style layer SHALL keep ChapterTool workflows usable at supported window sizes. The main window SHALL preserve separate layout bounds for its toolbar, chapter workspace, bottom options, content preview, and status strip. Layout changes SHALL preserve drafts, candidate identity, chapter selection, and the document history cursor. + +#### Scenario: Main window uses the global styles +- **WHEN** the main window opens +- **THEN** the load and save area, chapter grid, options area, and status strip SHALL remain available +- **AND** controls SHALL not overlap + +#### Scenario: Tool window uses a narrow supported size +- **WHEN** a tool window is resized to its minimum width +- **THEN** primary actions SHALL remain visible +- **AND** text SHALL remain inside its control bounds + +#### Scenario: Main window crosses the options breakpoint repeatedly +- **WHEN** a loaded main window with a pending preview changes between widths 860 and 861 and then returns to its original width +- **THEN** the preview SHALL remain in a separate allocated region below the options form +- **AND** the form, preview, and status strip SHALL not overlap +- **AND** layout changes SHALL not evaluate, apply, or cancel the candidate +- **AND** the draft, candidate identity, chapter selection, and history cursor SHALL remain unchanged + +#### Scenario: Minimum main window shows a long preview +- **WHEN** the main window renders at 760×520 with long localized labels and a long pending preview +- **THEN** the toolbar, usable chapter viewport, options inputs, preview entry point, Apply, Cancel, and status strip SHALL remain reachable +- **AND** long results SHALL scroll within their allocated region or remain reachable through the complete-difference surface +- **AND** result text SHALL not cover an input or action + +## ADDED Requirements + +### Requirement: History opens in a button-invoked dialog +Avalonia 编辑历史 MUST 由历史按钮打开独立模态弹框。主窗口 MUST NOT 常驻显示历史列表或为历史预留侧栏空间。历史弹框 MUST 使用当前文档会话的历史和命令。打开、关闭或调整弹框尺寸 MUST NOT 创建历史事务。 + +#### Scenario: Load without opening history +- **WHEN** 用户加载章节文档但没有点击历史按钮 +- **THEN** 主窗口不显示历史列表或历史侧栏占位 +- **AND** 章节工作区使用完整中央宽度 +- **AND** 历史按钮具有本地化的可访问名称 + +#### Scenario: Open and dismiss history +- **WHEN** 用户点击历史按钮 +- **THEN** 一个属于主窗口的模态历史弹框打开 +- **AND** 弹框显示当前节点、撤销、重做、分支导航和当前会话生命周期提示 +- **AND** 主窗口后台点击、快捷键和拖放不能通过弹框修改文档 +- **WHEN** 用户通过关闭按钮、窗口关闭或 Escape 退出弹框 +- **THEN** 弹框关闭并归还焦点到历史按钮 +- **AND** 仅关闭弹框不会回滚弹框内已执行的历史导航 +- **AND** 主窗口章节区域不因开关弹框而改变分配尺寸 + +#### Scenario: Navigate history in the dialog +- **WHEN** 用户在弹框中执行撤销、重做或选择保留的另一分支 +- **THEN** 操作调用当前会话的既有历史命令 +- **AND** 弹框更新当前节点、按钮可用性和操作描述 +- **AND** 主窗口章节数据刷新为对应历史状态 +- **AND** 关闭并重新打开后显示相同的当前历史状态 + +#### Scenario: Reopen history and release its resources +- **WHEN** 用户反复打开和关闭历史弹框 +- **THEN** 同一时刻最多存在一个该会话的历史弹框 +- **AND** 已关闭弹框释放内容树及会话订阅 +- **AND** 无可用文档会话时历史按钮禁用 + +#### Scenario: Inspect a long branching history +- **WHEN** 历史包含大量条目、多个分支和长操作描述 +- **THEN** 用户能通过虚拟化列表滚动及键盘导航访问保留节点 +- **AND** 当前节点和分支关系不只依赖颜色或空格识别 +- **AND** 撤销、重做和关闭操作保持可达 + +### Requirement: Chapter grid fills its allocated workspace +章节表格 MUST 填充顶部工具区与底部选项之间的中央分配区。该区域 MUST NOT 为已隐藏的空状态或历史保留独立空白行。空状态 MUST 在相同工作区居中显示。列标题、值和编辑控件 MUST 保持可读。 + +#### Scenario: Loaded table uses the full central area +- **WHEN** 用户在 760×600、1280×800 或 760×520 的窗口加载足够多的章节 +- **THEN** 表格边界覆盖中央分配区的完整可用宽度和高度 +- **AND** 表格下方没有额外的等高空白区域 +- **AND** 用户能滚动到最后一个章节并提交单元格编辑 +- **AND** 表格虚拟化和扩展选择继续工作 + +#### Scenario: Empty and loaded states share the same area +- **WHEN** 窗口尚未加载章节 +- **THEN** 空状态图像和提示在章节工作区居中 +- **AND** 空状态不会阻止加载或拖放操作 +- **WHEN** 章节加载成功 +- **THEN** 空状态隐藏且表格使用同一完整工作区 + +#### Scenario: Resize with long chapter values +- **WHEN** 用户调整窗口尺寸并编辑长章节名称、时间或帧值 +- **THEN** 列保留合理最小宽度,标题和内容不相互覆盖 +- **AND** 必要的滚动保持可用 +- **AND** 编辑按稳定列身份提交,选择和滚动位置在身份仍有效时保持 + +### Requirement: Main-window content previews have a bounded review region +表达式及主窗口其他待确认内容操作 MUST 显示在独立结果区域。该区域 MUST 显示操作名称、作用范围、完整变更计数和可检查的结果。状态条 MUST 只显示简短状态,不能作为唯一的差异展示。Apply 和 Cancel MUST 位于结果滚动区之外。没有预览时,结果区域 MUST 折叠。 + +#### Scenario: Preview an expression without overlapping options +- **WHEN** 用户输入有效表达式 `t + 1` 并得到候选 +- **THEN** 预览出现在独立结果区域 +- **AND** 保存格式、XML 语言、命名、编号偏移和表达式输入不被结果文字覆盖 +- **AND** 用户能检查预览并点击 Apply 或 Cancel + +#### Scenario: Review another content operation +- **WHEN** 用户准备命名、编号偏移或帧率变换候选 +- **THEN** 同一结果区域显示该操作的名称、范围和修改前后值 +- **AND** 用户不需要从状态条的技术摘要判断将要提交的内容 +- **AND** Apply 绑定该区域所展示的候选 + +#### Scenario: Inspect all differences from a compact result +- **WHEN** 候选包含 1,000 个章节的变化,紧凑区域只能显示部分条目 +- **THEN** 摘要计数覆盖完整候选 +- **AND** 界面明确表示当前只显示部分条目 +- **AND** 所有差异都能通过滚动或完整差异入口访问 +- **AND** 完整差异入口展示同一个候选且不重新计算表达式 + +#### Scenario: Clear a preview +- **WHEN** 用户取消候选、应用成功或文档会话结束 +- **THEN** 对应预览结果和确认操作清除 +- **AND** 结果区域不保留空白占位或过期值 + +### Requirement: Main-window preview values use localized business formats +主窗口预览 MUST 从 typed snapshot 展示本地化业务值。它 MUST 按章节组织修改前、修改后和变化量。时间变化、帧信息更新和属性变化 MUST 明确区分。普通结果 MUST NOT 直接显示 ticks、内部字段标识、原始枚举或对象转储。 + +#### Scenario: Review a one-second change +- **WHEN** 候选将章节时间从 00:00:00 改为 00:00:01 +- **THEN** 对比显示章节身份、可读的原时间、目标时间和正一秒变化量 +- **AND** 同一章节的帧信息变化在该章节的明细中显示 +- **AND** 原始 `StartTicks` 数字不是默认时间表示 + +#### Scenario: Distinguish frame-only and property-only changes +- **WHEN** 候选只更新帧信息或持久化属性而没有时间变化 +- **THEN** 摘要明确说明没有章节时间变化 +- **AND** 相关更新以本地化字段和原值、目标值显示 +- **AND** 同一章节的多种变化只计为一个受影响章节 + +#### Scenario: Preserve precise and missing values +- **WHEN** 时间差小于一毫秒,或原帧信息缺失而目标为零帧 +- **THEN** 时间精度能区分实际变化且变化量不显示为零 +- **AND** 缺失帧值与零帧使用不同的业务表示 +- **AND** 帧率依据不同的两侧各自显示依据且不生成误导的帧差 + +#### Scenario: Localize normal review content +- **WHEN** 用户使用中文或英文检查预览 +- **THEN** 字段标签、单位、空值、状态和错误摘要使用当前语言 +- **AND** accuracy 等枚举含义以业务说明展示 +- **AND** 长文本可换行或展开且含义不只依赖颜色 + +### Requirement: Preview UX preserves candidate transactions +预览展示和布局调整 MUST 保留现有候选事务语义。准备 MUST 保持已提交章节和历史不变。Apply MUST 只提交展示的有效且当前的候选。输入、目标或历史变化 MUST 使旧候选不可应用。取消、失败和过期状态 MUST 不提交部分结果。 + +#### Scenario: Apply and undo the reviewed result +- **WHEN** 用户确认一个有效且当前的候选 +- **THEN** 提交值与展示值完全相同且只创建一个事务 +- **AND** 应用路径不重新计算表达式 +- **AND** 一次撤销恢复该事务的全部变化 + +#### Scenario: Cancel or reject an invalid result +- **WHEN** 用户取消预览,或候选无效、无变化、等待计算或正在计算 +- **THEN** 不创建内容事务 +- **AND** 不符合提交条件时 Apply 禁用 +- **AND** 错误和等待状态位于结果区域且不覆盖输入 + +#### Scenario: Invalidate a ready result +- **WHEN** 用户修改输入、切换目标或通过历史弹框改变基础内容 +- **THEN** 旧候选不能提交 +- **AND** 界面显示等待新预览或过期状态 +- **AND** 拒绝过期提交时保留草稿并提供更新预览入口 + +### Requirement: UX regressions require rendered workflow evidence +实现 MUST 通过真实渲染和交互验证以上要求。测试 MUST 使用实际生产资源和确定性会话数据。截图 MUST 补充行为断言,不能替代断言。验收 MUST 包含中文、英文、默认、宽和最小窗口,以及布局阈值两侧。 + +#### Scenario: Verify layout and interactions +- **WHEN** 对本 change 执行验收 +- **THEN** Headless 覆盖 760×600、1280×800、760×520、860 和 861 宽度及宽→窄→宽切换 +- **AND** 断言章节表格覆盖中央分配区,选项和结果边界不相交 +- **AND** 验证历史开关及分支、末行编辑、完整差异访问、应用、取消和一步撤销 +- **AND** 代表性状态在放大字体和浅色、深色主题下保持可用 +- **AND** 默认、宽、窄截图保存于 `artifacts/main-window-editing-ux/` 并复核 diff --git a/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/tasks.md b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/tasks.md new file mode 100644 index 00000000..483e1a31 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-repair-main-window-editing-ux/tasks.md @@ -0,0 +1,40 @@ +## 1. 建立回归证据 + +- [x] 1.1 重新执行 `openspec list --json`、`openspec status --change repair-main-window-editing-ux --json` 和 `openspec validate repair-main-window-editing-ux --strict`。复核既有历史及表达式 change 的兼容要求。 +- [x] 1.2 在 `MainWindowHeadlessTests` 或同属 Headless 项目的专用回归类中加入中央表格区域和预览重叠的失败用例。使用生产资源、确定性章节和 `RunJobs`。 +- [x] 1.3 建立空文档、多章节、长名称、长分支历史、`t + 1`、无效表达式及命名、编号偏移、帧率预览 fixture。记录修改前截图到 `artifacts/main-window-editing-ux/`。 + +## 2. 将历史移入弹框 + +- [x] 2.1 在 `AuxiliaryTools.cs`、窄会话端口和 `StandardToolCatalogFactory` 中加入历史工具。历史状态及导航必须复用当前 workspace。 +- [x] 2.2 实现历史视图和 ViewModel。提供虚拟化列表、当前节点标识、分支关系、撤销、重做和会话生命周期提示。 +- [x] 2.3 将主窗口历史 ToggleButton 替换为打开命令。移除历史侧栏、占位列和过时的展开状态。无会话时禁用入口。 +- [x] 2.4 验证桌面模态宿主及已有 Embedded 宿主的次级展示。覆盖重复打开、关闭、Escape、后台交互隔离、焦点归还和资源释放。 +- [x] 2.5 在 ViewModel 与 Headless 测试中验证弹框撤销、重做和分支导航。验证关闭重开后的当前节点及主表格结果。 + +## 3. 修正章节和选项布局 + +- [x] 3.1 将章节表格和空状态放入同一个可伸展工作区。移除中央双 star 行。保留表格列最小宽度、编辑和虚拟化。 +- [x] 3.2 将选项表单和预览结果容器分离。调整 `ApplyAdvancedOptionsLayout`,确保所有宽窄状态具有有效的控件定位。 +- [x] 3.3 为结果设置高度边界及独立滚动。将确认和取消操作放在结果滚动区之外。无结果时折叠区域。 +- [x] 3.4 验证默认、宽和最小窗口下的末行滚动、单元格提交、空状态切换,以及宽→窄→宽之后的选择和草稿保留。 + +## 4. 修正内容预览展示 + +- [x] 4.1 用 typed candidate 和已有 `ExpressionPreviewProjector` 的业务格式替换主窗口技术字符串。按章节显示前值、后值和变化量。 +- [x] 4.2 增加时间、帧信息和属性的分类计数。覆盖缺失值、不同帧率依据及小于一毫秒的变化。 +- [x] 4.3 将命名、编号偏移和帧率候选接入同一可检查结果区域。显示操作名称和范围。状态条只保留简短状态。 +- [x] 4.4 为紧凑结果提供完整计数、截取说明和完整差异入口。确保 1,000 个章节的全部差异可达。完整视图必须展示同一候选。 +- [x] 4.5 验证有效、等待、无变化、错误、过期和失败状态。输入变化必须立即禁用旧候选。保留草稿及适用的更新入口。 +- [x] 4.6 验证应用提交展示值且不重新计算、取消不提交、失败不部分提交及一次撤销恢复全部效果。布局切换不得改变候选身份。 + +## 5. 本地化和验收 + +- [x] 5.1 在共享 locale `.axaml` 中增加或复用历史及预览业务标签。验证中文和英文的长标签、按钮名称、单位和状态。保留既有语言切换能力。 +- [x] 5.2 翻译资源变化后运行 `uv run --project scripts scripts/axaml-to-json.py`,再运行 `uv run --project scripts scripts/axaml-to-json.py --check`。仅在环境未安装时先执行 `uv sync --project scripts`。 +- [x] 5.3 增加实际控件边界断言。覆盖 760×600、1280×800、760×520、860 和 861 宽度,以及连续尺寸切换。断言表格填充中央区、预览与表单不相交、操作可点击。 +- [x] 5.4 验证代表性放大字体和浅色、深色主题。验证键盘导航、弹框关闭和焦点归还。关闭所有弹出控件并释放测试 DataContext。 +- [x] 5.5 顺序运行 `dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore` 和 `dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore`。修复本 change 引起的失败。 +- [x] 5.6 将中文和英文的主窗口、历史弹框及预览默认、宽、窄截图写入 `artifacts/main-window-editing-ux/`。复核列表空白、文本重叠、完整差异访问和操作可达性。 +- [x] 5.7 更新 `docs/code-map/avalonia.md` 和 `docs/code-map/testing.md` 中变化的工具入口及测试归属。复核短句、术语及路径。 +- [x] 5.8 再次运行 `openspec validate repair-main-window-editing-ux --strict`。报告测试结果与截图路径。若推送代码,先运行 `python scripts/check-ci.py`。 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/README.md b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/README.md new file mode 100644 index 00000000..daec7bb8 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/README.md @@ -0,0 +1,3 @@ +# unified-editing-01-document-model + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/design.md b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/design.md new file mode 100644 index 00000000..128fb540 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/design.md @@ -0,0 +1,21 @@ +## Context + +`ChapterSet` is currently shared across import, editing, transforms, and export. The target design needs an immutable document boundary while preserving existing pure services during migration. + +## Goals / Non-Goals + +**Goals:** Introduce immutable document value types, stable identities, exact time metadata, and a snapshot-based serializer boundary. + +**Non-Goals:** Route all UI edits through transactions or add history in this change. + +## Decisions + +- Keep existing `ChapterSet` services as pure adapters during migration. Convert at the boundary and do not keep it as session state. +- Store chapter times as `long` ticks and rational frame rates. Convert legacy floating-point values once when adapting input. +- Give document, track, and chapter IDs explicit value types. Preserve IDs through value-only edits; allocate IDs in candidate construction for new structures. +- Make the serializer accept a captured document-derived value and format options only. Move interactive transforms out of serialization before changing host wiring. + +## Risks / Trade-offs + +- [Legacy formats have ambiguous duration semantics] → Preserve an explicit unknown duration and add format-specific adapter cases. +- [Public Core contracts may be consumed by CLI or Node.js] → Keep compatibility adapters until change 08 and verify existing API behavior. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/proposal.md new file mode 100644 index 00000000..93a51699 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/proposal.md @@ -0,0 +1,19 @@ +## Why + +The interactive application currently carries editable chapters, clip backups, projection rules, and exported output through separate state paths. This change establishes one immutable document snapshot as the source for current content and gives later transactions a stable, precise contract. + +## What Changes + +- Add an immutable document model for ordered tracks, chapters, metadata, duration, frame rate, numbering, and segment boundaries. +- Add stable document, track, and chapter identities and deterministic ticks/frame conversion rules. +- Adapt imported chapter sets into a document without retaining a second editable copy. +- Make a captured document snapshot the input to pure format serialization. + +## Capabilities + +### New Capabilities +- `editable-chapter-document`: Immutable document snapshots, stable identities, time semantics, and serialization input. + +## Impact + +Core models, import adapters, export services, and their public tests. Host editing remains on its current path until subsequent changes migrate commands. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/specs/editable-chapter-document/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/specs/editable-chapter-document/spec.md new file mode 100644 index 00000000..955f4fb4 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/specs/editable-chapter-document/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Editable documents are immutable snapshots +Core SHALL represent current editable content as an immutable document with ordered tracks and chapters. A host SHALL receive no mutable reference to a published collection. + +#### Scenario: Adapting imported chapters +- **WHEN** an importer result is opened as an editable document +- **THEN** the adapter SHALL create one document snapshot with stable document, track, and chapter identities +- **AND** it SHALL NOT retain a second editable chapter set + +#### Scenario: Candidate changes are isolated +- **WHEN** a service computes a changed document from a published snapshot +- **THEN** the published snapshot SHALL remain unchanged until the candidate is explicitly accepted + +### Requirement: Document time and frame values are deterministic +The document SHALL store time as signed 64-bit 100-nanosecond ticks, known frame rates as reduced positive rational values, and unknown duration or frame rate as absent values. + +#### Scenario: Frame conversion uses the shared rounding rule +- **WHEN** a frame value is converted to ticks or ticks to frames +- **THEN** conversion SHALL round to the nearest integer with midpoint away from zero +- **AND** overflow SHALL return a diagnostic failure + +#### Scenario: Unknown duration has no upper bound +- **WHEN** a document has unknown duration and a chapter start is edited +- **THEN** validation SHALL NOT treat unknown duration as a zero-length upper bound + +### Requirement: Serialization consumes a captured document snapshot +Format serialization SHALL receive the explicit current document snapshot and format-only options. It SHALL NOT execute naming, expression, time-offset, or other interactive transforms. + +#### Scenario: Export captures current document values +- **WHEN** a document is serialized after an edit +- **THEN** output SHALL contain the edited value exactly once +- **AND** serializing SHALL NOT change the document diff --git a/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/tasks.md new file mode 100644 index 00000000..57fabfe7 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-01-document-model/tasks.md @@ -0,0 +1,11 @@ +## 1. Document contracts + +- [x] 1.1 Add immutable document, track, chapter, identity, rational frame-rate, and duration value types. +- [x] 1.2 Add validation and explicit ChapterSet import/export adapters; preserve unknown duration semantics. +- [x] 1.3 Add deterministic ticks/frame conversion with midpoint-away-from-zero rounding and overflow diagnostics. + +## 2. Serialization boundary + +- [x] 2.1 Add snapshot-based pure serialization entry points and separate format options from content transforms. +- [x] 2.2 Add Core behavior tests for immutability, identity preservation, unknown duration, conversion boundaries, and no transform replay. +- [x] 2.3 Run `dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --no-restore` and fix failures. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/README.md b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/README.md new file mode 100644 index 00000000..fc7de4b4 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/README.md @@ -0,0 +1,3 @@ +# unified-editing-02-atomic-transactions + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/design.md b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/design.md new file mode 100644 index 00000000..ccf9ea27 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/design.md @@ -0,0 +1,23 @@ +## Context + +Document values introduced in change 01 are immutable. Hosts still call editing services through several paths, so a shared commit boundary must be introduced without disabling existing actions. + +## Goals / Non-Goals + +**Goals:** Provide a single session mutation queue, atomic publication, typed outcomes, stale-token checks, cancellation, and retry safety. + +**Non-Goals:** Add undo/redo history, migrate every content command, or change clip merge semantics. + +## Decisions + +- Add a Core session API that accepts a captured base token and a candidate-producing operation. +- Validate and allocate all commit metadata before swapping the current session state reference. +- Treat cancellation before the swap as cancellation and cancellation after the swap as an applied result. +- Add an identity-preserving adapter for non-structural `ChapterSet` edit results. The adapter SHALL reject row-count changes because it cannot infer stable identities for inserted or removed rows. +- Keep the existing editing commands usable. Change 04 owns routing every workspace content command through the transaction API. +- Make transaction IDs bind to a stable request fingerprint and cached result. + +## Risks / Trade-offs + +- [A command may bypass the new session during migration] → Keep current commands unchanged in this step. Change 04 owns complete command migration and its entry-point inventory. +- [Queue serialization may block UI work] → Keep candidate calculation outside the short atomic publication section and surface cancellation. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/proposal.md new file mode 100644 index 00000000..b6c38871 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/proposal.md @@ -0,0 +1,22 @@ +## Why + +The immutable document needs one commit boundary before user operations can be safely unified. This change introduces session identity, revision checks, cancellation, idempotency, and all-or-nothing candidate application while keeping the current edit commands usable. + +## What Changes + +- Add one Core document session state with current document, state identity, mutation revision, and command queue. +- Add atomic candidate validation and commit results for success, no change, conflict, cancellation, and resource failure. +- Deduplicate retries by transaction identity and reject reuse with a different request. +- Add a stable-ID-preserving adapter for non-structural results from the existing `ChapterSet` editing API. Keep existing workflows usable. Route every workspace content command in change 04. + +## Capabilities + +### New Capabilities +- `atomic-edit-transactions`: Serialized, atomic document mutations and stale-operation rejection. + +### Modified Capabilities +- None. + +## Impact + +Core session and editing adapters, and Core/session tests. Depends on `unified-editing-01-document-model`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/specs/atomic-edit-transactions/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/specs/atomic-edit-transactions/spec.md new file mode 100644 index 00000000..bc338372 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/specs/atomic-edit-transactions/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Document mutations commit atomically +A document session SHALL compute and validate a complete candidate before publishing it. A failed, cancelled, invalid, or unchanged operation SHALL leave the document and session revision unchanged. + +#### Scenario: Candidate contains one invalid result +- **WHEN** a batch operation produces any invalid row +- **THEN** the session SHALL reject the entire candidate +- **AND** it SHALL preserve the original document + +#### Scenario: Candidate is unchanged +- **WHEN** a command produces the same canonical document value +- **THEN** it SHALL return `NoChange` without increasing the mutation revision + +### Requirement: Session commits reject stale work and serialize commands +Each candidate SHALL capture its document identity, state identity, and mutation revision. The session SHALL serialize mutations and reject a candidate whose captured token is no longer current. + +#### Scenario: An edit occurs while a candidate is computing +- **WHEN** another mutation commits before the candidate is applied +- **THEN** the stale candidate SHALL be rejected without changing the current document + +#### Scenario: Cancellation before publication +- **WHEN** an operation is cancelled before atomic publication +- **THEN** no candidate state or history-visible metadata SHALL be published + +### Requirement: Retried transactions are idempotent +The session SHALL return the original result for a retry with the same transaction ID and request. It SHALL reject reuse of that ID with a different request. + +#### Scenario: Commit notification is retried +- **WHEN** the same committed transaction is submitted again +- **THEN** the original result SHALL be returned and the document SHALL not change again + +#### Scenario: Transaction ID is reused with different input +- **WHEN** a transaction ID is submitted with a different operation payload +- **THEN** the session SHALL reject it and preserve the document diff --git a/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/tasks.md new file mode 100644 index 00000000..526bfc91 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-02-atomic-transactions/tasks.md @@ -0,0 +1,11 @@ +## 1. Session mutation kernel + +- [x] 1.1 Add `SessionState`, base tokens, typed transaction outcomes, and a serialized command queue over the document from change 01. +- [x] 1.2 Implement candidate validation, no-change detection, atomic publication, cancellation boundaries, and resource-failure rollback. +- [x] 1.3 Add transaction ID request binding and idempotent result replay. + +## 2. Existing workflow adapter + +- [x] 2.1 Add a stable-ID-preserving adapter for non-structural `ChapterSet` edit results. Keep current commands usable. Change 04 owns complete workspace command routing. +- [x] 2.2 Add Core behavior tests for atomic batch failure, cancellation, stale work, duplicate requests, and conflicting IDs. +- [x] 2.3 Run `dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --no-restore` and fix failures. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/README.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/README.md new file mode 100644 index 00000000..d8a49559 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/README.md @@ -0,0 +1,3 @@ +# unified-editing-03-history-tree + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/design.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/design.md new file mode 100644 index 00000000..333943f0 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/design.md @@ -0,0 +1,22 @@ +## Context + +Change 02 provides atomic session publication. The required history is an in-memory tree, not a bounded undo stack, and it must avoid copying the complete document for each small edit. + +## Goals / Non-Goals + +**Goals:** Retain all branches, restore states exactly, keep revisions monotonic, and make change-set cost proportional to actual edits. + +**Non-Goals:** Persist history across application or browser restarts, add history import/export, or add checkpoints as a separate subsystem. + +## Decisions + +- Keep an immutable root snapshot and reversible domain change sets at each edge. Store forward and inverse changes. +- Record a preferred child at each branch point. Expose other children for explicit redo selection. +- Reconstruct a target state privately, then publish it through the transaction kernel's atomic state swap. +- Use iterative traversal for deep histories. Do not prune nodes automatically. +- Increment mutation revision on undo, redo, and branch changes even when the state identity returns to an earlier value. + +## Risks / Trade-offs + +- [Memory grows with edits and retained branches] → Store deltas, share immutable payloads, expose current-session scope, and report recoverable allocation failure without deleting history. +- [Inverse changes can be incorrect] → Compare complete canonical document snapshots and identities in deterministic and property-based sequence tests. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/history-measurements.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/history-measurements.md new file mode 100644 index 00000000..c322b3a0 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/history-measurements.md @@ -0,0 +1,7 @@ +# History Measurements + +The representative run used the `net10.0` Core test project in Debug mode on Windows x64. It created 1,200 one-chapter edits and retained 1,201 nodes. It then undid the full path and navigated to the leaf and root. + +The commit loop allocated 6,279,992 bytes and took 20 ms. The full sequence allocated 12,654,704 bytes. This includes 1,200 commits, 1,200 undos, and two full-depth navigations. Root navigation took 17,798 stopwatch ticks at 10,000,000 ticks per second, or about 1.8 ms. + +These figures include the transaction idempotency cache and temporary reconstruction allocations. They measure total allocation, not retained heap size or the retained size of history deltas alone. They describe one local run and do not define performance thresholds. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/proposal.md new file mode 100644 index 00000000..85789a35 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/proposal.md @@ -0,0 +1,22 @@ +## Why + +Atomic commits provide reversible state boundaries, but users still cannot recover previous edits or choose an alternate future after undo. This change adds a session-owned history tree that retains every committed state for the lifetime of the document session. + +## What Changes + +- Store immutable root state and reversible domain change sets in an in-memory history tree. +- Add undo, redo on the preferred path, alternate branch selection, and current-state navigation. +- Preserve every committed branch without count-, time-, or memory-threshold pruning. +- Keep history attached to the document session and expose operation descriptions for hosts. + +## Capabilities + +### New Capabilities +- `session-edit-history`: In-memory history tree, undo, redo, branch switching, and resource-failure behavior. + +### Modified Capabilities +- None. + +## Impact + +Core session state, change-set storage, and history tests. Depends on `unified-editing-02-atomic-transactions`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/specs/session-edit-history/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/specs/session-edit-history/spec.md new file mode 100644 index 00000000..0d3bb20d --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/specs/session-edit-history/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Session history retains all committed branches +Each document session SHALL retain its root and every committed state in an in-memory history tree. The system SHALL NOT prune history by operation count, age, or memory threshold. + +#### Scenario: Editing after undo creates a branch +- **WHEN** a new edit is committed while the cursor is not at a leaf +- **THEN** the previous child path SHALL remain reachable +- **AND** the new state SHALL be added as another child + +#### Scenario: History contains many edits +- **WHEN** the session commits more edits than any conventional fixed history limit +- **THEN** all committed nodes SHALL remain reachable until the session ends + +### Requirement: Undo, redo, and branch selection restore committed states +Undo and redo SHALL restore a previously committed state without rerunning the operation. Redo SHALL support the preferred child and an explicitly selected retained branch. + +#### Scenario: Undo then redo +- **WHEN** the user undoes a committed edit and redoes it +- **THEN** the exact committed document and stable identities SHALL be restored +- **AND** mutation revision SHALL continue increasing + +#### Scenario: Redo chooses an alternate branch +- **WHEN** multiple retained child branches exist and the user selects one +- **THEN** the cursor SHALL move to that branch's committed state without changing other branches + +#### Scenario: Undo at root or redo at leaf +- **WHEN** undo has no parent or redo has no child +- **THEN** the operation SHALL return `NoChange` and SHALL NOT increase the revision + +### Requirement: History failure preserves the current session +If change-set construction, branch navigation, or a recoverable resource allocation fails before publication, the session SHALL retain its document, history nodes, preferred branch, and cursor. + +#### Scenario: History node allocation fails +- **WHEN** a commit cannot allocate its change set or indexes +- **THEN** the operation SHALL return a resource failure and preserve the original session state + +#### Scenario: Cross-branch reconstruction fails +- **WHEN** reconstruction fails before the target is ready to publish +- **THEN** no intermediate document SHALL be visible and the cursor SHALL remain unchanged diff --git a/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/tasks.md new file mode 100644 index 00000000..be508d0c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-03-history-tree/tasks.md @@ -0,0 +1,12 @@ +## 1. Reversible history data + +- [x] 1.1 Define domain change sets with forward and inverse operations for document values and identities. +- [x] 1.2 Add root, parent/child nodes, preferred-child tracking, cursor, and operation descriptions to the Core session. +- [x] 1.3 Store deltas and shared immutable payloads so a one-field edit does not copy the full document. + +## 2. Navigation and validation + +- [x] 2.1 Implement atomic undo, preferred redo, explicit branch selection, and no-change boundary outcomes. +- [x] 2.2 Add deterministic, generated-sequence, deep-history, branch, idempotency, and injected-failure Core tests. +- [x] 2.3 Verify no automatic history pruning and record representative allocation and navigation measurements. +- [x] 2.4 Run `dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --no-restore` and fix failures. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/README.md b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/README.md new file mode 100644 index 00000000..8b06b360 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/README.md @@ -0,0 +1,3 @@ +# unified-editing-04-content-operations + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/design.md b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/design.md new file mode 100644 index 00000000..906a31ac --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/design.md @@ -0,0 +1,24 @@ +## Context + +The current tool surfaces include table edits, templates, regular expressions, frame operations, and expressions. Some currently produce projected output rather than current content. + +## Goals / Non-Goals + +**Goals:** Make every non-clip content operation an atomic, reversible document edit with preview and explicit target scope. + +**Non-Goals:** Redesign merge, split, and append semantics; build the history panel; or add disk-backed history. + +## Decisions + +- Introduce typed candidate results per operation and adapt existing pure services into candidate builders. +- Capture target IDs, document identity, and mutation revision before computation. Reject stale output rather than relocating targets. +- Keep tool parameters as drafts/preferences only. Commit their calculated document values once. +- Route Avalonia content operations through the active `ChapterWorkspace.ContentSession`. Batch operations expose a typed preview, apply, and cancel flow. Single-cell edits commit on the editor's existing Enter, Tab, or valid focus-loss path. +- Keep WASM host wiring and the full history panel/branch chooser in change 06. Core candidate APIs in this change remain host-callable and testable. +- Preserve separate draft undo behavior in text controls and document history behavior outside active text editing. +- Return structured validation diagnostics. Never convert invalid input or partial batch output into a successful candidate. + +## Risks / Trade-offs + +- [Existing tools have different error policies] → Normalize them at candidate adapters and add one failure scenario per operation family. +- [Migration can leave direct writes] → Track each existing operation in task inventory and require one history round-trip workflow per operation family. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/proposal.md new file mode 100644 index 00000000..8645cc3f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/proposal.md @@ -0,0 +1,22 @@ +## Why + +The document and history kernel only helps users when normal editing actions create reversible commits. This change routes cell edits and content tools through candidate computation and one atomic history transaction. + +## What Changes + +- Migrate cell editing, insert/delete, move/order, numbering, metadata, template naming, regular-expression replacement, offsets, frame-rate actions, and expressions. +- Capture explicit target IDs and a base token before asynchronous computation. +- Validate the full candidate and show before/after differences before applying one transaction. +- Make tools one-shot operations; retained tool parameters do not remain active output rules. + +## Capabilities + +### New Capabilities +- `unified-content-editing`: Reversible edits, explicit candidate previews, and atomic application of all non-clip content operations. + +### Modified Capabilities +- None. + +## Impact + +Core editing/transform adapters, Avalonia and Wasm command wiring as needed for applying candidates, and behavior tests. Depends on `unified-editing-03-history-tree`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/specs/unified-content-editing/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/specs/unified-content-editing/spec.md new file mode 100644 index 00000000..b024b325 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/specs/unified-content-editing/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: All content tools commit one complete candidate +Content tools SHALL capture a document snapshot and explicit target identities, compute a complete candidate, validate it, and apply it as one history transaction. A row failure, cancellation, or stale base SHALL reject the whole candidate. + +#### Scenario: Batch candidate has one invalid row +- **WHEN** a template, expression, or other batch candidate contains an invalid result +- **THEN** no part of the candidate SHALL be committed +- **AND** the current state and history cursor SHALL remain unchanged + +#### Scenario: Preview is cancelled or stale +- **WHEN** a preview is cancelled or its base token no longer matches the session +- **THEN** it SHALL NOT change the document +- **AND** a stale preview SHALL require recomputation before apply + +### Requirement: Cell edits have explicit draft and commit semantics +Cell editing SHALL capture the chapter ID, field, original value, and base token. Enter, Tab, or valid focus loss SHALL commit one transaction; Escape SHALL discard the draft. Invalid input SHALL remain visible and SHALL NOT be coerced to zero. + +#### Scenario: Same canonical value is entered +- **WHEN** a cell commits the value it already contains +- **THEN** no history node SHALL be created + +#### Scenario: Undo is invoked during text editing +- **WHEN** a text control owns focus +- **THEN** its undo command SHALL affect only the draft +- **AND** document history SHALL remain unchanged + +### Requirement: Tool operations are one-shot and scope is explicit +Template, expression, replacement, offset, frame-rate, numbering, ordering, and metadata tools SHALL show their target scope and compute from the captured original snapshot. Applying a tool SHALL store the result as current document content and SHALL NOT leave a transform rule active during export. + +#### Scenario: Expression is applied then a cell is edited +- **WHEN** an expression is applied and a transformed time is edited in the table +- **THEN** the edited value SHALL become current content +- **AND** later serialization SHALL NOT run the expression again + +#### Scenario: Operation targets selected chapters +- **WHEN** a tool uses selection scope +- **THEN** it SHALL capture the selected chapter IDs at calculation start and SHALL NOT substitute different rows after reordering + +#### Scenario: Expression calculation fails for one target +- **WHEN** any target expression fails, times out, or is cancelled +- **THEN** the entire operation SHALL fail without partial content or history changes + +### Requirement: Timing edits preserve document invariants +Time edits, shifts, scaling, delete normalization, and frame-rate actions SHALL update their defined related fields consistently, validate overflow and end-before-start, and reject invalid results before commit. + +#### Scenario: Delete normalization shifts explicit ends +- **WHEN** chapter starts are normalized after deletion +- **THEN** each retained start and explicit end SHALL receive the same checked shift +- **AND** one undo SHALL restore the complete prior document + +#### Scenario: Scale collapses a segment +- **WHEN** time scaling would make a segment zero length or overflow +- **THEN** the candidate SHALL be rejected and the current state SHALL remain unchanged diff --git a/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/tasks.md new file mode 100644 index 00000000..19cd0bbe --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-04-content-operations/tasks.md @@ -0,0 +1,12 @@ +## 1. Candidate adapters + +- [x] 1.1 Add candidate builders for insert, delete, reorder, cell value, numbering, and metadata operations. +- [x] 1.2 Adapt template naming, regular-expression replacement, offsets, FPS actions, and expressions to calculate from captured snapshots. +- [x] 1.3 Add complete candidate validation for time order policy, ticks overflow, end-before-start, scaling, and per-row failures. + +## 2. Command integration + +- [x] 2.1 Route each existing content command through session apply and remove its direct document mutation path. +- [x] 2.2 Preserve editor drafts, focus/Enter/Tab/Escape behavior, and block dependent commands on invalid drafts. +- [x] 2.3 Add Core and host behavior tests proving commit/undo/redo identity and whole-batch failure for every operation family. +- [x] 2.4 Run the Core, Avalonia ViewModel, and relevant Headless test projects sequentially with `--no-restore`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/README.md b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/README.md new file mode 100644 index 00000000..ca2cb9a7 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/README.md @@ -0,0 +1,3 @@ +# unified-editing-05-clip-operations + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/design.md b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/design.md new file mode 100644 index 00000000..7f94beca --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/design.md @@ -0,0 +1,22 @@ +## Context + +`ClipSession` keeps original clip groups and combined output in parallel. The target model treats segment boundaries as structural metadata around the one current chapter document. + +## Goals / Non-Goals + +**Goals:** Make merge, split, and append reversible document transactions over current content. + +**Non-Goals:** Provide a hidden pre-merge content backup, auto-truncate cross-segment ends, or persist history. + +## Decisions + +- Represent segment identity, source metadata, interval, and media placement as track structure. Do not store chapter arrays in boundaries. +- Implement merge and split as candidate builders with explicit identity maps; preserve IDs for surviving chapters and allocate new track IDs. +- Use left-closed/right-open intervals. Assign a marker at total duration to the final segment. +- Require an explicit policy for incompatible frame rates before combining. +- Treat append source reading as asynchronous candidate construction and use the session token at publication. + +## Risks / Trade-offs + +- [Legacy restore UI implies restoring original content] → Rename the action to “Split by boundaries” and test that post-merge edits survive. +- [End times can cross split boundaries] → Return a localized conflict with no commit; do not truncate implicitly. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/proposal.md new file mode 100644 index 00000000..055aa53b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/proposal.md @@ -0,0 +1,22 @@ +## Why + +Clip combine and restore currently retain parallel chapter content, which conflicts with one current document and loses edits made after combining. This change makes merge, split, and append edits to the current structure and records them in the same history tree. + +## What Changes + +- Merge selected current tracks into one track while preserving names, chapter identities, explicit ends, source metadata, and segment layout. +- Replace restore-from-backup with splitting the current merged track at its current segment boundaries. +- Make append a candidate based on the current merged content, allocate new identities, and reject stale asynchronous results. +- Keep merge undo separate from split, with both represented as reversible transactions. + +## Capabilities + +### New Capabilities +- `clip-structure-editing`: Transactional merge, split-by-boundaries, and append over current document content. + +### Modified Capabilities +- None. + +## Impact + +Core segment/session services and Avalonia/Wasm append and clip commands. Depends on `unified-editing-04-content-operations`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/specs/clip-structure-editing/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/specs/clip-structure-editing/spec.md new file mode 100644 index 00000000..f4ca24bf --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/specs/clip-structure-editing/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Merge replaces current tracks in one transaction +Merge SHALL use the current edited content, preserve chapter names, identities, explicit end times, and required source information, and replace selected tracks with one merged track. It SHALL retain boundaries and media placement without a second chapter backup. + +#### Scenario: Merge after editing a source track +- **WHEN** the user edits a chapter and then merges its track +- **THEN** the merged track SHALL contain the edited value and the same chapter identity +- **AND** merge SHALL create one undoable transaction + +#### Scenario: Merge tracks with different frame rates +- **WHEN** source tracks have different effective frame rates +- **THEN** merge SHALL apply an explicit compatible frame-rate policy or report a conflict +- **AND** it SHALL NOT silently interpret every time using the first track rate + +### Requirement: Split uses current merged content and boundaries +Split SHALL distribute current chapters by their current start time and segment intervals. It SHALL preserve chapter identity and value, convert current start and explicit end values to the target segment timeline, and report an explicit-end conflict that crosses a boundary. + +#### Scenario: Chapter starts at a boundary +- **WHEN** a chapter start equals a segment boundary +- **THEN** the chapter SHALL belong to the segment after the boundary, except a marker at total duration which belongs to the last segment + +#### Scenario: Merged chapter was edited before split +- **WHEN** the user edits a merged chapter and splits by boundaries +- **THEN** split SHALL use the edited current chapter and SHALL NOT restore pre-merge chapter data + +#### Scenario: End time crosses a segment boundary +- **WHEN** an explicit end time falls outside its chapter's assigned segment +- **THEN** split SHALL report the conflict and SHALL NOT silently truncate or duplicate the chapter + +### Requirement: Append preserves edits and commits atomically +Append SHALL build a complete candidate from the captured current document, preserve all existing edits, allocate fresh identities for imported content, and commit once only if the captured session token remains current. + +#### Scenario: Append completes after the session changes +- **WHEN** another edit or document replacement occurs while append is reading input +- **THEN** the append result SHALL be rejected as stale and SHALL NOT overwrite the newer document + +#### Scenario: Append follows merge +- **WHEN** the user appends another source to a merged document +- **THEN** existing edits and boundaries SHALL remain intact and imported tracks SHALL be included in one undoable result diff --git a/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/tasks.md new file mode 100644 index 00000000..20179879 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-05-clip-operations/tasks.md @@ -0,0 +1,12 @@ +## 1. Clip candidates + +- [x] 1.1 Replace parallel editable clip backups with track boundary and placement metadata. +- [x] 1.2 Implement merge over current document values with identity maps, preserved ends/names/source metadata, and frame-rate policy. +- [x] 1.3 Implement split-by-boundaries over current values, interval edge rules, structure-row routing, and cross-boundary diagnostics. +- [x] 1.4 Implement append candidate construction with fresh identities, stale-token checks, and atomic publication. + +## 2. Host workflow and verification + +- [x] 2.1 Update combine/restore affordances to merge/split semantics without removing available clip workflows. +- [x] 2.2 Add Core round-trip, edit-before-split, end-conflict, nested-boundary, identity, append, cancellation, and stale-session tests. +- [x] 2.3 Run Core and affected Avalonia ViewModel/Headless test projects sequentially with `--no-restore`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/README.md b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/README.md new file mode 100644 index 00000000..7994126f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/README.md @@ -0,0 +1,3 @@ +# unified-editing-06-host-workflows + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/design.md b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/design.md new file mode 100644 index 00000000..fe3f20a5 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/design.md @@ -0,0 +1,22 @@ +## Context + +Avalonia and Wasm currently coordinate their own chapter editing and projection paths. Both need to present the Core session history without duplicating it. + +## Goals / Non-Goals + +**Goals:** Provide consistent undo/redo/history workflows, keyboard routing, status, and per-document Core session use in both hosts. + +**Non-Goals:** Change the history storage model or add persistence across refresh/restart. + +## Decisions + +- Keep history authoritative in Core. Host view models request commands and project visible node labels only. +- Use the existing shortcut catalog and conflict validation for application shortcuts; preserve platform defaults and text-control ownership. +- Virtualize the history panel and create detailed entries on demand. +- Keep host presentation adapters separate where platform behavior differs, but share transaction and navigation semantics. +- Localize history-lifetime and result messages through the shared translation source; generate browser JSON from AXAML. + +## Risks / Trade-offs + +- [Shortcut handling may override editor behavior] → Exercise real focus and draft workflows in Headless and browser tests. +- [History branches can be large] → Limit display object creation by visible/expanded range while keeping nodes in Core. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/proposal.md new file mode 100644 index 00000000..9013add1 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/proposal.md @@ -0,0 +1,22 @@ +## Why + +Core history and migrated operations need consistent controls in both interactive hosts. Users must be able to undo, redo, inspect branches, understand session-only history, and keep text-control drafts separate from document edits. + +## What Changes + +- Connect Avalonia and browser workspaces to the same Core document session and command queue. +- Add undo/redo commands with operation names, preferred redo, branch selection, and a virtualized history panel. +- Route platform shortcuts while preserving text editing undo and accessibility/focus behavior. +- Show draft, candidate, applied, failed, and session-history lifetime status distinctly. + +## Capabilities + +### New Capabilities +- `host-history-workflows`: User-facing history, branch navigation, keyboard scope, and host parity. + +### Modified Capabilities +- None. + +## Impact + +Avalonia ViewModels, XAML, shortcut catalog, Wasm workspace/page/browser guards, localization, and UI tests. Depends on `unified-editing-05-clip-operations`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/specs/host-history-workflows/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/specs/host-history-workflows/spec.md new file mode 100644 index 00000000..626403dd --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/specs/host-history-workflows/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Interactive hosts share the Core document session +Avalonia and browser workspaces SHALL read and mutate one Core document session per open document. Views in one session SHALL use its command queue and observe the same snapshot. + +#### Scenario: Two views mutate one session +- **WHEN** two views issue content commands for the same document +- **THEN** both commands SHALL serialize through one Core session +- **AND** both views SHALL refresh from the committed session snapshot + +#### Scenario: Separate browser tabs open the same file +- **WHEN** two browser tabs import the same source independently +- **THEN** each tab SHALL have a separate document identity and history + +### Requirement: Hosts expose undo, redo, and history branches +Each interactive host SHALL provide undo and redo commands, the next operation description, the preferred redo path, and a branch choice for alternate retained futures. The history tree display SHALL create only visible and expanded entries. + +#### Scenario: User selects a retained alternate branch +- **WHEN** the user opens redo branch choices and selects a retained branch +- **THEN** the session SHALL restore that branch and each host SHALL display its current document + +#### Scenario: Deep history is displayed +- **WHEN** a long history is opened in the history panel +- **THEN** the panel SHALL virtualize visible entries without dropping Core history nodes + +### Requirement: Editing shortcuts respect text drafts and platform defaults +Windows and Linux SHALL provide Ctrl+Z undo and Ctrl+Y/Ctrl+Shift+Z redo. macOS SHALL provide Cmd+Z and Cmd+Shift+Z redo. Shortcut routing SHALL use the shortcut catalog and SHALL not intercept text-control draft editing. + +#### Scenario: Text editor owns focus +- **WHEN** a text control is editing a draft and the user presses its undo shortcut +- **THEN** only the control draft SHALL change +- **AND** the document history cursor SHALL remain unchanged + +#### Scenario: Undo button is used with an active draft +- **WHEN** a document undo button is invoked while a draft exists +- **THEN** the host SHALL discard the draft first and then undo one committed document transaction + +### Requirement: Host status distinguishes candidates, application, and session lifetime +Hosts SHALL distinguish candidate preview, applied in-memory edits, exported output, and failed commits. The history panel SHALL state that history lasts only for the current document session. + +#### Scenario: User cancels a preview +- **WHEN** the user cancels a candidate preview +- **THEN** the document SHALL remain unchanged and status SHALL NOT say the edit was applied diff --git a/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/tasks.md new file mode 100644 index 00000000..2e75abfe --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-06-host-workflows/tasks.md @@ -0,0 +1,13 @@ +## 1. Host session wiring + +- [x] 1.1 Compose Avalonia ViewModels and WasmWorkspace with the Core document session and remove host-local duplicate document ownership. +- [x] 1.2 Add named undo/redo commands, preferred redo, alternate branch selection, and virtualized history panel. +- [x] 1.3 Add platform-specific shortcut catalog entries and preserve text-editor draft undo scope. + +## 2. Workflow parity + +- [x] 2.1 Add localized applied/exported/failed/history-lifetime status and show candidate diffs before tool commits. +- [x] 2.2 Add behavior tests for Avalonia focus, Escape, shortcuts, branch choice, history virtualization, and browser parity/tab independence. +- [x] 2.3 Regenerate locale JSON with `uv run --project scripts scripts/axaml-to-json.py` and verify `--check`. +- [x] 2.4 Run affected Avalonia ViewModel and Headless projects sequentially, then Wasm tests with `--no-restore`. +- [x] 2.5 Capture default, wide, and narrow Avalonia layout evidence under `artifacts/`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/README.md b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/README.md new file mode 100644 index 00000000..74dda29c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/README.md @@ -0,0 +1,3 @@ +# unified-editing-07-session-lifecycle + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/design.md b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/design.md new file mode 100644 index 00000000..ba12393a --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/design.md @@ -0,0 +1,22 @@ +## Context + +The session owns history and UI drafts, while import, append, and export can complete asynchronously. A new document must not inherit stale results or baselines from the old one. + +## Goals / Non-Goals + +**Goals:** Make app-initiated session loss explicit, isolate asynchronous work, release old session references, and report export state for the actual captured content. + +**Non-Goals:** Persist history, prevent forced browser refresh, or add cross-process file locking. + +## Decisions + +- Stage import and session creation off to the side. Confirm loss only after a usable replacement candidate exists, then recheck the old session token. +- Use document identity and runtime generation tokens for every async result. Invalidate and cancel them when a session ends. +- Derive per-track exported state from canonical content digest, format options, and captured state ID. +- Allow an export to own its immutable document snapshot only; it must not retain the history tree or update a later session. +- Verify release through weak references and controlled collection, not immediate working-set reduction. + +## Risks / Trade-offs + +- [Close confirmation can race with edits] → Recheck revision at the final replacement boundary. +- [Digest fields may omit format-relevant data] → Define canonical serialization inputs and cover each format with public behavior tests. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/proposal.md new file mode 100644 index 00000000..be05e3b8 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/proposal.md @@ -0,0 +1,22 @@ +## Why + +History belongs to one live document session. Replacement, reload, close, asynchronous import, and export must not move old work into a new session or falsely mark new edits as exported. + +## What Changes + +- Build replacement sessions before confirming and swapping them into the host. +- Confirm application-initiated session loss when history, drafts, or unexported edits exist. +- Cancel old session tasks and release history, drafts, previews, subscriptions, and UI node projections. +- Track export baselines per track using captured state, options, and content digest. + +## Capabilities + +### New Capabilities +- `document-session-lifecycle`: Session replacement, release, user confirmation, async isolation, and export baselines. + +### Modified Capabilities +- None. + +## Impact + +Core session lifecycle, Avalonia and Wasm load/reload/close/export orchestration, and lifecycle tests. Depends on `unified-editing-06-host-workflows`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/specs/document-session-lifecycle/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/specs/document-session-lifecycle/spec.md new file mode 100644 index 00000000..2e915fad --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/specs/document-session-lifecycle/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Session replacement is built before the old session is discarded +Hosts SHALL build and validate a replacement document session before asking to discard the current session. Failed construction or cancellation SHALL preserve the old session. + +#### Scenario: Import fails during replacement +- **WHEN** replacement import or session construction fails +- **THEN** the active document, history, draft, and export baseline SHALL remain available + +#### Scenario: Current session changes during confirmation +- **WHEN** the old session is edited while replacement confirmation is open +- **THEN** the host SHALL recheck loss conditions before replacing it + +### Requirement: Ending a session cancels work and releases history ownership +Closing or replacing a session SHALL invalidate its token, cancel its outstanding tasks, and release host references to history nodes, drafts, previews, subscriptions, and history display items. An in-flight read-only export MAY retain only its captured document snapshot. + +#### Scenario: Old asynchronous result arrives after replacement +- **WHEN** a load, append, or export from the prior session completes after a new session opens +- **THEN** it SHALL NOT mutate the new session or its export baseline + +#### Scenario: Session ends +- **WHEN** the user confirms closing or replacing the active session +- **THEN** its history SHALL become unreachable from the host while a captured export may finish with content only + +### Requirement: Hosts explain session-only history before destructive replacement +Application-initiated close, reload, or replacement SHALL offer a continue/cancel choice when the current session has history, an active draft, or unexported content. Browser refresh or process termination SHALL NOT be represented as recoverable history. + +#### Scenario: User cancels replacement +- **WHEN** the user cancels the loss confirmation +- **THEN** the current session, draft, history, and export state SHALL remain unchanged + +#### Scenario: User reloads the application +- **WHEN** the user reopens a source after process restart or browser refresh +- **THEN** a new document session and history root SHALL be created + +### Requirement: Export baselines describe only the captured track snapshot +Successful export SHALL record the captured track's canonical content digest, format options, and state identity. Failed or asynchronous stale exports SHALL NOT mark later content or another session as exported. + +#### Scenario: Edit continues during export +- **WHEN** an export succeeds for a captured snapshot after a newer edit commits +- **THEN** only the captured snapshot SHALL be marked exported and the newer edit SHALL remain dirty + +#### Scenario: One track is exported +- **WHEN** a document with multiple tracks exports one track +- **THEN** other tracks SHALL remain unexported unless separately included in a successful export diff --git a/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/tasks.md new file mode 100644 index 00000000..47c021c6 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-07-session-lifecycle/tasks.md @@ -0,0 +1,12 @@ +## 1. Replacement lifecycle + +- [x] 1.1 Add session-end token invalidation, cancellation, event unsubscription, and host reference release. +- [x] 1.2 Stage imports and replacement documents before confirmation and recheck revision before swapping sessions. +- [x] 1.3 Add localized close/reload/replace confirmation based on active history, drafts, and unexported edits. + +## 2. Export baseline and lifecycle evidence + +- [x] 2.1 Capture export snapshots and update per-track digest/state/format baseline only after successful output. +- [x] 2.2 Add tests for canceled/failed replacement, stale load/append/export, new-session isolation, draft preservation, and collectible ended sessions. +- [x] 2.3 Add tests proving export retains only a content snapshot and never clears history. +- [x] 2.4 Run affected Core, Avalonia non-Headless, Headless, and Wasm tests sequentially with `--no-restore`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/README.md b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/README.md new file mode 100644 index 00000000..3733a866 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/README.md @@ -0,0 +1,3 @@ +# unified-editing-08-compatibility-adapters + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/design.md b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/design.md new file mode 100644 index 00000000..ce3c0479 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/design.md @@ -0,0 +1,21 @@ +## Context + +Interactive state moves to the document session. Stateless consumers still need convenient one-shot conversion, and CLI transform flags are an existing user-facing contract. + +## Goals / Non-Goals + +**Goals:** Preserve supported Core, CLI, and Node.js behavior while routing all output through pure serialization after a single transform pass. + +**Non-Goals:** Persist stateful history in conversion calls or define raw CLI argument parsing outside DotMake. + +## Decisions + +- Keep a clearly named compatibility facade that adapts old option shapes to operation candidates and then to the pure serializer. +- Keep CLI command and option definitions in `DotMake.CommandLine`; reject conflicting options before source import. +- Reuse the shared expression engine and format services for parity. +- State the deprecation window and replacement API in public API and Node.js package documentation. + +## Risks / Trade-offs + +- [Stateless adapters cannot detect already transformed values] → Document the boundary and make the pure serialization API obvious for callers that retain results. +- [Compatibility layer becomes permanent] → Record its version policy and schedule removal only in a future explicitly versioned change. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/proposal.md new file mode 100644 index 00000000..7e9055a1 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/proposal.md @@ -0,0 +1,22 @@ +## Why + +The interactive document model needs one-shot transforms, but Core, CLI, and Node.js callers may rely on current conversion/export options. Compatibility adapters must preserve those entry points without reintroducing persistent projection state or double application. + +## What Changes + +- Keep public Core and Node.js APIs usable through explicit compatibility adapters. +- Convert legacy naming/expression options into one candidate transform, then call pure serialization. +- Preserve CLI `--expression` and `--expression-preset` semantics using `DotMake.CommandLine` arguments. +- Define deprecation and removal conditions for legacy transform-bearing export options. + +## Capabilities + +### New Capabilities +- `one-shot-transform-compatibility`: One-time conversion transforms for Core, CLI, and Node.js. + +### Modified Capabilities +- None. + +## Impact + +Core public API, CLI workflows, Node.js wrapper, command-line tests, package/API documentation. Depends on `unified-editing-07-session-lifecycle`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/specs/one-shot-transform-compatibility/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/specs/one-shot-transform-compatibility/spec.md new file mode 100644 index 00000000..6130727c --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/specs/one-shot-transform-compatibility/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Compatibility APIs transform once then serialize current content +Compatibility entry points SHALL convert legacy transform options into one explicit content operation and pass its result to pure serialization. Reusing transformed content SHALL require the pure serializer and SHALL NOT reapply transforms. + +#### Scenario: Legacy Core export includes an expression option +- **WHEN** a caller uses the compatibility export entry point with an expression option +- **THEN** the expression SHALL run once for that call before serialization +- **AND** the result SHALL match applying the operation explicitly then serializing + +#### Scenario: Caller serializes already transformed content +- **WHEN** a caller supplies already transformed content to the pure serializer +- **THEN** the serializer SHALL preserve its values without replaying any transform + +### Requirement: CLI expression options remain one-shot conversion inputs +CLI `--expression` and `--expression-preset` SHALL remain structured DotMake command options and SHALL be applied before pure serialization. + +#### Scenario: CLI converts with an expression +- **WHEN** `convert` is called with an expression or preset +- **THEN** the selected expression SHALL run once and each supported format SHALL serialize the transformed snapshot + +#### Scenario: CLI rejects conflicting expression options +- **WHEN** both expression options are supplied +- **THEN** CLI validation SHALL fail before import and SHALL use its normal non-zero validation result + +### Requirement: Compatibility lifetime and deprecation are explicit +Public Core and Node.js adapters SHALL document their compatibility behavior and migration path. They SHALL NOT retain transform rules in an interactive session. Deprecated transform-bearing options SHALL follow the declared version policy. + +#### Scenario: Adapter is called repeatedly +- **WHEN** a stateless adapter is called for separate conversions +- **THEN** each call SHALL apply only the options supplied for that call and SHALL NOT create interactive history diff --git a/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/tasks.md new file mode 100644 index 00000000..8b753e81 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-08-compatibility-adapters/tasks.md @@ -0,0 +1,12 @@ +## 1. Compatibility boundary + +- [x] 1.1 Add/retain pure serializer APIs and adapters from legacy Core export options to one-shot document operations. +- [x] 1.2 Update CLI conversion composition so DotMake-bound expression options transform once before serialization. +- [x] 1.3 Migrate Node.js wrappers to explicit transform-then-serialize behavior and document caller migration. + +## 2. Public behavior verification + +- [x] 2.1 Add API behavior tests comparing compatibility calls with explicit transform plus serialization across supported formats. +- [x] 2.2 Add CLI tests for inline expression, preset, conflicts, unknown presets, and no-expression default. +- [x] 2.3 Add Node.js package tests for transforms, repeated calls, and serialization without double application. +- [x] 2.4 Run Core, CommandLine, and Node.js checks sequentially; verify CLI arguments remain defined and parsed only through DotMake. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/.openspec.yaml b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/.openspec.yaml new file mode 100644 index 00000000..6a87d2b2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-03 diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/README.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/README.md new file mode 100644 index 00000000..d5a4d627 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/README.md @@ -0,0 +1,3 @@ +# unified-editing-09-remove-legacy-paths + +Implementation step from docs/tasks/unified-editing-and-unbounded-history.md diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/cutover-inventory.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/cutover-inventory.md new file mode 100644 index 00000000..6c458bd2 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/cutover-inventory.md @@ -0,0 +1,20 @@ +# Interactive content cutover inventory + +This inventory maps active host mutation paths to the Core session and behavior coverage. +Import, stateless conversion, and serializer consumers remain at their existing boundaries. + +| Entry point | Session transaction | Behavior coverage | +| --- | --- | --- | +| Avalonia cell edit | `ClipEditingCoordinator.Edit` prepares a stable-ID candidate. `ApplyCandidateAsync` commits it to `ContentSession`. | `MainWindowViewModelTests`; `MainWindowInteractionHeadlessTests` | +| Avalonia insert, duplicate, delete, reorder, offsets, naming, expression, and FPS changes | `ClipEditingCoordinator.ExecuteCandidateAsync` commits one `ChapterContentPreview`. | `ChapterContentCandidateBuilderTests`; `MainWindowViewModelTests`; `MainWindowInteractionHeadlessTests` | +| Avalonia frame display refresh | `CommitNonStructuralChapterSetResult` converts a same-shape frame result to a candidate and commits it. It rejects row-count changes. | `ChapterWorkspaceTests`; `MainWindowViewModelTests` | +| Avalonia merge, boundary split, and append | `ChapterWorkspace.ToggleClipStructure` and `AppendClipSource` commit candidates to the same `ContentSession`. | `ChapterClipCandidateBuilderTests`; `ChapterWorkspaceTests`; `MainWindowViewModelTests` | +| Browser row edit, insert, duplicate, delete, frame shift, FPS change, naming, and expression | `WasmWorkspace` calls `ChapterWorkspace.ExecuteTrackCandidate`; it commits one selected-track candidate. | `WasmWorkspaceTests` | +| Browser merge, boundary split, and append | `WasmWorkspace` routes operations through `ChapterWorkspace` clip transactions. | `WasmWorkspaceTests`; `ChapterWorkspaceTests` | +| Table row refresh | Avalonia `WorkspaceContentRows` and Wasm `RefreshDisplay` materialize the selected track from the current session document. Frame display is temporary. | `MainWindowViewModelTests`; `WasmWorkspaceTests`; Headless interaction tests | +| Interactive preview and export | Hosts capture the committed `ChapterSet` compatibility view and serialize it with transform flags disabled. | `WasmWorkspaceTests`; `MainWindowViewModelTests`; `ChapterExportServiceTests` | +| CLI and Node.js legacy conversion | Stateless adapters transform once, then call pure serialization. They do not create an interactive session. | `ChapterToolCliApplicationTests`; `ChapterExportServiceTests`; package `core-api.test.ts` | + +`ChapterWorkspace.CurrentChapterSet` is a temporary selected-track view. `ChapterImportEntry.ChapterSet` is import output used to construct the session root. Neither is a second editable workspace store. + +The session history stores the root document and reversible deltas. Undo and redo use those in-memory values and do not reopen source files. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/design.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/design.md new file mode 100644 index 00000000..227adf25 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/design.md @@ -0,0 +1,21 @@ +## Context + +Changes 01-08 establish the current document, atomic edit kernel, complete history tree, operation and clip migration, both hosts, session lifecycle, and stateless compatibility boundaries. Old projection and direct mutation paths must now be retired. + +## Goals / Non-Goals + +**Goals:** Complete the cutover, remove obsolete interactive state, update ownership documentation, and establish final behavior/resource evidence. + +**Non-Goals:** Introduce new product capabilities or make history persistent. + +## Decisions + +- Remove a legacy path only after searching its runtime consumers and proving every active workflow reaches the Core session. +- Preserve stateless Core/CLI/Node compatibility adapters from change 08, but keep them outside interactive workspace state. +- Update current code maps and specs in the same change as ownership and public behavior cutover. +- Validate full-solution .NET behavior, Node/browser workflows, large-history behavior, session release, and responsive UI evidence. + +## Risks / Trade-offs + +- [A dynamic consumer may still use a legacy API] → Inventory public API consumers and add behavior coverage before deletion. +- [Large-history tests cannot prove infinite physical memory] → Report scale and environment, verify no pruning policy, and test recoverable failure injection. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/history-measurements.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/history-measurements.md new file mode 100644 index 00000000..96831f18 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/history-measurements.md @@ -0,0 +1,17 @@ +# History Measurements + +The scale run used the Core test project in Debug mode on Windows x64 with .NET 10.0.12. It used 32 edits per branch and varied chapter count and retained branch count. It measured allocations and build time while creating branches. It measured 32 undo and redo operations. It measured retained process heap after a full collection. It then timed navigation from the last branch to the first branch. With one branch, the navigation measurement covers root-to-leaf navigation because there is no alternate branch. + +| Chapters | Branches | Retained nodes | Allocated bytes | Retained heap bytes after full GC | Build and branch ms | Undo 32 µs | Redo 32 µs | Branch navigation µs | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 1 | 33 | 143,872 | 2,578,560 | 9 | 11,932.3 | 416.7 | 235.9 | +| 1 | 4 | 129 | 799,312 | 2,782,856 | 1 | 129.4 | 83.1 | 97.0 | +| 1 | 8 | 257 | 1,764,488 | 3,182,360 | 2 | 285.8 | 103.7 | 102.6 | +| 100 | 1 | 33 | 488,096 | 3,090,344 | 0 | 656.0 | 644.5 | 732.5 | +| 100 | 4 | 129 | 2,766,864 | 2,944,344 | 5 | 850.9 | 696.0 | 1,274.4 | +| 100 | 8 | 257 | 5,893,128 | 3,563,192 | 12 | 730.5 | 663.3 | 1,220.0 | +| 1,000 | 1 | 33 | 3,662,544 | 3,720,944 | 3 | 7,447.9 | 5,440.3 | 5,305.7 | +| 1,000 | 4 | 129 | 20,981,952 | 4,400,928 | 32 | 7,081.5 | 5,467.7 | 11,721.1 | +| 1,000 | 8 | 257 | 44,158,216 | 7,001,672 | 69 | 5,619.2 | 5,291.9 | 15,958.3 | + +The run retained all 257 nodes at the largest branch count. For 1,000-chapter documents, branch navigation took 5.3 ms from root to leaf with one branch, 11.7 ms between four branches, and 16.0 ms between eight branches. Allocation and retained heap increased with document size and branch count. Retained heap is a process-wide approximation. It includes the test runner and runtime, so it is not an isolated history-tree size. The first row includes runtime warm-up and the measurements are not performance thresholds. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/proposal.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/proposal.md new file mode 100644 index 00000000..fb68f348 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/proposal.md @@ -0,0 +1,22 @@ +## Why + +The migration is complete only when the old projection and direct mutation paths cannot compete with the unified document session. This change closes the migration, updates navigation documentation, and records behavior and performance evidence. + +## What Changes + +- Remove persistent interactive projection state and obsolete parallel editable clip backups. +- Remove or constrain direct mutation and transform-during-export paths after all consumers have migrated. +- Update code maps and primary test ownership for the unified session model. +- Run complete cross-project verification, history/resource checks, browser workflows, and UI layout evidence. + +## Capabilities + +### New Capabilities +- `unified-editing-migration-completion`: No competing interactive state path remains and all migrated workflows are verified. + +### Modified Capabilities +- None. + +## Impact + +Core, Avalonia, Wasm, CLI, Node.js, code maps, existing specifications, and the full test/build pipeline. Depends on `unified-editing-08-compatibility-adapters`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/specs/unified-editing-migration-completion/spec.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/specs/unified-editing-migration-completion/spec.md new file mode 100644 index 00000000..5faa4848 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/specs/unified-editing-migration-completion/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Interactive content has one authoritative edit path +After migration, every interactive content command SHALL commit to the current document session. No host table, preview, or export path SHALL read a competing editable chapter set or persistent transform projection. + +#### Scenario: User edits then previews and exports +- **WHEN** a user edits a chapter or applies a tool operation +- **THEN** the table, preview, and each supported export SHALL read the same committed document content + +#### Scenario: Repository migration is reviewed +- **WHEN** the migration is declared complete +- **THEN** every prior content mutation entry point SHALL be listed with its session transaction and behavior test +- **AND** no required content command SHALL remain disabled as a migration workaround + +### Requirement: History verification includes branch, failure, and resource behavior +The system SHALL verify full-state identity across undo/redo and branch navigation, injected recoverable failures, deep-history traversal, and retained history without automatic truncation. + +#### Scenario: Source files disappear during a live session +- **WHEN** the imported source is removed after loading +- **THEN** undo, redo, and branch switching SHALL use in-memory history and SHALL remain available + +#### Scenario: Large history is measured +- **WHEN** the performance suite exercises increasing depth, branch count, and edit size +- **THEN** it SHALL report memory, allocation, undo, and distant-branch navigation data for the recorded environment +- **AND** the system SHALL NOT prune history to pass the measurement + +### Requirement: Migration documentation and verification match shipped behavior +Current code maps and OpenSpec requirements SHALL identify the unified session owner and primary tests. Completion reporting SHALL list executed validation commands, performance evidence, screenshots when UI layout changed, and any unavailable checks. + +#### Scenario: Maintainer follows the code map +- **WHEN** a maintainer looks up editing, session, host, export, or test ownership +- **THEN** the current code map SHALL point to the unified document, transaction, history, and host integration entry points diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/tasks.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/tasks.md new file mode 100644 index 00000000..1578171b --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/tasks.md @@ -0,0 +1,15 @@ +## 1. Cutover + +- [x] 1.1 Inventory every former direct mutation, projection, clip-backup, preview, and export consumer; map each to a session transaction and behavior test. +- [x] 1.2 Remove obsolete interactive projection state, duplicate editable chapter collections, and direct mutation paths. +- [x] 1.3 Keep and document only the stateless compatibility adapters defined in change 08. +- [x] 1.4 Update `docs/code-map/core.md`, `avalonia.md`, `contracts.md`, and `testing.md` for new ownership and test locations. +- [x] 1.5 Synchronize the changed workspace and transform/export specs. The change remains active and is not archived. + +## 2. Completion evidence + +- [x] 2.1 Verify generated full-state edits, deep history, branch navigation, injected resource failures, atomic publication, and stale async operation outcomes. +- [x] 2.2 Verify session release and export snapshot retention through `ChapterWorkspaceTests`. +- [x] 2.3 Record allocation, retained heap, build time, undo, and distant-branch timing for increasing document sizes and branch counts in `history-measurements.md`. +- [x] 2.4 Run the full solution, Node.js package checks, and OpenSpec validation. Record browser and environment limitations in `verification.md`. +- [x] 2.5 Capture and review default, wide, and narrow UI evidence. Record screenshot paths and review notes in `verification.md`. diff --git a/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/verification.md b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/verification.md new file mode 100644 index 00000000..70506a07 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unified-editing-09-remove-legacy-paths/verification.md @@ -0,0 +1,22 @@ +# Verification + +## Automated checks + +- `dotnet test ChapterTool.slnx --no-restore`: passed 1,353 tests. Four environment-gated tests were skipped. The two Matroska integration tests need `mkvextract`. The full-disc parity test needs `CHAPTERTOOL_RUN_FULL_DISC_PARITY=1`. The Windows registry test is not applicable on Windows. +- `dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --no-restore`: passed 732 tests, including history scale measurements. +- `npm test` in `packages/chaptertool`: passed lint, type checking, build, and 28 tests across four files. +- `npm run doctor` and `npm run pack:verify` in `packages/chaptertool`: passed. The environment has .NET SDK 10.0.401. The `wasm-tools` workload is absent, so the build uses the unoptimized WebAssembly runtime. Vitest reported a missing source-map source file for a generated runtime bundle. +- `openspec validate unified-editing-09-remove-legacy-paths --strict`: passed. +- `openspec validate --all`: 33 items passed. The existing `log-tool-viewing` spec failed strict validation because requirement 7 has no `SHALL` or `MUST` keyword. This spec is outside this change and was not modified. + +The repository has Node.js tests for the browser package but no browser automation project or configured browser test command. This verification did not exercise the WebAssembly package in a graphical browser. + +## Screenshots + +Headless capture passed with `CHAPTERTOOL_UI_SCREENSHOT_SET=unified-editing-09` and the `Capture_main_window_and_tool_views_when_requested` test. + +- Default: `artifacts/unify-sourcegit-design-system/unified-editing-09/main-default.png` +- Wide: `artifacts/unify-sourcegit-design-system/unified-editing-09/main-wide.png` +- Narrow: `artifacts/unify-sourcegit-design-system/unified-editing-09/main-narrow.png` + +The chapter grid and bottom options remain visible at all three widths. The narrow capture shows the edit history panel clipped vertically at the 512-pixel test height. The changed content view does not add overlapping controls. Screenshots show visual layout only; accessibility names and keyboard behavior are covered by existing UI tests. diff --git a/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/.openspec.yaml b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/.openspec.yaml new file mode 100644 index 00000000..f98e139f --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/design.md b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/design.md new file mode 100644 index 00000000..666374eb --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/design.md @@ -0,0 +1,184 @@ +## Context + +The user selected option A from [the interaction design](../../../docs/tasks/expression-preview-interaction-design.md). The expression editor and chapter comparison must share one independent tool surface. The desktop host uses Avalonia. The Web host uses Blazor WebAssembly. + +`ChapterContentPreview` already contains `Before`, `Candidate`, `TargetIds`, `BaseToken`, and a preview identity. `ChapterContentOperationSession.ApplyAsync` can commit the candidate without evaluating Lua again. The existing difference records contain display strings. They do not provide a suitable typed presentation contract. + +Avalonia currently limits its summary to eight differences and recalculates on Apply. Web uses a flat list and falls back to internal field names. Web can also add a detected frame rate during preparation. A preview must expose these persisted effects even when chapter times do not change. + +Prerequisites are the implemented changes `live-expression-preview`, `fix-wasm-modal-layout`, and the unified-editing transaction series. Their artifacts are still unarchived. Main specs retain some older projection wording. Implementation must use the current candidate model. Before eventual archive, synchronize prerequisite deltas in dependency order, then synchronize this additive capability. Do not copy old projection behavior into this change or archive other changes as part of its implementation. + +## Implementation Findings + +- `ChapterContentOperationSession.Prepare` captures immutable `Before` and `Candidate` documents, target identities, a base token, validation errors, and a generated difference list. `ApplyAsync` submits the existing candidate through `SessionState.ExecuteAsync`; its builder does not reevaluate the expression. +- `ChapterContentCandidateBuilder.ApplyExpression` targets every chapter in its input document. The Avalonia path uses the active content session. The Web path captures `CurrentTrackIndex`, adds detected frame-rate metadata before building, evaluates only the focused track, and restores the other tracks afterward. +- `EditableChapterDocument` has document metadata, ordered tracks, chapter fields, and track segment metadata. The generic difference list does not include segment changes, so the shared projection must compare the immutable snapshots directly. +- Avalonia hosts expression tools as reusable native windows. `AvaloniaWindowService` currently disposes the tool ViewModel on native close, but the expression ViewModel has no host close port. Successful preview application also leaves that window open. +- Avalonia preview input changes use a 180 ms timer and do not track input composition. The ViewModel recalculates in the Apply command and again after conflicts. It has no explicit stale or applying state. +- Web already uses the shared modal component and restores expression and preset parameters on cancel. Its workspace recalculates after a conflict, while its dialog enables Apply using the difference list and empty input is converted to `t` during preparation. +- The workspace candidate is an immutable document snapshot. A projection can preserve frame values as strings and expose each side's rational frame-rate basis without parsing presentation strings. Frame text is not guaranteed to be numeric, so numeric frame deltas must be optional. + +## Goals / Non-Goals + +**Goals:** + +- Give both hosts the same scope, result structure, terminology, and confirmation rules. +- Show complete chapter-level before/after values and distinguish time, frame, and property changes. +- Commit exactly the result the user reviewed as one undoable operation. +- Keep multiline scripts and long results usable on desktop and narrow Web viewports. +- Preserve localization, keyboard input, focus, themes, and current export semantics. + +**Non-Goals:** + +- Add selected-chapter or all-track scope controls. +- Add per-row approval, candidate editing, a timeline, or a second working document. +- Change Lua syntax, frame conversion policy, persistent document fields, or history storage. +- Implement alternative layouts B and C. +- Require syntax-highlighting or completion parity between Avalonia and Web. + +## Decisions + +### 1. Use option A in both hosts + +The order is title, scope, preset, multiline editor, result summary, chapter comparison, expandable property changes, and footer. The footer contains Cancel followed by Apply changes. Both buttons remain present across states. The compact layout omits step headings and instructional paragraphs. + +Wide layouts use columns for chapter identity, before, after, and delta. Narrow layouts use labeled entries. Avalonia reflows below 640 DIP. Web reflows below 760 CSS px. The Web editor starts at two lines and can expand. Avalonia starts with its compact editor and can expand to 160 DIP. The result remains accessible when the script is long. + +Default filtering is changed chapters. An all-chapters view provides context. The default unit is time. A frame view and row details expose frame values and accuracy. Filters only affect presentation. + +Option B was rejected because switching between complete tables weakens direct comparison. Option C was rejected because moving between steps interrupts live review. No preference toggle selects between these layouts. + +### 2. Derive one typed preview projection from immutable snapshots + +Add a small host-neutral projection under Core's existing session area. It must not reference Avalonia, Blazor, or localized UI resources. Both hosts consume this projection. Host adapters resolve shared resource keys and format culture-dependent values. + +The projection contains these logical values; exact type names are implementation choices: + +| Value | Source and purpose | +| --- | --- | +| Scope | Captured track identity/name, participating chapter identities, excluded separator count | +| Summary | Unique changed chapters, time-changed chapters, frame-information updates, unchanged times, property changes | +| Chapter comparison | Stable identity, display number/name, before/after ticks, typed frame values and accuracy, change categories | +| Property comparison | Typed before/after values with explicit document or track ownership | +| Diagnostics | Existing errors with locations only when available | + +Use snapshot values, not parsing of `ToString()` output. Match chapters by identity and preserve track order. Frame accuracy counts can overlap time-change counts. The affected-chapter total must use a set union. Exclude separators from expression chapter counts even if existing `TargetIds` includes them. + +Unknown or newly supported persisted changes must remain visible through a labeled details entry. Add explicit handling and tests for every field that the expression candidate currently changes. Do not silently filter a difference out because its field is unfamiliar. + +Public UI text must use business labels. Unknown frame rate and uncomputed frame data are distinct from zero. Audit the existing accuracy tolerance before choosing translations for its enum values. Time precision must increase when millisecond formatting would conceal a real change. Compare frame deltas only when both values have a compatible frame-rate basis. + +The alternative of separate string builders was rejected because it would preserve drift between hosts. A general-purpose diff framework is unnecessary for this scoped feature. + +### 3. Capture scope and expose all side effects + +Preparation captures the current track and its chapter identities. No selection means no implicit change of scope. Scope text remains visible during review. + +If frame-rate detection changes document or segment metadata outside chapter rows, show its actual ownership in the property section. Do not label document-wide metadata as a change to only the current track. Preserve the existing calculation policy in this change; make its effects reviewable. + +Empty input is an incomplete draft and cannot be applied. An explicit `t` is evaluated normally. It can still expose persisted frame-information changes. No-change detection uses the complete candidate, not just time deltas or a filtered list. + +### 4. Own one current candidate and explicit state per tool session + +States are unavailable, empty, waiting, computing, ready, unchanged, invalid, stale, applying, and failed. Success closes the tool. Each state has a localized explanation and a defined Apply eligibility. + +Input or preset changes invalidate the previous candidate immediately. Use approximately 250 ms debounce in both hosts. Input-method composition must finish before scheduling evaluation. Each calculation captures a draft revision and scope. Only the latest result can enter ready state. Closing or replacing the document invalidates all outstanding results. + +Old values can remain visible during computation, but must carry an old-preview label. They cannot be applied. Computation must not block normal editor interaction; use the existing host-appropriate scheduling facilities and cancellation limits. Do not add a worker architecture without measured need. + +A changed document token or scope enters stale state. The user invokes Update preview, reviews the new result, and confirms again. The prior stale-preview contract's new preparation occurs through this refresh action. It does not authorize committing that new result under the earlier click. + +### 5. Commit the reviewed candidate without evaluation + +Apply requires a valid, changed candidate whose draft revision, scope, and session base still match. The handler captures that candidate and submits its existing identity through the transaction entry point. Remove Avalonia's Apply-time `RefreshPreviewNow` behavior. + +The commit queue remains the final stale-state authority. A conflict keeps the tool open, preserves the script, explains staleness, and requires refresh. A repeated event or retry reuses the same transaction identity. Input controls and dismissal are disabled for the short applying state. Errors keep enough state to retry or correct the draft. + +No valid subset can commit after a row error, timeout, or cancellation. A no-change result produces no history entry. Undo restores the complete previous snapshot. Redo restores the committed candidate without running Lua. + +Automatically recalculating during Apply was rejected because the user might commit an unseen result. + +### 6. Use one close and success lifecycle + +Capture the expression, preset, and source metadata when opening. Cancel, window Close, and outer Escape discard the candidate and restore that parameter baseline without a second confirmation. Backdrop clicks do not dismiss the tool. Completion popups receive Escape first. + +After a successful commit, both hosts close the expression surface. Preserve the chapter-table scroll and selected identity where possible. Return focus to the opener and report a localized change summary. Retain applied parameters for another explicitly opened operation. Parameters do not remain active export rules. + +Use the existing narrow auxiliary-tool/session ports. Extend a focused close callback or lifecycle port if needed. Do not make the expression ViewModel depend on the main-window ViewModel. Avalonia must prevent background content actions while this review surface is active, using its existing host facilities. Web continues to use the shared modal lifecycle. + +Ctrl/Cmd+Z inside the script editor stays within the draft. Enter inserts a newline. Background document shortcuts and file drops cannot modify the document through the active modal surface. No new global Apply shortcut is introduced. + +### 7. Reuse localization and test real behavior + +Add labels and plural/count templates to shared locale AXAML files. Generate Web JSON with the repository script. Use existing theme resources and accessible control labels. Announce result summaries, not the whole comparison after every keystroke. + +Test the typed projection through public APIs with real snapshots. Test host state transitions and actual candidate commits. Use Avalonia Headless only in its dedicated process and collection. Web browser tests must interact with rendered controls and verify cancellation, history, focus, geometry, and hit targets. Screenshots supplement assertions. + +## Risks / Trade-offs + +- [Pending prerequisite specs contain older behavior] → Reconcile prerequisite deltas before this change is archived. Keep this change's shared capability additive. +- [Frame-only effects look like time edits] → Separate categories and explicitly test the screenshot-shaped case. +- [Detected frame rate affects broader metadata] → Show property ownership and complete before/after values. +- [A large candidate slows rendering] → Use virtualization or paging without truncating accessible results or counts. Verify 1,000 chapters. +- [IME and asynchronous results vary by host] → Exercise composition, revision changes, close races, and stale commits in both host suites. +- [A native tool window does not currently behave modally] → Verify the actual host lifecycle before wiring close and background isolation. Preserve typed host contracts. +- [Tiny time changes appear unchanged] → Raise display precision from the typed ticks and verify sub-millisecond examples. + +## Migration Plan + +1. Confirm prerequisite candidate and tool-host entry points. Reuse existing transaction tests. +2. Add the shared typed projection and focused tests. +3. Implement Avalonia state, layout, and lifecycle against the new projection. +4. Implement Web state, layout, and lifecycle against the same projection. +5. Add shared locale text and regenerate Web resources. +6. Run affected suites sequentially. Capture default, wide, and narrow evidence for both hosts. +7. Update code-map ownership and finalize the design record. + +No persisted settings or document schema migration is required. Rollback can revert presentation and lifecycle wiring together without rewriting user documents. Candidate data remains temporary. + +## Open Questions + +No product decision blocks implementation. Option A is selected. Defaults are current-track scope, time units, changed-only filtering, close on success, and baseline restoration on cancel. The exact responsive breakpoint and accuracy labels must be resolved through layout checks and the current tolerance semantics during implementation. + +## Implementation and Acceptance Evidence + +- Core now exposes `ExpressionPreviewProjection` from immutable before and candidate documents. It compares complete persisted values, retains track order and scope, excludes separator chapters from expression counts, records property ownership, and calculates frame deltas only when frame bases match. Projection and candidate transaction coverage passes in `ChapterTool.Core.Tests`. +- Avalonia and Web consume the shared projection. Both surfaces keep empty input unapplied, mark old results stale, require an explicit refresh after conflicts, and apply the captured candidate without evaluating Lua again. Avalonia stores applied expression settings through a settings-only port. Web reports the committed change counts. +- Avalonia uses a modal native tool window with apply-time close protection, outer Escape routing, and a success close callback. Its Headless workflow covers live review, filters, frame values, cancellation, close, and one-step undo. Web uses the shared modal, and a small JavaScript bridge delays preview scheduling until IME composition ends. +- Screenshots are available at `artifacts/expression-preview/avalonia-default.png`, `avalonia-wide.png`, `avalonia-narrow.png`, `web-default.png`, `web-wide.png`, and `web-narrow.png`. Avalonia narrow layout uses stacked before/after/delta rows. Browser checks cover 320 px, 390×844, and 844×390 viewports. Long-result scrolling is covered by M05. Existing localization and theme workflows cover English, Chinese, and light/dark palettes. Physical mobile keyboard behavior was not tested on a device; browser WebKit composition coverage passed. +- Final sequential .NET checks passed: Core 740, Avalonia 261, Avalonia Headless 104, and Wasm 44 tests. Avalonia and Wasm builds passed with zero warnings and errors. Locale JSON generation and `--check` passed. The final Chromium and WebKit modal/expression runs each passed 15/15. An earlier full browser matrix exposed obsolete expectations and the missing composition bridge; those failures were fixed before the final focused runs. +- `openspec validate "unify-expression-preview-interaction" --strict` passed. The change updates `docs/code-map/core.md`, `avalonia.md`, and `testing.md` with ownership and test paths. + +## Visual Review Follow-up + +The user reported that the first implementation was hard to understand. Visual review found four causes. The editor and filters consumed too much space. Property changes appeared before the time comparison. The summary gave every category equal emphasis. The editor did not explain the time variable or its unit. + +The first visual follow-up added numbered headings for editing and review. It explained `t` in seconds and gave the `t + 5` example. The primary summary stated how many chapter times would change. A secondary summary retained unique affected chapter counts, frame updates, and property changes. Property values followed the comparison in expandable details. The comparison emphasized candidate values. Web filters used checkboxes. The Web Apply button used the accent color. + +Avalonia keeps the editor above an independent result scroll area. It reflows comparisons when the view width changes. Web uses more of the available portrait height. With the two-chapter fixture, the second chapter's changed time remains above the footer at 390×844. Both hosts retain track order and show frame-only entries. All property owners and before/after values remain available. + +English and Chinese screenshots are in `artifacts/expression-preview/`. Each host has default, wide, and narrow images. Chinese filenames use the `-zh` suffix. Browser assertions verify visible changed values above the footer. Headless assertions verify before/after alignment and delta placement after resize. The Chinese rendered summary confirms that one changed chapter and two participating chapters are not reversed. + +Follow-up checks passed: Avalonia 261, Avalonia Headless 105, and Wasm 44 tests. Desktop and Web builds reported zero warnings and errors. The locale generator check and browser TypeScript check passed. The modal and expression browser suite passed 46 tests across Chromium, Firefox, and WebKit. Two duplicate screenshot runs were skipped because Chromium captures the shared image set. The Firefox composition fixture now waits for listener readiness and sends a composition input event. Physical mobile keyboard behavior remains unverified. + +Primary commands: + +```text +dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore +dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore +dotnet test tests/ChapterTool.Wasm.Tests/ChapterTool.Wasm.Tests.csproj --no-restore +uv run --project scripts scripts/axaml-to-json.py --check +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts expression-preview-screenshots.spec.ts unified-editing.spec.ts modal-layout.spec.ts +``` + +Run the browser command from `tests/ChapterTool.Wasm.E2E/` after publishing and preparing the site. After the final Apply color change, the screenshot and M02/M05 geometry checks passed seven tests against the updated CSS. Two duplicate screenshot runs were skipped. + +## Compact Layout Follow-up + +The user requested less space and less explanatory text. Both hosts now omit numbered headings, variable instructions, duplicate editor labels, repeated unchanged-time text, and normal footer guidance. The summaries use short count labels. Error, pending, stale, and applying states remain available. The editor keeps its accessible name. + +Avalonia places scope and preset controls in one row. Its outer padding is 10×8 DIP. Chapter cards use 6 DIP padding and smaller gaps. Web uses a narrower dialog, smaller content gaps, and a footer with only the two actions during normal review. Touch targets retain their existing minimum size. Expandable details keep all property owners and before/after values. + +English and Chinese default, wide, and narrow screenshots were refreshed in `artifacts/expression-preview/`. The desktop Headless suite passed 105 tests. The Avalonia suite passed 261 tests. Web publication and locale generation checks passed. + +The compact browser run passed 31 tests across Chromium and WebKit. One duplicate screenshot run was skipped. The TypeScript check and strict OpenSpec validation passed. The run used the primary browser command above with --project=chromium --project=webkit. diff --git a/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/proposal.md b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/proposal.md new file mode 100644 index 00000000..e339c870 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/proposal.md @@ -0,0 +1,34 @@ +## Why + +Avalonia and Web expose expression candidates as flat technical differences. Users cannot readily compare chapter times or distinguish time edits from frame-information updates. The user selected design A in [the interaction design](../../../docs/tasks/expression-preview-interaction-design.md): an independent expression surface with editing and chapter-level before/after review in one view. + +## What Changes + +- Replace flat difference text with a structured, read-only chapter comparison in both Avalonia and Blazor WebAssembly. +- Show the current-track scope, complete change counts, time differences, frame-information changes, and document properties with localized labels. +- Use a multiline editor above the preview. Reflow chapter comparisons into vertical entries on narrow screens. +- Keep Cancel and Apply changes visible. Disable Apply for empty drafts, pending computation, invalid results, unchanged candidates, stale candidates, and active submissions. +- Commit the exact candidate that the user reviewed. Do not recalculate and commit a replacement in the same confirmation action. +- Align close, cancellation, focus restoration, success feedback, and one-step undo behavior across both hosts. +- Add behavior and layout coverage for both hosts, including the supplied screenshot's frame-only changes. + +## Capabilities + +### New Capabilities + +- `expression-preview-interaction`: Shared expression review, presentation, state, and confirmation requirements for Avalonia and Web. + +### Modified Capabilities + +None. This change adds a shared interaction contract over the existing candidate and transaction capabilities. It does not replace the Lua language or document model. + +## Impact + +- Avalonia expression tool view, ViewModel, narrow session ports, and tool-host lifecycle. +- Web expression dialog, workspace presentation, modal styling, and focus lifecycle. +- Shared host-neutral preview presentation and existing immutable candidate snapshots. +- Shared locale AXAML files and generated Web locale JSON. +- Core/session verification, Avalonia unit and Headless coverage, Web workspace tests, and browser E2E coverage. +- Relevant code-map pages and design-document status. + +The completed changes `live-expression-preview` and `fix-wasm-modal-layout` remain unarchived. This change builds on their implemented behavior and records that dependency in its design. It does not archive or rewrite those changes. No new runtime package, expression syntax, history storage, or scope selector is required. diff --git a/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/specs/expression-preview-interaction/spec.md b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/specs/expression-preview-interaction/spec.md new file mode 100644 index 00000000..8fb365a4 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/specs/expression-preview-interaction/spec.md @@ -0,0 +1,210 @@ +## ADDED Requirements + +### Requirement: Both hosts provide one expression review surface +Avalonia and Web MUST provide an independent expression tool with the editor and chapter comparison in the same surface. Both hosts MUST show the current track name, participating chapter count, preset selector, multiline editor, preview summary, and persistent Cancel and Apply changes actions. The committed chapter table MUST remain unchanged during preparation. + +#### Scenario: Open the expression tool in either host +- **WHEN** the user opens the expression tool in Avalonia or Web +- **THEN** the tool displays the current-track scope and the stored expression parameters +- **AND** a nonempty expression starts a read-only preview for that scope +- **AND** excluded separator rows are identified separately from participating chapters + +#### Scenario: Select and edit a multiline preset +- **WHEN** the user selects a multiline preset and then changes its text +- **THEN** the editor preserves line breaks and indentation +- **AND** the preset selection becomes Custom +- **AND** the tool refreshes its preview without changing the document or history + +### Requirement: Preview groups differences by chapter +Both hosts MUST show one comparison entry per chapter in track order, matched by stable identity. Each entry MUST expose the chapter number and name, before value, after value, and signed time delta. Changed-only filtering and time units MUST be the defaults. Users MUST be able to view all chapters, switch to frame values, and inspect frame details. The preview MUST remain read-only. + +#### Scenario: Inspect a five-second shift +- **WHEN** a valid candidate shifts a chapter from 00:01:00 to 00:01:05 +- **THEN** one entry shows both times and a positive five-second delta +- **AND** its frame-information changes are grouped with that chapter + +#### Scenario: Filter or change units +- **WHEN** the user switches between changed and all chapters or between time and frame units +- **THEN** only the presentation changes +- **AND** candidate identity, target scope, and application content remain unchanged +- **AND** no expression evaluation is required for the display switch + +#### Scenario: Inspect a large result +- **WHEN** a candidate contains 1,000 changed chapters +- **THEN** counts cover all 1,000 chapters +- **AND** every changed chapter is reachable through scrolling or paging +- **AND** the preview does not silently truncate differences to eight entries + +### Requirement: Summary distinguishes persisted effects +The preview MUST distinguish time changes, frame-information updates, and document or track property changes. Affected-chapter totals MUST count unique identities. Overlapping categories MUST NOT be added as if they were disjoint. Complete candidate equality MUST determine no-change behavior. + +#### Scenario: Frame data changes without time changes +- **WHEN** the candidate updates three chapters' frame data and leaves all chapter times unchanged +- **THEN** the summary explicitly states that chapter times are unchanged +- **AND** it identifies the three frame-information updates +- **AND** the comparison does not claim that three times moved + +#### Scenario: One chapter changes in two categories +- **WHEN** the same chapter changes time and frame accuracy +- **THEN** it counts as one affected chapter +- **AND** both categories remain inspectable + +#### Scenario: Frame-rate detection changes metadata +- **WHEN** preparation adds a frame rate or modifies segment or document metadata +- **THEN** a separate property section displays before and after values +- **AND** it identifies the actual owner of each property, including effects beyond the selected track +- **AND** those changes remain visible even if no chapter time changes + +#### Scenario: Only properties change +- **WHEN** a valid current candidate changes a persisted property but no chapter time +- **THEN** the summary names the property change +- **AND** Apply remains available after the complete result is ready + +### Requirement: Values use clear localized business formats +Both hosts MUST use localized field labels, units, missing-value states, and diagnostic summaries. They MUST format values from typed snapshots rather than parse technical difference strings. They MUST NOT expose raw enum names or object dumps as normal preview content. Time formatting MUST retain enough precision to distinguish actual changes. + +#### Scenario: Distinguish missing values from zero +- **WHEN** original frame information is absent and the result is frame zero +- **THEN** the original is labeled Not calculated and the result is labeled zero frames +- **AND** an unknown frame rate is labeled Not set rather than zero + +#### Scenario: Compare frame rates +- **WHEN** before and after frame values use different frame rates +- **THEN** each side displays its own frame-rate basis +- **AND** the preview omits an incompatible numeric frame delta +- **AND** rational rates remain inspectable, such as 24000/1001 + +#### Scenario: Display a sub-millisecond change +- **WHEN** two distinct times would have identical millisecond display text +- **THEN** the preview increases displayed precision enough to distinguish them +- **AND** the delta is not displayed as zero + +#### Scenario: Display accuracy and additional fields +- **WHEN** frame accuracy or another persisted field changes +- **THEN** its business label and before/after meaning are accessible in details +- **AND** accuracy text reflects the actual tolerance policy +- **AND** an unrecognized field is not silently omitted from review + +### Requirement: Live preview has explicit readiness states +Both hosts MUST expose unavailable, empty, waiting, computing, ready, unchanged, invalid, stale, applying, and failed states with an understandable status. Apply MUST be disabled except for a valid, changed, current candidate. Cancel and Apply MUST remain visible. Input changes MUST immediately invalidate the previous candidate. + +#### Scenario: Empty or unchanged input +- **WHEN** there is no participating chapter, the script is empty, or the complete candidate equals its base +- **THEN** the tool explains the applicable reason and disables Apply +- **AND** it creates no document history entry +- **AND** empty input is not silently evaluated as an identity expression + +#### Scenario: Explicit identity expression has side effects +- **WHEN** the user enters `t` explicitly and preparation changes persisted frame information +- **THEN** the tool displays those effects and evaluates readiness from the complete candidate +- **AND** it does not report no changes solely because times are equal + +#### Scenario: Input replaces a ready preview +- **WHEN** the user changes the script after a valid preview +- **THEN** Apply becomes disabled before the next calculation completes +- **AND** any retained previous values are labeled as an old preview +- **AND** only the latest draft revision can become ready + +#### Scenario: Input-method composition and late results +- **WHEN** input composition is active or a result arrives after the tool closes +- **THEN** incomplete composition does not trigger evaluation +- **AND** a closed tool ignores the late result without modifying the document or reopening + +### Requirement: Errors reject the complete operation +Both hosts MUST show current evaluation, validation, timeout, and cancellation outcomes without offering partial application. Error locations MUST be shown when available and MUST NOT be invented. Warnings MUST be distinguishable from blocking errors. + +#### Scenario: One chapter fails +- **WHEN** evaluation produces a negative time for chapter eight after seven successful calculations +- **THEN** the tool shows the error and disables Apply for the entire candidate +- **AND** no chapter or history state changes + +### Requirement: Apply commits only the reviewed candidate +Both hosts MUST submit the displayed candidate without reevaluating the expression in the Apply action. Eligibility MUST bind to the same draft revision, target scope, document session, and base version. The transaction boundary MUST reject stale candidates and deduplicate repeated submission of the same candidate. + +#### Scenario: Confirm a reviewed result +- **WHEN** the user applies a valid current preview +- **THEN** the committed values exactly match the displayed candidate +- **AND** the application path does not evaluate the expression again +- **AND** one atomic history transaction contains all candidate changes + +#### Scenario: Refresh after a stale base +- **WHEN** document content, history position, or target scope changes after preparation +- **THEN** the old candidate cannot commit +- **AND** the tool remains open with the script and an Update preview action +- **WHEN** the user updates the preview +- **THEN** a new candidate is shown for inspection +- **AND** applying it requires another explicit confirmation + +#### Scenario: Change races with submission +- **WHEN** the base changes after the UI readiness check but before the transaction commits +- **THEN** the transaction rejects the candidate +- **AND** the tool enters stale state rather than committing a newly calculated replacement + +#### Scenario: Duplicate submission or retry +- **WHEN** a double-click or request retry submits the same reviewed candidate more than once +- **THEN** its stable submission identity permits at most one commit +- **AND** the applying state disables editing, Apply, and dismissal until the outcome is known + +#### Scenario: Recover from an application failure +- **WHEN** application fails without committing +- **THEN** the tool preserves the draft and reports the reason +- **AND** it enables correction or retry only when that action is valid + +### Requirement: Both hosts share cancellation and success behavior +Cancel, Close, and outer Escape MUST discard pending work, restore the entry expression parameters, and close the surface without modifying the document or history. Backdrop clicks MUST NOT dismiss it. Successful application MUST close it and return the user to committed chapters with an accurate summary. Undo and redo MUST use stored document states. + +#### Scenario: Cancel a modified draft +- **WHEN** the user changes a preset or script and invokes Cancel, Close, or outer Escape before submission +- **THEN** the candidate and pending calculations are discarded +- **AND** reopening shows the expression, preset, and source metadata from before that canceled edit +- **AND** no additional confirmation dialog or document transaction occurs + +#### Scenario: Apply and undo all effects +- **WHEN** the user applies a candidate that changes times, frame data, and metadata +- **THEN** the tool closes and the chapter table shows the committed result +- **AND** table position and chapter selection are preserved where their identities remain available +- **AND** one Undo restores all of those effects +- **AND** Redo restores the same result without rerunning Lua +- **AND** export uses committed values without rerunning the expression + +#### Scenario: Deliberately apply the same script again +- **WHEN** the user reopens the tool after applying `t + 5` +- **THEN** retained parameters prepare a new preview from the current committed document +- **AND** another explicit Apply creates a separate five-second shift and history transaction + +### Requirement: Review remains responsive and accessible +Wide surfaces MUST use a readable comparison table. Narrow surfaces MUST reflow into labeled chapter entries without losing before/after values or actions. Both hosts MUST provide accessible labels, keyboard navigation, theme-aware emphasis, and focus restoration. Meaning MUST NOT depend on color alone. + +#### Scenario: Narrow and short Web viewports +- **WHEN** the tool opens at 320px width, 390×844, or 844×390 with long script and diagnostic text +- **THEN** content remains readable without page-level horizontal overflow +- **AND** Close, Cancel, and Apply do not overlap and remain reachable by actual interaction +- **AND** touch targets are at least 44 by 44 CSS pixels +- **AND** a mobile keyboard does not permanently obscure dismissal or application + +#### Scenario: Resize Avalonia and enlarge text +- **WHEN** the user resizes the desktop tool between default, wide, and minimum supported sizes or enlarges text +- **THEN** chapter values and the footer remain accessible without overlapping controls +- **AND** long localized labels remain readable or can be expanded + +#### Scenario: Use editing keys and close the tool +- **WHEN** the script editor has focus +- **THEN** Enter inserts a newline and Ctrl/Cmd+Z operates on the script draft +- **AND** a completion popup consumes Escape before the tool does +- **AND** background shortcuts and drops cannot modify the document through the active modal surface +- **WHEN** the tool closes +- **THEN** focus returns to its opener + +#### Scenario: Announce preview updates +- **WHEN** a new preview becomes ready +- **THEN** assistive technology receives a concise status summary rather than every comparison row +- **AND** the tool does not move focus to Apply or away from the editor + +### Requirement: Cross-host verification covers shared semantics +Implementation MUST verify the same preview fixtures and transaction outcomes in both hosts. Layout evidence MUST include default, wide, and narrow surfaces. Screenshots MUST supplement behavior assertions rather than replace them. + +#### Scenario: Verify the selected design +- **WHEN** this change is checked for completion +- **THEN** automated coverage verifies time changes, frame-only changes, property-only changes, invalid results, stale results, cancellation, duplicate submission, and one-step undo +- **AND** Avalonia Headless and Web browser checks verify rendered workflows and accessible actions +- **AND** screenshots under `artifacts/` record the supported layouts diff --git a/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/tasks.md b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/tasks.md new file mode 100644 index 00000000..965f4a49 --- /dev/null +++ b/openspec/changes/archive/2026-10-09-unify-expression-preview-interaction/tasks.md @@ -0,0 +1,55 @@ +## 1. Confirm integration boundaries + +- [x] 1.1 Inspect the current candidate, frame-accuracy, and transaction APIs. Confirm the prerequisite behavior from `live-expression-preview`, `fix-wasm-modal-layout`, and the unified-editing changes without archiving or rewriting them. +- [x] 1.2 Trace both expression entry points and Avalonia tool-host close/focus paths. Identify the narrow lifecycle hooks needed for one review surface and background isolation. Record any implementation-specific adjustment in `design.md`. + +## 2. Shared preview data and transaction evidence + +- [x] 2.1 Add a host-neutral typed preview projection from immutable before/candidate snapshots. Preserve stable chapter identity, track order, current-track scope, separator exclusions, and property ownership. +- [x] 2.2 Add complete change classification and counts. Distinguish time, frame-information, and property effects. Use complete candidate equality for no-change behavior and retain every supported changed field. +- [x] 2.3 Add public-API tests for overlapping counts, frame-only and property-only effects, missing values, rational frame rates, incompatible frame bases, sub-millisecond changes, and 1,000 accessible chapter comparisons. +- [x] 2.4 Verify exact-candidate commit, stale-token rejection, duplicate identity handling, one-step undo, and redo without expression reevaluation. Extend existing transaction tests only where these outcomes lack coverage. + +## 3. Avalonia state and review surface + +- [x] 3.1 Replace summary-string state in `ExpressionToolViewModel` with typed preview data and explicit readiness states. Add initial preview, empty-input handling, debounce, composition handling, revision checks, and stale refresh. +- [x] 3.2 Remove Apply-time expression reevaluation. Submit the reviewed candidate with its existing identity. Preserve the draft on conflict or failure and prevent duplicate application. +- [x] 3.3 Replace the eight-difference text block in `ExpressionToolView.axaml` with option A: scope, multiline editor, summary, property changes, before/after table, changed/all filter, time/frame switch, and frame details. +- [x] 3.4 Add narrow vertical entries and complete scrolling or paging. Keep Cancel and Apply visible. Use existing theme resources and accessible labels, and preserve readable values at supported window sizes. +- [x] 3.5 Wire cancellation, native Close, outer Escape, and success through narrow tool-host ports. Restore entry parameters on cancel, close after success, preserve table position/selection, restore focus, and isolate background content actions. +- [x] 3.6 Add Avalonia unit coverage for pending, unchanged, invalid, stale, applying, failed, cancel, and repeated-operation behavior. Prove Apply does not evaluate Lua again. +- [x] 3.7 Add isolated Headless workflow coverage for multiline/preset edits, before/after review, filters, frame-only details, keyboard routing, close behavior, and successful application followed by one Undo. + +## 4. Web state and review surface + +- [x] 4.1 Replace flat Web difference formatting with the shared projection. Capture current-track scope and expose complete chapter and property effects through `WasmWorkspace`. +- [x] 4.2 Update `ExpressionDialog.razor` to use multiline editing, composition-aware debounce, explicit states, stale refresh, and exact-candidate application. Remove the dialog's empty-input identity fallback behavior. +- [x] 4.3 Implement option A with the same summary, comparison, filter, units, details, and footer order as Avalonia. Reflow to vertical entries on narrow screens and keep all rows accessible. +- [x] 4.4 Preserve the shared modal lifecycle. Verify cancel baseline restoration, late-result suppression, applying-state dismissal rules, background isolation, focus restoration, and accurate success feedback. +- [x] 4.5 Extend Web workspace tests for complete no-change detection, frame/property-only candidates, stale refresh, exact-candidate commit, cancellation, and one-step undo/redo. +- [x] 4.6 Extend browser E2E tests for rendered comparisons, multiline presets, input composition, fast edits, invalid candidates, cancel/close/Escape, repeated Apply events, focus, and post-apply export values. + +## 5. Localization and cross-host acceptance + +- [x] 5.1 Add shared locale labels and count templates for scope, states, change categories, units, details, missing values, and success. Verify accuracy wording against its actual tolerance semantics and keep internal enum/object strings out of normal presentation. +- [x] 5.2 Run `uv run --project scripts scripts/axaml-to-json.py` and `uv run --project scripts scripts/axaml-to-json.py --check`. Run `uv sync --project scripts` first only if the scripts environment is absent. +- [x] 5.3 Run affected Core, Avalonia, isolated Headless, and Wasm test projects sequentially with `dotnet test --no-restore`. Use `docs/code-map/testing.md` to select the exact projects. Restore only if dependency assets change. Build the desktop host if its project files change. +- [x] 5.4 Run the browser regression commands documented in `docs/testing/wasm-browser-e2e-plan.md`, including the relevant Chromium and WebKit expression/modal cases. Assert geometry and actual hit targets in default, wide, 320px-wide, 390×844, and 844×390 layouts. +- [x] 5.5 Capture Avalonia and Web default, wide, and narrow screenshots under `artifacts/`. Check long scripts/names/errors, enlarged text, Chinese and English, light/dark themes, and mobile-keyboard accessibility. Record evidence paths and any real-device limitation. +- [x] 5.6 Update affected code-map ownership, entry points, and test references. Update the selected design document with the implemented outcome and verification evidence without changing unrelated guidance. +- [x] 5.7 Review every scenario in `specs/expression-preview-interaction/spec.md` against the implementation and test evidence. Run `openspec validate unify-expression-preview-interaction --strict` and record remaining blockers before declaring implementation complete. + +## 6. Improve review clarity after visual feedback + +- [x] 6.1 Explain expression input and units. Give editing and review clear headings in both hosts. +- [x] 6.2 Put time comparisons before expandable property details. Keep all persisted effects visible in the summary. +- [x] 6.3 Reduce editor and filter space. Verify that the first comparison is visible at default and narrow portrait sizes. +- [x] 6.4 Reflow desktop comparisons at narrow widths. Preserve keyboard controls and persistent actions. +- [x] 6.5 Generate shared locales. Run affected UI checks and capture updated screenshots. +- [x] 6.6 Record visual findings and verification in the design. + +## 7. Compact the review surface + +- [x] 7.1 Remove step headings, instructional paragraphs, footer guidance, and repeated labels. Reduce spacing and shorten localized summaries in both hosts. +- [x] 7.2 Verify rendered editing, comparisons, details, and footer actions. Refresh English and Chinese screenshots at default, wide, and narrow sizes. +- [x] 7.3 Record the compact layout and check results in the design. diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/.openspec.yaml b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/.openspec.yaml new file mode 100644 index 00000000..81264324 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/design.md b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/design.md new file mode 100644 index 00000000..ec43f021 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/design.md @@ -0,0 +1,170 @@ +## Context + +`SessionHistoryTree` retains an immutable root and reversible `DomainChangeSet` values. `SessionHistoryNodeSnapshot` exposes parent, children, preferred child, and a description. It does not expose node details, operation parameters, or commit time. + +`HistoryEntryViewModel.Create` produces a depth-first list. `HistoryToolView` renders each item as a navigation button. Its template does not use the recorded depth for layout. `HistoryToolViewModel` delegates navigation through `IHistorySessionPort`. + +`ChapterContentPreview.Differences` contains formatted strings. It does not cover all segment properties or stable track matching. The new history inspector must use typed history data rather than these preview strings. + +The desktop history tool is modal. The embedded Avalonia host also uses the shared view. The browser has a separate `HistoryDialog.razor`; its presentation is outside this change. + +## Goals / Non-Goals + +**Goals:** + +- Make the retained branch topology visible. +- Let users inspect every node without moving the document cursor. +- Explain exact changes from a node's parent, including structural and property-only edits. +- Make restoration explicit and keep alternate futures reachable. +- Preserve responsive layout, keyboard access, localization, session lifetime, and delta-based storage. + +**Non-Goals:** + +- History export or history persistence across sessions. +- History import, branch deletion, automatic pruning, or saved history checkpoints. +- Comparison between arbitrary nodes. +- Rebuilding the browser history dialog or unrelated preview surfaces. +- A free-form graph editor, node dragging, pan/zoom, or a new graph package. + +## Decisions + +### 1. Separate inspection state from the document cursor + +`HistoryToolViewModel` must own `SelectedNodeId`, stable hierarchical node models, expansion state, detail request state, and the narrow-layout page. Core must continue to own the document cursor and preferred redo edges. + +On first open, select the current node and expand its ancestor path. Reopening starts from the current node; it does not persist the previous dialog's selection. Clicking or using arrow keys only changes inspection selection. Double-click and Enter must not restore content. Enter moves focus to the inspector. An explicit restore button invokes the existing session navigation path. + +After a successful restore, undo, or redo, select the resulting current node, expand its ancestors, and bring it into view. Preserve other expansion choices in the open dialog. On failure, keep the current node and inspection selection. Display the failure beside the actions. + +The restore button is disabled for the current node, an invalid selection, an ended session, or an active navigation command. A detail-query failure must not disable an otherwise valid restore target. Duplicate restore submissions must be blocked while navigation is running. + +Alternative considered: retain immediate navigation on node click and add a separate details icon. This makes ordinary browsing change the document and creates small repeated hit targets. Inspection-first rows make the behavior predictable. + +### 2. Render the retained topology with the native TreeView + +Use Avalonia `TreeView` controls in wide and narrow layouts. Each stable node view model exposes its actual child nodes through a bound children collection. The control supplies the file-tree expansion, indentation, selection, and keyboard behavior. Do not draw a separate lane rail or flatten parent and child nodes into a visual list. + +Build the child model iteratively from retained child IDs. Preserve node view model instances by ID across refreshes so selection and expansion remain stable. Expand the current node's ancestor path when the dialog opens and after navigation. Inspection selection and current position remain separate. A selection event with no item during TreeView materialization must not clear the retained selection or inspector. + +Keep native collapse behavior. Collapsing an ancestor retains the inspected descendant and its details. Expanding the ancestor makes that row visible again. Locate Current expands and reveals the document cursor without restoring content. + +Each row shows the localized operation title and a distinct Current marker. The TreeView selection state marks the inspected node. Show a preferred redo label in the explicit redo choices. Use a tooltip for a truncated title and expose current, selection, and expansion state through accessibility properties. + +### 3. Keep the inspector compact and data-led + +Show the selected node title, one local timestamp, nonzero effect counts, meaningful captured parameters, and actual before/after changes. Hide empty descriptions, status text, parameter headings, change filters, and columns. Do not show explanatory comparison paragraphs. Do not repeat a field's new value above its before/after row. + +For chapter edits, identify the object by its historical track and chapter position. This context must not repeat a changed chapter name. Show the Delta column only when a numeric time or duration change has a real delta. Show the initial document's source and ordered read-only chapter values on the root node. + +Render field details in a virtualized list. Keep selectable text for copy operations. The details page keeps Restore in a fixed footer. + +### 4. Add a read-only Core detail query + +Add a typed query on `SessionState` for one retained node. A result must identify the captured root/session, node, parent, operation metadata, summary, and typed changes. Distinguish available, not found, ended/canceled, and recoverable resource failure results. + +Capture one immutable history version. Resolve both sides from that version. Querying must not enter a mutation transaction, change the cursor, update preferred children, invalidate a preview, or execute an operation. The query must link caller cancellation with `LifetimeToken`. + +The authoritative difference is the stored edge change set. Project it into public read-only DTOs without exposing the internal mutation types. Resolve missing context, such as unchanged track names, chapter positions, or segment frame rates, from private parent-state reconstruction when needed. Use the existing iterative change-set reconstruction approach. Do not publish reconstructed states. Do not retain a complete document snapshot at every node. + +Keep tree summaries lightweight and compute them from committed changes. Load field-level details only for the selected node. The dialog retains at most the selected detail result; cancel and release replaced results. It must not populate details for every retained node. + +For the root, return the captured initial document overview and ordered read-only chapter values. It has no parent-relative changes. A single-chapter edit must retain delta-sized history storage even when a user inspects it. + +Alternative considered: navigate to the parent and target, then compare the live document. This mutates session state during inspection. Reusing preview string differences would miss typed and structural information. + +### 5. Match changes by stable identity and preserve value meaning + +Use document, track, chapter, and segment identities for matching. Preserve retained track order and before/after chapter positions for display. Names are labels, never matching keys. + +Typed changes must cover all editable document properties; track name, membership, and order; chapter number, name, start/end ticks, frames, accuracy, kind, membership, and order; and segment membership, order, placement, duration, rates, and source/media metadata. Metadata-only changes must produce visible detail rows even when no chapter changes. + +Count distinct added, removed, and modified chapters. A retained chapter with several field changes or an order change counts once as modified. Added or removed chapters must not also count as modified. Track replacement may show complete added/removed track groups. When a stable chapter moves between tracks, identify it as a move rather than unrelated deletion and addition. Generated identities from merge/split operations must be shown honestly; do not invent identity correspondence. + +Present unchanged values only as contextual labels. Distinguish object absence, null/unknown values, and empty text. Show exact signed time deltas. A rounded visible value must expose precise time in expanded detail so submillisecond edits remain visible. Resolve frame context from the corresponding history state and captured operation parameters. Do not use the current main-window FPS selector to reinterpret a past change. If the rate is unknown, label it unknown. + +Group changes by track and chapter. Show a compact count line only for nonzero effects. Show parameters only when they provide information beyond the field rows. Provide All, Added, Removed, and Modified filters when changes exist. Keep the filter available when its current choice has no results. Changes in document and track properties belong to Modified. Use virtualization or incremental realization for large groups. Wide mode uses Field / Before / After columns. Narrow mode stacks labeled before/after values. Text must support selection and copying. + +### 6. Capture operation metadata with successful publication + +Introduce a small immutable operation descriptor with a stable kind, optional typed parameter payload, and diagnostic fallback description. Core must assign commit time when publishing the history node. Root load time is captured when the session is created. + +Keep existing description-based callers source-compatible with an optional descriptor. Missing metadata uses a localized generic edit title and exposes a meaningful original description as secondary detail. Do not parse descriptions such as `Edit Name` to classify nodes. Hide missing time and parameters. Never fabricate metadata. + +Avalonia commands that publish content changes must supply descriptors for cell edits, insert/delete, expression application, template/naming/numbering operations, frame shifts, frame-rate changes, merge/split, and append. Propagate them through `ClipEditingCoordinator`, `ChapterContentOperationSession`, and the relevant workspace entry points. Capture script text, scope, and effective parameters with the reviewed candidate. Apply must retain those captured values even if the live draft changes. Capture display FPS where it affects the operation. A display-only frame update must retain its existing history-free behavior. + +Publish descriptor, time, changes, and node in the same atomic state swap. Failed, canceled, stale, and no-change requests must not create metadata-only nodes. An idempotent retry must reuse the original node and time. Undo/redo must preserve metadata and must not rerun scripts. + +Operation descriptors contain immutable data only. They must not retain views, command delegates, service objects, or live draft references. Do not add a general dictionary-based audit framework. The metadata remains owned by the in-memory session. + +Alternative considered: store localized operation titles. This would freeze the language used at commit time. Stable operation kinds let the UI change language without rewriting history. + +### 7. Define dialog geometry and action behavior + +Use a target default size of 1000 by 700 DIPs and a minimum size of 520 by 420 DIPs, constrained to the available host area. At an available content width of at least 800 DIPs, show tree and inspector with a draggable splitter. Start at 40/60 proportions and protect minimum pane widths of 280 and 360 DIPs. Below that width, show History and Details pages. Selection stays visible on returning to History. Switching layout must preserve selection, expansion, and scroll anchors. + +The header contains Undo, Redo with alternate-choice affordance, and Locate Current. The session-lifetime notice is secondary text. The footer contains status, Restore to This Node, and Close. In narrow mode, show Restore on the Details page. Footer actions must remain reachable independently of scroll position. + +In narrow mode, selecting a row keeps the History page open. Enter or the Details tab opens the selected inspector. The History tab returns to the selected row and its scroll anchor. + +The wide layout follows this wireframe. The tree uses standard file-tree expanders. The Current marker and selected-row highlight have separate meanings. + +```text ++--------------------------------------------------------------------+ +| Edit history | +| Undo | Redo v | Locate current Session-only history | ++-------------------------+------------------------------------------+ +| History tree | Selected operation: Shift | +| v Initial document | 1 chapter modified | +| v Rename | | +| v Expression | Object / Field / Before / After | +| Rename [current] | Chapter 3 / Start / 00:10 / 00:11 | +| Shift [selected] | | +| | | +| | | +| | Shift: +24 frames; effective FPS: 24 | ++-------------------------+------------------------------------------+ +| Status Restore to this node | Close | ++--------------------------------------------------------------------+ +``` + +Labels such as `修改章节名称`, `修改前`, `修改后`, `恢复到此节点`, and `当前状态` must come from the shared locale resources. The wireframe illustrates composition only; its example values do not define a required fixture. + +Redo runs from the current document node, not the inspection selection. If the current node has multiple direct children, offer those choices with localized summaries and mark the preferred child. Selecting a redo choice is an explicit navigation action. Viewing another node must not change the preferred redo child. + +The native TreeView owns arrow-key selection and expansion. Enter focuses details. Tab reaches header, tree, inspector, restore, and close controls. Escape preserves the existing dialog-close behavior. Focus returns to the history button after close. + +Inspection leaves pending content previews intact. Before an explicit restore, undo, or redo, show an inline notice that navigation discards the pending preview. Use the existing command invalidation path when the user executes that action. A failure may still leave that preview discarded; document and cursor must remain unchanged. Do not add a confirmation modal to routine reversible navigation. + +Alternative considered: stack both panes at all widths. This reduces the tree viewport and makes navigation through long history difficult. + +### 8. Keep session and asynchronous ownership explicit + +Extend `IHistorySessionPort` with typed history/detail access while preserving command ownership in the main-window session. `HistoryToolViewModel` owns a cancelable detail request and a selection generation. Accept a result only when root identity, selected node, request generation, and view lifetime still match. + +Query loading and errors appear only in the inspector. A stale completion must not replace newer details. Session end clears selection, rows, details, and actions. Closing the dialog cancels detail requests and releases subscriptions and detail payloads. Restore/navigation execution state must follow the existing observed command boundary. + +Both native and embedded Avalonia hosts must use the same view and port behavior. Shared Core API changes must preserve Wasm callers. No browser UI tasks or new browser persistence behavior are included. + +## Risks / Trade-offs + +- [Tree refresh can lose selection or expansion] -> Reuse canonical node models and verify native control workflows with deterministic branch fixtures. +- [Old operation descriptions have no structured context] -> Preserve exact diffs and label missing metadata explicitly. Add descriptors at actual command entry points. +- [Structural changes need context beyond the stored changed fields] -> Reconstruct a private parent state on demand and release it after projection. +- [Large histories and root inspections can allocate substantial UI data] -> Build hierarchy iteratively, keep collapsed descendants unrealized, virtualize detail rows, cancel obsolete queries, and measure deep-history and large-batch cases. +- [Shared Core changes can affect browser commands] -> Keep optional metadata additive and run existing Wasm workspace and parity tests. +- [Localized text and enlarged fonts can crowd actions] -> Verify responsive geometry, accessible names, dark theme, and enlarged-font layouts through Headless workflows. + +## Migration Plan + +1. Add Core DTOs, read-only queries, and optional metadata while preserving existing calls. +2. Route Avalonia operation descriptors and history detail access through the existing session ports. +3. Replace the history row button interaction with tree selection and explicit restoration. +4. Add locales, generate Wasm locale projections, and verify shared-host behavior. +5. Run affected tests sequentially. Capture review images under `artifacts/edit-history-tree-details/` at default, wide, and narrow sizes. +6. Update the code map when implementation owners and primary tests are established. + +This change needs no stored-data migration. Rollback restores the previous dialog and removes the new optional query/metadata paths. It does not require a history file conversion. + +## Open Questions + +No product-scope decision blocks implementation. Geometry values are initial implementation targets. Adjust them only when rendered evidence requires it, while preserving the specified responsive behavior. diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/proposal.md b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/proposal.md new file mode 100644 index 00000000..ce0d8b75 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/proposal.md @@ -0,0 +1,36 @@ +## Why + +The edit-history dialog renders a retained tree as a flat sequence of buttons. Users cannot see branch relationships or inspect exact changes, and clicking a node immediately restores document content. + +## What Changes + +- Render the Avalonia history dialog with the native file-tree component and a separate concise detail inspector. +- Distinguish the current document node from the node selected for inspection. Selecting a node must only inspect it. +- Show each node's changes relative to its parent. Include chapter values, additions, deletions, order, track changes, segment changes, and document properties. +- Show the root's initial document overview and read-only chapter values. +- Provide an explicit restore action, preferred redo indication, and alternate redo choices. +- Record structured operation identity, available operation parameters, and commit time with successful history publication. Localize the presentation without parsing English operation descriptions. +- Keep the dialog usable at wide and narrow sizes. Preserve keyboard access, theme integration, modal isolation, and current-session lifetime. +- Exclude history export, history persistence across sessions, arbitrary node-to-node comparisons, branch deletion, and browser history UI redesign. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `session-edit-history`: Add read-only, parent-relative node details and atomic operation metadata. Preserve retained branches, exact restoration, and delta-based storage. +- `avalonia-ui-shell`: Separate history inspection from restoration. Add native TreeView presentation, meaningful node details, responsive layout, and accessible navigation to the existing modal history dialog. + +## Impact + +- Core history ownership: `src/ChapterTool.Core/Session/SessionEditHistory.cs` and `SessionState.cs`. +- Operation metadata propagation: `ChapterContentOperationSession.cs`, `ChapterWorkspace.cs`, `src/ChapterTool.Avalonia.UI/Workflows/ClipEditingCoordinator.cs`, and the main-window editing and expression command paths. +- Shared Avalonia presentation: `HistoryEntryViewModel.cs`, `ViewModels/Tools/HistoryToolViewModel.cs`, and `Views/Tools/HistoryToolView.axaml` with its code-behind. +- Host boundaries: `PlatformPorts/SessionPorts/WorkspaceToolSession.cs` and the desktop history descriptor in `src/ChapterTool.Avalonia/Services/StandardToolCatalogFactory.cs`. The embedded Avalonia host must use the same inspection behavior. +- Localization: shared AXAML locale resources and generated Wasm locale projections. +- Verification: Core history tests, Avalonia ViewModel tests, Avalonia Headless workflows, and existing Wasm session tests for shared Core compatibility. +- Documentation: update `docs/code-map/avalonia.md` and `docs/code-map/testing.md` during implementation when the new ownership and tests exist. +- No new package, storage format, history export API, or persistence service is required. diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..1e5a654e --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,203 @@ +## MODIFIED Requirements + +### Requirement: History opens in a button-invoked dialog + +The Avalonia history button MUST open a separate modal dialog for the current document session. The main window MUST NOT reserve permanent space for a history list or sidebar. The dialog MUST use the current session's history and commands. Opening, closing, inspecting, or resizing the dialog MUST NOT create history transactions. Selecting a history node MUST inspect it. Restoration MUST require an explicit history action. + +#### Scenario: Load without opening history +- **WHEN** a user loads a chapter document without opening history +- **THEN** the main window does not show a history list or reserved history sidebar +- **AND** the chapter workspace uses its full allocated central width +- **AND** the history button has a localized accessible name + +#### Scenario: Open and dismiss history +- **WHEN** a user opens history +- **THEN** one modal history dialog owned by the main window opens +- **AND** it shows the current node, inspection selection, undo, redo, branch choices, and session-lifetime notice +- **AND** background clicks, shortcuts, and drag/drop cannot modify the document through the dialog +- **WHEN** the user closes the dialog through Close, window close, or Escape +- **THEN** focus returns to the history button +- **AND** closing does not roll back completed history navigation +- **AND** the main-window chapter region does not change its allocated size + +#### Scenario: Inspect a node in the dialog +- **WHEN** the user selects, double-clicks, or presses Enter on a retained node +- **THEN** the dialog displays its details without restoring document content +- **AND** Enter moves focus to the detail region +- **AND** document revision, current history node, preferred redo choices, and pending content preview remain unchanged + +#### Scenario: Navigate history in the dialog +- **WHEN** the user explicitly invokes Restore to This Node, Undo, Redo, or an alternate redo choice +- **THEN** the action uses the current session's existing history command behavior +- **AND** successful navigation refreshes the main-window chapter data and the dialog's current marker, inspection selection, action availability, and descriptions +- **AND** reopening the dialog shows the same current history state +- **AND** navigation does not add a history node or discard retained branches + +#### Scenario: Reopen history and release its resources +- **WHEN** the user repeatedly opens and closes history +- **THEN** at most one dialog exists for that session at a time +- **AND** each closed dialog releases its content, subscriptions, and detail requests +- **AND** reopening selects the current node +- **AND** the history button is disabled without an available document session + +#### Scenario: Inspect a long branching history +- **WHEN** history has many entries, several branches, and long operation titles +- **THEN** users can reach retained nodes through native tree scrolling, expansion, and keyboard navigation +- **AND** current position and branch relationships do not depend only on color or spaces +- **AND** undo, redo, restore, and close actions remain reachable + +## ADDED Requirements + +### Requirement: History presents connected branch topology + +The dialog SHALL render the retained hierarchy in native Avalonia `TreeView` controls. Each node SHALL expose its actual children to the tree control. The tree SHALL provide file-tree expansion, indentation, selection, and keyboard behavior. The dialog SHALL NOT render a separate connector rail or flattened visual lane projection. Current position SHALL have a marker distinct from selected-row highlighting. Preferred redo SHALL be identified in the explicit redo choices. + +#### Scenario: Editing after undo creates a visible fork +- **WHEN** a new edit is committed after undo while an older child path remains retained +- **THEN** history shows both child paths connected to their actual common parent +- **AND** each path remains reachable for inspection and explicit restoration + +#### Scenario: Selection differs from the current node +- **WHEN** a user inspects an earlier or alternate node +- **THEN** the selected row has a selection indicator +- **AND** the current node retains a separate Current marker +- **AND** viewing the node does not change the preferred redo edge + +#### Scenario: Locate a hidden current node +- **WHEN** the current node lies inside a collapsed fork and the user invokes Locate Current +- **THEN** the dialog expands its ancestor path and brings the current row into view +- **AND** locating the current node selects it for inspection without restoring content + +#### Scenario: Scroll and expand a large tree +- **WHEN** the user scrolls, collapses, or expands branches in a large history +- **THEN** the TreeView retains the correct parent and child relationships +- **AND** collapsed descendants are not realized as visible controls +- **AND** Core history nodes are not removed + +#### Scenario: Collapse the inspected descendant +- **WHEN** collapsing a fork hides the selected inspection node +- **THEN** the native tree retains its inspection selection and details +- **AND** expanding the fork makes the inspected row visible again +- **AND** the document cursor remains unchanged + +### Requirement: Node details explain the selected committed change + +The inspector SHALL show the selected node title, its available timestamp, nonzero effect counts, meaningful captured parameters, and typed before/after field values. It SHALL hide empty text, headings, and inapplicable columns. It SHALL show change filters only when the node has changes. It SHALL keep that filter reachable when its current choice has no matches. It SHALL NOT show explanatory comparison paragraphs or repeat a changed value above its field row. Chapter changes SHALL use historical track and chapter-position context. The initial node SHALL show the source and ordered read-only chapter values. Operation titles and field labels SHALL follow the active locale. + +#### Scenario: Inspector has no applicable optional content +- **WHEN** a node has no meaningful parameters or time deltas +- **THEN** the inspector omits the parameter section and Delta column +- **AND** empty descriptions and status text do not reserve blank lines + +#### Scenario: A filter has no matches +- **WHEN** the selected change filter has no matching fields +- **THEN** the inspector hides the empty field table and keeps the filter available +- **AND** the user can return to All changes + +#### Scenario: Inspect a name edit +- **WHEN** a node changes one chapter name +- **THEN** the inspector identifies the historical track and chapter position and shows the exact old and new name once +- **AND** the summary reports one modified chapter + +#### Scenario: Inspect a batch or property-only edit +- **WHEN** a node changes multiple chapters or only document, track, or segment properties +- **THEN** all actual changes can be reached through virtualized, scrollable details +- **AND** All, Added, Removed, and Modified filters preserve the full unfiltered summary counts +- **AND** property-only changes remain visible with zero changed chapters + +#### Scenario: Inspect the initial node +- **WHEN** the root node is selected +- **THEN** the inspector labels it Initial State and shows its initial source, track overview, and ordered read-only chapter values +- **AND** it does not display a fabricated before/after edit + +#### Scenario: Read precise and long values +- **WHEN** a changed time is below ordinary precision or text is too long for its initial cell +- **THEN** expanded detail exposes the exact time and signed delta or full text +- **AND** displayed text can be selected and copied +- **AND** unknown values, empty text, and absent objects have distinct localized labels + +#### Scenario: Switch locale with existing history +- **WHEN** the locale changes between supported English, Chinese, and Japanese resources +- **THEN** titles, summaries, field labels, parameters, status, and accessible names update without new transactions +- **AND** operation kind is not inferred from an English description +- **AND** missing structured metadata uses a localized generic title with the original description available as secondary detail + +### Requirement: History restoration actions are explicit and observable + +Restore SHALL target the inspection selection. Undo and Redo SHALL target the current document node. The dialog SHALL identify the preferred redo child and expose alternate direct-child choices. Navigation SHALL expose execution and failure state. Inspection SHALL preserve pending previews; explicit navigation SHALL use the existing preview-discard behavior. + +#### Scenario: Restore the selected node +- **WHEN** a user invokes Restore to This Node on a valid non-current selection +- **THEN** the selected committed document is restored without rerunning its operation +- **AND** duplicate navigation submissions are blocked during execution +- **AND** the resulting current node becomes selected and visible + +#### Scenario: Selection is already current +- **WHEN** the selected node is the current document node +- **THEN** Restore is disabled and the inspector indicates Current State + +#### Scenario: Choose an alternate redo path +- **WHEN** the current node has multiple direct children +- **THEN** Redo exposes those children with localized summaries and a preferred-child indicator +- **AND** selecting a choice explicitly restores that child rather than the inspection selection + +#### Scenario: Navigate with a pending preview +- **WHEN** a pending content preview exists and the user inspects history +- **THEN** inspection preserves that preview +- **AND** navigation actions show a notice that executing them discards the preview +- **WHEN** the user executes a history navigation action +- **THEN** the command discards the preview through the existing invalidation path + +#### Scenario: Restoration fails +- **WHEN** restoration fails before publication +- **THEN** the current document and cursor remain unchanged +- **AND** the selected inspection node remains selected +- **AND** the dialog displays a localized failure and remains usable + +### Requirement: History inspection handles asynchronous and session lifetimes + +The inspector SHALL expose loading, available, and error states. Results SHALL be accepted only for the current selection, session root, request generation, and open view lifetime. Closing or ending the session SHALL cancel detail work and release owned references. + +#### Scenario: A slow query completes after a newer selection +- **WHEN** a user selects node A and then node B before A's query completes +- **THEN** only B's result can populate the current inspector +- **AND** A's obsolete work is canceled or its result is discarded + +#### Scenario: Detail query fails for a valid target +- **WHEN** inspection fails for a retained non-current node +- **THEN** the inspector shows an error and a retry action +- **AND** an otherwise valid Restore action remains available + +#### Scenario: The session is replaced or ended +- **WHEN** the active session ends or is replaced while the dialog has detail work +- **THEN** old rows, selection, details, and actions are cleared or the dialog is closed by its host +- **AND** no old result is displayed in a new session + +### Requirement: History layout and input remain responsive and accessible + +Wide layouts SHALL show a resizable tree pane beside an inspector. Narrow layouts SHALL provide History and Details pages with preserved selection, expansion, and scroll anchors. Footers SHALL remain reachable independently of content scrolling. Native and embedded Avalonia hosts SHALL provide the same inspection and explicit-restoration semantics. + +#### Scenario: Resize between wide and narrow layouts +- **WHEN** the dialog is resized between its wide and narrow layouts +- **THEN** no tree, details, or footer control overlaps or becomes unreachable +- **AND** the selected node, expansion choices, and scroll anchors remain available +- **AND** long titles and values can still be read through tooltips or expanded detail + +#### Scenario: Complete the workflow with a keyboard +- **WHEN** a user navigates without a pointer +- **THEN** native tree arrow keys select rows or expand branches, and Enter enters details +- **AND** Tab reaches the history actions, inspector, restore, and close controls +- **AND** Escape closes the dialog and returns focus to the history button +- **AND** selection alone does not restore content + +#### Scenario: Inspect through narrow pages +- **WHEN** a user selects a row on the narrow History page +- **THEN** the History page remains open while the selected detail is prepared +- **WHEN** the user invokes Enter or the Details tab +- **THEN** the selected inspector becomes visible +- **AND** returning through the History tab preserves the selected row and scroll anchor + +#### Scenario: Theme and text size change +- **WHEN** history is rendered in light or dark theme with long localized strings or enlarged UI text +- **THEN** native tree expanders, current/selected indicators, values, and enabled actions remain readable +- **AND** accessible names communicate current, selected, and expanded states without color-only cues diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/session-edit-history/spec.md b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/session-edit-history/spec.md new file mode 100644 index 00000000..fd78bc63 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/specs/session-edit-history/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: Node inspection is a read-only parent-relative query + +The session SHALL provide typed details for every retained history node. A non-root node's details SHALL describe the change from its own parent to that node. Inspection SHALL NOT move the cursor, alter preferred children, change mutation revision, create history nodes, or rerun an operation. One result SHALL use one captured immutable history version. + +#### Scenario: Inspect an alternate branch +- **WHEN** the current cursor is on one branch and a query inspects a node on another branch +- **THEN** the query returns that node's changes relative to its parent +- **AND** the current document, cursor, preferred children, revision, and retained nodes remain unchanged + +#### Scenario: Inspect while navigation occurs +- **WHEN** history navigation occurs during an inspection query +- **THEN** the query returns a consistent captured result or an explicit cancellation result +- **AND** it does not mix values from different history versions + +#### Scenario: Query cannot complete +- **WHEN** the requested node is missing, the session has ended, the caller cancels, or recoverable resource exhaustion prevents inspection +- **THEN** the query reports a typed unavailable, canceled, or resource-failure result +- **AND** it does not publish an intermediate document or mutate history + +### Requirement: History details preserve typed and structural differences + +Node details SHALL match objects by stable identity and expose changed values with before/after presence. Details SHALL cover editable chapter fields, chapter membership and order, track membership and order, track names, segment membership and order, segment placement and source/media properties, and editable document properties. Absence, null/unknown values, and empty text SHALL remain distinguishable. + +#### Scenario: One chapter has several changed fields +- **WHEN** one commit changes a chapter's name, start time, and frame information +- **THEN** details expose all changed fields and their exact before/after values +- **AND** the summary counts one modified chapter + +#### Scenario: Chapters are added and deleted +- **WHEN** a commit adds one chapter and deletes another +- **THEN** details identify the added and deleted objects and their available values +- **AND** added and deleted chapters are not also counted as modified + +#### Scenario: Order or track membership changes +- **WHEN** retained chapter identities change order or move between retained tracks +- **THEN** details expose their before/after positions and track membership +- **AND** a moved stable identity is not represented as unrelated deletion and addition + +#### Scenario: Structural operations create new identities +- **WHEN** merge, split, or append creates or removes track, chapter, or segment identities +- **THEN** details expose the actual retained identities and structural changes +- **AND** the query does not invent correspondence between different identities + +#### Scenario: No chapter field changes +- **WHEN** a commit changes only document frame rate, a track name, or segment placement or source metadata +- **THEN** details expose those property changes +- **AND** the summary does not claim that chapter fields changed + +#### Scenario: Empty and absent values differ +- **WHEN** a field changes between empty text, unknown/null, and a present value, or its object is added or removed +- **THEN** the typed result preserves each state without collapsing them into the same string + +### Requirement: History details retain historical time and frame meaning + +Details SHALL preserve exact time values and historical frame-rate context. Signed time differences SHALL use the node's before/after values. Current host display preferences SHALL NOT change historical stored values or operation parameters. Missing historical rate information SHALL remain explicitly unknown. + +#### Scenario: Main-window FPS changes after an edit +- **WHEN** the user changes the display FPS and then inspects an earlier frame edit +- **THEN** details retain that edit's stored frame values and available historical rate context +- **AND** the current display FPS is not substituted as the edit's effective rate + +#### Scenario: A submillisecond value changes +- **WHEN** an edit changes time by less than the ordinary display precision +- **THEN** the detail result retains the exact before/after values and signed delta + +### Requirement: Root inspection exposes the initial document + +Root details SHALL expose the initial document overview and ordered read-only chapter values. The root SHALL be identified as the initial state and SHALL NOT have a fabricated parent or change set. + +#### Scenario: Inspect the load node +- **WHEN** a user queries the history root after later edits +- **THEN** the result describes the initial document's source, tracks, chapters, and available properties +- **AND** the initial values remain separate from current document values +- **AND** no parent-relative edit is reported + +### Requirement: Operation metadata is immutable and atomically committed + +History nodes SHALL support a stable operation kind, optional typed captured parameters, and publication time. Available metadata SHALL be committed atomically with the node and its changes. Descriptions SHALL remain diagnostic fallback data. Callers that provide only an operation description SHALL remain supported without description parsing or invented metadata. + +#### Scenario: A reviewed expression is applied +- **WHEN** a prepared expression candidate is committed after the live draft changes +- **THEN** the history node retains the script, scope, and effective parameters captured for that candidate +- **AND** it does not retain the later draft values + +#### Scenario: A transaction does not commit +- **WHEN** a request is invalid, stale, canceled, fails publication, or makes no canonical change +- **THEN** no node or standalone operation metadata is added + +#### Scenario: A transaction is retried or restored +- **WHEN** an idempotent transaction is retried or its node is visited through undo or redo +- **THEN** the original operation metadata and publication time remain unchanged +- **AND** no operation is rerun to rebuild metadata + +#### Scenario: A caller has no structured metadata +- **WHEN** a supported caller commits using an operation description only +- **THEN** the node still supports exact change inspection and normal restoration +- **AND** missing parameters or time remain explicitly unavailable + +### Requirement: Inspection preserves delta-based session ownership + +History SHALL retain delta-based node storage. Inspection SHALL NOT attach a complete reconstructed document to every node or prune retained history. Detail work SHALL support cancellation and remain owned by the current in-memory session. History export and cross-session history persistence SHALL NOT be introduced by this capability change. + +#### Scenario: Inspect a deep history +- **WHEN** a session has a long linear path and retained alternate branches +- **THEN** traversal and private reconstruction do not require recursive call depth proportional to history length +- **AND** all retained nodes remain reachable +- **AND** inspection does not add permanent full-document copies to those nodes + +#### Scenario: The session ends during inspection +- **WHEN** the document session ends while a detail query runs +- **THEN** its lifetime token cancels outstanding detail work +- **AND** host-owned references to old detail results can be released diff --git a/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/tasks.md b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/tasks.md new file mode 100644 index 00000000..f054cc66 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-improve-edit-history-tree-details/tasks.md @@ -0,0 +1,53 @@ +## 1. Core node inspection + +- [x] 1.1 Add typed public node-detail, summary, field-value presence, and query-outcome models. Keep internal change-set mutation types private. +- [x] 1.2 Add a cancelable `SessionState` node query over one captured immutable history version. Handle missing nodes, ended sessions, cancellation, and recoverable resource failure without publication. +- [x] 1.3 Project chapter value, addition, deletion, membership, and order changes by stable identity. Count each modified chapter once. +- [x] 1.4 Project document, track, and segment property and structural changes. Resolve required historical labels, positions, and frame context through private iterative reconstruction. +- [x] 1.5 Provide root overview and ordered read-only chapter details. Keep lightweight tree summaries separate from selected-node field details. +- [x] 1.6 Extend `SessionEditHistoryTests.cs` and add `SessionHistoryInspectionTests.cs` under `tests/ChapterTool.Core.Tests/Session/` for alternate-branch inspection, exact typed values, moves, property-only edits, root inspection, cancellation, and failure outcomes. Assert unchanged cursor, revision, preferred edges, and document through public APIs. +- [x] 1.7 Extend existing deep-history measurements to include inspection and temporary allocation. Verify retained branches and delta-sized storage without automatic pruning. + +## 2. Atomic operation metadata + +- [x] 2.1 Add an immutable operation kind and typed optional parameter descriptor. Preserve existing description-based Core callers. +- [x] 2.2 Capture root load time and node publication time. Publish metadata atomically with successful changes. Verify no-change, stale, failed, canceled, and retried transactions through Core tests. +- [x] 2.3 Carry captured descriptors through `ChapterContentPreview` and `ChapterContentOperationSession`. Preserve candidate script, target scope, and effective parameters when live drafts change. +- [x] 2.4 Supply descriptors from Avalonia cell edits, insert/delete, expression, template/naming/numbering, frame-shift, and frame-rate operations. Capture effective display FPS where it affects results. +- [x] 2.5 Supply descriptors for workspace merge/split, append, and other commands that publish content changes. Preserve display-only frame updates without history nodes. Preserve Wasm callers and existing transaction behavior. +- [x] 2.6 Add command-level tests in `tests/ChapterTool.Avalonia.Tests/ViewModels/MainWindowViewModelTests.cs` for metadata capture, localized operation identity, and exact undo/redo without operation re-execution. + +## 3. History session port and inspection state + +- [x] 3.1 Extend `IHistorySessionPort` and its main-window adapter with typed tree/detail access, pending-preview state, and observed navigation outcomes. +- [x] 3.2 Add current/selected state separation, stable hierarchical children, expansion state, and Locate Current to the history ViewModels. +- [x] 3.3 Add cancelable detail loading, retry, request-generation checks, root identity checks, and disposal. Ignore obsolete completions after selection changes or session end. +- [x] 3.4 Add explicit restore and alternate direct-child redo actions. Disable duplicate submissions, preserve selection on failure, and select/reveal the current node after success. +- [x] 3.5 Add focused non-Headless history ViewModel tests for selection without navigation, expansion and collapse, tree topology, current-path markers, stale queries, retry, restore availability, and session teardown. + +## 4. Native tree and detail UI + +- [x] 4.1 Replace navigation buttons with native Avalonia `TreeView` controls backed by stable hierarchical node models. +- [x] 4.2 Add localized operation titles, distinct current and selected indicators, preferred redo choices, native expansion, and full-title tooltips. +- [x] 4.3 Add a concise inspector with nonzero effect counts, meaningful parameters, typed before/after values, exact time deltas, presence labels, selectable text, and useful change filters. Hide empty headings and explanatory prose. +- [x] 4.4 Add root chapter inspection and incremental or virtualized detail realization for large batches. Add loading, query-error, and retry states. +- [x] 4.5 Add explicit Restore, navigation status, and the pending-preview discard notice. Preserve inspection-only behavior for single-click, double-click, and Enter. +- [x] 4.6 Add a resizable wide split layout and narrow History/Details pages. Preserve selection, expansion, and scroll anchors across resize and page changes. +- [x] 4.7 Update the desktop history size constraints and verify the shared view in the embedded Avalonia host. Keep footers reachable within available host bounds. +- [x] 4.8 Implement tree keyboard navigation, inspector focus entry, close focus restoration, and accessible branch/current/selected/expanded names. + +## 5. Localization and workflow verification + +- [x] 5.1 Add English, Chinese, and Japanese history titles, summaries, field labels, parameter labels, presence states, and status messages to the shared AXAML resources. Use generic localized fallback titles without parsing descriptions. +- [x] 5.2 Run `uv sync --project scripts` if the environment is absent. Run `uv run --project scripts scripts/axaml-to-json.py`, then the same command with `--check`. Do not edit generated Wasm JSON by hand. +- [x] 5.3 Add Headless workflows for inspect versus restore, alternate redo, pending-preview preservation and discard, failure feedback, root details, filtering, long values, and large-tree realization. +- [x] 5.4 Extend `AvaloniaWindowServiceHeadlessTests` and embedded-host coverage for modal isolation, repeated open/close, detail cancellation, content detachment, and focus return. Keep UI tests in the Headless process and collection. +- [x] 5.5 Verify default 1000x700, wide 1280x800, and narrow 520x600 history windows. Verify light/dark themes, enlarged text, and all three locales with workflow and viewport-bound assertions. +- [x] 5.6 Capture and inspect review screenshots under `artifacts/edit-history-tree-details/` for the verified layouts and branch/detail states. Fix clipping, tree interaction, and inspector visibility errors. Do not treat image creation alone as a test assertion. + +## 6. Final checks and documentation + +- [x] 6.1 Run `dotnet test tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj --no-restore`, then the Avalonia, Wasm, and Avalonia Headless test projects sequentially with `--no-restore`. Restore once only if dependency or generated asset changes require it. +- [x] 6.2 Run `dotnet build src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj --no-restore` if its project file changes. Run broader solution checks only when changed consumers or unresolved failures require them. +- [x] 6.3 Update `docs/code-map/avalonia.md` and `docs/code-map/testing.md` with actual query, projection, lifecycle, and primary test ownership. Review the changed documentation for direct active sentences. +- [x] 6.4 Run `openspec validate improve-edit-history-tree-details --strict`. Report primary checks and screenshot paths. Before any code push, run `python scripts/check-ci.py` with the repository prerequisites. diff --git a/openspec/changes/compact-repeating-frame-decimals/.openspec.yaml b/openspec/changes/compact-repeating-frame-decimals/.openspec.yaml new file mode 100644 index 00000000..53f62d70 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/compact-repeating-frame-decimals/design.md b/openspec/changes/compact-repeating-frame-decimals/design.md new file mode 100644 index 00000000..2f5059e6 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/design.md @@ -0,0 +1,140 @@ +## Context + +The selected design concerns decimal frame presentation. An arrow compares a committed value with a candidate value. It does not represent rounding. + +Current formatting has several owners: + +- `FrameRateService` produces plain numeric frame text. +- `ChapterEditingOptions` and application settings define integer rounding, fixed decimal places, and full precision. +- `ChapterRowViewModel` retains the visible original frame baseline during preview. +- `ExpressionPreviewProjection` projects typed comparison values. +- Avalonia settings apply runtime-safe preferences before Save. +- Web settings activate saved drafts after persistence succeeds. + +`FrameRateOption` currently stores a decimal rate and a stable code. Its decimal value alone does not prove an exact rational rate. These boundaries must remain explicit. + +## Goals / Non-Goals + +**Goals:** + +- Match the selected overline display in committed and before/after frame cells. +- Put the preference in Settings → General, beside existing frame display preferences. +- Use one presentation policy for both sides. +- Preserve numeric editing, full-value inspection, semantic colors, and document behavior. +- Support the same presentation semantics in Avalonia and Web. + +**Non-Goals:** + +- Add a display selector, recurring-decimal toggle, or preview checkbox to the main workflow. +- Change rounding defaults, fixed decimal-place defaults, expression semantics, accuracy tolerance, history, or export. +- Add recurring-decimal notation to editable values, serialized chapter content, CLI output, or exported files. +- Redesign the existing settings lifecycle or expression review workflow. + +## Decisions + +### 1. Use one Settings-only boolean + +Add `ShowRepeatingFrameDecimals`, serialized as `application.showRepeatingFrameDecimals`. Default it to `true`. Use the localized Chinese label “显示小数循环节”. Describe it as “未取整时,用上划线简化循环小数;关闭后按原小数格式显示。” + +Keep the control visible but disabled while integer rounding is active. Retain its value when rounding changes. Place it beside the existing frame display and decimal-place preferences. Do not add a new settings tab. + +Enable the checkbox only in the “do not truncate” mode. The fixed-place mode already defines an explicit truncation policy, and integer mode has no decimal cycle. Retain the checkbox value when switching modes. Apply the host's existing settings lifecycle. Avalonia live-applies the preference, saves it explicitly, and restores its saved value on discard. Web treats it as a draft and activates it after a successful Save. Neither host creates a chapter transaction for this preference. + +Alternative: a selector above the chapter grid. Rejected because the user explicitly requested the preference in Settings. + +### 2. Give compact recurring decimals a defined precedence + +Use three frame display modes on every supported frame display surface: integer rounding, a configured number of decimal places, and no truncation. Use this order inside the selected mode: + +1. Integer rounding active: retain the existing integer text and accuracy styling. +2. Rounding inactive, preference enabled, exact rational provenance available, and repetend at most six digits: display the complete non-repeating prefix and one overlined repetend. +3. Otherwise: use the selected ordinary numeric policy. Fixed-place mode uses its configured limit. No-truncation mode uses the available full numeric precision when compact cycle notation is disabled or unavailable. + +The fixed decimal-place setting remains stored. It controls fixed-place mode. It does not truncate the prefix or repetend of an exact compact value in no-truncation mode. Settings helper text must explain this precedence. + +For compact presentation, an exact integer has no decimal point or zero tail. A finite decimal has no overline and uses the ordinary numeric policy. A zero value remains different from absent frame information. + +Alternative: truncate the cycle to the selected decimal-place count. Rejected because an overline would then assert a different number. + +### 2a. Keep rounding, fixed places, and full precision as explicit modes + +Persist the frame display mode as `round`, `decimal-places`, or `full-precision`. Map the main-window rounding control to the same mode state. Turning rounding off from `round` selects full precision to preserve the existing main-window behavior; turning it off while another mode is active preserves that mode. Enable the repeating-decimal checkbox only in `full-precision`, where it controls compact cycle notation. + +### 3. Format from authoritative typed inputs + +Use a shared host-neutral formatter that returns sign, integer digits, non-repeating digits, repetend digits, plain numeric text, and accessible description. Keep localized labels in host resources. + +For a timestamp-derived frame value with an authoritative rational rate `p/q`, use `StartTicks * p / (TimeSpan.TicksPerSecond * q)`. Reduce the fraction with integer arithmetic. Use `BigInteger` where multiplication can exceed the current integer range. + +Record exact rates through their authoritative option code or numerator/denominator metadata. For example, `Fps23976` identifies `24000/1001`. Do not infer that fraction from a rounded decimal, a localized display label, or a tolerance match from `FindByValue`. + +Use long division and repeated remainders to find the shortest period. Preserve the non-repeating prefix. Keep the sign separate, including negative values between minus one and zero. + +Use at most 512 long-division steps per distinct input. If the period is longer than six digits or the bound is reached, return ordinary numeric presentation. Cache results by immutable value, rate basis, and display policy where virtualization causes repeated requests. Do not cache by chapter name or row position. + +When a stored frame value has no reliable timestamp/rate provenance, use its original numeric presentation. Do not invent a cycle from a repeated substring in a finite decimal string. + +Alternative: scan `FramesInfo` for repeated text. Rejected because rounding tails and fixed-precision decimals do not prove an infinite repetition. + +### 3a. Keep authoritative frame calculations rational end to end + +When a timestamp has an authoritative rate `p/q`, calculate its frame value from `StartTicks * p / (TimeSpan.TicksPerSecond * q)`. Keep the numerator and denominator exact through display rounding, fixed-place formatting, and frame-accuracy comparison. Use `BigInteger` for products and scaled tolerance comparisons that can exceed fixed-width integers. + +Expression preview must use each typed before and candidate timestamp and its own rational rate. It must not convert ticks to decimal seconds before multiplying by a decimal FPS value. An explicitly selected supported rate may supply its rational metadata when the typed snapshot has no rate. An approximate or unknown rate keeps the current numeric path and cannot establish a recurring cycle. + +This decision improves arithmetic precision without changing expression meaning, tolerance values, rounding mode, candidate equality, or exported content. Decimal text remains a presentation result. It is not an input to candidate comparison when typed timestamps and rates are available. + +### 4. Preserve each side's value and rate basis + +Project the committed baseline and candidate from their own typed values and frame-rate basis. Apply the same formatter options to both. Never recompute the baseline using candidate timestamps or candidate frame metadata. + +Keep existing frame-change and candidate-equality decisions independent of display strings. A presentation toggle must not rerun an expression or invalidate an otherwise current candidate. + +At `24000/1001 fps`, the selected `t + 0.0002` example must render: + +- `00:00:52.094`: `1249.(006993)` → `1249.0(117882)`. +- `00:03:38.301`: `5233.(990009)` → `5233.9(948051)`. +- `00:06:48.408`: `9792` → `9792.0(047952)`. + +Parentheses identify the overlined digits in this document only. Product cells render a continuous overline and no parentheses. Neither side is converted to an integer merely because the other side has an integer value. + +Alternative: reuse the already-rounded candidate frame string. Rejected because it can reproduce the incorrect mixed integer/decimal comparison from the first screenshot. + +### 5. Use structured numeric layout + +Use the existing semantic monospace font. Reserve independent integer, decimal-point, and fractional areas for each side. Right-align integer digits and left-align fractional digits. Reserve blank fractional space for exact integers. + +Give the two comparison areas equal width and keep the arrow in its own narrow area. Size shared fractional space from the visible values and font metrics. Do not insert spaces into numeric text to simulate alignment. + +With no frame change, center one formatted value in the existing frame cell. With a frame change, retain the existing before → after composition. Keep the short header “帧”. On narrow surfaces, use the existing two-line comparison or table scrolling. + +Draw one thin continuous line only above repetend glyphs. Reserve enough top space to avoid clipping. Do not use combining overline characters as the underlying text. Preserve the existing neutral color for unrounded values and the existing per-side semantic styling for rounded values. + +Alternative: superscripts or an ellipsis alone. Rejected because they reduce readability or fail to identify the exact repeated block. + +### 6. Keep plain values available + +Tooltips or existing accessible details must expose the plain numeric expansion, its displayed precision, and the exact fraction when available. Accessible text must identify the side, prefix, and repetend without depending on the line or color. + +Entering a frame-cell edit must use the existing plain numeric editor value. Leaving an edit unchanged must preserve the original value exactly. Ordinary copy must copy plain numeric text, without overline glyphs, parentheses, or ellipses. + +Keep display parts out of domain fields, candidate comparisons, history snapshots, and serializers. Do not change QPFile or other export input to use compact text. + +## Risks / Trade-offs + +- [Finite decimal rates resemble rational standards] → Require authoritative rate provenance; otherwise retain ordinary text. +- [A six-digit display limit omits longer cycles] → Use the existing numeric fallback and expose details. Never overline a truncated cycle. +- [Existing decimal-place preferences conflict with an exact cycle] → Define the precedence in Settings helper text and tests. +- [Font metrics clip the overline] → Verify font enlargement, both themes, and default, wide, and narrow surfaces. +- [Formatting changes hide a small real difference] → Preserve typed difference detection and full-value details. +- [Presentation changes leak into editing or export] → Keep a separate display model and verify unchanged edit round trips and export equality. + +## Migration Plan + +Add an optional boolean to the existing version-one application settings shape. Missing values load as enabled. Loading alone must not rewrite desktop or browser storage. Explicit `false` must survive normalization and Save. + +Deploy the formatter and both host renderers together. Keep all original plain text available for fallback. Disabling the preference restores ordinary numeric presentation. A previous version can ignore the additive field without changing chapter content. + +## Open Questions + +None. Default enabled, Settings-only placement, six-digit repetend limit, existing numeric fallback, and host-specific settings lifecycles are the planning decisions for this change. diff --git a/openspec/changes/compact-repeating-frame-decimals/proposal.md b/openspec/changes/compact-repeating-frame-decimals/proposal.md new file mode 100644 index 00000000..d567ca57 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/proposal.md @@ -0,0 +1,35 @@ +## Why + +Frame values derived from timestamps can lose precision when code converts tick counts to decimal seconds and multiplies by a decimal frame rate. The resulting long expansions also make chapter rows and before/after comparisons difficult to scan. The user requested exact rational calculation where rate provenance is available, compact mathematical presentation, and a Settings preference. + +## What Changes + +- Add a Settings-only checkbox named “显示小数循环节”. Enable it by default. +- Calculate frame values from timestamp ticks and authoritative rational frame rates through the calculation and comparison path. Keep approximate rate inputs on the ordinary numeric path. +- Render an exact recurring frame value as an integer part, a non-repeating prefix, and one overlined repetend. +- Apply the same presentation policy to committed, before, and candidate frame values. +- Keep one value for an unchanged frame cell. Keep the existing before → after comparison for a changed cell. +- Align decimal points independently on each side. Keep the arrow at a stable position. +- Provide integer rounding, fixed decimal places, and no-truncation modes in Settings. Keep the main rounding control synchronized with the selected mode. Turning rounding off from integer mode selects no truncation. +- Preserve plain numeric editing, full-value inspection, semantic colors, calculation, candidate equality, history, and export. +- Store the preference in the existing application settings section. Do not add a display selector, toggle, or new preview control to the main window or chapter grid. + +## Capabilities + +### New Capabilities + +- `compact-frame-decimal-display`: Exact recurring-decimal presentation, Settings-only control, consistent before/after formatting, aligned layout, and plain numeric editing across Avalonia and Web. + +### Modified Capabilities + +- `versioned-settings-document`: Persist an optional repeating-decimal preference with a default for existing settings documents. + +## Impact + +- Shared presentation logic uses typed chapter timestamps and authoritative frame-rate metadata. +- Application settings contracts, Avalonia settings bindings, and Web settings drafts carry the new preference. +- Avalonia chapter-cell templates and Web chapter rendering consume structured number parts. +- Fixed decimal places and no-truncation remain explicit frame display modes. The repeating-cycle setting applies only to no-truncation mode. +- Shared locale resources provide the setting label and description. Generated Web locale JSON follows the existing generation workflow. +- Existing Core, Contracts, Infrastructure, Avalonia, Headless, and Web verification paths cover the affected behavior. +- No new package, settings schema version, CLI option, document field, or export format is required. diff --git a/openspec/changes/compact-repeating-frame-decimals/specs/compact-frame-decimal-display/spec.md b/openspec/changes/compact-repeating-frame-decimals/specs/compact-frame-decimal-display/spec.md new file mode 100644 index 00000000..3ca71f47 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/specs/compact-frame-decimal-display/spec.md @@ -0,0 +1,180 @@ +## ADDED Requirements + +### Requirement: Authoritative frame calculations preserve tick and rational precision + +When a typed timestamp and authoritative rational frame rate are available, frame values MUST be calculated from timestamp ticks and the rate numerator and denominator. Calculation, rounding, fixed-place display, and accuracy comparison MUST NOT first convert ticks to decimal seconds or replace the rational rate with a decimal approximation. The calculation MUST use bounded integer arithmetic that can represent intermediate products without overflow. An approximate or unknown rate MUST retain the existing numeric calculation path and MUST NOT claim exact rational provenance. + +#### Scenario: Calculate an exact fractional frame value +- **WHEN** a timestamp is 52.094 seconds and its authoritative frame rate is 24000/1001 +- **THEN** the frame value is derived from ticks multiplied by 24000 over ticks-per-second multiplied by 1001 +- **AND** rounding and decimal presentation use that same exact rational value + +#### Scenario: Compare values using their own rational rates +- **WHEN** an expression preview has before and candidate snapshots with different rational frame rates +- **THEN** each frame value is calculated from its own timestamp and rate +- **AND** neither side is recalculated with the other side's rate + +#### Scenario: Retain approximate rate behavior +- **WHEN** only an approximate decimal frame rate is available +- **THEN** the system uses the existing ordinary numeric calculation and presentation +- **AND** it does not infer a rational rate or recurring decimal + +### Requirement: Auto frame rate retains one effective rate across the workflow + +When Auto is selected, Avalonia and Web MUST keep the Auto selection visible and retain the detected frame-rate option as the effective rate for calculations. They MUST preserve its authoritative rational rate through frame presentation, editing, expression preview, frame-rate conversion, and frame shifts. Automatic detection MUST run in integer, fixed-place, and full-precision display modes. + +#### Scenario: Display a detected rational rate with full precision +- **WHEN** Auto detects 24000/1001 from chapter timestamps while full precision is selected +- **THEN** the selected option remains Auto +- **AND** frame values use the detected numerator and denominator +- **AND** a provable recurring value can use its compact repeating-decimal presentation + +#### Scenario: Shift frames while Auto remains selected +- **WHEN** the user previews or applies a frame shift in Auto mode +- **THEN** the offset uses the detected effective frame rate and its rational basis +- **AND** the resulting timestamp uses integer tick arithmetic without converting the rate to a rounded decimal + +### Requirement: Repeating decimal presentation is controlled only in Settings + +Avalonia and Web MUST expose three frame display modes: integer rounding, a configured number of decimal places, and no truncation. They MUST expose one localized “显示小数循环节” checkbox in the existing General frame-display settings group. The preference MUST default to enabled. The main workflow, chapter header, chapter rows, and expression review MUST NOT contain a repeating-decimal selector or toggle. The checkbox MUST be enabled only in no-truncation mode and MUST retain its value when the mode changes. The main-window rounding control and Settings mode MUST stay synchronized. + +#### Scenario: Open Settings and inspect the chapter grid +- **WHEN** the user opens Settings with no-truncation mode selected +- **THEN** the frame-display group exposes the repeating-decimal checkbox +- **AND** its helper text explains compact recurring decimals and the ordinary numeric fallback +- **AND** the chapter grid has no additional display control + +#### Scenario: Keep the preference when the display mode changes +- **WHEN** the user selects integer rounding or fixed decimal places +- **THEN** the checkbox becomes unavailable without changing its stored value +- **WHEN** the user selects no truncation +- **THEN** the checkbox becomes available with its previous value +- **AND** the main-window rounding control reflects the selected mode + +### Requirement: Settings retain the existing host lifecycle + +The new preference MUST follow the existing host settings lifecycle. Avalonia MUST live-apply, explicitly save, and restore the loaded or saved value on discard. Web MUST keep edits in its draft until Save succeeds. Reset MUST restore the enabled default through that host's existing reset behavior. Changing the preference MUST NOT change chapter content, create history, or rerun an expression. + +#### Scenario: Live-apply and discard in Avalonia +- **WHEN** the user changes the preference in Avalonia Settings +- **THEN** committed and visible candidate frame presentation refreshes without writing settings +- **WHEN** the user discards that unsaved settings change +- **THEN** presentation returns to the saved preference +- **AND** chapter content, selection, candidate identity, and history remain unchanged + +#### Scenario: Save a Web draft +- **WHEN** the user changes the preference in Web Settings and successfully saves +- **THEN** the saved preference becomes active on the chapter grid +- **AND** Cancel or a persistence failure before a successful Save leaves the preceding active preference intact + +### Requirement: Exact repeating decimals use a continuous overline + +In no-truncation mode with the preference enabled, the system MUST display a provable rational frame value with a repetend of at most six digits as sign, integer digits, decimal point, complete non-repeating prefix, and one overlined repetend. The overline MUST cover only the shortest repeating block. The presentation MUST preserve leading zeros in prefixes and repetends. An exact integer MUST omit its zero fractional tail. A finite decimal MUST use ordinary numeric formatting without an overline. + +#### Scenario: Render a purely recurring value +- **WHEN** the authoritative timestamp is 52.094 seconds and the frame rate is 24000/1001 +- **THEN** the cell displays integer digits 1249 and fractional digits 006993 +- **AND** one continuous overline covers all six fractional digits +- **AND** the underlying value remains unchanged + +#### Scenario: Render a non-repeating prefix +- **WHEN** the timestamp becomes 52.0942 seconds at 24000/1001 fps +- **THEN** the cell displays integer digits 1249, prefix 0, and repetend 117882 +- **AND** the overline covers 117882 and does not cover the prefix 0 + +#### Scenario: Remove an exact zero tail +- **WHEN** the exact frame value is 9792 +- **THEN** compact presentation displays 9792 without a decimal point +- **AND** it reserves fractional layout space without adding digits + +#### Scenario: Preserve a negative fractional value +- **WHEN** a supported frame value has a negative sign and an absolute value below one +- **THEN** its sign remains visible before the integer zero +- **AND** the overline applies only to its repetend + +### Requirement: Cycle detection uses authoritative provenance and bounded work + +The formatter MUST use typed frame values or timestamps with their authoritative rational rate basis. It MUST NOT infer an infinite cycle from a repeated substring, rounded decimal rate, localized rate label, or tolerant rate match. It MUST use repeated remainders to identify a cycle. It MUST stop after at most 512 long-division steps. A longer-than-six-digit cycle, an exhausted budget, or unavailable exact provenance MUST use the existing ordinary numeric display policy without an overline. + +#### Scenario: A numeric string contains apparent repetition +- **WHEN** a frame value is available only as finite numeric text with repeated digits and a rounding tail +- **THEN** the formatter preserves the ordinary numeric presentation +- **AND** it does not assert an exact recurring value + +#### Scenario: A cycle exceeds the display limit +- **WHEN** a provable frame value has a repetend longer than six digits +- **THEN** the cell uses ordinary numeric formatting and existing precision preferences +- **AND** it does not overline a truncated block +- **AND** the exact fraction remains accessible in existing value details + +### Requirement: Before and after values share the same presentation policy + +Committed values, visible original values, and candidate values MUST use one presentation policy. Both comparison sides MUST use the existing integer mode when rounding is active. Both sides MUST use the same selected decimal policy when rounding is inactive. Each side MUST retain its own value, frame-rate basis, and semantic accuracy state. Display formatting MUST NOT determine candidate equality, hide a typed change, or manufacture an unchanged-cell arrow. + +#### Scenario: Compare the selected fractional shift +- **WHEN** a candidate applies t + 0.0002 to 52.094 seconds at 24000/1001 fps in no-truncation mode with the preference enabled +- **THEN** the original shows 1249 with overlined fractional block 006993 +- **AND** the candidate shows 1249, prefix 0, and overlined block 117882 +- **AND** an arrow separates the values without converting either side to an integer + +#### Scenario: Compare an integer original with a fractional candidate +- **WHEN** the candidate changes the exact frame value 9792 to 9792 plus 24/5005 +- **THEN** the original shows 9792 +- **AND** the candidate shows 9792, prefix 0, and overlined block 047952 +- **AND** both sides remain in the same unrounded presentation mode + +#### Scenario: Disable recurring presentation +- **WHEN** the user disables the preference while rounding is inactive +- **THEN** both sides use the existing ordinary numeric format and decimal-place preference +- **AND** neither side keeps an overline +- **AND** candidate identity, readiness, and content remain unchanged + +#### Scenario: The frame value did not change +- **WHEN** there is no candidate or the candidate has no frame-value change +- **THEN** the frame cell displays one value +- **AND** it does not add an arrow or duplicate the value + +#### Scenario: A candidate changes the frame-rate basis +- **WHEN** the candidate uses a different frame-rate basis from the original +- **THEN** each side is formatted from its own typed value and rate basis +- **AND** existing rate details remain available +- **AND** the original is not recalculated using the candidate's rate + +### Requirement: Decimal frame cells use stable accessible alignment + +Frame cells MUST use the semantic monospace font. Integer digits MUST align to the right of their reserved area. Fractional digits MUST align to the left of their reserved area. Each comparison side MUST align decimal points vertically across rows. Before and after areas MUST have equal width with a stable arrow area between them. Exact integers MUST retain empty fractional space. Existing narrow layouts MUST preserve both values through labeled accessibility content, two-line comparison, or table scrolling. + +#### Scenario: Scan varying integer lengths +- **WHEN** rows contain 0, 1249, 9792, and 13215 with different fractional content +- **THEN** decimal-point positions align within each side +- **AND** arrow positions remain stable +- **AND** the overline does not change row height or clip the glyphs + +#### Scenario: Resize or enlarge the font +- **WHEN** the user uses supported default, wide, and narrow widths or enlarges the monospace font +- **THEN** values, overlines, and arrows remain readable without overlap +- **AND** the existing frame header and semantic colors remain intact + +### Requirement: Presentation preserves inspection editing copy and export + +Value details MUST expose the plain numeric expansion with its displayed precision and the exact fraction when available. Accessible descriptions MUST distinguish original and candidate values and identify the non-repeating prefix and cycle. Frame editing and ordinary copy MUST use plain numeric text. Display notation MUST NOT enter domain fields, candidate snapshots, history, or serializers. + +#### Scenario: Inspect and copy a compact value +- **WHEN** the user inspects or copies a compact recurring frame value +- **THEN** details expose the numeric expansion and exact fraction +- **AND** ordinary copy contains numeric text without overline glyphs, parentheses, or ellipses + +#### Scenario: Enter and leave an unchanged edit +- **WHEN** the user edits a compact frame cell and accepts its unchanged numeric editor value +- **THEN** the exact underlying value remains unchanged +- **AND** no transaction is created merely to replace display notation + +#### Scenario: Export or undo after a display change +- **WHEN** the user changes the preference and exports, applies an existing candidate, or uses Undo or Redo +- **THEN** the resulting content and history match the same actions before the presentation change +- **AND** exported frame numbers do not contain display notation + +#### Scenario: Distinguish missing frames from zero +- **WHEN** one comparison side has no calculated frame information and the other has zero frames +- **THEN** the missing side retains its localized missing-value label +- **AND** the zero side displays 0 diff --git a/openspec/changes/compact-repeating-frame-decimals/specs/versioned-settings-document/spec.md b/openspec/changes/compact-repeating-frame-decimals/specs/versioned-settings-document/spec.md new file mode 100644 index 00000000..369f2222 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/specs/versioned-settings-document/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Repeating frame display is an additive application preference + +The system MUST persist the optional boolean application.showRepeatingFrameDecimals in the existing version-one settings document. The value MUST default to true when absent. An explicit false MUST survive normalization and saving. Loading this additive preference MUST NOT rewrite an otherwise valid current-version document. Saving it MUST preserve all other application, theme, font, and shortcut values. + +#### Scenario: Load existing desktop settings +- **WHEN** a valid version-one settings.json omits application.showRepeatingFrameDecimals +- **THEN** the normalized preference is true +- **AND** loading alone leaves the file unchanged + +#### Scenario: Restore an explicitly disabled preference +- **WHEN** application.showRepeatingFrameDecimals is false +- **THEN** normalization and reopening retain false +- **AND** ordinary decimal presentation remains active when rounding is inactive + +#### Scenario: Save without changing unrelated preferences +- **WHEN** the user saves a changed repeating-frame preference +- **THEN** the aggregate uses the existing save operation +- **AND** schemaVersion remains one +- **AND** other settings content is preserved + +#### Scenario: Load and save browser settings +- **WHEN** the existing browser settings document lacks the optional preference +- **THEN** the preference defaults to true without rewriting storage on load +- **WHEN** the user saves a false draft value +- **THEN** the existing storage key retains false for the next browser session +- **AND** unrelated settings remain unchanged + +### Requirement: Frame display mode persists full precision + +The application settings MUST accept and persist `frameDisplayMode` values `round`, `decimal-places`, and `full-precision`. Unknown values MUST continue to normalize to `round`. The setting MUST retain the existing schema version and preserve the selected decimal-place count for later mode changes. + +#### Scenario: Save and reload no-truncation mode +- **WHEN** the user selects no truncation and saves Settings +- **THEN** the normalized settings store `full-precision` +- **AND** reloading Settings selects no truncation with the saved decimal-place count unchanged diff --git a/openspec/changes/compact-repeating-frame-decimals/tasks.md b/openspec/changes/compact-repeating-frame-decimals/tasks.md new file mode 100644 index 00000000..ac10ab18 --- /dev/null +++ b/openspec/changes/compact-repeating-frame-decimals/tasks.md @@ -0,0 +1,81 @@ +## 1. Settings contract and persistence + +- [x] 1.1 Add the default-enabled ShowRepeatingFrameDecimals preference to application settings and the equivalent Web settings model. Preserve explicit false through normalization. +- [x] 1.2 Add settings tests for missing fields, false round trips, no rewrite on load, version-one compatibility, and preservation of unrelated aggregate sections. + +## 2. Shared exact-number presentation + +- [x] 2.1 Expose authoritative rational metadata for supported rate options and typed presentation inputs. Keep unknown or approximate rate provenance on the ordinary numeric path. +- [x] 2.2 Add the shared structured frame formatter with integer arithmetic, shortest-cycle detection, non-repeating prefixes, separate signs, a six-digit cycle limit, and a 512-step work limit. +- [x] 2.3 Implement rounding-first precedence and the existing decimal-place fallback. Keep display parts separate from frame text stored in domain and history models. +- [x] 2.4 Add formatter coverage for 1249.(006993), 1249.0(117882), 5233.9(948051), 9792, and 9792.0(047952). Include finite decimals, negative fractions, leading zeros, unknown provenance, and long cycles. +- [x] 2.5 Calculate authoritative frame values, rounding, fixed-place formatting, accuracy, and expression comparisons from timestamp ticks and rational rates. Keep approximate rates on the existing numeric path. +- [x] 2.6 Add exact arithmetic tests for tick/rate products, rounding boundaries, tolerance boundaries, and before/candidate values with independent rates. + +## 3. Settings-only controls and localization + +- [x] 3.1 Add the Avalonia General settings checkbox beside existing frame preferences. Live-apply it, preserve it during rounding changes, disable its control in integer mode, and restore it on discard. +- [x] 3.2 Add the equivalent Web settings draft control. Activate it only after successful Save. Preserve current reset, Cancel, error, and close behavior. +- [x] 3.3 Add Chinese, English, and Japanese labels and helper text to the shared locale resources. Generate Web JSON with uv run --project scripts scripts/localization/axaml-to-json.py and verify with --check. +- [x] 3.4 Verify save, reload, reset, discard, integer-mode availability, and absence of recurring-decimal controls on the main workflow in settings ViewModel and Web tests. + +## 4. Avalonia frame rendering + +- [x] 4.1 Feed committed, original, and candidate presentation from their own typed snapshots and rate basis. Use one formatter policy and retain the original baseline. +- [x] 4.2 Render one continuous overline over repetend glyphs. Align decimal points within equal before/after areas and keep the arrow area stable. Preserve existing semantic font, colors, and narrow layout. +- [x] 4.3 Retain the single-value cell for unchanged frames. Refresh presentation without changing candidate identity, document content, history, row identity, selection, or scroll. +- [x] 4.4 Preserve plain numeric editing, unchanged editor round trips, ordinary numeric copy, full-value details, missing-versus-zero states, and accessible side descriptions. + +## 5. Web frame rendering + +- [x] 5.1 Connect chapter cells and frame-valued review surfaces to the shared formatter and saved preference. Preserve each side's rate basis. +- [x] 5.2 Render overlines and stable decimal alignment with the existing responsive table or narrow comparison layout. +- [x] 5.3 Preserve plain numeric editing and copy, accessible details, single-value unchanged cells, and candidate readiness when presentation refreshes. + +## 6. Behavior and layout verification + +- [x] 6.1 Add Avalonia ViewModel and Headless tests for t + 0.0002 at 24000/1001, shared before/after modes, unchanged cells, zero/missing states, and preferences changed during a current preview. +- [x] 6.2 Add Web workspace and browser coverage for the same fixture, settings Save and Cancel, numeric editing, and responsive comparisons. +- [x] 6.3 Verify that presentation changes leave candidate commit, Undo, Redo, and export content unchanged, including QPFile numeric output. +- [x] 6.4 Run affected Core, Infrastructure, Avalonia, Headless, and Web tests in the existing project-specific verification paths. Run .NET test projects sequentially and use --no-restore when assets are present. +- [x] 6.5 Capture and review default, wide, and narrow layouts in both themes and with enlarged monospace text. Store screenshots under artifacts/compact-repeating-frame-decimals/ and pair them with behavior assertions. +- [x] 6.6 Update relevant code-map ownership and test references if the formatter or runtime wiring introduces new primary entry points. Run openspec validate compact-repeating-frame-decimals --strict. + +## 7. Explicit full-precision display mode + +- [x] 7.1 Add and persist the `full-precision` frame display mode alongside rounding and fixed decimal places. +- [x] 7.2 Synchronize the Settings mode, main rounding control, and frame formatting policy in Avalonia and Web. +- [x] 7.3 Enable the repeating-cycle setting only in full-precision mode, retain its value across mode changes, and localize the new option. +- [x] 7.4 Add Core, settings, and browser/Headless coverage for all three modes, including full-precision cycle rendering and ordinary fallback. + +## 8. Auto frame-rate consistency + +- [x] 8.1 Keep the selected Auto option separate from the detected effective option in Avalonia and Web. +- [x] 8.2 Use the effective rational rate for frame display, editing, expression preview, and frame-shift operations while Auto remains selected. Detect Auto in all frame display modes. +- [x] 8.3 Add Core, Avalonia, and Web regression coverage for repeating decimals and exact frame shifts in Auto mode. + +## 14. Preserve exact imported rate provenance + +- [x] 14.1 Preserve authoritative rational frame rates from MPLS import through editable document and selected-track projections without promoting approximate rates. +- [x] 14.2 Reproduce and fix startup-settings-then-file-load behavior for full-precision cycle display. Cover MPLS import and document adapter round trips. + +## 9. Preview comparison presentation regressions + +- [x] 9.1 Center frame values across their cells and provide an unclipped arrow area in wide and narrow comparisons. +- [x] 9.2 Preserve the current expression candidate when toggling the main frame-rounding presentation option. + +## 10. Live settings presentation refresh + +- [x] 10.1 Refresh existing frame rows when Settings changes the display mode, including decimal places to full precision while integer rounding remains off. + +## 11. Full-precision frame editor round trips + +- [x] 11.1 Preserve the chapter ticks and full-precision frame text when an unchanged decimal frame value is committed with exact rational or decimal frame rates. + +## 12. Narrow frame preview layout + +- [x] 12.1 Keep the before and candidate frame values on two rows and align the arrow with the candidate value. Cover the layout with an Avalonia Headless assertion. + +## 13. Settings-driven frame presentation startup + +- [x] 13.1 Initialize the non-persistent main-window rounding control from the saved frame display mode. Keep main-window toggles session-only and ensure rounding suppresses cycle notation. diff --git a/openspec/changes/exact-frame-rate-domain/.openspec.yaml b/openspec/changes/exact-frame-rate-domain/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/exact-frame-rate-domain/api-inventory.md b/openspec/changes/exact-frame-rate-domain/api-inventory.md new file mode 100644 index 00000000..c515e8cc --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/api-inventory.md @@ -0,0 +1,24 @@ +# Frame-rate API inventory + +The `remove-v23-compatibility-surfaces` change defines the current 23.x API. +This table records the resulting contracts. A public modifier alone does not +establish a compatibility obligation. + +| Boundary | Evidence | Current contract | +| --- | --- | --- | +| `ChapterSet` | Core README and importer/exporter consumers | `FrameRateValue` is the only rate field and constructor input. Numeric projections and compatibility deconstruction are removed. | +| `ChapterEditingService` | Core editing services and exact Node operations | Frame operations accept `FrameRateValue`. Numeric FPS overloads are removed. | +| `ChapterFpsTransformService.ChangeFps` | Core transform service and candidate builder | Source and target rates use `FrameRateValue`. | +| `FrameRateService` | Desktop, WASM, and Node exact package | `FindByRate` accepts `FrameRateValue`. Options and frame results carry canonical rates. Numeric lookup and projection aliases are removed. | +| `IChapterExpressionEngine` | Core README and expression-engine consumers | The documented `Evaluate` extension remains. Lua numeric values are expression scalars and do not establish model-rate authority. | +| `ChapterExpressionService` and expression authoring | Core expression workflow | Internal operations use `FrameRateValue`. Expression-language FPS inputs retain their language contract. | +| `ChapterExportService`, `IChapterExporter`, and `ChapterSerializationOptions` | Core README and serialization consumers | Serialization is pure. Options contain format settings and an optional canonical computation rate. `ChapterExportOptions` and transform-bearing export methods are removed. | +| `ChapterOutputProjectionService` | CLI content conversion and projection tests | Content transformations run before serialization through `ChapterOutputProjectionOptions`. | +| `ChapterConversionService` | Core conversion service and exact Node package | Frame conversions accept `FrameRateValue`. Optional v2 timecode mapping remains supported. | +| `ChapterSegmentService` | Core combine/append feature and exact Node package | Mixed rates must produce a conflict when scalar output cannot represent them. | +| Node `ChapterTool` | Package source, README, and tests | The exact typed API is the package entry point. Tick and rate data use lossless strings. Numeric chapter/rate DTOs and `.exact`, `.project`, and `.export` compatibility methods are removed. Format, import, language, and encoding catalogs remain. | +| CLI rate options | CLI convert command and CLI README | `--frame-rate` remains approximate numeric user input. `--frame-rate-ratio` supplies an exact rational. | + +Internal documents, segments, candidates, history, export fingerprints, host +state, frame presentation, and selectors use canonical rates. Internal JSExport +bindings can change with the package wrappers. diff --git a/openspec/changes/exact-frame-rate-domain/design.md b/openspec/changes/exact-frame-rate-domain/design.md new file mode 100644 index 00000000..6635d849 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/design.md @@ -0,0 +1,212 @@ +## Context + +**Current contract:** This design records the original migration plan. The follow-up +`remove-v23-compatibility-surfaces` change supersedes its selected numeric API and +transform-export compatibility decisions. Its API inventory is authoritative for 23.x. + +`ChapterSet` stores `double FramesPerSecond` and optional `ExactFrameRate`. Editable documents and segments also have parallel rate fields. `EditableChapterDocumentAdapter.FromLegacyFrameRate` scales a double to an integer ratio. `WithFallbackFrameRate` can turn a detected rate into document metadata. + +`ExactFrameMath` already calculates with integer ratios for rational and decimal inputs. Some exporters and compatibility services still calculate from `TimeSpan.TotalSeconds`. Node DTOs expose FPS and times as JavaScript numbers. Existing Node consumers change those returned fields directly. `packages/chaptertool/src/types.ts` contains handwritten contracts; the package build emits declarations from those sources. + +The user confirmed gradual compatibility only for manual numeric input and established external APIs during the original plan. The follow-up removes the selected Core/Node numeric facades in 23.x. Canonical internal rates, exact CLI ratio input, and explicit computation-rate selection remain current. + +## Goals / Non-Goals + +**Goals:** + +- Store one canonical rate value per current or source rate. +- Preserve ticks and rate provenance through every supported typed workflow. +- Keep established external signatures, result fields, and Node object mutation workflows usable. +- Remove internal numeric rate signatures and parallel compatibility state. +- Make rate selection, suggestions, mixed-rate handling, failures, and rounding reviewable. +- Keep source metadata unchanged after current-content transformations. +- Give C#, CLI, and Node callers an explicit exact input path. + +**Non-Goals:** + +- Change tick storage or introduce persistent document history. +- Add arbitrary-precision Lua arithmetic, SMPTE/drop-frame notation, or a general VFR rate model. +- Extract new FPS metadata from ffprobe or change the supported catalog. +- Change established timestamp text formats, CUE's 75-unit time base, or MPLS PTS-to-time import quantization. +- Add Node session/history APIs or change the existing JSON chapter export file schema. +- The original implementation retained documented adapters. The follow-up `remove-v23-compatibility-surfaces` change removes them within the current 23.x line, as directed by the maintainer. + +## Decisions + +### 1. Canonical value, validity, and equality + +Add immutable `FrameRateValue`. Its states are `Rational(ChapterFrameRate)`, `Approximate(decimal)`, and `Unknown`. The default value must be Unknown. A rational must have positive signed-64-bit components and must reduce by GCD. An approximate value must be positive and representable as a decimal. + +`default(ChapterFrameRate)` is currently possible despite constructor checks. All public factories, converters, serializers, and operation boundaries must reject its zero components. Invalid input must not become Unknown. Constructors can throw argument exceptions; Try factories and workflow boundaries must return diagnostics. + +Equality and hashing must include kind and normalized payload. `48000/2002` equals `24000/1001`. Approximate `24.0` equals approximate `24`, but differs from rational `24/1`. Labels and tolerant catalog matching must not determine equality. + +Internal frame-rate options must carry a canonical rate directly. Catalog selection must pass its rational value, without numeric matching or parsing a label. Core and Node frame-rate APIs do not expose numeric FPS projections. Hosts can format values for display. Auto and Reserved have no usable computation rate. Detection returns suggestions, not source authority. + +An alternative that stores numeric FPS plus an optional exact value leaves conflicting states possible. It is rejected. + +### 2. Import, aggregation, and source ownership + +Each editable segment must carry current `FrameRateValue` and original `SourceFrameRate`. Original source rate includes explicit Unknown. Missing source metadata must not be filled from a later current rate. Remove parallel `ExactFrameRate` members from internal documents and segments. Keep derived aliases only where an established external contract needs them. + +Track and document summaries must inspect every participating segment. A summary is known only when all contributors are known and equal by kind and payload. Known plus Unknown is Unknown. Exact plus numerically equal Approximate is not a uniform authoritative rate. No first-segment, first-video-stream, or non-null-only fallback is allowed. A track without segments uses its explicit content rate. Selected-track projections must not borrow an unrelated track's document summary. + +Use these importer rules: + +| Source | Canonical rate policy | +| --- | --- | +| MPLS/BDMV | Resolve the relevant primary video rate per play item. Preserve recognized catalog rationals. Missing or conflicting primary-video metadata produces Unknown and a diagnostic where appropriate. | +| DVD IFO | Preserve the existing authoritative NTSC `30000/1001` or PAL `25/1` interpretation. NAV timing changes timestamp evidence, not rate authority. | +| XPL | Parse timeBase/tickBase without a double arithmetic path. Those clock units and the current hard-coded 24 FPS do not establish video FPS. Report Unknown unless actual video-rate metadata establishes a rate. | +| Text/XML/VTT/CUE/media chapter input | Keep Unknown when no video rate is reported. A supplied decimal video rate is Approximate. CUE's 75 units per second and media time_base are timestamp scales, not video FPS. | + +The [Toshiba playback-system patent, description of FIG. 23B](https://patents.google.com/patent/US20070086102A1/en) identifies XPL timeBase as a title-timeline frequency and tickBase as an independently configured page/application clock. The domain policy follows from that distinction: neither clock alone proves the encoded video's frame rate. Preserve the existing XPL timestamp interpretation in this change. + +MPLS split entries retain their own rates. Aggregate playlists must retain per-item rate/boundary metadata for document adaptation. Add immutable metadata to the existing import projection when necessary; do not add another editable chapter backup. + +Scalar `ChapterSet` compatibility operations cannot represent a mixed timeline. They must report a conflict when flattening would discard required per-segment rate information. Document operations can preserve mixed segments. Merge and append must not reinterpret ticks using the first rate. Existing merge conflict policy remains the default; this change does not add a piecewise frame-number transform. + +### 3. Computation context and Auto suggestions + +Resolve an operation rate in this order: explicit caller-selected rate, then a uniform known current rate for the target scope. Missing or mixed scope needs explicit selection. A known approximate source rate remains approximate under Auto; it must not be replaced by the closest catalog rational. + +Auto can calculate suggested frame display with confidence. The suggestion is separate from content and operation inputs. It must not change source/current metadata, document equality, history, export baselines, or the rate used by a content operation. An empty detector result remains a low-confidence suggestion. + +Selecting a valid catalog entry explicitly supplies that entry's rational rate for the requested calculation. It does not itself create content history. A reviewed FPS conversion commits its target as the current rate. Ordinary frame edits and export overrides use the selected calculation rate without rewriting original source metadata. + +Add a typed optional computation-rate override to serialization options for frame-number formats. Preserve the old options constructor. An override affects captured output only. Unknown-rate time-only serialization remains valid. A valid v2 timecode map supplies chapter-to-QPFile frame mapping without requiring a constant FPS. Invalid provided FPS still fails validation; it must not be silently replaced by the map. + +Approximate custom rates and exact rates outside the catalog need a stable custom selector entry on load. Both hosts must preserve its kind and value on reselection, track switching, and language changes. + +Implicit fallback detection is rejected because it hides the source uncertainty. + +### 4. Arithmetic and rounding boundaries + +Use `ExactFrameMath` or equivalent BigInteger products for ticks, frame counts, and both rate kinds. A decimal can be decomposed into its exact coefficient and scale for arithmetic. This does not change Approximate provenance. Do not require decimal multiplication if it can overflow before the final result is checked. + +All four source/target pairs must work: rational/rational, rational/approximate, approximate/rational, and approximate/approximate. No approximate target may force an exact source through a decimal or double. + +Use these boundaries: + +| Operation | Rounding | +| --- | --- | +| Frame cell value to ticks | Keep the entered integer or finite decimal frame count exact until nearest tick; midpoint away from zero. | +| FPS conversion | Round source frame position once to an integer, midpoint away from zero; convert that frame to target ticks with the same tick rule. | +| Segment timeline conversion | Convert absolute start and end with the chapter rule; duration is converted end minus converted start. | +| QPFile/celltimes without timecode map | Convert ticks to integer frames once, midpoint away from zero. | +| Display and repeating decimals | Round only presentation. Never parse an unchanged rounded display back into content. | +| Timecode map and timestamp text | Keep their existing mapping/format policy. They must not mutate canonical ticks or rate metadata. | + +Core calculations must not use `TotalSeconds`, a display number, or a compatibility FPS projection as input. Check final signed-64-bit bounds, end-before-start, and segment collapse. Overflow must return a diagnostic and reject the whole candidate. Keep separators excluded from content arithmetic. + +An explicit ChangeFps action keeps its source-frame quantization rule even when numeric rates are equal. It is unchanged only when the resulting ticks and canonical metadata equal the base snapshot. A rate metadata assignment without a timing transform must preserve ticks. Off-grid conversion is not generally reversible; Undo must restore the exact prior snapshot. + +### 5. Candidates, history, and output state + +Rate comparison must include canonical kind and payload in documents, segments, property diffs, history deltas, and export fingerprints. Equivalent reduced rationals must not create false changes. Exact-to-approximate changes must not disappear merely because a displayed FPS matches. + +Capture operation rates with the candidate. Apply must commit the reviewed candidate once. A display preference change can refresh its presentation but must preserve the candidate. Changing an operation-rate draft invalidates the corresponding prepared candidate. Track switching, content changes, and history navigation retain existing stale-candidate rules. + +Undo, redo, branch restoration, merge, split, and append must preserve current and source rates separately. Unknown source rates must remain Unknown through repeated conversions. Export preview and save must use the same captured ticks, typed override, and rounding policy. Display preferences and Auto suggestions must not enter the content fingerprint. + +### 6. Numeric normalization and removed compatibility surfaces + +Compatibility is an input boundary, not an internal migration layer. The follow-up removes the selected numeric Core/Node facades and transform-bearing export contract in 23.x. + +| Consumer | Migration policy | +| --- | --- | +| Core documents, segments, calculations, history, host state, and internal service interfaces | Replace numeric and parallel-rate contracts directly. Do not retain old overloads for repository callers. | +| UI catalog and operation selectors | Pass canonical rationals directly. No double or decimal compatibility path is needed for selection. | +| User-entered numeric FPS and existing numeric CLI options | Accept the existing input form once and classify it as Approximate. | +| Core/Node frame-rate model and service APIs | Use `FrameRateValue`. Remove numeric getters, constructors, lookup overloads, and operation overloads. | +| Core/Node export APIs | Serialize prepared content with format settings. Apply content changes through explicit operations before serialization. | +| Node `ChapterTool` | Expose the typed API as the package entry point. Remove numeric chapter DTOs and the `.exact`, `.project`, and `.export` facades. Keep non-rate catalogs. | + +Inventory the documented and distributed external API surface before editing. A C# `public` modifier alone does not establish a compatibility obligation. Preserve only contracts that the current specs and API inventory identify. Remove parallel source/exact fields and numeric FPS overloads. + +Legacy positive numeric input means Approximate even for `24`, `23.976`, or `24000d/1001d`. Normalize doubles once from invariant shortest round-trip text. Accept only a positive decimal representation without clamping, underflow-to-zero, or additional decimal rounding. Decimal inputs retain their exact coefficient and scale subject to normalization. + +Legacy metadata FPS `0` represents Unknown. Zero supplied to a required computation-rate parameter is invalid. Negative, non-finite, out-of-range, or malformed supplied values fail. Explicit rational metadata and catalog selection must be preserved directly. + +No numeric FPS property or constructor remains on `ChapterSet`. Import boundaries +interpret absent metadata as Unknown. CLI numeric FPS input must be positive and +becomes Approximate. A valid canonical model must not carry deferred numeric errors +or an invalid rate state. + +Migrate expression contexts and internal interfaces directly. If the API inventory proves an established external expression-engine extension contract, keep its adapter at the external boundary. Do not retain every internal interface or context signature speculatively. + +### 7. Node exact wire schema + +Expose typed wire bindings directly through `ChapterTool` for import, chapter edits, shifts, zones, combine/append, detection/frame projection, FPS conversion, expressions, pure serialization, and conversion tools. Do not expose numeric chapter/rate DTOs or a second `.exact` facade. Keep non-rate format and import catalogs. Serialization must not apply content transforms. + +The exact rate wire union is: + +```ts +type FrameRateDto = + | { kind: "rational"; numerator: string; denominator: string } + | { kind: "approximate"; value: string } + | { kind: "unknown" }; +``` + +Rational components use positive invariant integer strings within Int64 range. Approximate values use invariant decimal strings, without exponent, grouping, or locale-specific punctuation. Output must be reduced/canonical. Invalid kinds, missing required payloads, extra state payloads, zero denominators, overflow, and rounded-to-fit decimals fail at the boundary. + +Exact chapter-set DTOs use `frameRate`, nullable `durationTicks`, chapter `startTicks`, and nullable `endTicks`. Tick values are signed Int64 strings and follow the existing document validity rules. A null duration is Unknown; a string zero duration is known zero. Exact operations accept rate DTOs or explicit catalog codes, not JavaScript numbers. No FPS double or seconds number is canonical in this facade. Keep seconds convenience projections out of the exact input contract to avoid conflicting representations. + +Adapt exact requests to typed single-track document snapshots without creating a session. Preserve `ChapterDuration` availability and ticks directly. Do not route exact requests through a numeric legacy DTO or its zero-duration sentinel. Retain precision through name/structure operations as well as FPS-dependent operations. + +Stateless scalar exact combine/append must reject mixed-rate flattening. They do not introduce a document/session API. App document paths retain segment metadata separately. + +The old Node numeric facade is removed. Tick and rate values use invariant strings so JavaScript cannot round values above its safe-integer range. + +Update `NodeJsonContext` source-generation registrations, handwritten `types.ts`, `index.ts`, emitted `dist/index.d.ts` through the package build, and actual packaged-runtime tests together. Do not edit generated declarations by hand. + +### 8. CLI input and output + +Add `--frame-rate-ratio N/D` through DotMake.CommandLine. Accept positive Int64 numerator/denominator with optional surrounding whitespace. Reduce the result. Reject malformed, zero, negative, or out-of-range components. Reject simultaneous numeric and ratio options before import or file creation. Migrate the internal `CliConvertRequest` directly to a canonical rate. Compatibility applies to the user-facing option syntax. + +An explicit rate overrides computation for this one-shot conversion, including FPS-dependent expression preparation. It does not retime chapters unless an expression explicitly does so. With neither option, use source rate; do not infer from Auto. + +Inspect remains terminal text. Keep its current identifiers and numeric FPS projection for compatibility. Add a rate-kind label and canonical ratio/decimal or Unknown text. Do not invent a CLI JSON result mode in this change. `--stdout` emits only exported content. Validation exits with code 1, not the unhandled-exception code. + +### 9. Lua boundary + +Keep numeric `fps` and `chapter.fps` for a known explicitly resolved rate. Add `fpsRateKind` and exact ratio text globals `fpsNumeratorText`/`fpsDenominatorText`. The proposed numeric `fpsNumerator`/`fpsDenominator` are available only when both integers are safely representable by the Lua numeric domain; otherwise they are nil. Approximate and Unknown expose no rational pair. + +Unknown context uses nil for `fps` and `chapter.fps`. Time-only expressions such as `t + 1` can run. Built-in FPS-dependent presets require an explicit rate. Custom Lua arithmetic that uses an absent rate fails structurally; do not decide dependencies by scanning raw source text. A script can explicitly handle missing data. + +Construct seconds from ticks before the Lua projection. If Lua returns the same numeric time it received, preserve the original ticks. Changed results return through one validated tick conversion. Recompute derived frame values from resulting ticks with the canonical rate. Update evaluation, analysis/completion context, symbol metadata, presets, and help together. Raw Lua arithmetic remains floating point, even when ratio text is available. + +## Risks / Trade-offs + +- [Callers used numeric Node DTOs] -> The 23.x migration removes these DTOs. Document the typed API and its wire values. +- [Established external records have more surface than FPS parameters] -> Inventory real contract usage and compile/run those consumers. Do not preserve unrelated internal signatures. +- [Mixed timelines lack one global frame grid] -> Preserve segment data in documents and require explicit scalar computation rates. Reject unsupported scalar flattening. +- [Auto no longer authorizes unknown-rate edits] -> Keep suggested display and localized feedback. Make manual rate selection available in both hosts. +- [Importer clocks resemble FPS] -> Test IFO authority, MPLS item aggregation, XPL clock handling, and CUE/media Unknown classification separately. +- [Long timelines overflow intermediate arithmetic] -> Use integer products and check final bounds. Verify failures leave content and output untouched. + +## Migration Plan + +1. Add value, normalization, and wire contracts with focused tests. +2. Add necessary external adapters and directly replace internal signatures and models. +3. Migrate import, segment/document adaptation, comparison/history, and fingerprints. +4. Migrate calculations, expressions, serializers, and complete host workflows. +5. Make the exact Node API primary and add CLI ratio input. +6. Verify installed/packed consumers and publish migration examples. + +No persisted document-history format needs migration. Do not persist new rate selections in settings without a separate requirement. + +## Precision Regression Evidence + +`ExactFrameRateWorkflowTests` must run 500 reversible cycles for all four Rational/Approximate source and target combinations. +Each cycle converts the rate, multiplies time by 1001/1000, divides by the same factor, and converts back. +The test must compare the result with the original ticks after every cycle. +An independent integer comparison must establish a loss below one source frame. +The timelines include off-grid ticks and `9007199254740993` ticks. +The test does not establish reversibility for arbitrary lossy transforms or arbitrary Lua scripts. +Lua remains floating-point arithmetic. +MPLS and IFO fixture regressions must also cover fractional frame edits, candidate isolation, history, both conversion directions, and export equality. + +## Open Questions + +No product decision remains open. Implementation must inventory retained public signatures and verify wire registrations against the compiled and packaged consumers. This inventory must not reopen the confirmed compatibility or Auto policies. diff --git a/openspec/changes/exact-frame-rate-domain/proposal.md b/openspec/changes/exact-frame-rate-domain/proposal.md new file mode 100644 index 00000000..0b85467f --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/proposal.md @@ -0,0 +1,44 @@ +## Why + +**Current contract:** `remove-v23-compatibility-surfaces` supersedes the compatibility +and Node facade decisions below. The selected numeric Core/Node APIs and transform +export options are removed in 23.x. See its design and API inventory. + +Frame rates pass through `double`, `decimal`, and optional rational fields. Adapters can create a rational from a rounded numeric FPS value. Exact source rates can then become approximate during editing or export. The current proposal also removes public result fields without a compatibility path and leaves exact API inputs undefined. + +Core needs one canonical rate value. Hosts and external callers need explicit rules for source metadata, computation choices, detection suggestions, and output precision. + +## What Changes + +- Introduce an immutable `FrameRateValue` with rational, approximate, and unknown states. +- Preserve rate kind and payload through import, documents, segments, candidates, history, transforms, and export snapshots. +- Use timestamp ticks and exact integer arithmetic until a declared rounding boundary. A decimal coefficient can participate in integer arithmetic without becoming an authoritative source rational. +- Replace internal models, service signatures, host state, and catalog selections directly with the canonical type. Keep compatibility only for manual numeric input and established external API signatures/result fields. +- Make the typed Node API the package entry point. Use lossless tick and rate strings. +- Keep non-rate catalogs and shared utility metadata available through the package. +- Add CLI `--frame-rate-ratio N/D`. Keep `--frame-rate` as approximate numeric input. The options must be mutually exclusive and bound through DotMake.CommandLine. +- Keep Auto detection available for suggested display. Unknown or mixed-rate frame operations must require an explicit computation rate. Time-only operations must remain available. +- Preserve original source rates separately from current rates after an explicit FPS conversion. Recompute aggregate rates across all participating segments. +- Cover compatibility, invalid/default values, overflow, mixed exact/approximate calculations, selector lifecycle, and complete desktop, Web, CLI, and Node workflows. + +## Capabilities + +### New Capabilities + +- `public-frame-rate-contracts`: Define canonical public rate inputs, lossless Node wire data, and error contracts. + +### Modified Capabilities + +- `editable-chapter-document`: Store canonical rates, source rates, and deterministic aggregate metadata. +- `chapter-core-transform-export`: Use typed rate arithmetic, explicit operation contexts, and documented quantization. +- `disc-playlist-media-importers`: Preserve source authority per play item and distinguish video FPS from source time bases. +- `command-line-conversion-workflows`: Add exact ratio input and explicit rate inspection labels. +- `clip-structure-editing`: Preserve segment rates and reject unsupported scalar-rate operations. +- `avalonia-ui-shell`: Keep Auto as a suggestion and preserve custom rate identity. +- `wasm-content-operation-parity`: Use the same rate resolution, candidate, and display rules in both hosts. + +## Impact + +Core models, import projections, frame services, expressions, conversion tools, exporters, history comparison, and export fingerprints must change. Avalonia and Web workspace consumers must migrate together. Node C# DTOs, handwritten TypeScript source contracts, generated declarations, package tests, and public documentation must change together. + +Implementation must verify the full solution and affected host/distribution consumers. This review changes planning artifacts only. Existing unrelated working-tree changes must remain intact. diff --git a/openspec/changes/exact-frame-rate-domain/specs/avalonia-ui-shell/spec.md b/openspec/changes/exact-frame-rate-domain/specs/avalonia-ui-shell/spec.md new file mode 100644 index 00000000..6c609b19 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: Auto frame-rate detection in the UI +The Avalonia main window SHALL expose Auto first in the frame-rate selector and SHALL surface detection feedback when Auto is active. Detection SHALL be a presentation suggestion. It SHALL NOT assign source authority or authorize unknown/mixed-rate content calculations. + +#### Scenario: Auto entry appears in the frame-rate selector +- **WHEN** the main window renders +- **THEN** the frame-rate ComboBox SHALL include Auto first and preserve the existing catalog row order +- **AND** a non-catalog current rate SHALL have an additional stable custom entry + +#### Scenario: Auto detection updates status text +- **WHEN** the user picks Auto and the chapter set has at least one non-separator chapter +- **THEN** the ViewModel SHALL report the detected display name and confidence through localized status text +- **AND** source/current metadata SHALL remain unchanged + +#### Scenario: Auto remains selected after detection +- **WHEN** Auto detection completes +- **THEN** selection SHALL stay on the Auto row +- **AND** later refreshes SHALL NOT turn the suggestion into a committed rate + +#### Scenario: Manual frame-rate choice does not emit detection status +- **WHEN** the user picks a valid non-Auto frame-rate row +- **THEN** the ViewModel SHALL use its canonical value as the explicit computation choice +- **AND** it SHALL NOT replace that choice with numeric lookup or detection +- **AND** selection alone SHALL NOT create content history + +#### Scenario: Unknown or mixed rate requires explicit choice +- **WHEN** Auto is active on unknown or mixed-rate content +- **THEN** suggested frame display SHALL remain available with uncertainty feedback +- **AND** frame edits, shifts, FPS conversion, and constant-FPS export SHALL require an explicit computation rate +- **AND** time-only editing and serialization SHALL remain available + +## ADDED Requirements + +### Requirement: Frame-rate selection preserves canonical identity +Selection and presentation SHALL preserve rate kind and payload through load, reselection, track changes, language changes, and settings refresh. Custom rates SHALL NOT be replaced by nearby catalog rates. + +#### Scenario: Load approximate catalog-like input +- **WHEN** a document carries Approximate 23.976 +- **THEN** its selector/display SHALL retain that classification +- **AND** no initial selection SHALL silently assign Rational 24000/1001 + +#### Scenario: Load non-catalog rational input +- **WHEN** a document carries a valid rational outside the catalog +- **THEN** the selector SHALL retain a stable entry for that rate +- **AND** calculation SHALL remain rational + +#### Scenario: Language changes on existing rows +- **WHEN** the user switches among Chinese, English, and Japanese +- **THEN** selected labels and existing rows SHALL refresh without changing canonical rate or ticks +- **AND** normal, editor, and preview frame values SHALL remain readable as one number + +#### Scenario: Editor closes without a value change +- **WHEN** a user enters and leaves a rounded frame editor without changing its value +- **THEN** the original chapter ticks and history position SHALL remain unchanged + +#### Scenario: Active preview survives display refresh +- **WHEN** settings refresh frame presentation while a valid candidate is pending +- **THEN** the candidate SHALL retain its reviewed ticks and computation rate +- **AND** the refresh SHALL NOT apply or replay the candidate diff --git a/openspec/changes/exact-frame-rate-domain/specs/chapter-core-transform-export/spec.md b/openspec/changes/exact-frame-rate-domain/specs/chapter-core-transform-export/spec.md new file mode 100644 index 00000000..dddf6355 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/chapter-core-transform-export/spec.md @@ -0,0 +1,118 @@ +## ADDED Requirements + +### Requirement: Core frame calculations use canonical rates +Core frame calculations SHALL consume canonical rate values. Legacy numeric FPS members SHALL serve only public boundary adaptation or presentation. Internal calculations SHALL NOT read them. + +#### Scenario: Rational conversion stays exact until rounding +- **WHEN** an operation uses Rational 24000/1001 +- **THEN** it SHALL calculate from timestamp ticks and integer numerator/denominator products +- **AND** it SHALL round only at the declared frame or tick boundary + +#### Scenario: Approximate arithmetic retains provenance +- **WHEN** an operation decomposes Approximate 23.976 into a decimal coefficient and scale for integer arithmetic +- **THEN** the numerical calculation SHALL preserve that decimal value until output rounding +- **AND** the result rate SHALL remain Approximate +- **AND** the arithmetic ratio SHALL NOT become authoritative source metadata + +#### Scenario: Mixed exact and approximate transform +- **WHEN** an FPS transform has one Rational rate and one Approximate rate +- **THEN** it SHALL preserve both input values through the calculation +- **AND** it SHALL NOT approximate the Rational input to share a decimal path + +#### Scenario: Final value exceeds supported bounds +- **WHEN** a frame or tick conversion exceeds signed Int64 bounds +- **THEN** the operation SHALL return an overflow diagnostic +- **AND** it SHALL leave content, history, and output targets unchanged + +### Requirement: Computation rates are resolved explicitly +An FPS-dependent operation SHALL use an explicit caller rate or the uniform known rate of its target scope. Detection and display suggestions SHALL NOT supply an implicit computation rate. + +#### Scenario: Unknown or mixed scope has no override +- **WHEN** frame editing, shifting, zones, FPS conversion, or constant-FPS export has no explicit rate and no uniform known scope rate +- **THEN** it SHALL return a structured missing-rate or mixed-rate failure +- **AND** it SHALL NOT use a first-segment, detected, or default catalog rate + +#### Scenario: Explicit choice resolves an unknown scope +- **WHEN** the caller explicitly selects a valid catalog rate or supplies a typed rate +- **THEN** the operation SHALL use that canonical rate +- **AND** an ordinary frame edit or serialization override SHALL preserve source metadata + +#### Scenario: Time-only operation has unknown rate +- **WHEN** a time edit, naming operation, time-only expression, or time-based serialization does not require FPS +- **THEN** Unknown rate SHALL NOT prevent that operation + +#### Scenario: Timecode map supplies the frame mapping +- **WHEN** Chapter-to-QPFile receives a valid v2 timecode map and no constant computation rate +- **THEN** it SHALL use the existing map policy without requiring a constant FPS +- **AND** it SHALL NOT invent authoritative FPS from map intervals + +### Requirement: Frame conversion boundaries are consistent +Frame editing, FPS transforms, and integer frame output SHALL use nearest-integer rounding with midpoint away from zero at their declared boundaries. Input and display rounding SHALL NOT precede those boundaries. + +#### Scenario: Fractional frame cell input +- **WHEN** a caller commits a valid finite decimal frame count with a known computation rate +- **THEN** Core SHALL retain its exact coefficient until conversion to nearest timestamp tick + +#### Scenario: FPS transform changes the rate +- **WHEN** a reviewed conversion changes from one canonical rate to another +- **THEN** each source frame position SHALL be quantized once before conversion to target ticks +- **AND** the committed current rate SHALL equal the target kind and payload +- **AND** original source rates SHALL remain unchanged + +#### Scenario: Segment duration derives from transformed bounds +- **WHEN** an FPS conversion transforms a segment +- **THEN** its absolute start and end SHALL use the same conversion rule as chapter times +- **AND** its duration SHALL equal converted end minus converted start +- **AND** identities, other tracks, and valid document bounds SHALL be preserved + +#### Scenario: Same rate conversion +- **WHEN** source and target rates have equal canonical values +- **THEN** an explicit FPS transform SHALL retain its documented source-frame quantization +- **AND** it SHALL produce no history node only when both resulting ticks and metadata are unchanged + +#### Scenario: Same numeric rate with different kind +- **WHEN** source and target are Rational 24/1 and Approximate 24 +- **THEN** review SHALL include the changed rate kind +- **AND** an explicit FPS transform SHALL retain its documented time quantization +- **AND** a metadata-only assignment SHALL preserve ticks + +#### Scenario: Frame export uses current ticks +- **WHEN** QPFile or celltimes serializes chapters +- **THEN** it SHALL calculate integer frames from ticks and the captured computation rate +- **AND** it SHALL NOT reuse frame text or TimeSpan.TotalSeconds +- **AND** serialization SHALL NOT mutate the document + +### Requirement: Lua exposes rate uncertainty and compatibility projections +Lua SHALL retain numeric fps globals for known resolved rates and SHALL expose rate kind and rational text components. Lua arithmetic SHALL remain floating point. Core arithmetic outside Lua SHALL use canonical rates and ticks. + +#### Scenario: Known rational Lua context +- **WHEN** a script runs with Rational 24000/1001 +- **THEN** fps and chapter.fps SHALL be numeric compatibility projections +- **AND** fpsRateKind SHALL identify Rational +- **AND** fpsNumeratorText and fpsDenominatorText SHALL contain the reduced integer strings +- **AND** the safe numeric component globals SHALL contain 24000 and 1001 + +#### Scenario: Rational components exceed numeric precision +- **WHEN** a rational component cannot be represented safely by the Lua numeric domain +- **THEN** the text component globals SHALL preserve it +- **AND** the numeric numerator/denominator pair SHALL be nil + +#### Scenario: Approximate Lua context +- **WHEN** a script runs with an Approximate rate +- **THEN** fps SHALL contain its numeric projection and fpsRateKind SHALL identify Approximate +- **AND** rational component globals SHALL be nil + +#### Scenario: Unknown Lua context +- **WHEN** an unknown-rate script evaluates t + 1 +- **THEN** it SHALL be allowed with nil fps and chapter.fps +- **AND** rate-dependent built-in presets SHALL require an explicit rate +- **AND** custom arithmetic using absent FPS SHALL fail with a structured diagnostic + +#### Scenario: Script returns unchanged projected time +- **WHEN** a script returns the same numeric time supplied to it +- **THEN** Core SHALL preserve the original timestamp ticks +- **AND** it SHALL NOT commit a rounded seconds projection + +#### Scenario: Authoring and execution share context +- **WHEN** a user analyzes or completes a script using the new rate globals +- **THEN** symbol metadata, help, validation, and execution SHALL describe the same rate kinds and availability diff --git a/openspec/changes/exact-frame-rate-domain/specs/clip-structure-editing/spec.md b/openspec/changes/exact-frame-rate-domain/specs/clip-structure-editing/spec.md new file mode 100644 index 00000000..f1487c2c --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/clip-structure-editing/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Merge replaces current tracks in one transaction +Merge SHALL use the current edited content, preserve chapter names, identities, explicit end times, and required source information, and replace selected tracks with one merged track. It SHALL retain boundaries and media placement without a second chapter backup. It SHALL preserve canonical current and source rates per segment. + +#### Scenario: Merge after editing a source track +- **WHEN** the user edits a chapter and then merges its track +- **THEN** the merged track SHALL contain the edited value and the same chapter identity +- **AND** merge SHALL create one undoable transaction + +#### Scenario: Merge tracks with different frame rates +- **WHEN** source tracks have different canonical rates, including known plus Unknown or different provenance at equal numeric values +- **THEN** merge SHALL apply an explicit compatible rate policy or report a conflict +- **AND** it SHALL NOT silently interpret every time using the first track rate + +#### Scenario: Merge tracks with equivalent rational rates +- **WHEN** all source rates reduce to the same rational +- **THEN** merge SHALL preserve that uniform canonical rate and the per-segment source metadata + +## ADDED Requirements + +### Requirement: Structural operations preserve canonical segment rates +Split and append SHALL preserve current and source rate kind/payload. Scalar compatibility adapters SHALL report a conflict when their contract cannot retain required mixed-rate segment information. + +#### Scenario: Split after conversion +- **WHEN** a converted track is split at its current boundaries +- **THEN** each resulting segment SHALL retain its current target rate and original source rate +- **AND** Unknown source metadata SHALL remain Unknown + +#### Scenario: Append a source with different rate +- **WHEN** append adds a source with a different canonical rate +- **THEN** the document SHALL retain each segment's rate +- **AND** affected aggregate summaries SHALL be recomputed across every contributor +- **AND** existing ticks SHALL NOT be reinterpreted + +#### Scenario: Legacy scalar projection cannot preserve mixed content +- **WHEN** a stateless scalar operation would discard required segment rate information +- **THEN** it SHALL return a conflict without producing a misleading uniform-rate result diff --git a/openspec/changes/exact-frame-rate-domain/specs/command-line-conversion-workflows/spec.md b/openspec/changes/exact-frame-rate-domain/specs/command-line-conversion-workflows/spec.md new file mode 100644 index 00000000..386c5c7f --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/command-line-conversion-workflows/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: CLI supports explicit exact and approximate computation rates +The CLI SHALL keep its numeric --frame-rate option and SHALL add --frame-rate-ratio N/D through DotMake.CommandLine. The options SHALL be mutually exclusive. Numeric input SHALL become Approximate. Ratio input SHALL become Rational. + +#### Scenario: Numeric option remains compatible +- **WHEN** convert receives a valid --frame-rate 23.976 +- **THEN** it SHALL normalize the input once to Approximate 23.976 before Core processing +- **AND** it SHALL NOT substitute Rational 24000/1001 + +#### Scenario: Exact ratio option is accepted +- **WHEN** convert receives --frame-rate-ratio 48000/2002 +- **THEN** it SHALL pass Rational 24000/1001 to the one-shot expression/export context + +#### Scenario: Conflicting options are rejected +- **WHEN** both frame-rate options are supplied +- **THEN** validation SHALL report the conflict to stderr and exit with code 1 +- **AND** it SHALL NOT import input or create output + +#### Scenario: Malformed or invalid supplied rate +- **WHEN** either option supplies non-positive, non-finite, unrepresentable, malformed, or out-of-range data +- **THEN** validation SHALL exit with code 1 and report the failure +- **AND** it SHALL NOT silently use a source, detected, or default rate + +#### Scenario: No override preserves exact import +- **WHEN** no override is supplied and the imported source has Rational 30000/1001 +- **THEN** Core expression preparation and frame export SHALL receive that same Rational rate + +#### Scenario: No override and unknown source +- **WHEN** a constant-FPS export needs a rate absent from the source +- **THEN** convert SHALL fail structurally with instructions to supply an explicit rate +- **AND** time-only conversion SHALL remain available + +#### Scenario: Override does not retime chapters +- **WHEN** convert supplies a ratio override without an expression +- **THEN** the override SHALL affect frame computation only +- **AND** it SHALL NOT perform an implicit FPS time transform + +### Requirement: CLI inspection explains rate provenance +Inspect SHALL remain terminal text. It SHALL preserve selection identifiers and the legacy numeric FPS projection while adding rate kind and canonical value labels. + +#### Scenario: Inspect reports exact rate +- **WHEN** inspect reports an authoritative rate +- **THEN** its text SHALL include the reduced rational and its rate kind + +#### Scenario: Inspect reports approximate or unknown rate +- **WHEN** inspect reports an Approximate or Unknown rate +- **THEN** the text SHALL identify that classification +- **AND** it SHALL NOT present a rounded value as authoritative + +#### Scenario: Stdout conversion remains content-only +- **WHEN** convert succeeds with --stdout +- **THEN** stdout SHALL contain only exported chapter content +- **AND** rate labels and diagnostics SHALL NOT be inserted into that content diff --git a/openspec/changes/exact-frame-rate-domain/specs/disc-playlist-media-importers/spec.md b/openspec/changes/exact-frame-rate-domain/specs/disc-playlist-media-importers/spec.md new file mode 100644 index 00000000..f6182de6 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/disc-playlist-media-importers/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Importers preserve source-driven frame-rate authority +Importers SHALL report Rational rates only when source video metadata or existing format interpretation establishes that rate. Timestamp clocks and display matches SHALL NOT establish video FPS. Import projections SHALL carry canonical rates without a double round-trip. + +#### Scenario: MPLS code maps to exact rate +- **WHEN** an MPLS play item contains a recognized primary-video rate code +- **THEN** its import projection SHALL retain the catalog's reduced rational rate + +#### Scenario: Unknown MPLS code remains unknown +- **WHEN** an MPLS play item has an absent, reserved, or unrecognized video rate code +- **THEN** it SHALL retain Unknown +- **AND** it SHALL NOT infer FPS from chapter positions + +#### Scenario: Aggregate MPLS contains mixed rates +- **WHEN** an MPLS or BDMV playlist contains differing or missing applicable play-item rates +- **THEN** adaptation SHALL preserve per-item rates and boundaries +- **AND** a scalar aggregate SHALL NOT claim the first recognized rate for the playlist + +#### Scenario: Primary video metadata conflicts +- **WHEN** applicable primary-video metadata supplies conflicting rates +- **THEN** the importer SHALL retain uncertainty and report a diagnostic where that conflict affects projection +- **AND** it SHALL NOT silently use the first stream's rate + +#### Scenario: IFO rate authority is independent of timing source +- **WHEN** a supported IFO title uses the existing NTSC or PAL interpretation +- **THEN** it SHALL report Rational 30000/1001 or Rational 25/1 respectively +- **AND** IFO versus NAV timing SHALL preserve that rate and the original imported timestamp calculation + +#### Scenario: Source clock differs from video rate +- **WHEN** XPL timeBase/tickBase, CUE 75-unit timing, or a media time_base is available without video-rate metadata +- **THEN** the chapter video rate SHALL be Unknown +- **AND** the importer SHALL NOT assign the clock scale or a hard-coded 24 FPS as source video rate + +#### Scenario: Numeric video metadata lacks rational authority +- **WHEN** a source explicitly supplies only a decimal video rate +- **THEN** the importer SHALL mark it Approximate +- **AND** document adaptation SHALL retain its value and kind + +#### Scenario: Import timestamps are not corrected by rate metadata +- **WHEN** an importer assigns canonical rate metadata +- **THEN** it SHALL preserve the established source timing calculation +- **AND** it SHALL NOT snap chapters to frames or compensate drift diff --git a/openspec/changes/exact-frame-rate-domain/specs/editable-chapter-document/spec.md b/openspec/changes/exact-frame-rate-domain/specs/editable-chapter-document/spec.md new file mode 100644 index 00000000..568cb574 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/editable-chapter-document/spec.md @@ -0,0 +1,86 @@ +## MODIFIED Requirements + +### Requirement: Document time and frame values are deterministic +The document SHALL store time as signed 64-bit 100-nanosecond ticks. Its canonical frame-rate value SHALL be Rational, Approximate, or Unknown. Rational values SHALL contain reduced positive Int64 components. Approximate values SHALL contain positive decimal values. Unknown duration SHALL remain explicit and SHALL NOT impose a zero upper bound. + +#### Scenario: Frame conversion uses the shared rounding rule +- **WHEN** a frame value is converted to ticks or ticks to frames +- **THEN** conversion SHALL round to the nearest integer with midpoint away from zero +- **AND** overflow SHALL return a diagnostic failure + +#### Scenario: Unknown duration has no upper bound +- **WHEN** a document has unknown duration and a chapter start is edited +- **THEN** validation SHALL NOT treat unknown duration as a zero-length upper bound + +#### Scenario: Default rate value is unknown +- **WHEN** the canonical rate value is default-initialized +- **THEN** it SHALL represent Unknown without a usable numeric payload + +#### Scenario: Invalid rational bypasses construction +- **WHEN** a default rational or an invalid deserialized rate reaches a public operation +- **THEN** the operation SHALL fail with a structured diagnostic +- **AND** it SHALL NOT divide by zero or reinterpret invalid input as Unknown + +## ADDED Requirements + +### Requirement: Editable documents preserve frame-rate provenance +The document and its segments SHALL store canonical rates without synthesizing source authority from a numeric projection. Source rates SHALL remain separate from current rates. + +#### Scenario: Exact source rate survives adaptation +- **WHEN** an importer provides rational 48000/2002 and its result is adapted to a document and typed public result +- **THEN** the current and source rates SHALL retain rational 24000/1001 +- **AND** adaptation SHALL NOT pass the rate through double FPS + +#### Scenario: Approximate source rate stays approximate +- **WHEN** a numeric API boundary supplies 23.976 without explicit rational metadata +- **THEN** the document SHALL store Approximate 23.976 +- **AND** catalog lookup or display refresh SHALL NOT change it to Rational + +#### Scenario: Unknown source survives conversion +- **WHEN** a segment with Unknown source rate is converted using explicit source and target computation rates +- **THEN** its current rate SHALL become the chosen target rate +- **AND** its source rate SHALL remain Unknown after further conversions and history navigation + +#### Scenario: Non-rate edit preserves metadata +- **WHEN** a chapter name or timestamp is edited +- **THEN** the document SHALL preserve segment rate kind and source metadata +- **AND** the edit SHALL NOT rebuild a rate from display text or legacy FPS + +### Requirement: Aggregate rate resolution includes every contributor +Track and document summaries SHALL be known only when all contributing rates are known and equal by kind and normalized payload. An empty scope SHALL be Unknown. A track without segments SHALL use its explicitly stored rate. + +#### Scenario: Known and unknown segments +- **WHEN** a scope contains Rational 24/1 and Unknown segments +- **THEN** its aggregate rate SHALL be Unknown +- **AND** each segment SHALL retain its own rate + +#### Scenario: Numeric equality does not erase provenance +- **WHEN** a scope contains Rational 24/1 and Approximate 24 +- **THEN** it SHALL NOT report a uniform authoritative rate + +#### Scenario: Equivalent ratios agree +- **WHEN** all contributing segments specify rational 24000/1001 or 48000/2002 +- **THEN** the aggregate SHALL be Rational 24000/1001 + +#### Scenario: Selected track differs from another track +- **WHEN** a selected track is Unknown and another track has a known rate +- **THEN** its projection SHALL remain Unknown +- **AND** it SHALL NOT borrow the other track's rate + +### Requirement: State comparison includes rate kind and payload +Document equality, candidate review, history, and export fingerprints SHALL compare canonical rates. Presentation text and detection suggestions SHALL NOT participate in content equality. + +#### Scenario: Rate kind changes at the same numeric value +- **WHEN** a candidate changes Rational 24/1 to Approximate 24 +- **THEN** review SHALL show a rate metadata change +- **AND** Apply, Undo, and Redo SHALL retain the exact recorded kinds +- **AND** canonical content comparison SHALL detect the change + +#### Scenario: Equivalent canonical rate is assigned +- **WHEN** a candidate assigns an equivalent reduced rational or a decimal with equivalent scale-normalized value +- **THEN** it SHALL NOT create a false rate change or history node + +#### Scenario: Display changes during review +- **WHEN** rounding, precision, tolerance, or language changes while a candidate is active +- **THEN** the candidate's canonical rates and ticks SHALL remain unchanged +- **AND** no content transaction SHALL be created diff --git a/openspec/changes/exact-frame-rate-domain/specs/public-frame-rate-contracts/spec.md b/openspec/changes/exact-frame-rate-domain/specs/public-frame-rate-contracts/spec.md new file mode 100644 index 00000000..fcd29dae --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/public-frame-rate-contracts/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Compatibility is confined to established external boundaries +Internal domain models, calculations, host state, and selector/service contracts SHALL use canonical rates. The selected numeric Core/Node APIs and transform-bearing export contract SHALL be removed in 23.x. Manual CLI frame-rate input SHALL remain a supported Approximate input. + +#### Scenario: Internal selector supplies a catalog option +- **WHEN** a host selects a catalog option +- **THEN** it SHALL pass the option's canonical rational directly +- **AND** it SHALL NOT adapt a display double or decimal back into an authoritative rate + +#### Scenario: Internal-only public helper changes +- **WHEN** a public helper has only repository consumers and no established external contract +- **THEN** its signature SHALL migrate directly +- **AND** compatibility overloads SHALL NOT be retained solely because it is public + +#### Scenario: CLI caller supplies a numeric FPS +- **WHEN** a caller supplies positive numeric FPS through `--frame-rate` +- **THEN** the boundary SHALL normalize it once to Approximate +- **AND** internal services SHALL receive only the canonical value + +### Requirement: Exact inputs are available to external callers +C# typed APIs and the Node package SHALL accept canonical rational and approximate values. Numeric inputs SHALL NOT be silently interpreted as catalog rationals. + +#### Scenario: Explicit rational input +- **WHEN** a caller supplies 48000/2002 explicitly +- **THEN** the operation SHALL use Rational 24000/1001 without a numeric round-trip + +#### Scenario: Explicit catalog code +- **WHEN** an exact API selects a valid catalog code +- **THEN** the operation SHALL use the catalog's canonical rate +- **AND** Auto, Reserved, and unknown codes SHALL NOT supply a valid explicit computation rate + +#### Scenario: Invalid numeric normalization +- **WHEN** a legacy boundary receives non-finite, negative, required-zero, out-of-range, or non-representable FPS +- **THEN** it SHALL preserve the established argument/validation failure category +- **AND** it SHALL NOT call Core with a clamped, zero, or substituted rate + +### Requirement: Node exposes one exact content facade +The package `ChapterTool` entry point SHALL expose typed content operations with lossless rate/tick data. Numeric chapter DTOs and the `.exact`, `.project`, and `.export` compatibility methods SHALL NOT be exposed. + +#### Scenario: Caller completes a content workflow +- **WHEN** a caller imports, edits, projects frames, converts FPS, and serializes through `ChapterTool` +- **THEN** each returned content value SHALL preserve canonical rate kind and ticks +- **AND** expressions, shifts, zones, combine/append, and conversion tools SHALL have corresponding typed entry points + +#### Scenario: Exact scalar combine would discard mixed rates +- **WHEN** an exact scalar chapter-set combine or append cannot represent its contributors' different rates +- **THEN** it SHALL return a structured conflict +- **AND** it SHALL NOT choose the first rate or flatten required metadata silently + +### Requirement: Exact Node wire values are invariant strings +The exact rate DTO SHALL be a discriminated union: rational with numerator/denominator strings, approximate with a decimal value string, or unknown without a numeric payload. Exact times SHALL use signed Int64 tick strings. Null duration SHALL represent Unknown duration; null chapter end SHALL represent an absent end. + +#### Scenario: Int64 exceeds JavaScript safe integer +- **WHEN** a valid rate component or tick value exceeds Number.MAX_SAFE_INTEGER +- **THEN** JSON serialization and parsing SHALL preserve its integer string exactly + +#### Scenario: Canonical rational serialization +- **WHEN** Rational 48000/2002 is returned +- **THEN** its wire value SHALL be `{"kind":"rational","numerator":"24000","denominator":"1001"}` + +#### Scenario: Decimal serialization is locale-independent +- **WHEN** Approximate 23.976 is returned under Chinese, English, or Japanese host culture +- **THEN** its wire value SHALL contain the same invariant decimal string +- **AND** parsing SHALL NOT accept a grouping separator or locale-dependent decimal marker + +#### Scenario: Exact wire payload is invalid +- **WHEN** a well-formed JSON request has an unknown kind, missing or conflicting state payload, zero denominator, negative rate, overflow, or rounded-to-fit decimal +- **THEN** the exact API SHALL return a structured rate validation failure +- **AND** it SHALL NOT invoke an operation or infer replacement metadata + +#### Scenario: Exact timestamp round-trip +- **WHEN** an exact caller returns startTicks, nullable endTicks, and nullable durationTicks unchanged +- **THEN** Core SHALL receive the same ticks and duration/end availability +- **AND** it SHALL NOT reconstruct them from seconds numbers + +### Requirement: Distributed declarations match runtime contracts +Node C# JSON registrations, handwritten TypeScript types, wrapper methods, and emitted package declarations SHALL describe the same exact contract. Public documentation SHALL state rounding and rate authority. + +#### Scenario: Package consumer uses typed input +- **WHEN** an installed package consumer compiles and runs a `ChapterTool` call +- **THEN** emitted declarations and the packaged Wasm runtime SHALL accept the documented DTO and return the documented rate/tick shape + +#### Scenario: Core consumer uses canonical input +- **WHEN** verification compiles a consumer of the public Core API +- **THEN** it SHALL use canonical rate and timestamp values +- **AND** it SHALL NOT require numeric compatibility scaffolding diff --git a/openspec/changes/exact-frame-rate-domain/specs/wasm-content-operation-parity/spec.md b/openspec/changes/exact-frame-rate-domain/specs/wasm-content-operation-parity/spec.md new file mode 100644 index 00000000..682cc5d5 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/specs/wasm-content-operation-parity/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: Shared frame display and conversion bounds remain consistent +Both hosts MUST derive frame text and accuracy from canonical rates and display preferences without content transactions. Frame edits and signed shifts MUST use the explicit computation choice or a uniform known target-scope rate. Detection MUST remain a display suggestion. Frame conversion MUST quantize absolute segment boundaries through the chapter conversion calculation. It MUST preserve segment identities, source rate metadata, and known document bounds. Selected-track operations MUST preserve other tracks. + +#### Scenario: Source FPS is absent +- **WHEN** either host detects a usable display suggestion while source/current rate is absent +- **THEN** frame editing MUST require an explicit computation rate +- **AND** refreshing the suggested display MUST create no content transaction + +#### Scenario: Convert rational FPS +- **WHEN** a reviewed rational-FPS conversion changes a segment timeline +- **THEN** each converted segment duration MUST equal its converted end minus its converted start +- **AND** the known document duration MUST contain every retained segment +- **AND** the operation MUST preserve source rate metadata and other tracks + +#### Scenario: Explicit rate enables the same edit in both hosts +- **WHEN** the user selects the same canonical rate and edits the same frame value on unknown-rate content in both hosts +- **THEN** both hosts MUST produce the same candidate ticks +- **AND** neither host MUST upgrade the original Unknown source rate + +#### Scenario: Auto preserves a known approximate rate +- **WHEN** a track already carries a known Approximate rate and Auto is selected +- **THEN** both hosts MUST preserve that rate for calculations +- **AND** a nearby catalog match MUST NOT supply rational authority + +## ADDED Requirements + +### Requirement: Portable rate workflow verification covers uncertainty and precision +Both hosts MUST preserve rate kind, ticks, selection identity, candidate lifecycle, and export prerequisites for Rational, Approximate, Unknown, and mixed content. Verification MUST include real window/browser interactions, localization, and stateful layouts. + +#### Scenario: Matching host workflow sequence +- **WHEN** verification loads the same inputs, selects rates, edits frames, reviews/applies a conversion, performs Undo/Redo, and exports in both hosts +- **THEN** committed ticks, current/source rates, and deterministic output MUST match + +#### Scenario: Current rate is custom +- **WHEN** a non-catalog rational or approximate rate is loaded and reselected across track and language changes +- **THEN** both hosts MUST retain its canonical identity +- **AND** existing rows MUST refresh in Chinese, English, and Japanese + +#### Scenario: Display settings change during pending review +- **WHEN** rounding, precision, tolerance, or repeating-decimal display changes +- **THEN** both hosts MUST preserve the reviewed candidate and history position +- **AND** changing the computation-rate draft MUST invalidate the corresponding prepared candidate + +#### Scenario: Stateful responsive layouts +- **WHEN** verification exercises empty, valid, invalid, pending, and stale rate workflows at default, wide, and narrow sizes +- **THEN** primary actions and validation MUST remain reachable +- **AND** time/frame values MUST remain unclipped in normal, editor, and preview cells +- **AND** screenshot evidence MUST accompany behavior assertions diff --git a/openspec/changes/exact-frame-rate-domain/tasks.md b/openspec/changes/exact-frame-rate-domain/tasks.md new file mode 100644 index 00000000..9e72fe18 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/tasks.md @@ -0,0 +1,66 @@ +## 1. Value and public contract baseline + +- [x] 1.1 Inventory established external FPS signatures, result fields, model usage, extension contracts, and Node DTOs. Separate them from internal-only public types and record the retained major-version-23 boundary surface. +- [x] 1.2 Add immutable `FrameRateValue` with Rational, Approximate, and default Unknown. Validate reduced positive Int64 rationals and reject default invalid `ChapterFrameRate`. +- [x] 1.3 Define canonical equality/hash and invariant formatting. Test equivalent ratios, decimal scale normalization, different provenance at equal numeric rates, and invalid states. +- [x] 1.4 Add diagnostic-returning numeric boundary normalization. Test invariant round-trip doubles, positive decimals, metadata zero versus operation zero, non-finite values, decimal range, and underflow. +- [x] 1.5 Replace internal C# signatures directly. Add only necessary external adapters/projections; verify retained API named arguments, construction/deconstruction/with usage, and proven extension contracts. + +## 2. Import and document ownership + +- [x] 2.1 Migrate `ChapterSet`, import projections, editable document/segment current rates, and source rates to canonical storage. Remove legacy double-to-rational synthesis. +- [x] 2.2 Implement uniform-rate resolution across every contributing segment/track. Test known plus Unknown, different values, exact plus Approximate, empty/no-segment tracks, and selected-track isolation. +- [x] 2.3 Preserve MPLS per-item rates and boundaries through split import and BDMV aggregate adaptation. Cover mixed/missing primary-video rates and prevent first-item scalar authority. +- [x] 2.4 Supply authoritative IFO PAL/NTSC rates independently of IFO/NAV timing. Preserve existing source timestamps and fallback behavior. +- [x] 2.5 Remove XPL double rate arithmetic and distinguish clock bases from video FPS. Keep no-rate text, CUE, and media imports Unknown. Add representative importer tests. +- [x] 2.6 Migrate merge/split/append and compatibility flattening to preserve rates or return explicit conflicts. Verify source Unknown survives repeated conversions and undo/redo. + +## 3. Core arithmetic and state + +- [x] 3.1 Migrate frame display, frame editing, signed shifts, zones, detection, and frame converters to typed inputs. Preserve display-only detection separately. +- [x] 3.2 Use integer coefficient/scale arithmetic for Approximate values without upgrading provenance. Verify long timelines and all four source/target rate-kind combinations. +- [x] 3.3 Migrate FPS conversion for chapters, explicit ends, duration, and absolute segment boundaries. Verify same-rate quantization, unchanged-result no-op, metadata-only assignments, and exact Undo of off-grid conversions. +- [x] 3.4 Return diagnostics for invalid/default rate, unknown/mixed scope, overflow, end-before-start, and collapsed segments. Verify whole-candidate rejection without content/history changes. +- [x] 3.5 Migrate document equality, candidate property diffs, history deltas/branch restoration, and export fingerprints to kind plus canonical payload. Exclude display preferences and suggestions. +- [x] 3.6 Capture computation rates in candidates and output snapshots. Verify operation-rate draft invalidation and display-preference refresh without recomputation or stale commit. + +## 4. Expressions and serialization + +- [x] 4.1 Migrate canonical expression contexts and internal engine contracts directly; keep adapters only for established external extension contracts. Allow Unknown-rate time-only scripts and reject unresolved FPS-dependent presets. +- [x] 4.2 Preserve known numeric Lua fps globals. Add kind, ratio text, and safely representable numeric ratio globals. Update analysis, symbols, completion, presets, and help. +- [x] 4.3 Preserve original ticks when Lua returns unchanged projected time. Recompute frame metadata from validated result ticks and canonical rate. Test nil FPS and unsafe-integer ratio components. +- [x] 4.4 Migrate QPFile, celltimes, zones, and Chapter-to-QPFile to ticks-based frame calculations. Cover midpoint-away-from-zero output and valid v2 timecode maps without constant FPS. +- [x] 4.5 Add compatible typed serialization-rate overrides. Verify time-only export on Unknown, explicit rate export on mixed content, preview/save equivalence, and no document mutation or expression replay. + +## 5. Desktop and Web workflows + +- [x] 5.1 Migrate both host workspaces and row/preview projections to canonical rates. Preserve custom approximate and non-catalog rational selector entries. +- [x] 5.2 Implement the confirmed Auto policy in both hosts. Keep detection suggestions visible, require explicit rates for missing/mixed FPS operations, and keep time-only operations available. +- [x] 5.3 Verify load, selection/reselection, frame edit, reviewed conversion, apply, undo/redo, export, and track switching with exact, Approximate, Unknown, and mixed fixtures in both hosts. +- [x] 5.4 Verify unchanged rounded frame cells retain ticks/history and active candidates survive display changes. Verify rate-draft changes and stale candidates cannot commit. +- [x] 5.5 Verify Chinese, English, and Japanese labels/rows, accessibility, and normal/editor/preview frame width. Cover empty, valid, invalid, pending, and stale states. +- [x] 5.6 Add real-window Headless and browser workflow assertions. Capture default/wide/narrow screenshots under `artifacts/exact-frame-rate-domain/`; use real browser key events for changed editing shortcuts. + +## 6. Node exact API + +- [x] 6.1 Add discriminated rate and Int64 tick-string DTOs with strict validation and canonical formatting. Register them in `NodeJsonContext`; test values above JavaScript safe-integer range. +- [x] 6.2 Expose typed content APIs through `ChapterTool` for import/edit/shift/zones, combine/append, detection/frame projection, conversion, expressions, and pure serialization. Preserve null versus known-zero duration and reuse canonical Core services. +- [x] 6.3 Remove numeric chapter/rate DTOs and transform-bearing `project`/`export` facades in 23.x. Keep format, import, language, and encoding catalogs. +- [x] 6.4 Update handwritten TypeScript types/wrappers and generate declarations through the package build. Add type and runtime coverage for exact inputs, outputs, nullable ends, Unknown, and malformed payloads. +- [x] 6.5 Run packaged Node round-trips for exact/approximate import, edits, conversion, and output. Verify ticks, rate kind, reduction, invalid/default rates, scalar mixed-rate conflicts, and diagnostic behavior. + +## 7. CLI boundary + +- [x] 7.1 Add mutually exclusive `--frame-rate-ratio N/D` through DotMake.CommandLine and migrate the internal request model directly. Validate syntax and positive Int64 components before import. +- [x] 7.2 Normalize old numeric options to Approximate and use imported canonical rate when neither override is supplied. Apply explicit overrides once before FPS-dependent expression/export. +- [x] 7.3 Keep terminal inspection identifiers and numeric FPS projection; add canonical kind/value labels. Preserve stdout content-only behavior and validation exit code 1. +- [x] 7.4 Test real CLI binding for numeric, ratio, reduced ratio, conflict, missing FPS, non-finite/range failures, expression conversion, and imported exact-rate export. + +## 8. Verification and documentation + +- [x] 8.1 Add cross-layer regressions for MPLS/IFO exact import -> edit -> candidate -> history -> conversion -> export. Include fractional frames, large ticks, both exact/approximate conversion directions, and 500 reversible arithmetic/conversion cycles with loss below one frame. +- [x] 8.2 Add external compiled Core consumers and packed Node consumers for legacy and typed contracts. Verify the NuGet consumer checks in `scripts/ci/verify-nuget.py` cover the retained surface. +- [x] 8.3 Update Core, CLI, and Node API documentation with removed API surfaces, typed wire shapes, Auto policy, mixed-rate limits, Lua limits, and rounding boundaries. +- [x] 8.4 Update relevant code-map ownership, entry points, and primary test references. If locales change, generate Wasm JSON with `uv run --project scripts scripts/localization/axaml-to-json.py` and verify with `--check`. +- [x] 8.5 Run `dotnet test ChapterTool.slnx --no-restore` with test projects in separate sequential processes. Run affected browser, Node package/type/declaration, and packed NuGet checks through the maintained CI entry points. +- [x] 8.6 Run `openspec validate exact-frame-rate-domain --strict` and validate all specs. Before any requested push, run `python scripts/ci/run.py` and verify required remote jobs for the pushed SHA. diff --git a/openspec/changes/exact-frame-rate-domain/verification.md b/openspec/changes/exact-frame-rate-domain/verification.md new file mode 100644 index 00000000..961e8796 --- /dev/null +++ b/openspec/changes/exact-frame-rate-domain/verification.md @@ -0,0 +1,126 @@ +# Verification Evidence + +The initial audit was correct for tasks 5.6, 8.1, and 8.3. +Checked task boxes did not establish their completion. +Task 8.6 makes the full host CI and remote SHA checks conditional on a requested push. +No push was requested. + +## Cross-Layer And Precision Coverage + +`tests/ChapterTool.Core.Tests/Editing/ExactFrameRateWorkflowTests.cs` imports real MPLS and IFO fixtures. +It edits a fractional frame and checks the candidate before commit. +It commits the reviewed candidate through the session kernel. +It converts Rational to Approximate and back. +It checks stable chapter identities and source-rate metadata. +It checks QPFile output with independent integer arithmetic. +Three Undo operations restore the imported document. +Three Redo operations restore the reviewed document and identical output. + +The same test file runs 500 cycles for each rate-kind pair. +Each cycle has four operations: rate conversion, multiplication, division, and reverse rate conversion. +The original timestamps include `1`, `12345678`, `863999999999`, and `9007199254740993` ticks. +The assertion checks loss against one original-rate frame after every cycle. +It uses integer cross multiplication instead of production conversion helpers. + +| Source kind | Target kind | Maximum loss in ticks | Maximum loss in source frames | +| --- | --- | ---: | ---: | +| Rational | Rational | 196666 | 0.47152687312687313 | +| Rational | Approximate | 196666 | 0.47152687312687313 | +| Approximate | Rational | 166835 | 0.400003596 | +| Approximate | Approximate | 166835 | 0.400003596 | + +`artifacts/exact-frame-rate-domain/precision-results.xml` contains the six passing test results and measured output. +These cases do not establish reversibility for arbitrary lossy operations or arbitrary Lua scripts. + +## Rendered Workflows + +`tests/ChapterTool.Avalonia.Headless.Tests/Headless/ExactFrameRateHeadlessTests.cs` opens a real desktop window. +It loads a real MPLS result into the window workflow. +It checks unchanged frame-editor commit, candidate display refresh, apply, Undo, and Redo. +The unchanged-editor regression reproduced an 833-tick loss before the fix. +`MainView` now compares the editor text with its initial text before dispatching a content edit. + +`tests/ChapterTool.Wasm.E2E/specs/exact-frame-rate-domain.spec.ts` uses actual file input, cell editors, and downloads. +It uses real browser key events for document and field Undo. +It checks explicit-rate editing on Unknown content. +It checks pending and stale candidates and three-language selector refresh. +Frame preview values use two rows to keep decimal digits within the frame column. +The test checks available width and no line wrapping. +Time previews use separate rows for before, candidate, and delta values. +Each complete time value must stay on one line inside its cell. +The browser boundary assertion reproduced overflow with the previous published layout. + +The Luna subagent added `tests/ChapterTool.Wasm.Tests/WasmFrameRateChoiceTests.cs`. +Its language regression reproduced a hard-coded Auto label before the fix. +The workspace now reads the existing `Main.AutoFrameRate` translation key. +The test checks selection, ticks, rate authority, and history during language changes. +Custom values use the public Core rate service because the workspace has no public arbitrary-rate import API. +The desktop Headless test verifies custom Rational and Approximate selection through the rendered ComboBox. + +Review images are under `artifacts/exact-frame-rate-domain/`. +Desktop images use `en-US/`, `zh-CN/`, and `ja-JP/` subdirectories. +Each desktop language has `desktop-default.png`, `desktop-wide.png`, `desktop-narrow.png`, and `desktop-editor.png`. +Web images use `web-default.png`, `web-wide.png`, and `web-narrow.png`. +The matching `web-*-time.png` images show the complete time column. +Additional Web images record empty, invalid, stale, and localized states. +Existing `main-diagnostics-layout.spec.ts`, `layout-behavior.spec.ts`, and desktop Headless tests cover stateful geometry and accessible controls. +Screenshots support visual review and do not replace assertions. + +Two existing browser assertions depended on synthetic 24/1 authority for text imports. +They now verify Unknown time-only evaluation and explicit all-row display. +One preliminary browser run overlapped site preparation and had transient resource 404 errors. +The final run must use a stable prepared site. + +## Public Documentation + +`src/ChapterTool.Core/README.md` documents canonical rates, the removal of numeric compatibility boundaries, Auto policy, mixed-rate limits, and rounding. +`src/ChapterTool.CommandLine/README.md` documents `--frame-rate-ratio`, mutual exclusion, diagnostics, imported authority, and Lua limits. +`packages/chaptertool/README.md` documents the exact `ChapterTool` entry point, wire examples, nullable duration, segments, overrides, and rounding boundaries. +`docs/code-map/core.md`, `docs/code-map/avalonia.md`, and `docs/code-map/testing.md` identify canonical ownership and primary regressions. + +## Checks + +The final .NET solution run uses separate sequential test processes: + +```text +dotnet test ChapterTool.slnx --no-restore --max-parallel-test-modules 1 --timeout 10m +``` + +The final solution run passed 1622 tests and skipped two of 1624 tests. +The skips cover an opt-in full disc check and a non-Windows registry check. +The final Headless project has 147 passing tests. +The final Web workspace project has 90 passing tests. + +The maintained CI entry point ran these checks: + +- `python scripts/ci/run.py --stage resources`: resource generation drift, Python lint, 47 runner tests, and PowerShell validation passed. +- `python scripts/ci/run.py --stage node --step build-node-js-package`: optimized runtime and TypeScript declarations built. +- `python scripts/ci/run.py --stage node --step type-check-node-js-package --step test-built-node-js-package`: types and 45 package tests passed after the build. +- `python scripts/ci/run.py --stage pack-node`: installed tarball consumer passed. +- `python scripts/ci/run.py --stage pack-nuget`: net8.0/net9.0/net10.0 Core packages and installed Core/CLI consumers passed. +- Browser restore, Release publish, dependency installation, Pages preparation, and engine installation ran through `scripts/ci/run.py --stage browser` step selection. +- `python scripts/ci/run.py --stage browser --browser-suite chromium --step type-check-browser-tests --step run-chromium-e2e`: browser types and all 76 Chromium tests passed against the final prepared site. + +`openspec validate exact-frame-rate-domain --strict` passed. +`openspec validate --all` passed all 45 items. + +The final exact workflow suite passed all nine tests across Chromium, Firefox, and WebKit. +It ran through `npm run test:e2e -- exact-frame-rate-domain.spec.ts` against the prepared Release site. + +Full default host CI and remote job verification remain conditional on a future push request. +This change has not been pushed or archived. + +## 23.x Compatibility Removal Follow-up + +The removal pass ran the .NET test projects in separate processes: + +- Core: 831 passed. +- Avalonia: 296 passed. +- Avalonia Headless: 148 passed. +- CommandLine: 64 passed. +- WASM workspace: 90 passed. +- Infrastructure: 173 passed, 1 opt-in test skipped. + +`npm test` passed lint, TypeScript checks, package build, and 30 Vitest tests. +`dotnet build ChapterTool.slnx --no-restore` passed with no warnings or errors. +No browser E2E or packed NuGet verification ran for this follow-up. diff --git a/openspec/changes/remove-v23-compatibility-surfaces/.openspec.yaml b/openspec/changes/remove-v23-compatibility-surfaces/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/remove-v23-compatibility-surfaces/design.md b/openspec/changes/remove-v23-compatibility-surfaces/design.md new file mode 100644 index 00000000..9375a485 --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/design.md @@ -0,0 +1,29 @@ +## Context + +See proposal.md. The canonical Core and Node exact contracts already carry rate kind, rational components, and timestamp ticks. The current compatibility paths duplicate those values through numeric fields and option projections. + +## Goals / Non-Goals + +**Goals:** Remove the selected compatibility contracts while preserving current editing, conversion, export, selector, and settings behavior through canonical APIs. + +**Non-Goals:** Change chapter file formats, CLI option syntax, current selector ordering, current browser settings key, or the 23.x package version line. + +## Decisions + +- Keep `ChapterSet` as the current importer/service model but remove its numeric FPS and exact-rate alias projections. Keep its canonical `FrameRate` property. +- Route public Node workflows through the exact tick-string/rate-kind API. Remove the old numeric Node wrapper and its DTOs. +- Replace transform-bearing export options with explicit candidate operations and pure serialization. Keep CLI conversion behavior by applying its selected operation before serialization. +- Rename `LegacyMplsCode` to `MplsFrameRateCode`; preserve integer values, catalog order, custom-rate sentinel, and reserved-rate handling. +- Remove old expression UI no-ops and bind the XAML converter to its concrete implementation. +- Remove predecessor localStorage key reads and cleanup. Do not migrate old browser preferences implicitly. +- Keep the current 23.x version line as directed; update all compatibility-window documentation to describe the removal in this release. + +## Risks / Trade-offs + +- [External Core and Node consumers can fail to compile or lose old settings] → Update migration docs and release notes to name exact replacements. +- [CLI or UI may accidentally replay a transform or change selector behavior] → Route every operation through the existing candidate model and preserve current option identities. +- [Older browser settings are ignored] → Keep the current key path unchanged and cover its load behavior in existing workflow checks. + +## Migration Plan + +Remove old signatures and DTOs after migrating in-repository callers. Update TypeScript declarations and package examples from the generated source. Keep `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries under Unreleased until release. diff --git a/openspec/changes/remove-v23-compatibility-surfaces/proposal.md b/openspec/changes/remove-v23-compatibility-surfaces/proposal.md new file mode 100644 index 00000000..c79e279d --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/proposal.md @@ -0,0 +1,25 @@ +## Why + +The exact frame-rate and editable-document APIs now provide the supported lossless path. The remaining numeric facades and no-op UI shims duplicate state and invite precision loss. Remove them in the prepared 23.4.0 release as explicitly requested. + +## What Changes + +- **BREAKING** Remove retained numeric Core and Node content APIs and the transform-bearing export options. Keep canonical rate values, tick strings, explicit operations, and serialization APIs. +- Rename `LegacyMplsCode` to `MplsFrameRateCode` while preserving its current values and selector behavior. +- Remove the old expression toggle and unused expression settings port method, and bind the completion converter directly to its implementation. +- Stop reading obsolete browser settings storage keys. Keep the current `chaptertool.wasm.settings` key. + +## Capabilities + +### New Capabilities + +- `public-frame-rate-contracts`: Define the canonical-only public Core and Node rate/content contracts after numeric facade removal. + +### Modified Capabilities + +- `one-shot-transform-compatibility`: Remove transform-bearing compatibility export entry points; retain explicit transform and serialization workflows. +- `wasm-settings-shortcuts`: Read browser settings only from the current storage key. + +## Impact + +Core and Node public contracts, the Node TypeScript package and generated declarations, Avalonia and WASM selectors, browser settings initialization, tests, release notes, and API documentation change. Existing consumers of removed numeric APIs or old export options must migrate to canonical `FrameRateValue`/exact Node values, explicit content operations, and pure serialization. diff --git a/openspec/changes/remove-v23-compatibility-surfaces/specs/one-shot-transform-compatibility/spec.md b/openspec/changes/remove-v23-compatibility-surfaces/specs/one-shot-transform-compatibility/spec.md new file mode 100644 index 00000000..3347a254 --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/specs/one-shot-transform-compatibility/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Core and Node exports serialize prepared content +Core and Node export APIs SHALL serialize the supplied chapter snapshot and SHALL NOT accept transform-bearing compatibility export options. Callers that need transformed content SHALL apply an explicit content operation, then call serialization. + +#### Scenario: Caller exports prepared content +- **WHEN** a caller serializes a chapter snapshot +- **THEN** serialization SHALL preserve the supplied chapter values +- **AND** it SHALL NOT apply expression, naming, numbering, or other content transforms + +#### Scenario: Caller transforms content before export +- **WHEN** a caller needs an expression, naming, numbering, or frame-rate operation +- **THEN** the caller SHALL apply the explicit operation to content first +- **AND** it SHALL serialize the resulting snapshot without replaying that operation + +#### Scenario: CLI converts with an expression +- **WHEN** `convert` is called with an expression or preset +- **THEN** the CLI SHALL apply the selected operation once before serialization +- **AND** CLI expression options SHALL remain structured DotMake command options + +## REMOVED Requirements + +### Requirement: Compatibility lifetime and deprecation are explicit +**Reason**: The user selected removal of the transform-bearing Core and Node export compatibility surfaces in the current 23.x release line. +**Migration**: Replace Core `ChapterExportOptions`/`ExportCompatibility` calls with explicit content operations and `ChapterSerializationOptions`. Replace Node `export` with exact content operations followed by `serialize`. diff --git a/openspec/changes/remove-v23-compatibility-surfaces/specs/public-frame-rate-contracts/spec.md b/openspec/changes/remove-v23-compatibility-surfaces/specs/public-frame-rate-contracts/spec.md new file mode 100644 index 00000000..e6f84497 --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/specs/public-frame-rate-contracts/spec.md @@ -0,0 +1,31 @@ +## Purpose + +Defines the canonical public frame-rate and chapter-content contracts for Core and Node consumers. Public operations preserve explicit rate provenance and timestamp ticks without legacy numeric projections. + +## ADDED Requirements + +### Requirement: Public content APIs use canonical rate and timestamp values +Public Core and Node content APIs SHALL use `FrameRateValue` or the exact Node rate union for rates, and timestamp ticks or exact tick strings for content time. They SHALL NOT expose numeric FPS or seconds compatibility projections, constructors, deconstructors, or operation overloads. + +#### Scenario: Core consumer constructs chapter content +- **WHEN** a caller constructs or updates a chapter set +- **THEN** the caller SHALL supply its canonical frame rate +- **AND** timestamps SHALL remain `TimeSpan` ticks or document ticks without conversion through floating-point seconds + +#### Scenario: Node consumer imports and edits content +- **WHEN** a caller completes a content workflow through the Node package +- **THEN** its public input and result types SHALL use exact rate kinds and integer tick strings +- **AND** no numeric seconds/FPS facade SHALL be exported + +#### Scenario: Caller needs an approximate frame rate +- **WHEN** a caller has an approximate numeric measurement +- **THEN** it SHALL explicitly construct an Approximate `FrameRateValue` or exact Node rate value at the boundary +- **AND** the system SHALL NOT infer Rational provenance from the numeric value + +### Requirement: Frame-rate selection exposes MPLS identity by format meaning +Selectable frame-rate options SHALL expose their Blu-ray MPLS rate code as `MplsFrameRateCode`. The code SHALL preserve the same values and reserved/custom behavior across Core, Avalonia, WASM, and Node DTOs. + +#### Scenario: Catalog option is selected +- **WHEN** desktop or browser UI maps a displayed frame-rate option +- **THEN** it SHALL preserve the option's existing MPLS code mapping and reserved entry behavior +- **AND** public DTOs SHALL use the `mplsFrameRateCode` field name diff --git a/openspec/changes/remove-v23-compatibility-surfaces/specs/wasm-settings-shortcuts/spec.md b/openspec/changes/remove-v23-compatibility-surfaces/specs/wasm-settings-shortcuts/spec.md new file mode 100644 index 00000000..ffb2083f --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/specs/wasm-settings-shortcuts/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Browser settings use the current storage key only +The browser app SHALL load persisted settings from `chaptertool.wasm.settings` only. It SHALL NOT read or delete predecessor storage keys. + +#### Scenario: Current settings are stored +- **WHEN** the current storage key contains valid settings +- **THEN** the app SHALL load those settings +- **AND** it SHALL leave unrelated local storage entries unchanged + +#### Scenario: Only predecessor settings are stored +- **WHEN** an obsolete storage key contains settings and the current key is absent +- **THEN** the app SHALL use current defaults +- **AND** it SHALL NOT copy or delete the predecessor entry diff --git a/openspec/changes/remove-v23-compatibility-surfaces/tasks.md b/openspec/changes/remove-v23-compatibility-surfaces/tasks.md new file mode 100644 index 00000000..21ad6e9f --- /dev/null +++ b/openspec/changes/remove-v23-compatibility-surfaces/tasks.md @@ -0,0 +1,17 @@ +## 1. UI and selector cleanup + +- [x] 1.1 Replace the expression completion converter alias with the concrete presentation type and remove the alias; verify the UI project builds. +- [x] 1.2 Remove the no-op expression toggle properties and unused settings port method, then update dependent tests and fakes; verify the Avalonia and WASM projects build. +- [x] 1.3 Rename `LegacyMplsCode` to `MplsFrameRateCode` across Core, desktop, WASM, Node DTOs, docs, and tests; verify no old identifier remains in shipped source. + +## 2. Core and Node API migration + +- [x] 2.1 Remove Core numeric compatibility constructors, projections, deconstructors, and frame-rate overloads; migrate all production consumers to canonical rates and ticks; verify Core and host projects build. +- [x] 2.2 Remove transform-bearing export options and compatibility export entry points; migrate CLI, WASM, and Node flows to explicit operations followed by serialization; verify affected projects build. +- [x] 2.3 Make the Node package expose only exact content contracts, remove numeric DTOs/wrappers, update declarations and package documentation; verify the package build completes. + +## 3. Browser storage and release documentation + +- [x] 3.1 Remove predecessor localStorage key migration and cleanup; verify the implementation references only the current settings key. +- [x] 3.2 Update API docs, code maps, release notes, and compatibility specs for the removals and migration paths; verify searches show no stale compatibility-window claims. +- [x] 3.3 Validate the change and inspect the complete diff; record browser E2E and packed NuGet checks not run in this follow-up. diff --git a/openspec/changes/sync-web-ui-with-avalonia/.openspec.yaml b/openspec/changes/sync-web-ui-with-avalonia/.openspec.yaml new file mode 100644 index 00000000..53f62d70 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/sync-web-ui-with-avalonia/design.md b/openspec/changes/sync-web-ui-with-avalonia/design.md new file mode 100644 index 00000000..978c0c6e --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/design.md @@ -0,0 +1,43 @@ +## Context + +The current browser main window exposes Reload and Append only through a context menu. XML language, numbering, and template actions are separated from the compact Avalonia options. Browser history inspection has less detail than the current desktop inspector. Browser settings drafts activate only after Save. + +## Goals / Non-Goals + +**Goals:** Match portable Avalonia workflows and make the same state transitions visible. Retain Core candidate identity, exact timing, atomic application, and in-memory history. Verify interactions at default, wide, narrow, short, and landscape sizes. + +**Non-Goals:** Native browser processes, filesystem paths, system font enumeration, history persistence, and a pixel copy of Avalonia controls. + +## Decisions + +1. Keep the browser shell in Razor. Expose a load menu through an ordinary named button. Keep one visible `Edit history` action on the main toolbar. Do not add separate Undo or Redo buttons there. +2. Put XML language, numbering, and template input beside the existing format and name controls. Use shared Core candidates for option preview. Candidate presentation shows changed fields in the original rows. Remove redundant visible summaries from the main surface. Put complete metadata in accessible tooltips or a focused dialog. +3. Preserve the original visible frame basis in candidate review. Display preference changes refresh candidate formatting without discarding the candidate. Applying a candidate restores selection and scrolling. +4. Add a browser history projection over Core inspection outcomes. Reuse localized operation and field identities. Separate inspection selection, current cursor, and preferred redo branch. Cancel previous detail requests and reject stale document identities. Provide tree/details navigation at narrow widths. +5. Apply valid browser settings drafts to runtime preferences without writing localStorage. Keep an entry snapshot for cancellation. Save persists one validated aggregate. Persistence failure leaves the dialog and current valid draft available. Do not replay content drafts or change the active session output format while saving its future default. +6. Keep optional export and content tool dialogs. Align useful values, candidate state, accessible actions, and responsive review presentation. Browser activity logs retain browser event data and filtered export; filesystem log folders remain a desktop boundary. +7. Assign independent file ownership to Luna agents. One agent owns Home and main CSS. Another owns history and its projection. Another owns portable tool components. The coordinator owns shared candidate APIs, integration, documentation, and sequential checks. +8. Determine applied expression state from typed operations on the current history branch and track. Naming and numbering changes do not replay the retained expression. Undo and Redo use the actual branch. Keep the script available for explicit expression authoring. +9. Show syntax diagnostics in a floating layer. Keep diagnostics outside normal layout flow so they do not resize the editor, grid, or other workflow zones. Preserve accessible names and keyboard access. + +## Difference Checklist + +| Area | Current difference | Result | +| --- | --- | --- | +| Load | Reload and Append require right click | Discoverable named load menu | +| Bottom controls | XML language, numbering, template hidden in dialogs | Compact main-window controls with stable localized identities | +| Main candidate | Expression-only inline review and duplicate summaries | Shared options review with compact before/candidate cells | +| Grid | Candidate basis, selection, and read-only behavior need workflow verification | Actual editing, cancellation, application, and export assertions | +| History | Summary-only details and preferred-path redo | Root rows, parameters, change filters, alternate redo choices | +| Main toolbar history | Separate Undo and Redo buttons duplicate the Avalonia history entry | One visible `Edit history` action; keep Undo and Redo in history and shortcut workflows | +| Expression diagnostics | Syntax errors occupy a normal layout row | Accessible floating diagnostics that do not shift workflow layout | +| Settings | Save-only activation | Live valid drafts, Save persistence, Discard rollback | +| Auxiliary tools | Coarse candidate and log detail presentation | Relevant localized values and accessible responsive actions | +| Browser boundaries | File input, downloads, localStorage, JS editor | Retain browser implementations and document intentional differences | + +## Risks / Trade-offs + +- Live draft changes can update the UI while settings are open. Restore the complete runtime entry snapshot on discard and verify unchanged content/history. +- Independent candidates can become stale after track or history changes. Keep Core base tokens and explicit refresh requirements. +- Narrow layouts need a readable comparison without shrinking timestamps. Allow internal grid scrolling while keeping page controls reachable. +- Parallel builds can lock shared outputs. Run all build and test commands in the coordinator, sequentially. diff --git a/openspec/changes/sync-web-ui-with-avalonia/proposal.md b/openspec/changes/sync-web-ui-with-avalonia/proposal.md new file mode 100644 index 00000000..a0d8d9f3 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/proposal.md @@ -0,0 +1,28 @@ +## Why + +The browser UI still exposes an older workflow than the current Avalonia UI. Main-window options, candidate review, history inspection, and live settings behave differently even though both hosts share Core. + +## What Changes + +- Use current Avalonia behavior as the reference for portable workflows. +- Expose a discoverable load menu and the XML language, numbering, and template controls in the browser main window. +- Align compact inline candidate review and keep display-only changes separate from committed content. +- Align history inspection, operation details, filtering, and retained redo branch choices. +- Apply valid settings drafts live while keeping Save, Discard, and browser storage outcomes distinct. +- Align portable tool presentation and verify default, wide, narrow, short, and landscape layouts. +- Preserve browser-specific file input, downloads, clipboard, editor, and native dialog boundaries. + +## Capabilities + +### New Capabilities + +- `wasm-main-window-parity`: Current Avalonia workflow parity in the browser toolbar, main options, chapter grid, and candidate review. +- `wasm-history-inspection`: Browser history inspection, detail filtering, alternate redo choices, and responsive navigation. + +### Modified Capabilities + +- `wasm-settings-shortcuts`: Valid settings drafts apply live without persistence; discard restores the entry runtime snapshot. + +## Impact + +Browser Razor components, workspace adapters, CSS, WASM behavior tests, browser E2E tests, and current code-map documentation change. Core remains the authority for candidates and history. No new runtime dependency or native browser capability is required. diff --git a/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-history-inspection/spec.md b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-history-inspection/spec.md new file mode 100644 index 00000000..56dfed00 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-history-inspection/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Complete read-only history inspection +The browser history inspector MUST expose localized operation details, parameters, root chapter rows, and meaningful before/after field values. It MUST filter added, removed, and modified changes. Inspection MUST NOT move the history cursor or alter content. Detail requests MUST be canceled or ignored when selection or the document identity changes. Recoverable inspection failures MUST support retry. + +#### Scenario: Inspect a node and filter its changes +- **WHEN** the user selects a retained node and filters its changed fields +- **THEN** the inspector shows the corresponding values and parameters +- **AND** the committed document and current history cursor remain unchanged + +### Requirement: Alternate retained redo choices +The browser history tool MUST offer direct children of the current cursor as redo choices. Choosing a preferred branch MUST NOT restore it until the user invokes Redo. Explicit restore MUST remain separate from inspection selection. + +#### Scenario: Review alternate futures +- **WHEN** two retained children follow the current cursor and the user selects one as the redo path +- **THEN** content stays unchanged until Redo +- **AND** Redo restores the selected child through the shared Core session + +### Requirement: Responsive and keyboard-accessible history navigation +Wide history layouts MUST show tree and details together. Narrow layouts MUST provide tree and details navigation with reachable restore and close actions. The current node MUST be locatable. Tree and inspection controls MUST retain keyboard and modal focus behavior. + +#### Scenario: Inspect and restore on a narrow viewport +- **WHEN** the user selects a history node at 390x640 and opens its details +- **THEN** values and the restore action are reachable +- **AND** Escape closes the dialog and restores focus to its opener diff --git a/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-main-window-parity/spec.md b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-main-window-parity/spec.md new file mode 100644 index 00000000..c89f6230 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-main-window-parity/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Discoverable portable main-window controls +The browser main window MUST expose Reload and Append through a visible load menu. It MUST expose XML language, numbering offset from zero through 1000, and template loading beside format and naming controls. XML language MUST be enabled only for XML output. Changing a content draft MUST NOT change committed chapters or exports. + +#### Scenario: Use main-window options +- **WHEN** a user loads chapters and changes naming, numbering, or a template +- **THEN** the main window retains a draft and offers explicit preview and application +- **AND** output remains unchanged until application + +#### Scenario: Choose XML output language +- **WHEN** a user selects XML output and changes UI language +- **THEN** XML choices use localized names with stable codes and preserve the selected code +- **AND** non-XML output disables the XML selector + +### Requirement: Compact inline reviewed candidates +The browser main window MUST present relevant before and candidate values in the original chapter cells. It MUST keep the visible original frame basis. The main surface MUST use compact apply, discard, and refresh actions without duplicate review summaries. Display preferences MUST NOT discard a candidate or create content history. Apply MUST commit the reviewed candidate once and preserve selection and scroll position. + +#### Scenario: Review and apply naming with numbering +- **WHEN** a user previews main-window naming and numbering drafts +- **THEN** changed names and numbers appear inside the original chapter table +- **AND** applying creates one atomic operation that one Undo restores + +#### Scenario: Refresh display during review +- **WHEN** a user changes frame display rounding during an active candidate +- **THEN** the review remains active and can still be applied or discarded +- **AND** committed timestamps and history are unchanged + +#### Scenario: Change other options after applying an expression +- **WHEN** the current history branch already contains the active track's applied expression and the user changes naming or numbering +- **THEN** the candidate retains the committed timestamps and does not replay that expression +- **AND** the editor retains the script for inspection or explicit expression authoring + +#### Scenario: Keep a candidate behind the expression editor +- **WHEN** the user opens the expression dialog while a valid main-window candidate is visible and changes its independent draft +- **THEN** the background candidate values and cell geometry remain unchanged +- **AND** Cancel restores the original main candidate and leaves exports unchanged + +### Requirement: Responsive portable tool workflows +Changed browser surfaces MUST preserve toolbar, grid, options, and status zones. Normal, editor, and candidate cells MUST keep timestamps on one line and decimals readable as one number. The default, wide, narrow, short, and landscape layouts MUST retain accessible primary actions and avoid page-level horizontal overflow. Portable export, log, and content dialogs MUST preserve cancellation, localized values, and modal isolation. + +#### Scenario: Use compact controls on a phone viewport +- **WHEN** the user opens and uses changed controls at 390x640 +- **THEN** primary actions remain reachable by real clicks and keyboard navigation +- **AND** chapter content can scroll internally without page-level horizontal overflow + +### Requirement: Main toolbar history entry +The browser main toolbar MUST expose one `Edit history` action. It MUST NOT expose separate Undo and Redo buttons. Undo and Redo shortcuts and history workflows MUST remain available. + +#### Scenario: Open history from the main toolbar +- **WHEN** a user opens history from the browser main toolbar +- **THEN** the toolbar exposes the same single history entry as Avalonia +- **AND** the user can still undo and redo through supported shortcuts and history actions + +### Requirement: Non-layout syntax diagnostics +The browser expression editor MUST show syntax diagnostics in an accessible floating layer. Diagnostics MUST NOT participate in normal layout flow or shift the editor, grid, or workflow controls. + +#### Scenario: Show syntax diagnostics while editing +- **WHEN** the expression editor contains invalid syntax +- **THEN** the diagnostic layer displays the errors and supports keyboard access +- **AND** the editor and main workflow geometry remain unchanged diff --git a/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-settings-shortcuts/spec.md b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-settings-shortcuts/spec.md new file mode 100644 index 00000000..2e6a1b72 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/specs/wasm-settings-shortcuts/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Settings drafts have explicit save and discard behavior + +The Web settings tool MUST compare normalized drafts with its entry snapshot. Valid draft changes MUST apply runtime preferences immediately without persistence or content transactions. Save MUST validate and persist one aggregate before closing. A validation or persistence failure MUST preserve the tool, draft, and last valid live preferences. Reset MUST update the draft and its valid live preview without persistence. Intentional Cancel MUST restore the entry runtime snapshot and close. Default output preferences MUST remain separate from an active document's session output choices. + +#### Scenario: Save valid settings +- **WHEN** the user changes and saves a valid settings draft +- **THEN** the runtime preview is active before Save and one aggregate is persisted on Save +- **AND** the settings tool closes without changing chapter content or history + +#### Scenario: Save fails +- **WHEN** storage rejects settings persistence +- **THEN** the tool remains open with a recoverable localized error and the draft +- **AND** active preferences and shortcuts retain their last valid live values +- **AND** chapter content and history remain unchanged + +#### Scenario: Reset then cancel +- **WHEN** the user resets settings and invokes Cancel +- **THEN** saved values remain unchanged and active preferences return to the entry snapshot +- **AND** reopening restores the entry saved values diff --git a/openspec/changes/sync-web-ui-with-avalonia/tasks.md b/openspec/changes/sync-web-ui-with-avalonia/tasks.md new file mode 100644 index 00000000..1dccd520 --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/tasks.md @@ -0,0 +1,27 @@ +## 1. Difference audit + +- [x] 1.1 Compare current Avalonia and Web main-window, settings, expression, history, and portable tool workflows. +- [x] 1.2 Record portable corrections and intentional browser boundaries in the design checklist. + +## 2. Main window and settings + +- [x] 2.1 Expose a named load menu and compact XML, numbering, and template controls. +- [x] 2.2 Review combined content options through shared Core candidates and render compact inline comparisons. +- [x] 2.3 Preserve candidate state, committed ticks, selection, and scrolling through display changes and application. +- [x] 2.4 Apply valid settings drafts live, save once, and restore runtime preferences on discard. +- [x] 2.5 Align the main toolbar with Avalonia by keeping one `Edit history` action and removing dedicated Undo and Redo buttons. +- [x] 2.6 Present expression syntax diagnostics in an accessible floating layer that does not participate in normal layout. + +## 3. History and portable tools + +- [x] 3.1 Add localized history parameters, root rows, complete field comparisons, filters, cancellation, and retry. +- [x] 3.2 Add direct-child redo branch choices and responsive keyboard-accessible tree/details navigation. +- [x] 3.3 Align export, log, and content dialog values, candidate state, cancellation, and accessible actions. + +## 4. Verification and documentation + +- [x] 4.1 Add and pass compiled WASM regressions for combined options, live preferences, and history choices. +- [x] 4.2 Add and pass browser workflow regressions for main controls, history inspection, settings rollback, and tool isolation. +- [x] 4.3 Verify default, wide, narrow, short, and landscape layouts and capture review screenshots. +- [x] 4.4 Run affected checks sequentially, validate localization and OpenSpec, and update current code-map ownership and primary tests. +- [x] 4.5 Add `specs/main-diagnostics-layout.spec.ts` to verify toolbar history parity and diagnostic geometry across empty, valid, invalid, pending, and stale states. diff --git a/openspec/changes/sync-web-ui-with-avalonia/verification.md b/openspec/changes/sync-web-ui-with-avalonia/verification.md new file mode 100644 index 00000000..040b40bd --- /dev/null +++ b/openspec/changes/sync-web-ui-with-avalonia/verification.md @@ -0,0 +1,184 @@ +## Scope + +Three `gpt-6-luna` agents compared and updated separate Web surfaces. +The coordinator integrated shared candidate APIs, localization, and verification. +The first review missed invalid main-expression layout and toolbar history placement. +The earlier successful checks did not establish complete UI parity. + +| Difference | Verified result | +| --- | --- | +| Load actions required a context click | A visible menu exposes Reload and Append MPLS | +| Main options were separated across tools | XML language, template loading, and numbering are available in the main window | +| Main preview covered expression changes only | One Core candidate combines naming, numbering, and an unapplied expression | +| Candidate application could disturb presentation | Selection and scroll position survive one atomic Apply | +| History showed coarse summaries | Root rows, typed parameters, field comparisons, filters, and direct-child redo choices are available | +| Settings activated only on Save | Valid drafts apply live; Save persists once; Cancel restores runtime preferences | +| Tool details differed from desktop workflows | Export validation, exact content comparisons, and browser log inspection retain drafts and failure feedback | +| Narrow and landscape controls lost readable width | Five viewport checks protect reachable actions, labels, and control widths | + +Applied expressions are derived from typed operations on the current history branch and track. +Naming and numbering do not execute the same applied expression again. +Undo and Redo preserve this distinction. +The script remains available in the editor. + +An open expression dialog retains the main candidate behind the modal. +Its independent draft does not change background values or cell geometry. + +## Initial verification + +| Check | Result | +| --- | --- | +| WASM compiled tests | 87 passed | +| Avalonia localization tests | 16 passed | +| Avalonia and Web public-API parity tests | 3 passed | +| Chromium full browser suite | 68 passed | +| Chromium after the final numeric-width adjustment | 14 passed | +| WebKit workflow regressions | 38 passed initially; the remaining case passed on isolated recheck | +| Release WASM publish | Passed | +| Browser TypeScript check | Passed | +| Generated locale check | Passed | +| OpenSpec strict validation | Passed | +| Whitespace check | Passed | + +Primary commands: + +```text +dotnet test tests/ChapterTool.Wasm.Tests/ChapterTool.Wasm.Tests.csproj --no-restore +dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore --filter FullyQualifiedName~LocalizationTests +dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore --filter FullyQualifiedName~PortableUiParityTests +dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --configuration Release --no-restore --output artifacts/wasm-e2e/publish -p:WasmBuildNative=false -p:WasmRunWasmOpt=false +npm run prepare:site +npm run typecheck +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts --project=chromium +uv run --project scripts scripts/localization/axaml-to-json.py --check +openspec validate sync-web-ui-with-avalonia --strict +``` + +Run the browser commands from `tests/ChapterTool.Wasm.E2E/`. +Windows invocations pass arguments to `node.exe` as an array. +The publish options match the browser CI profile. + +The first WebKit run timed out while waiting for the initial Load button to become actionable in the viewport capture case. +The same case passed without a code change on isolated recheck. +The cause is not established. +The initial failure trace and the successful recheck report are retained. + +WebKit command: + +```text +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts --project=webkit modal-layout.spec.ts unified-editing.spec.ts layout-behavior.spec.ts main-window-parity.spec.ts history-parity.spec.ts portable-tools-parity.spec.ts settings-localization.spec.ts repeating-frame-display.spec.ts +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts --project=webkit main-window-parity.spec.ts --grep "main candidate actions" +``` + +Browser reports: + +- `artifacts/wasm-e2e/report-parity-complete-chromium/` +- `artifacts/wasm-e2e/report-parity-layout-chromium/` +- `artifacts/wasm-e2e/report-parity-complete-webkit/` +- `artifacts/wasm-e2e/report-parity-webkit-load-recheck/` + +Reviewed main-window images: + +- `artifacts/web-ui-parity-review/main-default.png` +- `artifacts/web-ui-parity-review/main-wide.png` +- `artifacts/web-ui-parity-review/main-narrow.png` +- `artifacts/web-ui-parity-review/main-short.png` +- `artifacts/web-ui-parity-review/main-landscape.png` + +## User-reported layout correction + +The Avalonia main expression row uses editor diagnostics and tooltips. +`MainView.axaml` keeps preview actions in the expression row. +`ExpressionEditor.axaml` defines a diagnostic popup outside normal layout. +The independent expression tool has a separate status row in its scrolling body. +That tool layout must not define the main-window diagnostic layout. +`HistoryToolView.axaml` owns Undo and Redo buttons. +The main toolbar exposes only the history entry. + +The browser now uses an editor diagnostic overlay. +The overlay opens on hover or keyboard focus. +It appears above the browser editor to remain visible near the bottom viewport edge. +The editor exposes invalid state and the associated diagnostic text to accessibility APIs. +Pending and stale messages remain available without adding layout rows. +Stable preview action heights prevent status transitions from shrinking the grid. +The browser main toolbar no longer has Undo or Redo buttons. +The history dialog retains both actions. +Document shortcuts retain their existing editing boundaries. + +The new browser regression failed before the fix. +Invalid syntax reduced the grid height by 64.78125 pixels at default and wide sizes. +It reduced the grid height by 82.78125 pixels at the narrow size. +The toolbar check also found the extra Undo button. +The first correction still changed action-row height during state transitions. +Its failed geometry assertions were retained. +The final correction makes those heights stable. + +Correction evidence is stored in `artifacts/wasm-e2e/report-diagnostics-before/`. +The intermediate failure reports are retained under `report-diagnostics-after/` and `report-diagnostics-final/`. +The last intermediate failure used an incorrect expected label, `Refresh preview`. +The shared resource and rendered button use `Update preview`. +The test now verifies that existing label. + +The desktop diagnostic hover test passed in its separate Headless process. +The initial command used unsupported `--filter` and ran no tests. +The successful command used the repository's xUnit filter option: + +```text +dotnet test tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj --no-restore --filter-method "*Expression_editor_opens_diagnostic_popup_when_pointer_hovers_a_diagnostic*" +``` + +The first complete correction run exposed a shortcut parameter bug. +The C# string array expanded into separate JS interop arguments. +The browser guard received `Ctrl+O` as a string and built a set of its characters. +Document Undo then ran together with native text Undo after a cell commit. +The reproduction changed `Opening` to `OpeningOpening`. +The fix passes the gesture array as one JS argument. +The regression verifies real keyboard event cancellation and exact document Undo/Redo results. +Field-level text Undo remains available. +Layout status assertions now target the status strip instead of matching two status regions. + +A later shared-output publish produced incompatible assemblies during concurrent Core edits and builds. +Expression preview reported a missing `EditableChapterDocument` constructor. +That browser run was stopped after the mismatch was confirmed. +Verification now publishes an isolated source snapshot under `artifacts/web-ui-verification-source/`. +The snapshot includes current Core changes without editing them. +`artifacts/web-ui-verification-source-manifest.json` records each copied source hash. +The isolated publish uses its own `bin` and `obj` paths. +The published site is prepared with `CHAPTERTOOL_E2E_PUBLISH=artifacts/wasm-ui-isolated-publish/wwwroot`. + +Final correction checks use the isolated publish: + +| Check | Result | +| --- | --- | +| Chromium complete suite | 73 passed | +| WebKit main diagnostics, main options, history, and smoke workflows | 16 passed | +| Avalonia diagnostic hover interaction | 1 passed | +| Release isolated WASM publish | Passed | +| TypeScript check | Passed | +| OpenSpec strict validation | Passed | + +The Chromium report is `artifacts/wasm-e2e/report-diagnostics-isolated-chromium/`. +The WebKit report is `artifacts/wasm-e2e/report-diagnostics-isolated-webkit/`. +Current `Home.razor` and `app.css` hashes match the tested snapshot. +Review images were refreshed from that Chromium run: + +- `artifacts/web-ui-parity-review/diagnostic-default.png` +- `artifacts/web-ui-parity-review/diagnostic-wide.png` +- `artifacts/web-ui-parity-review/diagnostic-narrow.png` +- The five `main-*.png` images listed above. + +The final browser commands are: + +```text +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts --project=chromium +node node_modules/@playwright/test/cli.js test --config=playwright.config.ts --project=webkit specs/main-diagnostics-layout.spec.ts specs/main-window-parity.spec.ts specs/history-parity.spec.ts specs/smoke.spec.ts +``` + +## Browser boundaries + +Browser file input replaces local filesystem pickers. +Browser downloads replace native save paths. +Browser storage replaces the desktop settings file. +The browser does not start native media tools or enumerate system fonts. +Browser activity logs use actual browser records. +Native log directories remain a desktop capability. diff --git a/openspec/specs/atomic-edit-transactions/spec.md b/openspec/specs/atomic-edit-transactions/spec.md new file mode 100644 index 00000000..c827177a --- /dev/null +++ b/openspec/specs/atomic-edit-transactions/spec.md @@ -0,0 +1,39 @@ +# atomic-edit-transactions Specification + +## Purpose +TBD - created by archiving change unified-editing-02-atomic-transactions. Update Purpose after archive. +## Requirements +### Requirement: Document mutations commit atomically +A document session SHALL compute and validate a complete candidate before publishing it. A failed, cancelled, invalid, or unchanged operation SHALL leave the document and session revision unchanged. + +#### Scenario: Candidate contains one invalid result +- **WHEN** a batch operation produces any invalid row +- **THEN** the session SHALL reject the entire candidate +- **AND** it SHALL preserve the original document + +#### Scenario: Candidate is unchanged +- **WHEN** a command produces the same canonical document value +- **THEN** it SHALL return `NoChange` without increasing the mutation revision + +### Requirement: Session commits reject stale work and serialize commands +Each candidate SHALL capture its document identity, state identity, and mutation revision. The session SHALL serialize mutations and reject a candidate whose captured token is no longer current. + +#### Scenario: An edit occurs while a candidate is computing +- **WHEN** another mutation commits before the candidate is applied +- **THEN** the stale candidate SHALL be rejected without changing the current document + +#### Scenario: Cancellation before publication +- **WHEN** an operation is cancelled before atomic publication +- **THEN** no candidate state or history-visible metadata SHALL be published + +### Requirement: Retried transactions are idempotent +The session SHALL return the original result for a retry with the same transaction ID and request. It SHALL reject reuse of that ID with a different request. + +#### Scenario: Commit notification is retried +- **WHEN** the same committed transaction is submitted again +- **THEN** the original result SHALL be returned and the document SHALL not change again + +#### Scenario: Transaction ID is reused with different input +- **WHEN** a transaction ID is submitted with a different operation payload +- **THEN** the session SHALL reject it and preserve the document + diff --git a/openspec/specs/avalonia-ui-shell/spec.md b/openspec/specs/avalonia-ui-shell/spec.md index bb57d27c..aed6f824 100644 --- a/openspec/specs/avalonia-ui-shell/spec.md +++ b/openspec/specs/avalonia-ui-shell/spec.md @@ -91,15 +91,15 @@ The UI shell SHALL expose documented main-window actions through commands. - **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. +The Avalonia shell SHALL route the active, user-configurable shortcut mapping and preserve context menu actions. #### Scenario: Global shortcuts route to commands -- **WHEN** the main window has focus -- **THEN** `Ctrl+O`, `Ctrl+S`, `Alt+S`, `Ctrl+R`, `F5`, `Ctrl+L`, and `F11` SHALL invoke the corresponding ViewModel commands +- **WHEN** the main window has focus and an active mapped gesture is pressed +- **THEN** the command associated with that gesture SHALL execute -#### Scenario: Clip shortcuts preserve legacy mapping -- **WHEN** `Ctrl+1` through `Ctrl+9` or `Ctrl+0` are pressed -- **THEN** they SHALL select clips 1 through 9 and clip 10 respectively when in range +#### Scenario: Default mapping preserves legacy behavior +- **WHEN** no shortcut overrides exist +- **THEN** `Ctrl+O`, `Ctrl+S`, `Alt+S`, `Ctrl+R`, `F5`, `Ctrl+L`, and `F11` SHALL invoke the corresponding ViewModel commands #### Scenario: Context menus use capability flags - **WHEN** load, clip, or chapter-row context menus open @@ -347,7 +347,7 @@ The Avalonia shell SHALL use a dedicated Lua expression/script editor for all ex - **THEN** the error and suggestion feedback SHALL be cleared ### Requirement: Lua expression script authoring -The Avalonia shell SHALL allow users to apply Lua expression transforms with built-in presets and external script selection. +The Avalonia shell SHALL allow users to author and apply Lua expression transforms with built-in presets and external script selection. Expression text SHALL remain an operation draft until the user explicitly applies its candidate. #### Scenario: Expression tool exposes Lua script editing - **WHEN** the expression tool window is opened @@ -356,22 +356,27 @@ The Avalonia shell SHALL allow users to apply Lua expression transforms with bui #### Scenario: Built-in Lua preset can populate script text - **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor -- **THEN** the tool SHALL show or insert the preset script text and apply that script through the owner ViewModel when the user applies the tool +- **THEN** the tool SHALL show or insert the preset script text +- **AND** it SHALL prepare a live candidate preview without changing the document +- **AND** it SHALL apply that script to the document only after the user confirms Apply #### Scenario: External Lua script can be selected - **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool - **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input - **AND** it SHALL use the file picker service abstraction to select and read the script text -- **AND** applying or using the loaded script SHALL pass that script text into chapter preview and save options without requiring Core to read the file path +- **AND** loading the script SHALL prepare a preview without changing the document +- **AND** applying the loaded script SHALL pass its text into the candidate operation without requiring Core to read the file path -#### Scenario: Lua expression or script participates in preview and save -- **WHEN** expression application is enabled and the current chapters are previewed or saved -- **THEN** the main ViewModel SHALL project chapter output using Lua expression/script options -- **AND** Lua diagnostics SHALL be surfaced through the same status/log diagnostic path as expression diagnostics +#### Scenario: Lua expression or script is applied as a document operation +- **WHEN** the user edits or loads a Lua expression script +- **THEN** the UI SHALL refresh a read-only candidate preview and surface Lua diagnostics through the status/log diagnostic path +- **AND** format preview and save SHALL use committed chapter values and SHALL NOT apply the expression draft +- **WHEN** the user applies a valid candidate +- **THEN** the UI SHALL commit the candidate as one undoable transaction #### Scenario: Simple arithmetic remains approachable through Lua - **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` -- **THEN** preview and save SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax +- **THEN** the live candidate preview SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax ### Requirement: Preview and save use shared Lua projection The Avalonia shell SHALL route preview, text preview, and save through the same Lua expression projection options and the same injected export/projection service path exposed by the main ViewModel/workspace. @@ -896,7 +901,7 @@ ChapterTool SHALL keep an attribution record for the imported SourceGit resource - **AND** `Themes.axaml` and `Styles.axaml` SHALL each include the SourceGit copyright and MIT license notice ### Requirement: Global styles preserve workflow usability -The ported style layer SHALL keep ChapterTool workflows usable at supported window sizes. +The ported style layer SHALL keep ChapterTool workflows usable at supported window sizes. Preview readiness, diagnostics, and inline comparison SHALL NOT add rows to the bottom options form or change the allocated chapter viewport bounds at a fixed window size and input layout. Narrow comparison cells MAY use two labeled lines without reducing the allocated viewport. #### Scenario: Main window uses the global styles - **WHEN** the main window opens @@ -908,6 +913,12 @@ The ported style layer SHALL keep ChapterTool workflows usable at supported wind - **THEN** primary actions SHALL remain visible - **AND** text SHALL remain inside its control bounds +#### Scenario: Inline comparison preserves chapter space +- **WHEN** a fixed-size main window moves through empty, computing, ready, invalid, unchanged, and stale preview states +- **THEN** its allocated chapter viewport bounds SHALL remain equal within layout rounding tolerance +- **AND** no detailed result panel SHALL appear below the options form +- **AND** responsive comparison SHALL remain readable without overlapping values + ### Requirement: Settings window uses compact imported form composition The settings window SHALL use the imported input and action styles. @@ -982,3 +993,459 @@ Host selection SHALL change platform effects and tool presentation without chang - **WHEN** two Avalonia hosts invoke the same typed source load command with equivalent source documents - **THEN** both shells SHALL apply the same workspace commit and projection rules - **AND** only their host-specific source and surface effects may differ + +### Requirement: Avalonia expression editing provides a live read-only preview +Avalonia expression inputs SHALL refresh a read-only candidate preview while the user edits the script or selects a preset. The preview SHALL remain separate from the editable chapter grid and SHALL require an explicit Apply action to change the document. + +#### Scenario: Main expression input refreshes the candidate preview +- **WHEN** the user edits the expression in the main window or selects an expression preset +- **THEN** the UI SHALL refresh the candidate from the current committed document +- **AND** it SHALL display changed chapter values or a no-change result +- **AND** the main chapter grid SHALL continue to display committed values + +#### Scenario: Expression tool shows live differences and diagnostics +- **WHEN** the expression tool is open and the user edits its Lua script +- **THEN** the UI SHALL refresh a read-only before-and-after preview +- **AND** it SHALL show current Lua and document validation errors +- **AND** it SHALL disable Apply while the candidate is invalid + +#### Scenario: User applies or cancels the live candidate +- **WHEN** the user applies a valid current expression candidate +- **THEN** the UI SHALL commit one history transaction and refresh the grid from the committed document +- **AND** format preview and save SHALL use those committed values without rerunning the expression +- **WHEN** the user cancels the candidate +- **THEN** the UI SHALL clear preview data without changing document content or history + +#### Scenario: Expression text is a draft without a persistent enable toggle +- **WHEN** the user edits expression text or selects a preset +- **THEN** the UI SHALL treat that value as an unapplied operation draft +- **AND** it SHALL require an explicit Apply action to commit it +- **AND** it SHALL NOT expose a persistent checkbox that silently enables expression projection during preview or save + +### Requirement: History opens in a button-invoked dialog + +The Avalonia history button MUST open a separate modal dialog for the current document session. The main window MUST NOT reserve permanent space for a history list or sidebar. The dialog MUST use the current session's history and commands. Opening, closing, inspecting, or resizing the dialog MUST NOT create history transactions. Selecting a history node MUST inspect it. Restoration MUST require an explicit history action. + +#### Scenario: Load without opening history +- **WHEN** a user loads a chapter document without opening history +- **THEN** the main window does not show a history list or reserved history sidebar +- **AND** the chapter workspace uses its full allocated central width +- **AND** the history button has a localized accessible name + +#### Scenario: Open and dismiss history +- **WHEN** a user opens history +- **THEN** one modal history dialog owned by the main window opens +- **AND** it shows the current node, inspection selection, undo, redo, branch choices, and session-lifetime notice +- **AND** background clicks, shortcuts, and drag/drop cannot modify the document through the dialog +- **WHEN** the user closes the dialog through Close, window close, or Escape +- **THEN** focus returns to the history button +- **AND** closing does not roll back completed history navigation +- **AND** the main-window chapter region does not change its allocated size + +#### Scenario: Inspect a node in the dialog +- **WHEN** the user selects, double-clicks, or presses Enter on a retained node +- **THEN** the dialog displays its details without restoring document content +- **AND** Enter moves focus to the detail region +- **AND** document revision, current history node, preferred redo choices, and pending content preview remain unchanged + +#### Scenario: Navigate history in the dialog +- **WHEN** the user explicitly invokes Restore to This Node, Undo, Redo, or an alternate redo choice +- **THEN** the action uses the current session's existing history command behavior +- **AND** successful navigation refreshes the main-window chapter data and the dialog's current marker, inspection selection, action availability, and descriptions +- **AND** reopening the dialog shows the same current history state +- **AND** navigation does not add a history node or discard retained branches + +#### Scenario: Reopen history and release its resources +- **WHEN** the user repeatedly opens and closes history +- **THEN** at most one dialog exists for that session at a time +- **AND** each closed dialog releases its content, subscriptions, and detail requests +- **AND** reopening selects the current node +- **AND** the history button is disabled without an available document session + +#### Scenario: Inspect a long branching history +- **WHEN** history has many entries, several branches, and long operation titles +- **THEN** users can reach retained nodes through native tree scrolling, expansion, and keyboard navigation +- **AND** current position and branch relationships do not depend only on color or spaces +- **AND** undo, redo, restore, and close actions remain reachable + +### Requirement: Chapter grid fills its allocated workspace +章节表格 MUST 填充顶部工具区与底部选项之间的中央分配区。该区域 MUST NOT 为已隐藏的空状态或历史保留独立空白行。空状态 MUST 在相同工作区居中显示。列标题、值和编辑控件 MUST 保持可读。 + +#### Scenario: Loaded table uses the full central area +- **WHEN** 用户在 760×600、1280×800 或 760×520 的窗口加载足够多的章节 +- **THEN** 表格边界覆盖中央分配区的完整可用宽度和高度 +- **AND** 表格下方没有额外的等高空白区域 +- **AND** 用户能滚动到最后一个章节并提交单元格编辑 +- **AND** 表格虚拟化和扩展选择继续工作 + +#### Scenario: Empty and loaded states share the same area +- **WHEN** 窗口尚未加载章节 +- **THEN** 空状态图像和提示在章节工作区居中 +- **AND** 空状态不会阻止加载或拖放操作 +- **WHEN** 章节加载成功 +- **THEN** 空状态隐藏且表格使用同一完整工作区 + +#### Scenario: Resize with long chapter values +- **WHEN** 用户调整窗口尺寸并编辑长章节名称、时间或帧值 +- **THEN** 列保留合理最小宽度,标题和内容不相互覆盖 +- **AND** 必要的滚动保持可用 +- **AND** 编辑按稳定列身份提交,选择和滚动位置在身份仍有效时保持 + +### Requirement: Main-window content previews have a bounded review region +表达式及主窗口其他待确认内容操作 MUST 显示在独立结果区域。该区域 MUST 显示操作名称、作用范围、完整变更计数和可检查的结果。状态条 MUST 只显示简短状态,不能作为唯一的差异展示。Apply 和 Cancel MUST 位于结果滚动区之外。没有预览时,结果区域 MUST 折叠。 + +#### Scenario: Preview an expression without overlapping options +- **WHEN** 用户输入有效表达式 `t + 1` 并得到候选 +- **THEN** 预览出现在独立结果区域 +- **AND** 保存格式、XML 语言、命名、编号偏移和表达式输入不被结果文字覆盖 +- **AND** 用户能检查预览并点击 Apply 或 Cancel + +#### Scenario: Review another content operation +- **WHEN** 用户准备命名、编号偏移或帧率变换候选 +- **THEN** 同一结果区域显示该操作的名称、范围和修改前后值 +- **AND** 用户不需要从状态条的技术摘要判断将要提交的内容 +- **AND** Apply 绑定该区域所展示的候选 + +#### Scenario: Inspect all differences from a compact result +- **WHEN** 候选包含 1,000 个章节的变化,紧凑区域只能显示部分条目 +- **THEN** 摘要计数覆盖完整候选 +- **AND** 界面明确表示当前只显示部分条目 +- **AND** 所有差异都能通过滚动或完整差异入口访问 +- **AND** 完整差异入口展示同一个候选且不重新计算表达式 + +#### Scenario: Clear a preview +- **WHEN** 用户取消候选、应用成功或文档会话结束 +- **THEN** 对应预览结果和确认操作清除 +- **AND** 结果区域不保留空白占位或过期值 + +### Requirement: Main-window preview values use localized business formats +主窗口预览 MUST 从 typed snapshot 展示本地化业务值。它 MUST 按章节组织修改前、修改后和变化量。时间变化、帧信息更新和属性变化 MUST 明确区分。普通结果 MUST NOT 直接显示 ticks、内部字段标识、原始枚举或对象转储。 + +#### Scenario: Review a one-second change +- **WHEN** 候选将章节时间从 00:00:00 改为 00:00:01 +- **THEN** 对比显示章节身份、可读的原时间、目标时间和正一秒变化量 +- **AND** 同一章节的帧信息变化在该章节的明细中显示 +- **AND** 原始 `StartTicks` 数字不是默认时间表示 + +#### Scenario: Distinguish frame-only and property-only changes +- **WHEN** 候选只更新帧信息或持久化属性而没有时间变化 +- **THEN** 摘要明确说明没有章节时间变化 +- **AND** 相关更新以本地化字段和原值、目标值显示 +- **AND** 同一章节的多种变化只计为一个受影响章节 + +#### Scenario: Preserve precise and missing values +- **WHEN** 时间差小于一毫秒,或原帧信息缺失而目标为零帧 +- **THEN** 时间精度能区分实际变化且变化量不显示为零 +- **AND** 缺失帧值与零帧使用不同的业务表示 +- **AND** 帧率依据不同的两侧各自显示依据且不生成误导的帧差 + +#### Scenario: Localize normal review content +- **WHEN** 用户使用中文或英文检查预览 +- **THEN** 字段标签、单位、空值、状态和错误摘要使用当前语言 +- **AND** accuracy 等枚举含义以业务说明展示 +- **AND** 长文本可换行或展开且含义不只依赖颜色 + +### Requirement: Preview UX preserves candidate transactions +预览展示和布局调整 MUST 保留现有候选事务语义。准备 MUST 保持已提交章节和历史不变。Apply MUST 只提交展示的有效且当前的候选。输入、目标或历史变化 MUST 使旧候选不可应用。取消、失败和过期状态 MUST 不提交部分结果。 + +#### Scenario: Apply and undo the reviewed result +- **WHEN** 用户确认一个有效且当前的候选 +- **THEN** 提交值与展示值完全相同且只创建一个事务 +- **AND** 应用路径不重新计算表达式 +- **AND** 一次撤销恢复该事务的全部变化 + +#### Scenario: Cancel or reject an invalid result +- **WHEN** 用户取消预览,或候选无效、无变化、等待计算或正在计算 +- **THEN** 不创建内容事务 +- **AND** 不符合提交条件时 Apply 禁用 +- **AND** 错误和等待状态位于结果区域且不覆盖输入 + +#### Scenario: Invalidate a ready result +- **WHEN** 用户修改输入、切换目标或通过历史弹框改变基础内容 +- **THEN** 旧候选不能提交 +- **AND** 界面显示等待新预览或过期状态 +- **AND** 拒绝过期提交时保留草稿并提供更新预览入口 + +### Requirement: UX regressions require rendered workflow evidence +实现 MUST 通过真实渲染和交互验证以上要求。测试 MUST 使用实际生产资源和确定性会话数据。截图 MUST 补充行为断言,不能替代断言。验收 MUST 包含中文、英文、默认、宽和最小窗口,以及布局阈值两侧。 + +#### Scenario: Verify layout and interactions +- **WHEN** 对本 change 执行验收 +- **THEN** Headless 覆盖 760×600、1280×800、760×520、860 和 861 宽度及宽→窄→宽切换 +- **AND** 断言章节表格覆盖中央分配区,选项和结果边界不相交 +- **AND** 验证历史开关及分支、末行编辑、完整差异访问、应用、取消和一步撤销 +- **AND** 代表性状态在放大字体和浅色、深色主题下保持可用 +- **AND** 默认、宽、窄截图保存于 `artifacts/main-window-editing-ux/` 并复核 + +### Requirement: Candidate comparisons appear in original chapter cells +候选预览 MUST 显示在原章节表的对应单元格内。原值和预览值 MUST 明确区分。时间变化 MUST 显示带符号的变化量。宽窗口 MUST 使用可读的同格横向比较;窄窗口 MUST 使用紧凑两行比较或表格内滚动。单元格 MUST NOT 显示“原/预览”或 `Original/Preview` 文本标签。图标、语义颜色、无障碍名称及工具提示 MUST 区分已提交值和预览值。普通逐章比较 MUST NOT 要求打开独立审阅弹框。 + +#### Scenario: Review a time shift in place +- **WHEN** 用户准备 `t + 1` 候选 +- **THEN** 对应章节的时间单元格显示原时间、预览时间和正一秒变化量 +- **AND** 章节名称和其他未变化字段继续显示已提交值 +- **AND** 短表头保持时间、名称、帧和编号 +- **AND** 图标和语义颜色明确表示尚未应用的预览,辅助功能名称或工具提示解释值的角色 +- **AND** 用户沿原章节位置即可比对结果 + +#### Scenario: Compare other fields +- **WHEN** 候选改变名称、编号或帧信息 +- **THEN** 对应原单元格显示实际原值和目标值 +- **AND** 未变化单元格不出现虚假的变化标记 +- **AND** 显示的原帧值与进入预览前同一行的可见帧值一致 +- **AND** 只有进入预览前没有可见帧计算值时才显示本地化缺失值 +- **AND** 原帧保留进入预览前可见行的准确、近似或中性颜色 +- **AND** 候选帧按候选准确度显示对应语义颜色,不使用统一的预览强调色 +- **AND** 文档或轨道属性差异可从相关现有控件的本地化工具提示检查 +- **AND** 缺失值与零值使用不同业务表示 + +### Requirement: Inline review preserves row identity and committed values +比较 MUST 通过稳定轨道及章节身份映射。原行顺序、未变化章节、选择、滚动和虚拟化 MUST 保持。准备和展示 MUST NOT 修改已提交字段或产生内容事务。 + +#### Scenario: Compare repeated names and shifted numbering +- **WHEN** 多个章节有相同名称且候选改变编号 +- **THEN** 对比通过稳定身份显示在正确原行 +- **AND** 不按名称、显示编号或行索引推断对应关系 + +#### Scenario: Review a large candidate +- **WHEN** 候选涉及 1,000 个章节并且用户滚动到末行 +- **THEN** 所有原章节仍然可达且比较值正确 +- **AND** 表格不默认只显示变化行或插入重复章节行 +- **AND** 可视区域分配高度不因候选大小变化 + +#### Scenario: Save while preview is visible +- **WHEN** 用户尚未确认候选而保存已提交内容 +- **THEN** 保存结果不包含未应用的预览值 +- **AND** 展示投影不改写正常编辑字段或导出源 + +### Requirement: Grid preview is read-only while candidate input remains editable +有效候选展示期间章节表及直接内容变更入口 MUST 只读。滚动和选择 MUST 继续可用。表达式输入 MUST 保持可编辑。输入或基础内容变化 MUST 立即使旧候选不可应用。 + +#### Scenario: Edit an expression during review +- **WHEN** 用户在预览状态修改表达式 +- **THEN** 旧候选立即失效且应用禁用 +- **AND** 旧目标值清除或明确标为过期,不能冒充当前结果 +- **AND** 自动准备完成后仅展示最新草稿对应候选 +- **AND** 输入编辑的撤销不改变文档历史 + +#### Scenario: Attempt a chapter edit during preview +- **WHEN** 用户尝试编辑单元格、插入或删除章节 +- **THEN** 直接内容编辑保持禁用,并明确提供放弃预览的动作 +- **AND** 放弃后普通表格编辑恢复 +- **AND** 预览准备前的单元格草稿按既有规则结束 + +### Requirement: Inline preview actions share the existing options row +主窗口 MUST 在原操作槽显示应用及放弃操作,并保留需要的更新和错误反馈。它 MUST NOT 新增确认行或结果面板。章节摘要和重复逐章详情入口 MUST NOT 显示。无法放入章节单元格的属性差异 MUST 通过相关现有操作的本地化工具提示检查。错误 MUST 使用现有错误反馈显示。 + +#### Scenario: Confirm an inline result +- **WHEN** 有效当前候选显示在原章节表中 +- **THEN** 原操作槽提供应用和放弃按钮 +- **AND** 原操作槽不显示章节摘要、完整范围摘要工具提示或重复逐章详情按钮 +- **AND** 无需先打开模态审阅才能应用 +- **AND** 入口标明实际操作而不把所有候选都误标为表达式 + +#### Scenario: Review property-only effects +- **WHEN** 候选只有文档或轨道属性变化 +- **THEN** 相关现有操作的本地化工具提示显示属性差异 +- **AND** 用户可查看原值、目标值和所属范围 +- **AND** 即使章节时间不变,完整候选有变化时仍可应用 +- **AND** 检查工具提示不增加底部布局行或修改候选 + +#### Scenario: Invalid, unchanged, or stale preview +- **WHEN** 候选无效、无变化、计算中或过期 +- **THEN** 应用禁用且原操作槽显示对应状态 +- **AND** 错误通过现有错误反馈查看 +- **AND** 更新过期结果需要显式动作,不能替换后自动提交 + +### Requirement: Apply and discard restore the normal table +应用 MUST 提交原表格实际展示的有效当前候选,不得重新计算表达式。放弃 MUST 移除比较并保持原文档。成功或放弃后 MUST 恢复普通编辑且尽可能保留选择和滚动。 + +#### Scenario: Apply and undo +- **WHEN** 用户点击应用 +- **THEN** 目标值成为普通表格的已提交值且比较标记消失 +- **AND** 只创建一次原子事务,一次撤销恢复全部变化 +- **AND** 重复点击不重复提交 + +#### Scenario: Discard without erasing input +- **WHEN** 用户点击放弃 +- **THEN** 原值恢复为普通表格,文档和历史保持不变 +- **AND** 表达式草稿保留 +- **AND** 同一草稿版本不立即自动重建被放弃候选 +- **AND** 下一次修改或显式准备可以创建新候选 + +### Requirement: Bottom option inputs share responsive label alignment +底部选项中的文本输入 MUST 使用响应式共享标签列,且不能按某个固定语言文本设置像素宽度。表达式编辑器 MUST 与同列输入左边界对齐,并在中、英、日文、字号变化及宽窄布局中保持一致。 + +#### Scenario: Align bottom text inputs +- **WHEN** 主窗口以支持语言和字体大小渲染 +- **THEN** 保存格式、XML 语言及表达式输入的同列控件左边界对齐 +- **AND** 断言实际 TextBox 或 ExpressionEditor 的边界,而不是只检查容器 + +### Requirement: Inline review requires rendered workflow evidence +验收 MUST 覆盖中文及英文、760×600、1280×800、760×520 和布局阈值两侧。测试 MUST 检查原值保持、身份映射、单元格可读性及表格可视区域。截图 MUST 补充行为断言。 + +#### Scenario: Validate the inline design +- **WHEN** 对本 change 执行验收 +- **THEN** 验证预览前后可视区域边界相同,允许窄布局两行单元格增加行高 +- **AND** 验证显示帧率 24000/1001 下原 0 和 8357 等可见帧值在预览中保持,且候选目标帧正确 +- **AND** 验证无候选时没有可见原/预标签、章节摘要或重复详情按钮 +- **AND** 验证箭头图标资源、颜色、accessible name 及工具提示 +- **AND** 验证稳定身份、未变化行、末行、名称及帧变化、属性独立变化和亚毫秒时间差 +- **AND** 验证应用、放弃、候选失效和一次撤销 +- **AND** 代表性字体放大及深浅主题保持可用 +- **AND** 截图保存在 `artifacts/compact-content-preview-review/` + +### Requirement: History presents connected branch topology + +The dialog SHALL render the retained hierarchy in native Avalonia `TreeView` controls. Each node SHALL expose its actual children to the tree control. The tree SHALL provide file-tree expansion, indentation, selection, and keyboard behavior. The dialog SHALL NOT render a separate connector rail or flattened visual lane projection. Current position SHALL have a marker distinct from selected-row highlighting. Preferred redo SHALL be identified in the explicit redo choices. + +#### Scenario: Editing after undo creates a visible fork +- **WHEN** a new edit is committed after undo while an older child path remains retained +- **THEN** history shows both child paths connected to their actual common parent +- **AND** each path remains reachable for inspection and explicit restoration + +#### Scenario: Selection differs from the current node +- **WHEN** a user inspects an earlier or alternate node +- **THEN** the selected row has a selection indicator +- **AND** the current node retains a separate Current marker +- **AND** viewing the node does not change the preferred redo edge + +#### Scenario: Locate a hidden current node +- **WHEN** the current node lies inside a collapsed fork and the user invokes Locate Current +- **THEN** the dialog expands its ancestor path and brings the current row into view +- **AND** locating the current node selects it for inspection without restoring content + +#### Scenario: Scroll and expand a large tree +- **WHEN** the user scrolls, collapses, or expands branches in a large history +- **THEN** the TreeView retains the correct parent and child relationships +- **AND** collapsed descendants are not realized as visible controls +- **AND** Core history nodes are not removed + +#### Scenario: Collapse the inspected descendant +- **WHEN** collapsing a fork hides the selected inspection node +- **THEN** the native tree retains its inspection selection and details +- **AND** expanding the fork makes the inspected row visible again +- **AND** the document cursor remains unchanged + +### Requirement: Node details explain the selected committed change + +The inspector SHALL show the selected node title, its available timestamp, nonzero effect counts, meaningful captured parameters, and typed before/after field values. It SHALL hide empty text, headings, and inapplicable columns. It SHALL show change filters only when the node has changes. It SHALL keep that filter reachable when its current choice has no matches. It SHALL NOT show explanatory comparison paragraphs or repeat a changed value above its field row. Chapter changes SHALL use historical track and chapter-position context. The initial node SHALL show the source and ordered read-only chapter values. Operation titles and field labels SHALL follow the active locale. + +#### Scenario: Inspector has no applicable optional content +- **WHEN** a node has no meaningful parameters or time deltas +- **THEN** the inspector omits the parameter section and Delta column +- **AND** empty descriptions and status text do not reserve blank lines + +#### Scenario: A filter has no matches +- **WHEN** the selected change filter has no matching fields +- **THEN** the inspector hides the empty field table and keeps the filter available +- **AND** the user can return to All changes + +#### Scenario: Inspect a name edit +- **WHEN** a node changes one chapter name +- **THEN** the inspector identifies the historical track and chapter position and shows the exact old and new name once +- **AND** the summary reports one modified chapter + +#### Scenario: Inspect a batch or property-only edit +- **WHEN** a node changes multiple chapters or only document, track, or segment properties +- **THEN** all actual changes can be reached through virtualized, scrollable details +- **AND** All, Added, Removed, and Modified filters preserve the full unfiltered summary counts +- **AND** property-only changes remain visible with zero changed chapters + +#### Scenario: Inspect the initial node +- **WHEN** the root node is selected +- **THEN** the inspector labels it Initial State and shows its initial source, track overview, and ordered read-only chapter values +- **AND** it does not display a fabricated before/after edit + +#### Scenario: Read precise and long values +- **WHEN** a changed time is below ordinary precision or text is too long for its initial cell +- **THEN** expanded detail exposes the exact time and signed delta or full text +- **AND** displayed text can be selected and copied +- **AND** unknown values, empty text, and absent objects have distinct localized labels + +#### Scenario: Switch locale with existing history +- **WHEN** the locale changes between supported English, Chinese, and Japanese resources +- **THEN** titles, summaries, field labels, parameters, status, and accessible names update without new transactions +- **AND** operation kind is not inferred from an English description +- **AND** missing structured metadata uses a localized generic title with the original description available as secondary detail + +### Requirement: History restoration actions are explicit and observable + +Restore SHALL target the inspection selection. Undo and Redo SHALL target the current document node. The dialog SHALL identify the preferred redo child and expose alternate direct-child choices. Navigation SHALL expose execution and failure state. Inspection SHALL preserve pending previews; explicit navigation SHALL use the existing preview-discard behavior. + +#### Scenario: Restore the selected node +- **WHEN** a user invokes Restore to This Node on a valid non-current selection +- **THEN** the selected committed document is restored without rerunning its operation +- **AND** duplicate navigation submissions are blocked during execution +- **AND** the resulting current node becomes selected and visible + +#### Scenario: Selection is already current +- **WHEN** the selected node is the current document node +- **THEN** Restore is disabled and the inspector indicates Current State + +#### Scenario: Choose an alternate redo path +- **WHEN** the current node has multiple direct children +- **THEN** Redo exposes those children with localized summaries and a preferred-child indicator +- **AND** selecting a choice explicitly restores that child rather than the inspection selection + +#### Scenario: Navigate with a pending preview +- **WHEN** a pending content preview exists and the user inspects history +- **THEN** inspection preserves that preview +- **AND** navigation actions show a notice that executing them discards the preview +- **WHEN** the user executes a history navigation action +- **THEN** the command discards the preview through the existing invalidation path + +#### Scenario: Restoration fails +- **WHEN** restoration fails before publication +- **THEN** the current document and cursor remain unchanged +- **AND** the selected inspection node remains selected +- **AND** the dialog displays a localized failure and remains usable + +### Requirement: History inspection handles asynchronous and session lifetimes + +The inspector SHALL expose loading, available, and error states. Results SHALL be accepted only for the current selection, session root, request generation, and open view lifetime. Closing or ending the session SHALL cancel detail work and release owned references. + +#### Scenario: A slow query completes after a newer selection +- **WHEN** a user selects node A and then node B before A's query completes +- **THEN** only B's result can populate the current inspector +- **AND** A's obsolete work is canceled or its result is discarded + +#### Scenario: Detail query fails for a valid target +- **WHEN** inspection fails for a retained non-current node +- **THEN** the inspector shows an error and a retry action +- **AND** an otherwise valid Restore action remains available + +#### Scenario: The session is replaced or ended +- **WHEN** the active session ends or is replaced while the dialog has detail work +- **THEN** old rows, selection, details, and actions are cleared or the dialog is closed by its host +- **AND** no old result is displayed in a new session + +### Requirement: History layout and input remain responsive and accessible + +Wide layouts SHALL show a resizable tree pane beside an inspector. Narrow layouts SHALL provide History and Details pages with preserved selection, expansion, and scroll anchors. Footers SHALL remain reachable independently of content scrolling. Native and embedded Avalonia hosts SHALL provide the same inspection and explicit-restoration semantics. + +#### Scenario: Resize between wide and narrow layouts +- **WHEN** the dialog is resized between its wide and narrow layouts +- **THEN** no tree, details, or footer control overlaps or becomes unreachable +- **AND** the selected node, expansion choices, and scroll anchors remain available +- **AND** long titles and values can still be read through tooltips or expanded detail + +#### Scenario: Complete the workflow with a keyboard +- **WHEN** a user navigates without a pointer +- **THEN** native tree arrow keys select rows or expand branches, and Enter enters details +- **AND** Tab reaches the history actions, inspector, restore, and close controls +- **AND** Escape closes the dialog and returns focus to the history button +- **AND** selection alone does not restore content + +#### Scenario: Inspect through narrow pages +- **WHEN** a user selects a row on the narrow History page +- **THEN** the History page remains open while the selected detail is prepared +- **WHEN** the user invokes Enter or the Details tab +- **THEN** the selected inspector becomes visible +- **AND** returning through the History tab preserves the selected row and scroll anchor + +#### Scenario: Theme and text size change +- **WHEN** history is rendered in light or dark theme with long localized strings or enlarged UI text +- **THEN** native tree expanders, current/selected indicators, values, and enabled actions remain readable +- **AND** accessible names communicate current, selected, and expanded states without color-only cues + diff --git a/openspec/specs/chapter-core-transform-export/spec.md b/openspec/specs/chapter-core-transform-export/spec.md index 793bcc7b..619f6f5c 100644 --- a/openspec/specs/chapter-core-transform-export/spec.md +++ b/openspec/specs/chapter-core-transform-export/spec.md @@ -236,25 +236,20 @@ The Core expression subsystem SHALL analyze Lua expression script text for edito - **WHEN** expression completions are returned - **THEN** each completion SHALL include a token kind/category suitable for the UI to visually distinguish variables, functions, keywords, and presets -### Requirement: Shared preview and save projection pipeline -The system SHALL use one Core output projection pipeline for preview and save so Lua expression transforms, diagnostics, numbering, and naming are consistent. - -#### Scenario: Projection does not mutate source chapters -- **WHEN** preview or save requests projected output from a source chapter set -- **THEN** Core SHALL return projected chapter data without mutating the original `ChapterInfo` input - -#### Scenario: Projection order is deterministic -- **WHEN** expression application, order shift, and name generation are all enabled -- **THEN** Core SHALL first apply Lua expression transforms to non-separator chapter times -- **AND** it SHALL normalize transformed times and refresh frame display state -- **AND** it SHALL then apply output numbering/order shift and output name generation - -#### Scenario: Preview and save share diagnostics -- **WHEN** Lua expression projection emits diagnostics during preview or save -- **THEN** the projection result SHALL include those diagnostics in the same structured form for both callers -- **AND** chapters whose Lua expression failed SHALL retain their original times while the rest of the output projection continues - -#### Scenario: Separators are structural during projection -- **WHEN** projected output contains separator rows +### Requirement: Interactive serialization does not transform content +The serializer SHALL format the chapter content it receives. Interactive naming, numbering, and expression changes SHALL be committed to the document before preview or export. + +#### Scenario: Preview and save serialize the same committed content +- **WHEN** preview and save use the same document snapshot and format options +- **THEN** both SHALL serialize the same chapter values +- **AND** serialization SHALL NOT apply expression, name, or numbering transforms + +#### Scenario: Compatibility conversion applies an explicit transform once +- **WHEN** a stateless compatibility API receives legacy transform options +- **THEN** it MAY adapt those options to one content transformation +- **AND** it SHALL serialize the transformed result without applying the transformation again + +#### Scenario: Separators are structural during serialization +- **WHEN** serialized output contains separator rows - **THEN** Core SHALL NOT execute Lua expressions for separator rows -- **AND** exported `OutputChapters` SHALL include only non-separator chapters after projection +- **AND** formats that omit separators SHALL report output only for supported chapter rows diff --git a/openspec/specs/chapter-workspace-session/spec.md b/openspec/specs/chapter-workspace-session/spec.md index e7cf8c72..d965a3ef 100644 --- a/openspec/specs/chapter-workspace-session/spec.md +++ b/openspec/specs/chapter-workspace-session/spec.md @@ -1,16 +1,14 @@ # chapter-workspace-session Specification ## Purpose -Define the explicit chapter workspace/session boundary that owns GUI chapter state, async revisions, projection state, and export preferences. - +Define the explicit chapter workspace/session boundary that owns GUI chapter state, async revisions, content operation drafts, and export preferences. ## Requirements - ### Requirement: Explicit chapter workspace owns session lifecycle The Avalonia shell SHALL own loaded chapter session state through an explicit workspace/session abstraction rather than an ad-hoc collection of independent fields on the main window ViewModel. #### Scenario: Workspace exists after construction - **WHEN** the main window ViewModel is constructed for normal GUI use -- **THEN** it SHALL hold or be composed with a single chapter workspace/session object that owns source path metadata, clip session state, the current editable chapter set, projection options, and export preferences used by commands +- **THEN** it SHALL hold or be composed with a single chapter workspace/session object that owns source path metadata, clip session metadata, one authoritative content session, operation drafts, and export preferences used by commands #### Scenario: Load replaces workspace session atomically - **WHEN** a source load succeeds @@ -84,21 +82,24 @@ The workspace SHALL represent multi-clip selection and combined-clip mode as an - **THEN** the workspace SHALL write the resulting chapter set back into the active clip session according to mode (selected split entry vs combined entry) - **AND** callers SHALL NOT need a separate `belongsToSelectedClip` boolean to decide ownership -### Requirement: Projection state is owned by the workspace -Naming mode, order shift, expression application, expression text/preset/source metadata, and last-successful expression projection cache SHALL be owned by a projection state surface on the workspace. +### Requirement: Operation drafts do not project committed content +Naming, numbering, and expression parameters SHALL remain drafts until the user applies a content operation. Draft values SHALL NOT change the current document, table rows, format preview, or export. -#### Scenario: Projection options feed rows and export identically -- **WHEN** projection options change or the current chapter set changes -- **THEN** chapter rows and preview/save projection inputs SHALL be derived from the same workspace projection state +#### Scenario: Draft options change +- **WHEN** the user edits template, numbering, or expression parameters +- **THEN** the workspace SHALL keep those values as operation drafts +- **AND** the current document, rows, preview, and export SHALL continue to use committed content -#### Scenario: Invalid mid-edit expression keeps last successful projection -- **WHEN** expression application is enabled and the current expression is temporarily invalid while a previous successful projection exists -- **THEN** displayed rows MAY continue showing the last successful projected output until a valid projection replaces it -- **AND** the diagnostic path SHALL still surface the current expression failure +#### Scenario: Operation candidate is applied +- **WHEN** the user applies a valid operation candidate +- **THEN** the workspace SHALL commit the candidate through `ContentSession` +- **AND** table rows, format preview, and export SHALL read that committed document +- **AND** undo SHALL restore the preceding committed content -#### Scenario: Expression state mutation does not thrash intermediate refreshes -- **WHEN** several expression-related fields are updated as one apply operation -- **THEN** the workspace/ViewModel SHALL avoid intermediate row refreshes that use partially updated expression fields +#### Scenario: Expression candidate is invalid +- **WHEN** any chapter expression result is invalid or violates document invariants +- **THEN** the operation SHALL fail without partially changing the document or history +- **AND** the current diagnostic SHALL be reported without retaining a prior projected row set ### Requirement: Export preferences are a workspace snapshot Save format, XML language, text encoding, BOM emission, and effective save-directory resolution inputs SHALL be readable as an export-preference snapshot from the workspace for save and preview. @@ -150,3 +151,35 @@ Clip mode transitions, load/replace, append, restore, projection refresh, and ex - **WHEN** unit tests overlap an older load with a newer load, or an in-flight append with a newer load, using deterministic controlled load services - **THEN** they SHALL assert that only the current revision's path, rows, and combine state remain after late completions - **AND** late progress from superseded operations SHALL NOT change the current progress state + +### Requirement: Expression draft previews remain read-only until applied +Expression drafts SHALL produce candidate previews from one current session snapshot. Preparing or refreshing a preview SHALL NOT change the committed document, table rows, export output, or history. Applying a valid current candidate SHALL create one atomic history transaction. + +#### Scenario: Expression draft refreshes its candidate +- **WHEN** the user changes expression text or selects a preset +- **THEN** the workspace SHALL compute a candidate from the current committed document snapshot +- **AND** the preview SHALL report before-and-after values and validation errors +- **AND** the committed document, rows, exports, and history SHALL remain unchanged + +#### Scenario: Invalid expression candidate is shown without partial changes +- **WHEN** expression evaluation or document validation fails +- **THEN** the workspace SHALL expose the current diagnostic in the preview +- **AND** it SHALL NOT change the committed document or history +- **AND** it SHALL NOT allow the candidate to be applied + +#### Scenario: Current valid expression candidate is applied once +- **WHEN** the user applies a valid candidate whose base snapshot is still current +- **THEN** the workspace SHALL commit the candidate as one transaction +- **AND** rows, format preview, and export SHALL use the committed candidate values +- **AND** undo SHALL restore the values before the expression operation without rerunning Lua + +#### Scenario: Stale expression candidate is not committed +- **WHEN** the document changes after the expression preview was prepared +- **THEN** applying that preview SHALL NOT overwrite the newer document +- **AND** the host SHALL prepare a new candidate from the latest snapshot + +#### Scenario: Expression preview is canceled +- **WHEN** the user cancels an expression preview +- **THEN** the preview SHALL be discarded +- **AND** the committed document, rows, export output, and history SHALL remain unchanged + diff --git a/openspec/specs/clip-structure-editing/spec.md b/openspec/specs/clip-structure-editing/spec.md new file mode 100644 index 00000000..31591c89 --- /dev/null +++ b/openspec/specs/clip-structure-editing/spec.md @@ -0,0 +1,44 @@ +# clip-structure-editing Specification + +## Purpose +TBD - created by archiving change unified-editing-05-clip-operations. Update Purpose after archive. +## Requirements +### Requirement: Merge replaces current tracks in one transaction +Merge SHALL use the current edited content, preserve chapter names, identities, explicit end times, and required source information, and replace selected tracks with one merged track. It SHALL retain boundaries and media placement without a second chapter backup. + +#### Scenario: Merge after editing a source track +- **WHEN** the user edits a chapter and then merges its track +- **THEN** the merged track SHALL contain the edited value and the same chapter identity +- **AND** merge SHALL create one undoable transaction + +#### Scenario: Merge tracks with different frame rates +- **WHEN** source tracks have different effective frame rates +- **THEN** merge SHALL apply an explicit compatible frame-rate policy or report a conflict +- **AND** it SHALL NOT silently interpret every time using the first track rate + +### Requirement: Split uses current merged content and boundaries +Split SHALL distribute current chapters by their current start time and segment intervals. It SHALL preserve chapter identity and value, convert current start and explicit end values to the target segment timeline, and report an explicit-end conflict that crosses a boundary. + +#### Scenario: Chapter starts at a boundary +- **WHEN** a chapter start equals a segment boundary +- **THEN** the chapter SHALL belong to the segment after the boundary, except a marker at total duration which belongs to the last segment + +#### Scenario: Merged chapter was edited before split +- **WHEN** the user edits a merged chapter and splits by boundaries +- **THEN** split SHALL use the edited current chapter and SHALL NOT restore pre-merge chapter data + +#### Scenario: End time crosses a segment boundary +- **WHEN** an explicit end time falls outside its chapter's assigned segment +- **THEN** split SHALL report the conflict and SHALL NOT silently truncate or duplicate the chapter + +### Requirement: Append preserves edits and commits atomically +Append SHALL build a complete candidate from the captured current document, preserve all existing edits, allocate fresh identities for imported content, and commit once only if the captured session token remains current. + +#### Scenario: Append completes after the session changes +- **WHEN** another edit or document replacement occurs while append is reading input +- **THEN** the append result SHALL be rejected as stale and SHALL NOT overwrite the newer document + +#### Scenario: Append follows merge +- **WHEN** the user appends another source to a merged document +- **THEN** existing edits and boundaries SHALL remain intact and imported tracks SHALL be included in one undoable result + diff --git a/openspec/specs/document-session-lifecycle/spec.md b/openspec/specs/document-session-lifecycle/spec.md new file mode 100644 index 00000000..33b3e7d8 --- /dev/null +++ b/openspec/specs/document-session-lifecycle/spec.md @@ -0,0 +1,52 @@ +# document-session-lifecycle Specification + +## Purpose +TBD - created by archiving change unified-editing-07-session-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Session replacement is built before the old session is discarded +Hosts SHALL build and validate a replacement document session before asking to discard the current session. Failed construction or cancellation SHALL preserve the old session. + +#### Scenario: Import fails during replacement +- **WHEN** replacement import or session construction fails +- **THEN** the active document, history, draft, and export baseline SHALL remain available + +#### Scenario: Current session changes during confirmation +- **WHEN** the old session is edited while replacement confirmation is open +- **THEN** the host SHALL recheck loss conditions before replacing it + +### Requirement: Ending a session cancels work and releases history ownership +Closing or replacing a session SHALL invalidate its token, cancel its outstanding tasks, and release host references to history nodes, drafts, previews, subscriptions, and history display items. An in-flight read-only export MAY retain only its captured document snapshot. + +#### Scenario: Old asynchronous result arrives after replacement +- **WHEN** a load, append, or export from the prior session completes after a new session opens +- **THEN** it SHALL NOT mutate the new session or its export baseline + +#### Scenario: Session ends +- **WHEN** the desktop window closes or the user confirms replacing the active session +- **THEN** its history SHALL become unreachable from the host while a captured export may finish with content only + +#### Scenario: Window close is canceled +- **WHEN** another close handler cancels the desktop window close +- **THEN** the active session and its lifetime token SHALL remain valid + +### Requirement: Hosts explain session-only history before destructive replacement +Reload or replacement SHALL offer a continue/cancel choice when the current session has history, an active draft, or unexported content. Closing the desktop window SHALL end the session without a session-loss confirmation. Browser refresh or process termination SHALL NOT be represented as recoverable history. + +#### Scenario: User cancels replacement +- **WHEN** the user cancels the loss confirmation +- **THEN** the current session, draft, history, and export state SHALL remain unchanged + +#### Scenario: User reloads the application +- **WHEN** the user reopens a source after process restart or browser refresh +- **THEN** a new document session and history root SHALL be created + +### Requirement: Export baselines describe only the captured track snapshot +Successful export SHALL record the captured track's canonical content digest, format options, and state identity. Failed or asynchronous stale exports SHALL NOT mark later content or another session as exported. + +#### Scenario: Edit continues during export +- **WHEN** an export succeeds for a captured snapshot after a newer edit commits +- **THEN** only the captured snapshot SHALL be marked exported and the newer edit SHALL remain dirty + +#### Scenario: One track is exported +- **WHEN** a document with multiple tracks exports one track +- **THEN** other tracks SHALL remain unexported unless separately included in a successful export diff --git a/openspec/specs/editable-chapter-document/spec.md b/openspec/specs/editable-chapter-document/spec.md new file mode 100644 index 00000000..cc6a4080 --- /dev/null +++ b/openspec/specs/editable-chapter-document/spec.md @@ -0,0 +1,37 @@ +# editable-chapter-document Specification + +## Purpose +TBD - created by archiving change unified-editing-01-document-model. Update Purpose after archive. +## Requirements +### Requirement: Editable documents are immutable snapshots +Core SHALL represent current editable content as an immutable document with ordered tracks and chapters. A host SHALL receive no mutable reference to a published collection. + +#### Scenario: Adapting imported chapters +- **WHEN** an importer result is opened as an editable document +- **THEN** the adapter SHALL create one document snapshot with stable document, track, and chapter identities +- **AND** it SHALL NOT retain a second editable chapter set + +#### Scenario: Candidate changes are isolated +- **WHEN** a service computes a changed document from a published snapshot +- **THEN** the published snapshot SHALL remain unchanged until the candidate is explicitly accepted + +### Requirement: Document time and frame values are deterministic +The document SHALL store time as signed 64-bit 100-nanosecond ticks, known frame rates as reduced positive rational values, and unknown duration or frame rate as absent values. + +#### Scenario: Frame conversion uses the shared rounding rule +- **WHEN** a frame value is converted to ticks or ticks to frames +- **THEN** conversion SHALL round to the nearest integer with midpoint away from zero +- **AND** overflow SHALL return a diagnostic failure + +#### Scenario: Unknown duration has no upper bound +- **WHEN** a document has unknown duration and a chapter start is edited +- **THEN** validation SHALL NOT treat unknown duration as a zero-length upper bound + +### Requirement: Serialization consumes a captured document snapshot +Format serialization SHALL receive the explicit current document snapshot and format-only options. It SHALL NOT execute naming, expression, time-offset, or other interactive transforms. + +#### Scenario: Export captures current document values +- **WHEN** a document is serialized after an edit +- **THEN** output SHALL contain the edited value exactly once +- **AND** serializing SHALL NOT change the document + diff --git a/openspec/specs/expression-preview-interaction/spec.md b/openspec/specs/expression-preview-interaction/spec.md new file mode 100644 index 00000000..08918e7d --- /dev/null +++ b/openspec/specs/expression-preview-interaction/spec.md @@ -0,0 +1,214 @@ +# expression-preview-interaction Specification + +## Purpose +TBD - created by archiving change unify-expression-preview-interaction. Update Purpose after archive. +## Requirements +### Requirement: Both hosts provide one expression review surface +Avalonia and Web MUST provide an independent expression tool with the editor and chapter comparison in the same surface. Both hosts MUST show the current track name, participating chapter count, preset selector, multiline editor, preview summary, and persistent Cancel and Apply changes actions. The committed chapter table MUST remain unchanged during preparation. + +#### Scenario: Open the expression tool in either host +- **WHEN** the user opens the expression tool in Avalonia or Web +- **THEN** the tool displays the current-track scope and the stored expression parameters +- **AND** a nonempty expression starts a read-only preview for that scope +- **AND** excluded separator rows are identified separately from participating chapters + +#### Scenario: Select and edit a multiline preset +- **WHEN** the user selects a multiline preset and then changes its text +- **THEN** the editor preserves line breaks and indentation +- **AND** the preset selection becomes Custom +- **AND** the tool refreshes its preview without changing the document or history + +### Requirement: Preview groups differences by chapter +Both hosts MUST show one comparison entry per chapter in track order, matched by stable identity. Each entry MUST expose the chapter number and name, before value, after value, and signed time delta. Changed-only filtering and time units MUST be the defaults. Users MUST be able to view all chapters, switch to frame values, and inspect frame details. The preview MUST remain read-only. + +#### Scenario: Inspect a five-second shift +- **WHEN** a valid candidate shifts a chapter from 00:01:00 to 00:01:05 +- **THEN** one entry shows both times and a positive five-second delta +- **AND** its frame-information changes are grouped with that chapter + +#### Scenario: Filter or change units +- **WHEN** the user switches between changed and all chapters or between time and frame units +- **THEN** only the presentation changes +- **AND** candidate identity, target scope, and application content remain unchanged +- **AND** no expression evaluation is required for the display switch + +#### Scenario: Inspect a large result +- **WHEN** a candidate contains 1,000 changed chapters +- **THEN** counts cover all 1,000 chapters +- **AND** every changed chapter is reachable through scrolling or paging +- **AND** the preview does not silently truncate differences to eight entries + +### Requirement: Summary distinguishes persisted effects +The preview MUST distinguish time changes, frame-information updates, and document or track property changes. Affected-chapter totals MUST count unique identities. Overlapping categories MUST NOT be added as if they were disjoint. Complete candidate equality MUST determine no-change behavior. + +#### Scenario: Frame data changes without time changes +- **WHEN** the candidate updates three chapters' frame data and leaves all chapter times unchanged +- **THEN** the summary explicitly states that chapter times are unchanged +- **AND** it identifies the three frame-information updates +- **AND** the comparison does not claim that three times moved + +#### Scenario: One chapter changes in two categories +- **WHEN** the same chapter changes time and frame accuracy +- **THEN** it counts as one affected chapter +- **AND** both categories remain inspectable + +#### Scenario: Frame-rate detection changes metadata +- **WHEN** preparation adds a frame rate or modifies segment or document metadata +- **THEN** a separate property section displays before and after values +- **AND** it identifies the actual owner of each property, including effects beyond the selected track +- **AND** those changes remain visible even if no chapter time changes + +#### Scenario: Only properties change +- **WHEN** a valid current candidate changes a persisted property but no chapter time +- **THEN** the summary names the property change +- **AND** Apply remains available after the complete result is ready + +### Requirement: Values use clear localized business formats +Both hosts MUST use localized field labels, units, missing-value states, and diagnostic summaries. They MUST format values from typed snapshots rather than parse technical difference strings. They MUST NOT expose raw enum names or object dumps as normal preview content. Time formatting MUST retain enough precision to distinguish actual changes. + +#### Scenario: Distinguish missing values from zero +- **WHEN** original frame information is absent and the result is frame zero +- **THEN** the original is labeled Not calculated and the result is labeled zero frames +- **AND** an unknown frame rate is labeled Not set rather than zero + +#### Scenario: Compare frame rates +- **WHEN** before and after frame values use different frame rates +- **THEN** each side displays its own frame-rate basis +- **AND** the preview omits an incompatible numeric frame delta +- **AND** rational rates remain inspectable, such as 24000/1001 + +#### Scenario: Display a sub-millisecond change +- **WHEN** two distinct times would have identical millisecond display text +- **THEN** the preview increases displayed precision enough to distinguish them +- **AND** the delta is not displayed as zero + +#### Scenario: Display accuracy and additional fields +- **WHEN** frame accuracy or another persisted field changes +- **THEN** its business label and before/after meaning are accessible in details +- **AND** accuracy text reflects the actual tolerance policy +- **AND** an unrecognized field is not silently omitted from review + +### Requirement: Live preview has explicit readiness states +Both hosts MUST expose unavailable, empty, waiting, computing, ready, unchanged, invalid, stale, applying, and failed states with an understandable status. Apply MUST be disabled except for a valid, changed, current candidate. Cancel and Apply MUST remain visible. Input changes MUST immediately invalidate the previous candidate. + +#### Scenario: Empty or unchanged input +- **WHEN** there is no participating chapter, the script is empty, or the complete candidate equals its base +- **THEN** the tool explains the applicable reason and disables Apply +- **AND** it creates no document history entry +- **AND** empty input is not silently evaluated as an identity expression + +#### Scenario: Explicit identity expression has side effects +- **WHEN** the user enters `t` explicitly and preparation changes persisted frame information +- **THEN** the tool displays those effects and evaluates readiness from the complete candidate +- **AND** it does not report no changes solely because times are equal + +#### Scenario: Input replaces a ready preview +- **WHEN** the user changes the script after a valid preview +- **THEN** Apply becomes disabled before the next calculation completes +- **AND** any retained previous values are labeled as an old preview +- **AND** only the latest draft revision can become ready + +#### Scenario: Input-method composition and late results +- **WHEN** input composition is active or a result arrives after the tool closes +- **THEN** incomplete composition does not trigger evaluation +- **AND** a closed tool ignores the late result without modifying the document or reopening + +### Requirement: Errors reject the complete operation +Both hosts MUST show current evaluation, validation, timeout, and cancellation outcomes without offering partial application. Error locations MUST be shown when available and MUST NOT be invented. Warnings MUST be distinguishable from blocking errors. + +#### Scenario: One chapter fails +- **WHEN** evaluation produces a negative time for chapter eight after seven successful calculations +- **THEN** the tool shows the error and disables Apply for the entire candidate +- **AND** no chapter or history state changes + +### Requirement: Apply commits only the reviewed candidate +Both hosts MUST submit the displayed candidate without reevaluating the expression in the Apply action. Eligibility MUST bind to the same draft revision, target scope, document session, and base version. The transaction boundary MUST reject stale candidates and deduplicate repeated submission of the same candidate. + +#### Scenario: Confirm a reviewed result +- **WHEN** the user applies a valid current preview +- **THEN** the committed values exactly match the displayed candidate +- **AND** the application path does not evaluate the expression again +- **AND** one atomic history transaction contains all candidate changes + +#### Scenario: Refresh after a stale base +- **WHEN** document content, history position, or target scope changes after preparation +- **THEN** the old candidate cannot commit +- **AND** the tool remains open with the script and an Update preview action +- **WHEN** the user updates the preview +- **THEN** a new candidate is shown for inspection +- **AND** applying it requires another explicit confirmation + +#### Scenario: Change races with submission +- **WHEN** the base changes after the UI readiness check but before the transaction commits +- **THEN** the transaction rejects the candidate +- **AND** the tool enters stale state rather than committing a newly calculated replacement + +#### Scenario: Duplicate submission or retry +- **WHEN** a double-click or request retry submits the same reviewed candidate more than once +- **THEN** its stable submission identity permits at most one commit +- **AND** the applying state disables editing, Apply, and dismissal until the outcome is known + +#### Scenario: Recover from an application failure +- **WHEN** application fails without committing +- **THEN** the tool preserves the draft and reports the reason +- **AND** it enables correction or retry only when that action is valid + +### Requirement: Both hosts share cancellation and success behavior +Cancel, Close, and outer Escape MUST discard pending work, restore the entry expression parameters, and close the surface without modifying the document or history. Backdrop clicks MUST NOT dismiss it. Successful application MUST close it and return the user to committed chapters with an accurate summary. Undo and redo MUST use stored document states. + +#### Scenario: Cancel a modified draft +- **WHEN** the user changes a preset or script and invokes Cancel, Close, or outer Escape before submission +- **THEN** the candidate and pending calculations are discarded +- **AND** reopening shows the expression, preset, and source metadata from before that canceled edit +- **AND** no additional confirmation dialog or document transaction occurs + +#### Scenario: Apply and undo all effects +- **WHEN** the user applies a candidate that changes times, frame data, and metadata +- **THEN** the tool closes and the chapter table shows the committed result +- **AND** table position and chapter selection are preserved where their identities remain available +- **AND** one Undo restores all of those effects +- **AND** Redo restores the same result without rerunning Lua +- **AND** export uses committed values without rerunning the expression + +#### Scenario: Deliberately apply the same script again +- **WHEN** the user reopens the tool after applying `t + 5` +- **THEN** retained parameters prepare a new preview from the current committed document +- **AND** another explicit Apply creates a separate five-second shift and history transaction + +### Requirement: Review remains responsive and accessible +Wide surfaces MUST use a readable comparison table. Narrow surfaces MUST reflow into labeled chapter entries without losing before/after values or actions. Both hosts MUST provide accessible labels, keyboard navigation, theme-aware emphasis, and focus restoration. Meaning MUST NOT depend on color alone. + +#### Scenario: Narrow and short Web viewports +- **WHEN** the tool opens at 320px width, 390×844, or 844×390 with long script and diagnostic text +- **THEN** content remains readable without page-level horizontal overflow +- **AND** Close, Cancel, and Apply do not overlap and remain reachable by actual interaction +- **AND** touch targets are at least 44 by 44 CSS pixels +- **AND** a mobile keyboard does not permanently obscure dismissal or application + +#### Scenario: Resize Avalonia and enlarge text +- **WHEN** the user resizes the desktop tool between default, wide, and minimum supported sizes or enlarges text +- **THEN** chapter values and the footer remain accessible without overlapping controls +- **AND** long localized labels remain readable or can be expanded + +#### Scenario: Use editing keys and close the tool +- **WHEN** the script editor has focus +- **THEN** Enter inserts a newline and Ctrl/Cmd+Z operates on the script draft +- **AND** a completion popup consumes Escape before the tool does +- **AND** background shortcuts and drops cannot modify the document through the active modal surface +- **WHEN** the tool closes +- **THEN** focus returns to its opener + +#### Scenario: Announce preview updates +- **WHEN** a new preview becomes ready +- **THEN** assistive technology receives a concise status summary rather than every comparison row +- **AND** the tool does not move focus to Apply or away from the editor + +### Requirement: Cross-host verification covers shared semantics +Implementation MUST verify the same preview fixtures and transaction outcomes in both hosts. Layout evidence MUST include default, wide, and narrow surfaces. Screenshots MUST supplement behavior assertions rather than replace them. + +#### Scenario: Verify the selected design +- **WHEN** this change is checked for completion +- **THEN** automated coverage verifies time changes, frame-only changes, property-only changes, invalid results, stale results, cancellation, duplicate submission, and one-step undo +- **AND** Avalonia Headless and Web browser checks verify rendered workflows and accessible actions +- **AND** screenshots under `artifacts/` record the supported layouts + diff --git a/openspec/specs/host-history-workflows/spec.md b/openspec/specs/host-history-workflows/spec.md new file mode 100644 index 00000000..b8055fd6 --- /dev/null +++ b/openspec/specs/host-history-workflows/spec.md @@ -0,0 +1,47 @@ +# host-history-workflows Specification + +## Purpose +TBD - created by archiving change unified-editing-06-host-workflows. Update Purpose after archive. +## Requirements +### Requirement: Interactive hosts share the Core document session +Avalonia and browser workspaces SHALL read and mutate one Core document session per open document. Views in one session SHALL use its command queue and observe the same snapshot. + +#### Scenario: Two views mutate one session +- **WHEN** two views issue content commands for the same document +- **THEN** both commands SHALL serialize through one Core session +- **AND** both views SHALL refresh from the committed session snapshot + +#### Scenario: Separate browser tabs open the same file +- **WHEN** two browser tabs import the same source independently +- **THEN** each tab SHALL have a separate document identity and history + +### Requirement: Hosts expose undo, redo, and history branches +Each interactive host SHALL provide undo and redo commands, the next operation description, the preferred redo path, and a branch choice for alternate retained futures. The history tree display SHALL create only visible and expanded entries. + +#### Scenario: User selects a retained alternate branch +- **WHEN** the user opens redo branch choices and selects a retained branch +- **THEN** the session SHALL restore that branch and each host SHALL display its current document + +#### Scenario: Deep history is displayed +- **WHEN** a long history is opened in the history panel +- **THEN** the panel SHALL virtualize visible entries without dropping Core history nodes + +### Requirement: Editing shortcuts respect text drafts and platform defaults +Windows and Linux SHALL provide Ctrl+Z undo and Ctrl+Y/Ctrl+Shift+Z redo. macOS SHALL provide Cmd+Z and Cmd+Shift+Z redo. Shortcut routing SHALL use the shortcut catalog and SHALL not intercept text-control draft editing. + +#### Scenario: Text editor owns focus +- **WHEN** a text control is editing a draft and the user presses its undo shortcut +- **THEN** only the control draft SHALL change +- **AND** the document history cursor SHALL remain unchanged + +#### Scenario: Undo button is used with an active draft +- **WHEN** a document undo button is invoked while a draft exists +- **THEN** the host SHALL discard the draft first and then undo one committed document transaction + +### Requirement: Host status distinguishes candidates, application, and session lifetime +Hosts SHALL distinguish candidate preview, applied in-memory edits, exported output, and failed commits. The history panel SHALL state that history lasts only for the current document session. + +#### Scenario: User cancels a preview +- **WHEN** the user cancels a candidate preview +- **THEN** the document SHALL remain unchanged and status SHALL NOT say the edit was applied + diff --git a/openspec/specs/keyboard-shortcut-customization/spec.md b/openspec/specs/keyboard-shortcut-customization/spec.md new file mode 100644 index 00000000..0bce2628 --- /dev/null +++ b/openspec/specs/keyboard-shortcut-customization/spec.md @@ -0,0 +1,67 @@ +## Purpose + +Provide a unified, testable, and persistent shortcut catalog for the main window and settings tool so users can safely customize gestures and use them in the current session. + +## Requirements + +### Requirement: Shortcut catalog exposes stable actions and defaults +The system SHALL expose a shortcut action catalog with stable identifiers, display names, default gestures, and availability descriptions. + +#### Scenario: Catalog contains existing actions +- **WHEN** the shortcut catalog is initialized +- **THEN** it SHALL contain save、reload、log、preview and clip-selection actions with their current default gestures + +#### Scenario: Defaults are deterministic +- **WHEN** no user shortcut overrides exist +- **THEN** runtime routing and visible menu gestures SHALL use the catalog defaults + +### Requirement: Users can edit shortcuts in settings +The settings tool SHALL provide a dedicated "Shortcuts" tab that allows users to record, clear, reset, and save gestures for configurable actions. + +#### Scenario: Record a new gesture +- **WHEN** the user presses a valid key combination in a shortcut row and saves +- **THEN** the draft gesture for that action SHALL update and appear in the current window + +#### Scenario: Reset one shortcut +- **WHEN** the user selects reset for an action +- **THEN** that action SHALL restore the catalog default gesture and other drafts SHALL remain unchanged + +### Requirement: Invalid and conflicting gestures are rejected +The system SHALL reject gestures that cannot be parsed, reserved key combinations without modifiers, and duplicate gestures assigned to multiple actions, and SHALL provide localizable error states. + +#### Scenario: Duplicate gesture +- **WHEN** two actions use the same non-empty gesture +- **THEN** the settings tool SHALL mark the conflict and prevent saving + +#### Scenario: Invalid gesture input +- **WHEN** the user enters text that Avalonia KeyGesture cannot parse +- **THEN** the row SHALL show a validation error and SHALL NOT change the saved mapping + +### Requirement: Runtime routing uses the active mapping +The main window SHALL route gestures to the corresponding commands according to the current session shortcut mapping; it SHALL NOT intercept editing shortcuts when a text input control has focus. + +#### Scenario: Customized gesture invokes command +- **WHEN** the user presses a customized gesture in the main window after saving it +- **THEN** the corresponding command SHALL execute, and the old default gesture SHALL no longer trigger that action unless it remains assigned to another action + +#### Scenario: Text editing preserves control shortcuts +- **WHEN** an editable control has focus and the user presses an editing shortcut +- **THEN** the shortcut router SHALL NOT execute an application command + +### Requirement: Shortcut changes apply and persist safely +Shortcut drafts SHALL follow the settings tool's existing load, live-apply, save, reset, and discard lifecycle, and SHALL preserve the last valid mapping when saving fails. + +#### Scenario: Save and reload +- **WHEN** the user saves valid shortcuts and reopens the settings tool +- **THEN** the saved gestures SHALL be restored one-to-one with the catalog action identifiers + +#### Scenario: Discard unsaved edits +- **WHEN** the user changes shortcuts and discards the settings +- **THEN** the current session SHALL restore the most recently saved mapping + +### Requirement: Headless workflow verifies shortcut customization +The system SHALL provide Avalonia Headless tests that drive the real settings view and main-window commands to verify shortcut customization behavior. + +#### Scenario: Headless customize workflow +- **WHEN** a Headless test records and saves a gesture in the Shortcuts tab and triggers the customized gesture +- **THEN** the test SHALL observe settings persistence, an updated menu gesture, and execution of the target command diff --git a/openspec/specs/log-export-archive/spec.md b/openspec/specs/log-export-archive/spec.md new file mode 100644 index 00000000..ecd00a04 --- /dev/null +++ b/openspec/specs/log-export-archive/spec.md @@ -0,0 +1,64 @@ +# log-export-archive Specification + +## Purpose +TBD - created by archiving change log-tool-enhancements. Update Purpose after archive. +## Requirements +### Requirement: Export is a secondary action + +The LogTool MUST expose an explicit export action from a labeled secondary +overflow surface. JSON and CSV choices must be available within that surface. +The primary list and search toolbar must remain usable without opening export. + +#### Scenario: Export choices stay out of the primary toolbar + +- **WHEN** the log tool opens with no menu open +- **THEN** search, count, and the severity filter remain visible while export + format choices are hidden in the secondary action surface + +#### Scenario: Export a selected format + +- **WHEN** the user chooses JSON or CSV and activates export +- **THEN** the host exporter receives that format and a snapshot of the current + visible log membership + +### Requirement: Export preserves the visible state + +Export MUST use the current severity-filtered and search-filtered membership by +default. It must not change selection, inspector visibility, filter values, or +the provider's bounded history. + +#### Scenario: Export after filtering + +- **WHEN** the user applies a severity filter or search query and exports +- **THEN** the output request contains only entries visible under those filters + in deterministic timestamp order + +#### Scenario: Export succeeds or fails + +- **WHEN** the exporter returns success or a recoverable failure +- **THEN** the list, selected row, inspector state, and filter values remain + unchanged and a compact localized status is shown + +### Requirement: Export failures are recoverable + +The export boundary MUST convert expected path, permission, encoding, and file +write failures into a failure result. The UI command must remain usable after a +failure and must not clear retained entries. + +#### Scenario: Export target cannot be written + +- **WHEN** the host cannot create or write the export target +- **THEN** the command completes without an uncaught file-system exception, + shows localized failure feedback, and leaves the log view usable + +### Requirement: Existing automatic archives remain unchanged + +The desktop logging composition MUST keep its existing rolling Serilog files +below `settings/logs/`. This change must not add a second archive workflow or +change retention settings. + +#### Scenario: Desktop logging starts + +- **WHEN** the desktop composition starts with a settings directory +- **THEN** the existing log directory and rolling sink behavior remain intact + diff --git a/openspec/specs/log-tool-viewing/spec.md b/openspec/specs/log-tool-viewing/spec.md new file mode 100644 index 00000000..6d2868bf --- /dev/null +++ b/openspec/specs/log-tool-viewing/spec.md @@ -0,0 +1,163 @@ +# log-tool-viewing Specification + +## Purpose +TBD - created by archiving change log-tool-enhancements. Update Purpose after archive. +## Requirements +### Requirement: The log tool opens as a list-first surface + +The LogTool MUST open with no selected entry and no inspector. The retained log +list must occupy the available content width until the user requests details. + +#### Scenario: Open an empty or populated log tool + +- **WHEN** the log tool is created with zero or more retained entries +- **THEN** the inspector is hidden, no entry is selected automatically, and the + list or localized empty state is visible + +#### Scenario: A live entry arrives + +- **WHEN** a new accepted entry arrives while the tool is open +- **THEN** the list updates without selecting the new entry, moving keyboard + focus, or opening the inspector + +### Requirement: Rows provide concise summaries + +Each retained entry MUST render as a stable compact row with a severity marker, +localized severity accessible text, local time, and a truncated summary. Optional +operation, category, or event identity may appear as muted context when it adds +information. Empty values must not reserve space. + +#### Scenario: Routine entry is rendered + +- **WHEN** an entry has a summary and timestamp but no extra identity +- **THEN** the row shows one readable summary line, a severity marker, and the + local time without empty badges or columns + +#### Scenario: Diagnostic identity is useful + +- **WHEN** an entry has category, operation, or event identity that is not in + the summary +- **THEN** the row exposes that identity as compact secondary context and the + inspector remains closed until explicitly requested + +### Requirement: Details require an explicit action + +Each row MUST expose an accessible details action. Selecting a row alone MUST +not open the inspector. Activating the details action, Enter, or Space must +select the entry and open the inspector. + +#### Scenario: Select without inspecting + +- **WHEN** the user clicks a row body +- **THEN** the row becomes selected and the list remains the only visible data + surface + +#### Scenario: Open and close the inspector + +- **WHEN** the user activates the selected row details action +- **THEN** the inspector displays that entry without removing the list on a + wide window +- **WHEN** the user closes the inspector or presses Escape +- **THEN** the list remains at its current filter and scroll state, the selected + row remains selected, and focus returns to the row or its details action + +### Requirement: The inspector uses progressive disclosure + +The inspector MUST show a compact context line and only non-empty message, +technical, property, exception, or structured sections. It must not show a +large repeated summary header or permanently visible empty tabs. + +#### Scenario: Sparse entry is inspected + +- **WHEN** the selected entry has no technical, property, exception, or nested + structured data +- **THEN** the inspector shows compact context and one localized empty state + without blank tab bodies + +#### Scenario: Technical entry is inspected + +- **WHEN** the selected entry has technical detail, an exception, or structured + values +- **THEN** the corresponding populated sections are available, while raw data + and copy actions remain secondary + +### Requirement: Structured values remain expandable + +Nested dictionaries and enumerable values MUST render as recursive nodes. A node +with children must expose an accessible disclosure control bound two-way to its +mutable expansion state. The projection must enforce a finite depth limit. + +#### Scenario: Expand a nested value + +- **WHEN** the selected entry contains nested structured state +- **THEN** the inspector shows a disclosure control and preserves the user's + expansion choice while that entry remains selected + +#### Scenario: Depth limit is reached + +- **WHEN** structured data exceeds the supported nesting depth +- **THEN** the terminal node shows a stable depth-limit marker and the view + remains responsive + +### Requirement: Search and severity filtering stay primary + +The LogTool MUST provide case-insensitive search and severity filtering without +changing retained provider history. Search must cover the summary, category, +operation, event identity, technical detail, exception text, and structured/raw +values. The filter surface must label its controls and expose an active state. + +#### Scenario: Search finds hidden diagnostic text + +- **WHEN** the query occurs only in exception text or structured state +- **THEN** the entry remains visible and the row exposes a localized indication + or highlight that the match is in technical data + +#### Scenario: Search highlights a summary + +- **WHEN** the query occurs in the visible summary +- **THEN** the matching segment is rendered with the log highlight style and + clearing the query removes stale highlights + +#### Scenario: Severity filter changes membership + +- **WHEN** the user selects a severity filter +- **THEN** only matching retained entries are shown, the count updates, and no + replacement entry is selected automatically + +### Requirement: Wide and narrow layouts remain usable + +The inspector MUST use a bounded width beside a star-sized list when the tool +is wide. When the list and inspector minimum widths cannot coexist, the +inspector must replace the list with a localized back action. Hidden columns, +fixed-width overflow, and clipped text are not valid responsive behavior. + +#### Scenario: Inspector opens at the wide size + +- **WHEN** details are opened in a wide tool window +- **THEN** the list remains visible, the inspector is bounded, and both surfaces + have independent scrolling where needed + +#### Scenario: Inspector opens at the narrow size + +- **WHEN** details are opened at the supported narrow width +- **THEN** the inspector fits within the window, exposes a back or close action, + and no control or text is clipped horizontally + +### Requirement: Localization and accessibility are preserved + +User-facing labels, empty states, status text, tooltips, and automation names MUST resolve from the shared locale resources. +Icon-only actions MUST have accessible names. +Keyboard navigation MUST reach the row details action, the inspector close/back action, structured disclosures, and the filter surface. + +#### Scenario: Locale changes while the tool is open + +- **WHEN** the active UI locale changes +- **THEN** list labels, inspector labels, filter labels, and accessibility names + refresh without losing the selected entry or expansion state + +#### Scenario: Keyboard inspection workflow + +- **WHEN** a keyboard user moves to a row and presses Enter or Space +- **THEN** the selected row opens its inspector and Escape returns focus to the + list + diff --git a/openspec/specs/one-shot-transform-compatibility/spec.md b/openspec/specs/one-shot-transform-compatibility/spec.md new file mode 100644 index 00000000..8554e10c --- /dev/null +++ b/openspec/specs/one-shot-transform-compatibility/spec.md @@ -0,0 +1,33 @@ +# one-shot-transform-compatibility Specification + +## Purpose + +Defines the boundary between explicit chapter content operations and pure serialization. + +## Requirements + +### Requirement: Core and Node serialization writes prepared content +Core and Node serialization APIs SHALL serialize the supplied chapter snapshot. They SHALL NOT accept transform-bearing export options or replay content operations. + +#### Scenario: Caller serializes prepared content +- **WHEN** a caller serializes a chapter snapshot +- **THEN** serialization SHALL preserve the supplied chapter values +- **AND** it SHALL NOT apply expressions, naming, numbering, frame-rate changes, or other content transforms + +#### Scenario: Caller transforms content before serialization +- **WHEN** a caller needs an expression, naming, numbering, or frame-rate operation +- **THEN** the caller SHALL apply the explicit operation to content first +- **AND** it SHALL serialize the resulting snapshot without replaying that operation + +### Requirement: CLI expressions remain explicit conversion inputs +CLI `--expression` and `--expression-preset` SHALL remain structured DotMake command options. The CLI SHALL apply the selected expression once before serialization. + +#### Scenario: CLI converts with an expression +- **WHEN** `convert` is called with an expression or preset +- **THEN** the CLI SHALL apply the selected operation once before serialization +- **AND** each supported format SHALL serialize the resulting snapshot + +#### Scenario: CLI rejects conflicting expression options +- **WHEN** both expression options are supplied +- **THEN** CLI validation SHALL fail before import +- **AND** it SHALL use its normal non-zero validation result diff --git a/openspec/specs/public-frame-rate-contracts/spec.md b/openspec/specs/public-frame-rate-contracts/spec.md new file mode 100644 index 00000000..26dd09d8 --- /dev/null +++ b/openspec/specs/public-frame-rate-contracts/spec.md @@ -0,0 +1,31 @@ +## Purpose + +Defines the canonical public frame-rate and chapter-content contracts for Core and Node consumers. Public operations preserve explicit rate provenance and timestamp ticks without legacy numeric projections. + +## Requirements + +### Requirement: Public content APIs use canonical rate and timestamp values +Public Core and Node content APIs SHALL use `FrameRateValue` or the exact Node rate union for rates, and timestamp ticks or exact tick strings for content time. They SHALL NOT expose numeric FPS or seconds compatibility projections, constructors, deconstructors, or operation overloads. + +#### Scenario: Core consumer constructs chapter content +- **WHEN** a caller constructs or updates a chapter set +- **THEN** the caller SHALL supply its canonical frame rate +- **AND** timestamps SHALL remain `TimeSpan` ticks or document ticks without conversion through floating-point seconds + +#### Scenario: Node consumer imports and edits content +- **WHEN** a caller completes a content workflow through the Node package +- **THEN** its public input and result types SHALL use exact rate kinds and integer tick strings +- **AND** no numeric seconds/FPS facade SHALL be exported + +#### Scenario: Caller needs an approximate frame rate +- **WHEN** a caller has an approximate numeric measurement +- **THEN** it SHALL explicitly construct an Approximate `FrameRateValue` or exact Node rate value at the boundary +- **AND** the system SHALL NOT infer Rational provenance from the numeric value + +### Requirement: Frame-rate selection exposes MPLS identity by format meaning +Selectable frame-rate options SHALL expose their Blu-ray MPLS rate code as `MplsFrameRateCode`. The code SHALL preserve the same values and reserved/custom behavior across Core, Avalonia, WASM, and Node DTOs. + +#### Scenario: Catalog option is selected +- **WHEN** desktop or browser UI maps a displayed frame-rate option +- **THEN** it SHALL preserve the option's existing MPLS code mapping and reserved entry behavior +- **AND** public DTOs SHALL use the `mplsFrameRateCode` field name diff --git a/openspec/specs/session-edit-history/spec.md b/openspec/specs/session-edit-history/spec.md new file mode 100644 index 00000000..492e3f32 --- /dev/null +++ b/openspec/specs/session-edit-history/spec.md @@ -0,0 +1,157 @@ +# session-edit-history Specification + +## Purpose +TBD - created by archiving change unified-editing-03-history-tree. Update Purpose after archive. +## Requirements +### Requirement: Session history retains all committed branches +Each document session SHALL retain its root and every committed state in an in-memory history tree. The system SHALL NOT prune history by operation count, age, or memory threshold. + +#### Scenario: Editing after undo creates a branch +- **WHEN** a new edit is committed while the cursor is not at a leaf +- **THEN** the previous child path SHALL remain reachable +- **AND** the new state SHALL be added as another child + +#### Scenario: History contains many edits +- **WHEN** the session commits more edits than any conventional fixed history limit +- **THEN** all committed nodes SHALL remain reachable until the session ends + +### Requirement: Undo, redo, and branch selection restore committed states +Undo and redo SHALL restore a previously committed state without rerunning the operation. Redo SHALL support the preferred child and an explicitly selected retained branch. + +#### Scenario: Undo then redo +- **WHEN** the user undoes a committed edit and redoes it +- **THEN** the exact committed document and stable identities SHALL be restored +- **AND** mutation revision SHALL continue increasing + +#### Scenario: Redo chooses an alternate branch +- **WHEN** multiple retained child branches exist and the user selects one +- **THEN** the cursor SHALL move to that branch's committed state without changing other branches + +#### Scenario: Undo at root or redo at leaf +- **WHEN** undo has no parent or redo has no child +- **THEN** the operation SHALL return `NoChange` and SHALL NOT increase the revision + +### Requirement: History failure preserves the current session +If change-set construction, branch navigation, or a recoverable resource allocation fails before publication, the session SHALL retain its document, history nodes, preferred branch, and cursor. + +#### Scenario: History node allocation fails +- **WHEN** a commit cannot allocate its change set or indexes +- **THEN** the operation SHALL return a resource failure and preserve the original session state + +#### Scenario: Cross-branch reconstruction fails +- **WHEN** reconstruction fails before the target is ready to publish +- **THEN** no intermediate document SHALL be visible and the cursor SHALL remain unchanged + +### Requirement: Node inspection is a read-only parent-relative query + +The session SHALL provide typed details for every retained history node. A non-root node's details SHALL describe the change from its own parent to that node. Inspection SHALL NOT move the cursor, alter preferred children, change mutation revision, create history nodes, or rerun an operation. One result SHALL use one captured immutable history version. + +#### Scenario: Inspect an alternate branch +- **WHEN** the current cursor is on one branch and a query inspects a node on another branch +- **THEN** the query returns that node's changes relative to its parent +- **AND** the current document, cursor, preferred children, revision, and retained nodes remain unchanged + +#### Scenario: Inspect while navigation occurs +- **WHEN** history navigation occurs during an inspection query +- **THEN** the query returns a consistent captured result or an explicit cancellation result +- **AND** it does not mix values from different history versions + +#### Scenario: Query cannot complete +- **WHEN** the requested node is missing, the session has ended, the caller cancels, or recoverable resource exhaustion prevents inspection +- **THEN** the query reports a typed unavailable, canceled, or resource-failure result +- **AND** it does not publish an intermediate document or mutate history + +### Requirement: History details preserve typed and structural differences + +Node details SHALL match objects by stable identity and expose changed values with before/after presence. Details SHALL cover editable chapter fields, chapter membership and order, track membership and order, track names, segment membership and order, segment placement and source/media properties, and editable document properties. Absence, null/unknown values, and empty text SHALL remain distinguishable. + +#### Scenario: One chapter has several changed fields +- **WHEN** one commit changes a chapter's name, start time, and frame information +- **THEN** details expose all changed fields and their exact before/after values +- **AND** the summary counts one modified chapter + +#### Scenario: Chapters are added and deleted +- **WHEN** a commit adds one chapter and deletes another +- **THEN** details identify the added and deleted objects and their available values +- **AND** added and deleted chapters are not also counted as modified + +#### Scenario: Order or track membership changes +- **WHEN** retained chapter identities change order or move between retained tracks +- **THEN** details expose their before/after positions and track membership +- **AND** a moved stable identity is not represented as unrelated deletion and addition + +#### Scenario: Structural operations create new identities +- **WHEN** merge, split, or append creates or removes track, chapter, or segment identities +- **THEN** details expose the actual retained identities and structural changes +- **AND** the query does not invent correspondence between different identities + +#### Scenario: No chapter field changes +- **WHEN** a commit changes only document frame rate, a track name, or segment placement or source metadata +- **THEN** details expose those property changes +- **AND** the summary does not claim that chapter fields changed + +#### Scenario: Empty and absent values differ +- **WHEN** a field changes between empty text, unknown/null, and a present value, or its object is added or removed +- **THEN** the typed result preserves each state without collapsing them into the same string + +### Requirement: History details retain historical time and frame meaning + +Details SHALL preserve exact time values and historical frame-rate context. Signed time differences SHALL use the node's before/after values. Current host display preferences SHALL NOT change historical stored values or operation parameters. Missing historical rate information SHALL remain explicitly unknown. + +#### Scenario: Main-window FPS changes after an edit +- **WHEN** the user changes the display FPS and then inspects an earlier frame edit +- **THEN** details retain that edit's stored frame values and available historical rate context +- **AND** the current display FPS is not substituted as the edit's effective rate + +#### Scenario: A submillisecond value changes +- **WHEN** an edit changes time by less than the ordinary display precision +- **THEN** the detail result retains the exact before/after values and signed delta + +### Requirement: Root inspection exposes the initial document + +Root details SHALL expose the initial document overview and ordered read-only chapter values. The root SHALL be identified as the initial state and SHALL NOT have a fabricated parent or change set. + +#### Scenario: Inspect the load node +- **WHEN** a user queries the history root after later edits +- **THEN** the result describes the initial document's source, tracks, chapters, and available properties +- **AND** the initial values remain separate from current document values +- **AND** no parent-relative edit is reported + +### Requirement: Operation metadata is immutable and atomically committed + +History nodes SHALL support a stable operation kind, optional typed captured parameters, and publication time. Available metadata SHALL be committed atomically with the node and its changes. Descriptions SHALL remain diagnostic fallback data. Callers that provide only an operation description SHALL remain supported without description parsing or invented metadata. + +#### Scenario: A reviewed expression is applied +- **WHEN** a prepared expression candidate is committed after the live draft changes +- **THEN** the history node retains the script, scope, and effective parameters captured for that candidate +- **AND** it does not retain the later draft values + +#### Scenario: A transaction does not commit +- **WHEN** a request is invalid, stale, canceled, fails publication, or makes no canonical change +- **THEN** no node or standalone operation metadata is added + +#### Scenario: A transaction is retried or restored +- **WHEN** an idempotent transaction is retried or its node is visited through undo or redo +- **THEN** the original operation metadata and publication time remain unchanged +- **AND** no operation is rerun to rebuild metadata + +#### Scenario: A caller has no structured metadata +- **WHEN** a supported caller commits using an operation description only +- **THEN** the node still supports exact change inspection and normal restoration +- **AND** missing parameters or time remain explicitly unavailable + +### Requirement: Inspection preserves delta-based session ownership + +History SHALL retain delta-based node storage. Inspection SHALL NOT attach a complete reconstructed document to every node or prune retained history. Detail work SHALL support cancellation and remain owned by the current in-memory session. History export and cross-session history persistence SHALL NOT be introduced by this capability change. + +#### Scenario: Inspect a deep history +- **WHEN** a session has a long linear path and retained alternate branches +- **THEN** traversal and private reconstruction do not require recursive call depth proportional to history length +- **AND** all retained nodes remain reachable +- **AND** inspection does not add permanent full-document copies to those nodes + +#### Scenario: The session ends during inspection +- **WHEN** the document session ends while a detail query runs +- **THEN** its lifetime token cancels outstanding detail work +- **AND** host-owned references to old detail results can be released + diff --git a/openspec/specs/unified-content-editing/spec.md b/openspec/specs/unified-content-editing/spec.md new file mode 100644 index 00000000..5fc3d46a --- /dev/null +++ b/openspec/specs/unified-content-editing/spec.md @@ -0,0 +1,58 @@ +# unified-content-editing Specification + +## Purpose +TBD - created by archiving change unified-editing-04-content-operations. Update Purpose after archive. +## Requirements +### Requirement: All content tools commit one complete candidate +Content tools SHALL capture a document snapshot and explicit target identities, compute a complete candidate, validate it, and apply it as one history transaction. A row failure, cancellation, or stale base SHALL reject the whole candidate. + +#### Scenario: Batch candidate has one invalid row +- **WHEN** a template, expression, or other batch candidate contains an invalid result +- **THEN** no part of the candidate SHALL be committed +- **AND** the current state and history cursor SHALL remain unchanged + +#### Scenario: Preview is cancelled or stale +- **WHEN** a preview is cancelled or its base token no longer matches the session +- **THEN** it SHALL NOT change the document +- **AND** a stale preview SHALL require recomputation before apply + +### Requirement: Cell edits have explicit draft and commit semantics +Cell editing SHALL capture the chapter ID, field, original value, and base token. Enter, Tab, or valid focus loss SHALL commit one transaction; Escape SHALL discard the draft. Invalid input SHALL remain visible and SHALL NOT be coerced to zero. + +#### Scenario: Same canonical value is entered +- **WHEN** a cell commits the value it already contains +- **THEN** no history node SHALL be created + +#### Scenario: Undo is invoked during text editing +- **WHEN** a text control owns focus +- **THEN** its undo command SHALL affect only the draft +- **AND** document history SHALL remain unchanged + +### Requirement: Tool operations are one-shot and scope is explicit +Template, expression, replacement, offset, frame-rate, numbering, ordering, and metadata tools SHALL show their target scope and compute from the captured original snapshot. Applying a tool SHALL store the result as current document content and SHALL NOT leave a transform rule active during export. + +#### Scenario: Expression is applied then a cell is edited +- **WHEN** an expression is applied and a transformed time is edited in the table +- **THEN** the edited value SHALL become current content +- **AND** later serialization SHALL NOT run the expression again + +#### Scenario: Operation targets selected chapters +- **WHEN** a tool uses selection scope +- **THEN** it SHALL capture the selected chapter IDs at calculation start and SHALL NOT substitute different rows after reordering + +#### Scenario: Expression calculation fails for one target +- **WHEN** any target expression fails, times out, or is cancelled +- **THEN** the entire operation SHALL fail without partial content or history changes + +### Requirement: Timing edits preserve document invariants +Time edits, shifts, scaling, delete normalization, and frame-rate actions SHALL update their defined related fields consistently, validate overflow and end-before-start, and reject invalid results before commit. + +#### Scenario: Delete normalization shifts explicit ends +- **WHEN** chapter starts are normalized after deletion +- **THEN** each retained start and explicit end SHALL receive the same checked shift +- **AND** one undo SHALL restore the complete prior document + +#### Scenario: Scale collapses a segment +- **WHEN** time scaling would make a segment zero length or overflow +- **THEN** the candidate SHALL be rejected and the current state SHALL remain unchanged + diff --git a/openspec/specs/unified-editing-migration-completion/spec.md b/openspec/specs/unified-editing-migration-completion/spec.md new file mode 100644 index 00000000..942fed2f --- /dev/null +++ b/openspec/specs/unified-editing-migration-completion/spec.md @@ -0,0 +1,36 @@ +# unified-editing-migration-completion Specification + +## Purpose +TBD - created by archiving change unified-editing-09-remove-legacy-paths. Update Purpose after archive. +## Requirements +### Requirement: Interactive content has one authoritative edit path +After migration, every interactive content command SHALL commit to the current document session. No host table, preview, or export path SHALL read a competing editable chapter set or persistent transform projection. + +#### Scenario: User edits then previews and exports +- **WHEN** a user edits a chapter or applies a tool operation +- **THEN** the table, preview, and each supported export SHALL read the same committed document content + +#### Scenario: Repository migration is reviewed +- **WHEN** the migration is declared complete +- **THEN** every prior content mutation entry point SHALL be listed with its session transaction and behavior test +- **AND** no required content command SHALL remain disabled as a migration workaround + +### Requirement: History verification includes branch, failure, and resource behavior +The system SHALL verify full-state identity across undo/redo and branch navigation, injected recoverable failures, deep-history traversal, and retained history without automatic truncation. + +#### Scenario: Source files disappear during a live session +- **WHEN** the imported source is removed after loading +- **THEN** undo, redo, and branch switching SHALL use in-memory history and SHALL remain available + +#### Scenario: Large history is measured +- **WHEN** the performance suite exercises increasing depth, branch count, and edit size +- **THEN** it SHALL report memory, allocation, undo, and distant-branch navigation data for the recorded environment +- **AND** the system SHALL NOT prune history to pass the measurement + +### Requirement: Migration documentation and verification match shipped behavior +Current code maps and OpenSpec requirements SHALL identify the unified session owner and primary tests. Completion reporting SHALL list executed validation commands, performance evidence, screenshots when UI layout changed, and any unavailable checks. + +#### Scenario: Maintainer follows the code map +- **WHEN** a maintainer looks up editing, session, host, export, or test ownership +- **THEN** the current code map SHALL point to the unified document, transaction, history, and host integration entry points + diff --git a/openspec/specs/versioned-settings-document/spec.md b/openspec/specs/versioned-settings-document/spec.md index 5a487107..841eccdb 100644 --- a/openspec/specs/versioned-settings-document/spec.md +++ b/openspec/specs/versioned-settings-document/spec.md @@ -1,20 +1,20 @@ # versioned-settings-document Specification ## Purpose -Define the unified, versioned settings document used for current application, theme, and font persistence. +Define the unified, versioned settings document used for current application, theme, font, and shortcut persistence. ## Requirements ### Requirement: Settings use one sectioned document -The system SHALL persist current user configuration in one `settings.json` document containing a top-level schema version and typed application, theme, and font sections. +The system SHALL persist current user configuration in one `settings.json` document containing a top-level schema version and typed application, theme, font, and shortcut sections. #### Scenario: Saving the aggregate creates the unified shape - **WHEN** aggregate settings are saved and no active configuration exists - **THEN** `settings.json` SHALL be written with the current `schemaVersion` -- **AND** it SHALL contain `application`, `theme`, and `font` child content +- **AND** it SHALL contain `application`, `theme`, `font`, and `shortcuts` child content #### Scenario: Settings workflow loads and saves once -- **WHEN** a settings workflow loads application, theme, and font values and then saves changes across one or more child sections +- **WHEN** a settings workflow loads application, theme, font, and shortcut values and then saves changes across one or more child sections - **THEN** it SHALL call the aggregate store load exactly once - **AND** it SHALL commit the updated aggregate exactly once - **AND** the store SHALL replace `settings.json` exactly once for that commit @@ -32,7 +32,7 @@ The system SHALL persist current user configuration in one `settings.json` docum #### Scenario: No section persistence stores exist - **WHEN** application services, ViewModels, CLI workflows, and tool discovery access persisted settings - **THEN** they SHALL depend on `ISettingsStore` -- **AND** the system SHALL NOT expose separate application, theme, or font persistence stores +- **AND** the system SHALL NOT expose separate application, theme, font, or shortcut persistence stores ### Requirement: Known settings versions upgrade explicitly The system SHALL identify the settings document structure with an integer `schemaVersion` and SHALL apply contiguous, ordered upgrade steps for supported older versions. diff --git a/openspec/specs/wasm-browser-e2e/spec.md b/openspec/specs/wasm-browser-e2e/spec.md new file mode 100644 index 00000000..078342e1 --- /dev/null +++ b/openspec/specs/wasm-browser-e2e/spec.md @@ -0,0 +1,109 @@ +# wasm-browser-e2e Specification + +## Purpose +TBD - created by archiving change add-wasm-browser-e2e. Update Purpose after archive. +## Requirements +### Requirement: Browser suite runs against deployment-shaped WASM output +The project SHALL provide a standalone Playwright Test and TypeScript suite that exercises the Blazor WebAssembly application in real browsers. The default target SHALL be the Release static output prepared for the GitHub Pages `/ChapterTool/` path. A local development-server configuration SHALL run the same scenarios at the root path for diagnosis. + +#### Scenario: Test the prepared Release site under the repository path +- **WHEN** a developer prepares and runs the default browser suite +- **THEN** the suite serves the prepared Release site and navigates to `http://127.0.0.1:5261/ChapterTool/` +- **AND** framework assets load from the repository subpath without falling back to the page for missing resources + +#### Scenario: Diagnose the application with the development server +- **WHEN** a developer runs the development browser configuration +- **THEN** Playwright starts the Blazor development server on the loopback address at the root path +- **AND** the same workflow assertions run without changing their expected behavior + +### Requirement: Static site preparation is safe and deployment-consistent +The preparation step SHALL transform a single Release publish into a static site directory with the required Pages base path and deployment files. It SHALL validate that any directory it clears is within `artifacts/wasm-e2e/`. The static server SHALL bind only to loopback, serve correct MIME types including `application/wasm`, return 404 for missing resources, and prevent paths from escaping its configured root. GitHub Pages SHALL test and upload the same prepared directory without rebuilding or rewriting it after tests. + +#### Scenario: Prepare static output for local browser testing +- **WHEN** the preparation script receives a valid publish directory +- **THEN** it creates the Pages-shaped site with the expected base path and static deployment files +- **AND** it does not delete files outside the validated E2E artifact directory + +#### Scenario: Serve an asset and reject missing or unsafe paths +- **WHEN** the static server receives requests for a WASM asset, a missing framework asset, and a path outside its root +- **THEN** it serves the asset with `application/wasm`, returns 404 for the missing asset, and refuses the path traversal request + +#### Scenario: Deploy the artifact that passed smoke tests +- **WHEN** the Pages workflow runs its pre-deployment smoke suite successfully +- **THEN** it uploads the same prepared site directory that the suite tested +- **AND** a failed smoke suite stops artifact upload and deployment + +### Requirement: Browser tests use accessible user interactions and observable outcomes +The application SHALL expose accessible names and stable readiness semantics needed to operate and observe its browser workflows. The browser suite MUST prefer roles, labels, and scoped dialog locators over brittle CSS paths or coordinates. Readiness MUST be set after initialization, settings restoration, and JavaScript event registration, and MUST NOT substitute for an actual interop smoke action. + +#### Scenario: Wait for application initialization and verify interop +- **WHEN** a browser test opens the application +- **THEN** it waits for the application readiness marker +- **AND** the smoke test imports a real file through the page before passing + +#### Scenario: Commit and verify an edited chapter +- **WHEN** a test changes a chapter field and performs a real blur action +- **THEN** it verifies the committed table value and the corresponding undo/redo or exported result +- **AND** it does not pass based only on the value still present in the focused input + +### Requirement: Browser workflows cover user-visible behavior and downloaded bytes +The suite SHALL cover the planned staged workflows: startup and import, chapter editing and history, preview and export, replacement confirmation, invalid input recovery, settings persistence and cancellation, localization, keyboard behavior, file boundaries, tab isolation, disc workflows, and responsive layout. Download tests MUST inspect the downloaded file's name and contents; encoding tests MUST inspect bytes. Expected exports MUST be fixed, reviewed fixtures and MUST NOT be generated by the implementation under test. + +#### Scenario: Complete a core import, edit, history, and download workflow +- **WHEN** a test imports a known OGM fixture, edits a chapter, undoes and redoes the change, then saves +- **THEN** the table and history controls show the expected state +- **AND** the real downloaded file matches a fixed expected result + +#### Scenario: Recover from invalid input without losing the current document +- **WHEN** a test imports malformed input while a document is open +- **THEN** the page shows a visible diagnostic and preserves the current document and history +- **AND** a subsequent valid import remains usable + +#### Scenario: Verify localization and encoded output +- **WHEN** a test selects English, Chinese, or Japanese in the application and saves representative chapter names +- **THEN** visible labels and the applied document language match the selected locale +- **AND** the downloaded text decodes correctly, with byte-level checks for BOM and UTF-16 formats + +### Requirement: Browser state boundaries and diagnostics are explicit +Each browser test SHALL start with an isolated BrowserContext unless it explicitly tests multiple pages in one context. Tests SHALL collect page errors, console errors, failed requests, and failed same-origin resource responses from before navigation, attach diagnostics, and fail on unexpected errors. Tests MUST interact through the page and MUST NOT call application internals or mock Core/WASM results. + +#### Scenario: Detect unexpected startup and resource errors +- **WHEN** a test navigates to the application +- **THEN** its diagnostics capture page, console, request, and same-origin resource failures +- **AND** unexpected failures cause the test to fail with attached diagnostic output + +#### Scenario: Verify independent chapter sessions across tabs +- **WHEN** two pages in the same BrowserContext edit separate documents +- **THEN** each page retains its own chapter and history state +- **AND** shared settings storage is not treated as chapter-session leakage + +### Requirement: CI gates browser regressions and retains artifacts +Pull requests and relevant pushes SHALL run the implemented Chromium behavior suite. The Pages workflow SHALL run Chromium `@smoke` against the prepared deployment artifact before deployment. A scheduled or manually triggered acceptance workflow SHALL run the full suite in Chromium, Firefox, and WebKit. CI SHALL retain reports, traces, failure screenshots, logs, and downloaded files when a run fails or succeeds. Retried flaky tests SHALL remain gate failures. + +#### Scenario: Run the Chromium pull request gate +- **WHEN** a relevant pull request or push changes the WASM application, its dependencies, browser suite, or build configuration +- **THEN** CI publishes and prepares the Release output and runs the implemented Chromium suite +- **AND** a failed test makes the job fail + +#### Scenario: Run cross-browser acceptance +- **WHEN** the scheduled or manually triggered acceptance workflow runs +- **THEN** it executes the full browser suite in Chromium, Firefox, and WebKit +- **AND** it reports pass, failure, and skip counts for each engine + +#### Scenario: Preserve diagnostics for failed CI runs +- **WHEN** an E2E job fails +- **THEN** CI uploads its reports, traces, screenshots, logs, and downloaded test files +- **AND** a test that passes only after retry remains a failure + +### Requirement: Test setup and acceptance evidence are documented +The E2E README and repository testing ownership documentation SHALL explain prerequisites, install and run commands, browser installation, development mode, report viewing, and failure diagnosis. The WASM README and testing code map SHALL identify the suite and its ownership. The first acceptance report SHALL distinguish planned coverage from executed coverage and record the commit, locked dependency and browser versions, operating system, commands, per-engine outcomes, duration, and artifact paths. + +#### Scenario: Set up the suite in a clean environment +- **WHEN** a developer follows the E2E README in a clean checkout +- **THEN** the developer can install locked dependencies, install the selected browsers, prepare the site, run type checking and tests, and open the report + +#### Scenario: Record first acceptance results +- **WHEN** implementation reaches the first acceptance run +- **THEN** the report records the required environment and command evidence +- **AND** it labels unexecuted coverage as planned instead of passed + diff --git a/openspec/specs/wasm-content-operation-parity/spec.md b/openspec/specs/wasm-content-operation-parity/spec.md new file mode 100644 index 00000000..4e92e8dc --- /dev/null +++ b/openspec/specs/wasm-content-operation-parity/spec.md @@ -0,0 +1,154 @@ +# wasm-content-operation-parity Specification + +## Purpose +TBD - created by archiving change align-wasm-ui-functional-parity. Update Purpose after archive. +## Requirements +### Requirement: Shared frame display and conversion bounds remain consistent + +Both hosts MUST derive frame text and accuracy from display preferences without content transactions. +Frame edits and signed shifts MUST use the displayed FPS. +Expression preparation MAY supply detected FPS to the selected track when source FPS is absent. +Frame conversion MUST quantize absolute segment boundaries through the chapter conversion calculation. +It MUST preserve segment identities, source FPS metadata, and known document bounds. +Selected-track operations MUST preserve other tracks. + +#### Scenario: Source FPS is absent +- **WHEN** either host detects a usable display FPS and the user edits a frame cell +- **THEN** both hosts produce the same chapter time +- **AND** refreshing the display creates no content transaction + +#### Scenario: Convert rational FPS +- **WHEN** a reviewed rational-FPS conversion changes a segment timeline +- **THEN** each converted segment duration equals its converted end minus its converted start +- **AND** the known document duration contains every retained segment +- **AND** the operation preserves source FPS metadata and other tracks + +### Requirement: Content parameters remain drafts until explicit application + +The Web host MUST keep naming, template, numbering, frame-shift, and frame-rate parameters as operation drafts. Parameter changes and candidate preparation MUST NOT change committed chapters, history, format preview, or export. The operation surface MUST identify the current-track scope and excluded separator rows. + +#### Scenario: Choose automatic names or load a template +- **WHEN** the user changes the naming draft or loads a valid template +- **THEN** the operation surface shows the draft and scope +- **AND** committed names, preview, export, and history remain unchanged + +#### Scenario: Change timing parameters +- **WHEN** the user selects a target frame rate or edits a frame-shift draft +- **THEN** the host prepares a read-only candidate for the current track +- **AND** timing changes require a separate Apply action + +### Requirement: Application commits the exact reviewed candidate + +The Web host MUST show relevant before/after values and complete change counts. Apply MUST be enabled only for a valid, changed, current candidate. Apply MUST commit the reviewed candidate once without recalculation. Repeated submission MUST create at most one transaction. Cancel, Close, and Escape before submission MUST discard the draft and candidate without content changes. + +#### Scenario: Apply and undo a naming candidate +- **WHEN** the user applies reviewed generated or template names +- **THEN** the committed values match the displayed candidate +- **AND** one transaction contains all changes and one Undo restores them +- **AND** Redo and export use stored content without replaying the operation + +#### Scenario: Candidate is invalid or unchanged +- **WHEN** a candidate fails validation or equals its base document +- **THEN** Apply remains disabled and the surface explains the reason +- **AND** the document and history cursor remain unchanged + +#### Scenario: Cancel a prepared conversion +- **WHEN** the user cancels, closes, or presses Escape before applying a frame-rate candidate +- **THEN** no chapter time, frame rate, segment metadata, or history node changes + +### Requirement: Candidate identity protects document and track scope + +Candidate application MUST validate the document session, target track, draft revision, and base version. A stale candidate MUST require explicit refresh and another review. Failed application MUST preserve the draft and report the failure. Applying MUST prevent duplicate edits or dismissal until the outcome is known. + +#### Scenario: History or track changes during review +- **WHEN** the committed version or selected track changes after candidate preparation +- **THEN** the old candidate cannot commit +- **AND** explicit refresh prepares a new candidate without applying it + +#### Scenario: Base changes during submission +- **WHEN** the document changes between the readiness check and transaction commit +- **THEN** the transaction rejects the candidate without partial changes +- **AND** the surface retains parameters for correction or refresh + +### Requirement: Preferences never replay content operations + +Saving settings or changing output, appearance, or frame-display preferences MUST NOT apply naming, numbering, expression, shift, or conversion drafts. Display refresh MUST read committed content. A successful naming or numbering operation MUST clear its active one-shot intent. Retained template text MUST require a new explicit operation before reuse. + +#### Scenario: Save appearance after manually renaming a chapter +- **WHEN** the user applies automatic names, manually renames one chapter, and saves theme or font settings +- **THEN** the manually edited name remains unchanged in the table and export +- **AND** settings save creates no content history node + +#### Scenario: Change rounding or output encoding +- **WHEN** the user changes rounding, frame precision, tolerance, format, XML language, encoding, or BOM +- **THEN** the corresponding presentation or output preference changes +- **AND** chapter content and content history remain unchanged +- **AND** a pending content draft is not applied + +#### Scenario: Save default format during an active session +- **WHEN** the user saves a new default format while a document has a selected session format +- **THEN** the active session format remains selected +- **AND** the saved default applies at the next application startup + +### Requirement: Signed frame shifts use shared timing validation + +Both hosts MUST accept integer frame-shift drafts from -1000000 through 1000000. The Web host MUST review shifts using the same current-track scope and FPS basis as Avalonia. Zero MUST produce an unchanged candidate. Invalid times, overflow, or missing required FPS MUST reject the whole candidate. + +#### Scenario: Review a valid negative shift +- **WHEN** a shift of minus one frame leaves every participating chapter time valid +- **THEN** both hosts show the same candidate times and related timing fields +- **AND** the Web document changes only after Apply + +#### Scenario: Shift would make a chapter time negative +- **WHEN** a signed shift would violate a chapter timing invariant +- **THEN** the entire candidate is invalid +- **AND** neither host commits partial chapter changes + +#### Scenario: Zero or out-of-range shift +- **WHEN** the user enters zero or a value outside the supported integer range +- **THEN** zero is reported as unchanged and an out-of-range value has a validation error +- **AND** neither value creates a history node + +### Requirement: Numbering controls reflect the existing Core policy + +Both hosts MUST expose numbering offsets from 0 through 1000. Zero MUST remain the neutral draft value. Negative, fractional, and out-of-range UI input MUST NOT be advertised as a valid numbering operation. Applying a nonzero offset MUST use the Core numbering result and separator handling. + +#### Scenario: Apply a positive offset +- **WHEN** the user previews and applies an offset of two to a track with ordinary chapters and separators +- **THEN** ordinary chapter numbers match the Core candidate in both hosts +- **AND** separator numbering follows the existing Core policy + +#### Scenario: Enter a negative offset in either host +- **WHEN** the user attempts to enter a negative numbering offset +- **THEN** the control prevents acceptance or reports a validation error +- **AND** no operation silently treats that input as a supported negative offset + +### Requirement: Portable operations produce equivalent host outcomes + +Verification MUST use matching inputs, target scopes, operations, and output preferences in both hosts. It MUST compare relevant committed document fields, one-step Undo/Redo outcomes, and serialized output. Web command enabled states and option labels MUST expose the same portable prerequisites. + +#### Scenario: Run the same operation sequence +- **WHEN** tests apply naming, numbering, frame edit, signed shift, conversion, and expression fixtures through both hosts +- **THEN** relevant committed values and exported bytes match for the same preferences +- **AND** each accepted operation has the expected atomic history behavior + +#### Scenario: Present localized XML choices +- **WHEN** the user changes UI language with XML output selected +- **THEN** Web XML choices expose localized language names and stable language codes +- **AND** the selected code remains unchanged and the selector is disabled for non-XML output + +### Requirement: Portable tool surfaces remain localized and accessible + +All new or changed Web operation, editor, settings, shortcut, and log controls MUST use shared locale resources. Icon actions MUST have accessible names. Modal focus containment and restoration MUST remain intact. Default, wide, narrow, narrow-short, and landscape layouts MUST keep primary actions reachable. Behavior assertions MUST accompany screenshot evidence. + +#### Scenario: Switch among supported locales +- **WHEN** the user selects English, Chinese, or Japanese and opens a changed tool +- **THEN** labels, statuses, validation, and accessible names resolve for that locale +- **AND** chapter text and operation identity remain unchanged + +#### Scenario: Use the changed tools at supported viewport sizes +- **WHEN** browser checks run at 1280x800, 1920x1080, 390x844, 390x640, and 844x390 +- **THEN** changed tools and primary actions can be reached and activated without page-level horizontal overflow +- **AND** closing each modal returns focus to a valid opener or workspace target +- **AND** screenshots record layouts in addition to those interaction assertions + diff --git a/openspec/specs/wasm-expression-editing/spec.md b/openspec/specs/wasm-expression-editing/spec.md new file mode 100644 index 00000000..b453d35f --- /dev/null +++ b/openspec/specs/wasm-expression-editing/spec.md @@ -0,0 +1,41 @@ +# wasm-expression-editing Specification + +## Purpose +TBD - created by archiving change live-expression-preview. Update Purpose after archive. +## Requirements +### Requirement: WASM expression editing provides a live read-only preview +The WASM application SHALL let users edit Lua expression drafts and inspect a live read-only candidate preview. The preview SHALL NOT mutate the committed document, grid rows, export output, or history. + +#### Scenario: Typing refreshes the browser candidate preview +- **WHEN** the user enters or edits expression text +- **THEN** the browser SHALL refresh a candidate preview from the current committed document +- **AND** it SHALL show changed chapter values, no-change state, or validation errors +- **AND** it SHALL keep the expression input usable without a persistent enable checkbox + +#### Scenario: Selecting a preset refreshes the browser preview +- **WHEN** the user selects a built-in expression preset +- **THEN** the editor SHALL populate the preset script and refresh the read-only preview +- **AND** selecting the preset SHALL NOT change chapter values or history + +#### Scenario: Invalid candidate cannot be applied +- **WHEN** Lua evaluation or document validation fails +- **THEN** the page SHALL display the current diagnostic +- **AND** the Apply action SHALL be disabled +- **AND** the committed grid and history SHALL remain unchanged + +#### Scenario: Applying a valid candidate updates committed chapters +- **WHEN** the user applies a valid current candidate +- **THEN** the page SHALL commit one atomic history transaction +- **AND** the chapter grid, format preview, and download SHALL use the committed values +- **AND** Undo SHALL restore the values before the expression operation without rerunning Lua + +#### Scenario: Canceling a candidate leaves the document unchanged +- **WHEN** the user cancels a live candidate preview +- **THEN** the page SHALL discard that preview +- **AND** the document, grid, export output, and history SHALL remain unchanged + +#### Scenario: Editing an applied row does not rerun the expression +- **WHEN** the user edits a chapter after applying an expression +- **THEN** the page SHALL retain the user's chapter value +- **AND** preview and download SHALL NOT run the previous expression again + diff --git a/openspec/specs/wasm-grid-expression-authoring/spec.md b/openspec/specs/wasm-grid-expression-authoring/spec.md new file mode 100644 index 00000000..f3d2e223 --- /dev/null +++ b/openspec/specs/wasm-grid-expression-authoring/spec.md @@ -0,0 +1,112 @@ +# wasm-grid-expression-authoring Specification + +## Purpose +TBD - created by archiving change align-wasm-ui-functional-parity. Update Purpose after archive. +## Requirements +### Requirement: Browser frame cells use Core edit semantics + +The Web chapter grid MUST permit edits to time, name, and frame fields through Core cell-edit semantics. Frame edits MUST use the same FPS basis, parsing, related-field updates, and validation as Avalonia. Separator and missing-FPS restrictions MUST match the applicable Core operation. + +#### Scenario: Edit a frame value +- **WHEN** the user commits a valid frame value for an ordinary chapter with valid FPS +- **THEN** the chapter time and derived frame display match the Avalonia result +- **AND** export uses the committed timing and one Undo restores the prior document + +#### Scenario: Edit a protected or invalid field +- **WHEN** the user attempts an unsupported separator edit or frame edit without required FPS +- **THEN** the host prevents or rejects that edit with an accessible explanation +- **AND** no partial content or history change occurs + +### Requirement: Browser cells have explicit draft and commit states + +A Web cell edit MUST capture chapter identity, field, original value, and base version. Enter, Tab, or valid focus loss MUST commit once. Escape MUST discard the draft. Invalid input MUST remain visible with a localized error and MUST NOT be coerced to zero. Text-control Undo MUST affect the draft while the input owns focus. + +#### Scenario: Enter is followed by focus loss +- **WHEN** the user presses Enter on a valid edit and the input then loses focus +- **THEN** at most one content transaction is created +- **AND** an unchanged canonical value creates no history node + +#### Scenario: Escape or local Undo +- **WHEN** the user presses Escape during an edit +- **THEN** the original value is restored without a document transaction +- **WHEN** the focused input receives a text Undo gesture +- **THEN** document history does not move + +#### Scenario: Invalid input stays available for correction +- **WHEN** a time or frame draft cannot be parsed or violates timing invariants +- **THEN** the draft remains visible with an accessible error +- **AND** committed rows, history, and export retain the preceding valid values + +#### Scenario: A row position becomes stale +- **WHEN** history navigation or clip replacement changes the row projection during an edit +- **THEN** the old edit cannot commit against a different chapter at that row index +- **AND** the host resolves the stale draft without changing unrelated chapters + +### Requirement: Table workflows remain reachable by keyboard and scrolling + +The Web grid MUST preserve surviving chapter selection identities and usable focus after accepted edits. Keyboard users MUST reach editable fields, selection actions, and portable row commands. A rendered table with 1000 chapters MUST make every row reachable without losing committed edits or blocking the primary workflow controls. + +#### Scenario: Edit a late row in a large table +- **WHEN** the user scrolls to and edits the last chapter in a 1000-row table +- **THEN** that chapter commits correctly and remains reachable +- **AND** preview, history, and output actions remain usable + +#### Scenario: Undo an edit on a selected chapter +- **WHEN** the user commits an edit and invokes document Undo with table focus +- **THEN** the previous content is restored +- **AND** the surviving chapter remains selected with a usable focus target + +### Requirement: Browser expression tools load Lua files as drafts + +The Web expression tool MUST provide a `.lua` file input. Loading MUST use the shared script-content rules and bounded input behavior. Success MUST populate the script draft and source metadata without committing chapters. Cancel, failed read, or invalid script content MUST preserve the entry expression state and document. + +#### Scenario: Load a multiline script +- **WHEN** the user loads an accepted Lua file containing multiline UTF-8 text +- **THEN** line breaks and script content appear in the editor +- **AND** the source filename is inspectable and a read-only preview starts +- **AND** document history remains unchanged until explicit Apply + +#### Scenario: File read fails or exceeds its limit +- **WHEN** Lua file input fails shared validation or bounded reading +- **THEN** the tool reports a localized error +- **AND** the preceding script, preset, source metadata, document, and history are preserved + +#### Scenario: Cancel a loaded script draft +- **WHEN** the user loads a script and closes the tool without applying it +- **THEN** the entry script, preset, and source metadata are restored on reopening +- **AND** no chapter content changes + +### Requirement: Browser authoring uses shared diagnostics and completion + +The Web editor MUST present Lua syntax highlighting, completion, and positioned diagnostics from the shared authoring service. Assistance MUST operate on the current draft and caret. Diagnostic positions MUST be shown only when the service supplies them. Completion MUST preserve surrounding text and multiline formatting. + +#### Scenario: Accept a completion +- **WHEN** the user requests and accepts a valid completion at the caret +- **THEN** the selected completion replaces only its intended draft range +- **AND** surrounding script text remains intact and no chapter transaction occurs + +#### Scenario: Rapid edits replace old diagnostics +- **WHEN** a later draft replaces an earlier draft before authoring results arrive +- **THEN** only diagnostics for the latest draft are displayed +- **AND** supplied positions map to that draft without invented locations + +### Requirement: Editor assistance preserves expression review and accessibility + +The enhanced editor MUST preserve the existing expression candidate review, debounce, composition, cancellation, and exact-candidate application contract. Highlighting MUST NOT duplicate text for assistive technology. Enter MUST insert a newline when completion is inactive. Completion MUST consume Escape before the outer dialog. Ctrl/Cmd+Z MUST remain local to the editor. + +#### Scenario: Type with an input method +- **WHEN** IME composition is active +- **THEN** incomplete composition does not trigger candidate evaluation or destructive completion +- **AND** composition completion schedules only the current draft + +#### Scenario: Dismiss completion before the dialog +- **WHEN** completion is open and the user presses Escape +- **THEN** completion closes and the expression tool remains open +- **WHEN** the user presses Escape again with no inner surface active +- **THEN** the expression tool follows its existing cancel and focus-restoration behavior + +#### Scenario: Use the editor at narrow sizes +- **WHEN** the expression tool is used at default, wide, narrow, or short supported sizes +- **THEN** editor text, diagnostics, completion, Cancel, and Apply remain reachable +- **AND** editor assistance does not create page-level horizontal overflow + diff --git a/openspec/specs/wasm-log-tool-parity/spec.md b/openspec/specs/wasm-log-tool-parity/spec.md new file mode 100644 index 00000000..ce47dc2a --- /dev/null +++ b/openspec/specs/wasm-log-tool-parity/spec.md @@ -0,0 +1,91 @@ +# wasm-log-tool-parity Specification + +## Purpose +TBD - created by archiving change align-wasm-ui-functional-parity. Update Purpose after archive. +## Requirements +### Requirement: Browser logs open as a bounded list-first surface + +The Web log tool MUST open without automatic selection or an inspector. It MUST retain the existing bounded browser log source. Rows MUST provide a concise summary, local time, and localized severity text. Live append MUST not steal focus or open details. Entries MUST have stable identity for selection and inspection. + +#### Scenario: Open populated logs and append an entry +- **WHEN** the user opens logs and a new retained entry arrives +- **THEN** the list updates without selecting the entry or opening an inspector +- **AND** keyboard focus remains at its current target + +### Requirement: Search and severity filter the browser projection + +The Web log tool MUST provide case-insensitive text search and severity filtering. Search MUST include message, details, and any recorded identity or structured fields. Filtering MUST preserve the retained source and show a visible count. It MUST not invent data absent from browser entries. + +#### Scenario: Search hidden details +- **WHEN** the query occurs only in a retained entry's details +- **THEN** the entry remains visible with a localized indication of the detail match +- **AND** opening details exposes that text + +#### Scenario: Change or clear filters +- **WHEN** the user changes severity or clears search +- **THEN** visible membership and count update without altering retained entries +- **AND** stale search highlights disappear + +### Requirement: Browser inspection requires an explicit action + +A row details action, Enter, or Space MUST open the inspector for that entry. Row selection alone MUST not open it. The inspector MUST show populated browser data and secondary raw/copy actions. Closing inspection MUST preserve filters and surviving selection. Filter exclusion or retention eviction MUST close an unavailable inspector without selecting a replacement. + +#### Scenario: Select and inspect an entry +- **WHEN** the user selects a row body +- **THEN** the list remains the only data surface +- **WHEN** the user activates its details action +- **THEN** the inspector displays that entry's recorded data + +#### Scenario: Entry leaves visible membership +- **WHEN** filtering or bounded retention removes the inspected entry from visible membership +- **THEN** the inspector closes or presents an explicit unavailable state +- **AND** no different entry is selected automatically + +#### Scenario: Return from details +- **WHEN** the user closes details or invokes its Escape action +- **THEN** the list retains its filters, scroll state, and surviving selection +- **AND** focus returns to the row or its details action + +### Requirement: Browser log copy and clear preserve defined state + +The tool MUST provide explicit copy for selected entry data and explicit clear for retained logs. Copy MUST not change retained entries or filters. Copy failure MUST be recoverable. Clear MUST empty the retained source and resolve selection and inspector state without stale data. + +#### Scenario: Copy details +- **WHEN** the user copies the inspected entry +- **THEN** the clipboard request contains the selected recorded data +- **AND** the list, filters, and inspector remain usable after success or failure + +#### Scenario: Clear retained entries +- **WHEN** the user activates Clear +- **THEN** retained logs, visible membership, selection, and inspector data are cleared +- **AND** a localized empty state is visible + +### Requirement: Log export captures the filtered membership + +The Web log tool MUST expose JSON and CSV through a secondary export surface. Export MUST capture current filtered membership and serialize it in deterministic timestamp order. Output MUST use UTF-8 and preserve Unicode, quotes, delimiters, and multiline details. The existing browser download adapter MUST deliver the result. Export MUST not change chapter export baselines, log retention, filters, selection, or inspector state. + +#### Scenario: Export filtered Unicode entries +- **WHEN** the user filters logs and exports JSON or CSV containing Unicode and multiline details +- **THEN** output contains exactly the captured visible entries in deterministic order +- **AND** JSON parses and CSV round-trips its quoted fields correctly +- **AND** no invisible retained entry is included + +#### Scenario: Export fails or a new entry arrives +- **WHEN** download fails or logs change after the export snapshot was captured +- **THEN** the tool reports the applicable outcome without changing the captured membership +- **AND** filters, surviving selection, inspector state, and retained entries remain usable + +### Requirement: Browser log presentation is responsive and localized + +Wide layouts MUST place a bounded inspector beside the list. Narrow layouts MUST show the inspector with a back action when both surfaces cannot fit. Search, severity, count, details, close/back, copy, clear, and export MUST have localized labels and accessible keyboard targets. Meaning MUST not depend on color alone. + +#### Scenario: Inspect at narrow and short sizes +- **WHEN** the user opens details at supported narrow or short sizes +- **THEN** details and back/close actions remain reachable without horizontal clipping +- **AND** returning restores the filtered list + +#### Scenario: Change locale while inspecting +- **WHEN** the UI language changes with a surviving entry inspected +- **THEN** controls and accessible labels update from shared locale resources +- **AND** the selected entry and recorded data remain stable + diff --git a/openspec/specs/wasm-modal-workflows/spec.md b/openspec/specs/wasm-modal-workflows/spec.md new file mode 100644 index 00000000..8faed337 --- /dev/null +++ b/openspec/specs/wasm-modal-workflows/spec.md @@ -0,0 +1,60 @@ +# wasm-modal-workflows Specification + +## Purpose +TBD - created by archiving change fix-wasm-modal-layout. Update Purpose after archive. +## Requirements +### Requirement: Independent tool dialogs +The browser UI SHALL display history, expression authoring, advanced export options, settings, logs, preview, frame shift, and related media through one shared modal lifecycle. Only one dialog SHALL be active. Tool content SHALL remain outside the main editing grid. + +#### Scenario: Open history on a short viewport +- **WHEN** the user opens history at 390×640 or 844×390 +- **THEN** the dialog close button is inside the viewport and accepts an actual click +- **AND** the chapter grid does not change size or overlap the dialog + +### Requirement: Accessible modal lifecycle +Dialogs SHALL have an accessible title, contain keyboard focus, make the background inert, close on Escape from focused inputs, and restore focus to the opener. Draft dialogs SHALL require explicit Apply or cancellation. Background shortcuts and file drops SHALL not change workspace data while a dialog is active. + +#### Scenario: Cancel an input-focused dialog +- **WHEN** the user edits a dialog input and presses Escape +- **THEN** the dialog closes without committing the draft +- **AND** focus returns to its opener + +### Requirement: Expression draft isolation +Expression authoring SHALL retain live debounced validation. Apply SHALL commit a valid candidate once. Cancel SHALL discard the candidate, cancel pending validation, and restore the previous expression and preset without changing chapters or history. + +#### Scenario: Cancel an invalid expression +- **WHEN** the user enters an invalid expression and cancels +- **THEN** committed chapters, history, and exported content remain unchanged +- **AND** reopening displays the previous expression + +#### Scenario: Apply a valid expression +- **WHEN** the user applies a valid candidate +- **THEN** the dialog closes and the chapter table reflects one committed operation +- **AND** undo and redo retain their existing behavior + +### Requirement: Advanced export draft isolation +Order shift, XML language, template loading, encoding, and BOM SHALL be available in advanced export options. Apply SHALL use the existing projection and settings persistence paths. Cancel SHALL leave those values and projections unchanged. + +#### Scenario: Cancel advanced options +- **WHEN** the user changes advanced export values or selects a template and cancels +- **THEN** exported bytes, persisted settings, and chapter projections remain unchanged + +### Requirement: Responsive workflow zones +The main UI SHALL retain toolbar, chapter grid, compact export controls, and status zones. Dialog bodies SHALL scroll independently. At widths of 320 pixels and above, dialog controls SHALL remain readable without page horizontal overflow. Mobile dialog action targets SHALL be at least 44 pixels high. + +#### Scenario: Long diagnostics in landscape +- **WHEN** expression diagnostics exceed the body height at 844×390 +- **THEN** the body scrolls and the header and footer remain reachable +- **AND** Apply, Cancel, and Close do not overlap + +### Requirement: Browser regression gates +The default browser suite SHALL assert modal geometry, focus, real hit targets, cancellation, history navigation, and background isolation. Visual checks SHALL cover idle and modal states in the fixed Linux environment. Pull requests SHALL run Chromium, targeted WebKit regression checks, and visual checks. + +#### Scenario: Detect a layout regression +- **WHEN** a change puts history back into the main grid or covers its close action +- **THEN** a default browser test fails without relying only on screenshot generation + +#### Scenario: Validate a published site +- **WHEN** CI checks the published browser artifact +- **THEN** a short-viewport smoke test opens and closes a native dialog + diff --git a/openspec/specs/wasm-settings-shortcuts/spec.md b/openspec/specs/wasm-settings-shortcuts/spec.md new file mode 100644 index 00000000..5cf6bdef --- /dev/null +++ b/openspec/specs/wasm-settings-shortcuts/spec.md @@ -0,0 +1,126 @@ +# wasm-settings-shortcuts Specification + +## Purpose +TBD - created by archiving change align-wasm-ui-functional-parity. Update Purpose after archive. +## Requirements +### Requirement: Browser shortcuts use the shared action catalog + +The Web host MUST use stable action identifiers from the shared shortcut catalog. It MUST expose load, save, reload, preview, log, previous clip, next clip, insert, delete, Undo, and Redo actions with their catalog defaults and applicable fixed aliases. A configurable Shortcuts settings page MUST support recording, clearing, per-action reset, and saving editable mappings. + +#### Scenario: Load default shortcuts +- **WHEN** no browser shortcut overrides exist +- **THEN** Web routes catalog defaults and applicable fixed aliases to available commands +- **AND** visible hints use that same mapping + +#### Scenario: Customize or clear an editable action +- **WHEN** the user records or clears an editable action and saves valid settings +- **THEN** runtime routing and visible hints use the saved value +- **AND** a replaced default no longer triggers the action unless it is a fixed alias or remains assigned + +#### Scenario: Reset one action +- **WHEN** the user resets one shortcut draft +- **THEN** that action returns to its catalog default +- **AND** other shortcut drafts remain unchanged + +### Requirement: Shortcut validation protects usable mappings + +The Web host MUST validate canonical gestures without depending on Avalonia key types. It MUST reject invalid, duplicate, fixed-alias-conflicting, or unsupported captured gestures with localized errors. It MUST keep the preceding active mapping until valid settings are saved. Browser-specific restrictions MUST be explicit and MUST preserve existing supported intercepted defaults. + +#### Scenario: Duplicate or invalid draft +- **WHEN** two commands share a conflicting gesture or a row contains an invalid gesture +- **THEN** settings marks the invalid rows and prevents Save +- **AND** the active mapping remains usable + +#### Scenario: Browser gesture cannot be delivered +- **WHEN** a user attempts to capture an unsupported browser-reserved gesture +- **THEN** the row explains the restriction and does not save it as a functioning shortcut + +### Requirement: Routing and default suppression use one active mapping + +Dispatch, hints, and application shortcut default suppression MUST derive from the same active mapping. Editable controls MUST keep text-editing gestures. Active dialogs MUST prevent background commands. Unavailable commands MUST not mutate the document. + +#### Scenario: Invoke row commands with table focus +- **WHEN** the user presses Insert or Delete with an eligible table selection and no active cell draft +- **THEN** the corresponding Core edit runs once +- **AND** text input focused inside a cell retains its own editing behavior + +#### Scenario: Navigate clips +- **WHEN** the user presses the configured previous/next clip gesture with eligible workspace focus +- **THEN** the active clip changes within the valid clip range +- **AND** reaching a range boundary does not wrap or select an invalid index + +#### Scenario: Use a customized gesture +- **WHEN** an eligible workspace target receives an active customized application gesture +- **THEN** the mapped command executes and its conflicting browser default is suppressed +- **AND** unrelated gestures are not suppressed as application actions + +#### Scenario: Modal or editor owns focus +- **WHEN** an expression editor, settings control, or active dialog owns input +- **THEN** background row, clip, load, and document-history commands do not run +- **AND** local editing and inner dismissal remain available + +### Requirement: Shortcut persistence is additive and compatible + +The Web settings document MUST retain its current storage key and existing application, theme, and font content. An optional shortcuts child MUST store canonical action mappings. Existing version-one settings without shortcuts MUST load defaults without a rewrite. Unknown actions and malformed stored mappings MUST normalize to a usable mapping without blocking other valid settings. + +#### Scenario: Load existing version-one settings +- **WHEN** the stored document contains valid application, theme, and font settings but no shortcuts child +- **THEN** those values are restored and shortcut defaults are active +- **AND** loading alone does not rewrite storage + +#### Scenario: Save shortcuts and reopen +- **WHEN** valid shortcut settings are saved and the application is reopened +- **THEN** action IDs and canonical mappings are restored +- **AND** unrelated output and appearance preferences are preserved + +### Requirement: Settings drafts have explicit save and discard behavior + +The Web settings tool MUST compare normalized drafts with its entry snapshot. Save MUST validate and persist one aggregate before activating saved settings and closing. A validation or persistence failure MUST preserve the tool, draft, and last valid active settings. Reset MUST modify only the draft. Intentional Cancel MUST discard and close. + +#### Scenario: Save valid settings +- **WHEN** the user saves a valid settings draft +- **THEN** one aggregate is persisted and the saved runtime preferences become active +- **AND** the settings tool closes without changing chapter content or history + +#### Scenario: Save fails +- **WHEN** storage rejects settings persistence +- **THEN** the tool remains open with a recoverable localized error and the draft +- **AND** active preferences and shortcuts retain their last valid values +- **AND** chapter content and history remain unchanged + +#### Scenario: Reset then cancel +- **WHEN** the user resets settings and invokes Cancel +- **THEN** saved values and active settings remain unchanged +- **AND** reopening restores the entry saved values + +### Requirement: Changed settings require a close decision + +Closing the Web settings tool by Close or Escape with changed drafts MUST offer Discard and Keep editing. Unchanged settings MUST close directly. Keep editing MUST preserve draft values and focus. Discard MUST restore the entry active snapshot and close without persistence. This requirement concerns the settings modal, not page-leave handling. + +#### Scenario: Keep a changed draft +- **WHEN** the user closes changed settings and selects Keep editing +- **THEN** the settings tool remains open with all draft values +- **AND** focus returns to the settings surface + +#### Scenario: Discard through close confirmation +- **WHEN** the user closes changed settings and selects Discard +- **THEN** the entry active settings remain in effect and no storage write occurs +- **AND** the modal closes and restores focus to its opener + +#### Scenario: Close unchanged settings +- **WHEN** normalized draft values equal the entry snapshot and the user presses Escape +- **THEN** the tool closes without an extra confirmation + + +### Requirement: Browser settings use the current storage key only +The browser app SHALL load persisted settings from `chaptertool.wasm.settings` only. It SHALL NOT read or delete predecessor storage keys. + +#### Scenario: Current settings are stored +- **WHEN** the current storage key contains valid settings +- **THEN** the app SHALL load those settings +- **AND** it SHALL leave unrelated local storage entries unchanged + +#### Scenario: Only predecessor settings are stored +- **WHEN** an obsolete storage key contains settings and the current key is absent +- **THEN** the app SHALL use current defaults +- **AND** it SHALL NOT copy or delete the predecessor entry diff --git a/packages/chaptertool/README.md b/packages/chaptertool/README.md index b624c711..83b1d840 100644 --- a/packages/chaptertool/README.md +++ b/packages/chaptertool/README.md @@ -1,31 +1,15 @@ # ChapterTool Node.js -This package exposes the portable ChapterTool Core API to Node.js through .NET WebAssembly. It does not use Blazor or a browser user interface. +Use `@chaptertool/node` to read, edit, transform, and export chapter markers from a Node.js application. It is useful for media pipelines, batch conversion, and services that need chapter support without a desktop UI. -## Build +The package runs the portable ChapterTool Core API through .NET WebAssembly. It does not use Blazor or require the .NET SDK on the consuming machine. -Run the following command from this directory: - -```bash -npm run build -``` - -The build requires the .NET 10 SDK. Run `npm run doctor` to inspect the active SDK and the optional WebAssembly build tools. - -The build uses `wasm-tools` when it is installed. The build prints a warning and continues with the standard runtime when the workload is not installed. Install the workload with this command: +## Install And Use ```bash -dotnet workload install wasm-tools +npm install @chaptertool/node ``` -The build publishes the pure WebAssembly host, transpiles and bundles the TypeScript API for the `es2022` target, and writes all package files to `dist/`. Files in `src/`, `scripts/`, and `test/` are not included in the npm tarball. - -The package uses `tsdown` to bundle the ESM entry point and generate the TypeScript declaration file. The .NET WebAssembly runtime stays as a package-local external asset. - -Run `npm pack` to create an installable tarball. The `prepack` script builds the runtime before npm creates the tarball. Package consumers need Node.js only. They do not need the .NET SDK. - -## Use - ```js import { ChapterTool } from "@chaptertool/node"; import { readFile } from "node:fs/promises"; @@ -34,9 +18,8 @@ const tool = new ChapterTool(); const imported = await tool.import(await readFile("chapters.txt"), { fileName: "chapters.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; -const exported = await tool.export(chapterSet, { format: "xml" }); +const exported = await tool.serialize(chapterSet, { format: "xml" }); if (!exported.success) { throw new Error(exported.diagnostics.map((item) => item.message).join("\n")); @@ -45,17 +28,117 @@ if (!exported.success) { console.log(exported.content); ``` -## Runtime Contract +## Transform And Serialize + +Use an explicit content operation when chapter values must change. `serialize` writes the +supplied snapshot as-is and never replays transforms. + +```js +const rate = { kind: "rational", numerator: "24000", denominator: "1001" }; +const transformed = await tool.applyExpression(chapterSet, true, "t + 1", rate); +const output = await tool.serialize(transformed.chapterSet, { format: "xml" }); +``` + +The package exposes only exact chapter content contracts. Apply expressions, naming, numbering, +and frame-rate changes through content operations before serialization. -The package validates public JavaScript inputs before it calls the .NET runtime. Invalid option objects, strings, booleans, numbers, and chapter indexes produce a JavaScript error. +The package accepts UTF-8 strings, `Buffer`, and `Uint8Array` input and supports the portable import and export formats listed below. -The package transfers complex values across the .NET WebAssembly boundary as JSON strings. This conversion is internal to the package. Callers use JavaScript objects and arrays through the typed API. +## Exact Ticks And Frame Rates + +The API uses tick strings and tagged frame-rate values. Rational components and approximate +values remain strings so JavaScript numbers do not round them. + +```ts +const imported = await tool.import(await readFile("input.mpls"), { + fileName: "input.mpls" +}); +const source = imported.groups[0].entries[0].chapterSet; +const rate = { kind: "rational", numerator: "24000", denominator: "1001" } as const; +const edited = await tool.editFrame(source, 0, "1.5", rate); +if (!edited.success || !edited.chapterSet) throw new Error("Frame edit failed"); +const output = await tool.serialize(edited.chapterSet, { format: "qpf" }); +``` + +Exact wire values use these shapes: + +```json +{ + "title": "Example", + "sourceName": null, + "importFormat": "Ogm", + "frameRate": { "kind": "rational", "numerator": "24000", "denominator": "1001" }, + "durationTicks": null, + "chapters": [{ "displayNumber": 1, "name": "Opening", "startTicks": "9007199254740993", "endTicks": null, "framesInfo": "", "frameAccuracy": "Neutral", "kind": "Marker" }] +} +``` + +`startTicks` and `endTicks` use signed Int64 decimal strings. +One tick is 100 ns. +An absent end uses `null`. +`durationTicks: null` means Unknown; `"0"` means a known zero duration. +Rational components must be positive Int64 strings and are reduced on output. +Approximate uses `{ "kind": "approximate", "value": "23.976" }`. +Its value must be a positive invariant decimal string within the .NET decimal range. +Unknown uses `{ "kind": "unknown" }` with no payload. +Conflicting payload fields, non-finite values, exponents, and invalid components return diagnostics. +Parse failures return `chapterSet: null`. +Operation failures preserve input content where available. + +Optional `segments` preserve `id`, `name`, `startTicks`, nullable `durationTicks`, `frameRate`, and `sourceFrameRate`. +Current rates can change during conversion; source rates retain their original authority. +Scalar combination must reject conflicting segment rates. +A known rate plus Unknown, different rates, or Rational plus Approximate resolves to Unknown. +The same numeric display does not make different kinds equal. + +Auto uses known content authority for computation. +Detection supplies display suggestions only when authority is missing or mixed. +Frame edits, shifts, zones, and frame exports then require an explicit computation rate. +Time-only edits and exports remain available. +Pass `computationRate` in exact output options to export mixed content at an explicit rate. +This override must not change the supplied content or source metadata. + +Exact methods cover import, cell edits, rename, insert, delete, shifts, zones, combine, append, +frame-rate detection, frame projection, conversion, expressions, and serialization. +`updateFrames` refreshes display metadata and must not assign a detected content rate. +`serialize` writes the supplied snapshot without replaying an expression. + +Frame arithmetic retains ticks and integer rate coefficients until its rounding boundary. +Fractional frame edits round once to ticks, with midpoints away from zero. +FPS conversion rounds to an integer source frame, then rounds once to target ticks. +Frame exports round once to an integer frame, with midpoints away from zero. +Display rounding must not change content. +Lua uses floating-point seconds; changed arithmetic results are not exact rational arithmetic. +An unchanged Lua time result retains the original ticks. +Unknown FPS is `nil` in Lua. +`fpsRateKind` exposes authority, and rational component text preserves large integers. +Numeric ratio components are available only when both are at most `9007199254740991`. + +## Build From Source + +Run the following command from this directory: + +```bash +npm run build +``` + +The build requires the .NET 10 SDK. Run `npm run doctor` to inspect the active SDK and the optional WebAssembly build tools. + +The build uses `wasm-tools` when it is installed. The build prints a warning and continues with the standard runtime when the workload is not installed. Install the workload with this command: + +```bash +dotnet workload install wasm-tools +``` + +The build writes the distributable package to `dist/`. + +Run `npm pack` to create an installable tarball. The `prepack` script builds the runtime before npm creates the tarball. Package consumers need Node.js only. They do not need the .NET SDK. -The WebAssembly runtime is initialized once per Node.js process. Concurrent `ChapterTool` instances share the initialized runtime. A failed startup can be retried by a later API operation. +## Runtime And Format Boundaries The package requires Node.js 20.x, 22.x, or 24 and later. It accepts UTF-8 strings, `Buffer`, and `Uint8Array` input. It supports the byte-based import formats provided by `ChapterTool.Core`. It does not run desktop tools such as `ffprobe`, `ffmpeg`, or `mkvtoolnix`. -Portable imports have a 64 MiB byte limit. The package reads the live limit from `NodeApi.GetMaxInputBytes` after the runtime starts. The package checks the UTF-8 or binary byte count before it creates a conversion copy. An input above the limit throws a `RangeError` with `code` set to `INPUT_TOO_LARGE`. +Portable imports have a 64 MiB byte limit. Inputs above the limit return an error with code `INPUT_TOO_LARGE`. Supported export codes are `txt`, `xml`, `qpf`, `timecodes`, `tsmuxer`, `cue`, `json`, `vtt`, and `celltimes`. diff --git a/packages/chaptertool/package-lock.json b/packages/chaptertool/package-lock.json index 654d0de0..e466e160 100644 --- a/packages/chaptertool/package-lock.json +++ b/packages/chaptertool/package-lock.json @@ -1,23 +1,23 @@ { "name": "@chaptertool/node", - "version": "23.3.2", + "version": "23.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chaptertool/node", - "version": "23.3.2", + "version": "23.31.0", "license": "GPL-3.0-or-later", "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.11", - "eslint": "^10.8.1", - "fs-extra": "^11.4.0", - "tsdown": "^0.22.14", + "@types/node": "^26.6.5", + "@vitest/coverage-v8": "^5.0.4", + "eslint": "^10.12.0", + "fs-extra": "^11.4.1", + "tsdown": "^0.23.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0", - "vitest": "^4.1.11" + "typescript-eslint": "^8.71.1", + "vitest": "^5.0.4" }, "engines": { "node": "^20.0.0 || ^22.0.0 || >=24.0.0" @@ -44,13 +44,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -60,9 +60,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -83,6 +83,30 @@ "node": ">=18" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -198,9 +222,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -288,9 +312,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -305,20 +329,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@quansync/fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", - "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.1.0.tgz", + "integrity": "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==", "dev": true, "license": "MIT", "dependencies": { @@ -329,9 +377,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -346,9 +394,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -363,9 +411,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -380,9 +428,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -397,9 +445,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -414,9 +462,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -431,9 +479,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -451,9 +499,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -471,9 +519,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -491,9 +539,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -511,9 +559,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -531,9 +579,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -551,9 +599,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -568,9 +616,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -585,9 +633,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -608,13 +656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -655,27 +696,27 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -688,15 +729,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -704,16 +745,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -729,14 +770,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -751,14 +792,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -769,9 +810,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -786,15 +827,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -811,9 +852,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -825,16 +866,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -853,16 +894,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -877,13 +918,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -895,29 +936,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.4.tgz", + "integrity": "sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.4", + "vitest": "5.0.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -925,40 +964,46 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -970,222 +1015,20 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@yuku-codegen/binding-darwin-arm64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.7.3.tgz", - "integrity": "sha512-hbssEr7iLNCWWdUbEt8cuOjUQ9loI+U/BVmdhNQ4CV1wAFGvWDO3niK9JD2WwTC9iib7E498qgZ3L/xJnoSb3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@yuku-codegen/binding-darwin-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.7.3.tgz", - "integrity": "sha512-WqwST3vVcNBTd1zaT1vP6pU8NkjJlLWN37Kqomc2c6TG9eky6sbAfHIhZEjSSyqFrMqsAt3mIu0jQZAODi401g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@yuku-codegen/binding-freebsd-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.7.3.tgz", - "integrity": "sha512-mCci4UPkZXYflXuHnPGl5sDsotBPLaBryFylDwZcqposktmumoOBIKp2gpzEmxQL/XlGdVWksmpYw9IRY43FNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@yuku-codegen/binding-linux-arm-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.7.3.tgz", - "integrity": "sha512-F1QIaRH5SeahrYjZVrVvIb5nHCZQMF7+YWAy6yTJh0Y2r2wLgqOQuhbOWcoKk+AyBiTOO2nAsFHNpVqRq2GjGw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-linux-arm-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.7.3.tgz", - "integrity": "sha512-JvkWNmN8MFbboX/5grRsAldGaB8ArRFgAaUYfQoohsqcuJZee8SLtc6itvwuntPbN9MGStn4GzdWk2lmSURqbA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.7.3.tgz", - "integrity": "sha512-DUnMTE/HCA7j1BAipWclGZCYfLZ/4SV5lldmV2kDmVMIywcw4PhtQ4Rfd/tO1K82JkjhQ+4a8XApcOIksOacnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-linux-arm64-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.7.3.tgz", - "integrity": "sha512-pNnXMnm9dlqd3V8C7nHaXgZnMLwP/CyM8B4mSAnkqVlhj2t3b8CMjesEV85SCbFlagzJk8FapfSghfxcXKuBow==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-linux-x64-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.7.3.tgz", - "integrity": "sha512-o5GRppYvYvPl4UJbgRSZpXXDh6rOWQzX0yLB+tDzv31T7mOuLitU6zgQZxzw5rwhPJDffCt/AVKMbtizEo+UtA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-linux-x64-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.7.3.tgz", - "integrity": "sha512-GdiyLD1bM3lhTUeGkA9ZZLjqU+xt5uVihgPm0e6TdBjCi6DMZORWe02oDlRbn9OZaV7AZosngXAq2jLk3TrBEw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@yuku-codegen/binding-win32-arm64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.7.3.tgz", - "integrity": "sha512-uQrxQDBQFIAUgHJIxcB/FAAUk0Wkyj3xGpHrp/C258nXnCTAH0KDaqyK5RN/TCkVYrpH+uCl+rGnwDLSGnPBkQ==", + "node_modules/@yuku-core/binding-android-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-android-arm64/-/binding-android-arm64-0.19.0.tgz", + "integrity": "sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==", "cpu": [ "arm64" ], @@ -1193,27 +1036,13 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "android" ] }, - "node_modules/@yuku-codegen/binding-win32-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.7.3.tgz", - "integrity": "sha512-UoF7tqMnVMUmIXPgDjFLhRezea6HaypwLDWNenCFxfJCzQNEa41lcHpxq1AN2Xl6GLzIDfAb+lQDi/O+zPCfyg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@yuku-parser/binding-darwin-arm64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.7.3.tgz", - "integrity": "sha512-JcFQSNEnjtyHQRjXO0G5rQt5xHq5MR+9nHqh+wt5MP30XIiccUYlcMIa3s7CBmj8E8WPGZC08k7LNGrA1OISTA==", + "node_modules/@yuku-core/binding-darwin-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-arm64/-/binding-darwin-arm64-0.19.0.tgz", + "integrity": "sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==", "cpu": [ "arm64" ], @@ -1224,10 +1053,10 @@ "darwin" ] }, - "node_modules/@yuku-parser/binding-darwin-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.7.3.tgz", - "integrity": "sha512-U0kNeI3VygP/+8sTOLTeLTjUyTYZfl9Wuq6xVKWPXHb2K7oI0sm8JXJ5xlhAbkUDQRi0N6yK5TKxS0sQT5M6UQ==", + "node_modules/@yuku-core/binding-darwin-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-darwin-x64/-/binding-darwin-x64-0.19.0.tgz", + "integrity": "sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==", "cpu": [ "x64" ], @@ -1238,10 +1067,10 @@ "darwin" ] }, - "node_modules/@yuku-parser/binding-freebsd-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.7.3.tgz", - "integrity": "sha512-Xc08FQTvxovy6pX+Co9fgv3N1H0OpLpph/ClbwnJkcdphY3kzN2GgIfir35kpPp8mOzlQgtAVQdeDyAYyjwx4w==", + "node_modules/@yuku-core/binding-freebsd-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-freebsd-x64/-/binding-freebsd-x64-0.19.0.tgz", + "integrity": "sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==", "cpu": [ "x64" ], @@ -1252,10 +1081,10 @@ "freebsd" ] }, - "node_modules/@yuku-parser/binding-linux-arm-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.7.3.tgz", - "integrity": "sha512-6bHeDiUd+0bmoJJ5wZVG+PcJkXQUdjy/AzHsOVcOSUtHtRTX2H3Z5RIHyPAh5OveLtT4RHyqaO3fEQGnHGX5/Q==", + "node_modules/@yuku-core/binding-linux-arm-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.19.0.tgz", + "integrity": "sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==", "cpu": [ "arm" ], @@ -1269,10 +1098,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-linux-arm-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.7.3.tgz", - "integrity": "sha512-trGERNLJGvXkkyvdWBKspTOBATd4lcmpPFPcy5HI5kC3rC3ZMHQDJ356OMSVUsR2NQnl++F0ZTZQucao9hrbeQ==", + "node_modules/@yuku-core/binding-linux-arm-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm-musl/-/binding-linux-arm-musl-0.19.0.tgz", + "integrity": "sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==", "cpu": [ "arm" ], @@ -1286,10 +1115,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-linux-arm64-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.7.3.tgz", - "integrity": "sha512-p1ELmzhqAX7SFUvH/tR5zSb8NsWZQ8ulw7PqatfXNmJMy34bkivtL0z2jPpVoMJXf5o2+TwBN29Z31CLL0wvkA==", + "node_modules/@yuku-core/binding-linux-arm64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.19.0.tgz", + "integrity": "sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==", "cpu": [ "arm64" ], @@ -1303,10 +1132,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-linux-arm64-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.7.3.tgz", - "integrity": "sha512-dZ79SrJ002ZXfBDmpQ47SF+06Q5bGOjFeoSK8xO97ter/bjLBgxSO4d7i9hhf+uj4xkrTPc/OFNVaeBAF3L4dA==", + "node_modules/@yuku-core/binding-linux-arm64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.19.0.tgz", + "integrity": "sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==", "cpu": [ "arm64" ], @@ -1320,10 +1149,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-linux-x64-gnu": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.7.3.tgz", - "integrity": "sha512-LjT64hVGWWbOmOYS+Fd8qScgyRxgUbudhFJbw8aeUVnhiNR1np36KnE0VFWrJeu7rTdNxuzVyV5o9JLqhBDTVQ==", + "node_modules/@yuku-core/binding-linux-x64-gnu": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.19.0.tgz", + "integrity": "sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==", "cpu": [ "x64" ], @@ -1337,10 +1166,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-linux-x64-musl": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.7.3.tgz", - "integrity": "sha512-5Nw3v5BqYbOi0ZdV+SImgVYa0KDBVAY/ZNPilTsJJU4phqa2cqEW4+aMgyieE/4bqpojt/B931kM+7pipazP9g==", + "node_modules/@yuku-core/binding-linux-x64-musl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-linux-x64-musl/-/binding-linux-x64-musl-0.19.0.tgz", + "integrity": "sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==", "cpu": [ "x64" ], @@ -1354,10 +1183,10 @@ "linux" ] }, - "node_modules/@yuku-parser/binding-win32-arm64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.7.3.tgz", - "integrity": "sha512-lFYXgHiq8tJKa6/e1/q8Su+IJVV4/CjoXbIJ7/GdPXPp9Hx0gh/8HqmGSsrwlY2w8/ohBcar9wxjm1rQ8D16bg==", + "node_modules/@yuku-core/binding-win32-arm64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-arm64/-/binding-win32-arm64-0.19.0.tgz", + "integrity": "sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==", "cpu": [ "arm64" ], @@ -1368,10 +1197,10 @@ "win32" ] }, - "node_modules/@yuku-parser/binding-win32-x64": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.7.3.tgz", - "integrity": "sha512-wy/fs4OrHBhKW8LDyZJRMyMQ3QOfbSQDp0WtMbtzWCVui/vTQEUJMGnwvRCppjchS/qIQfbWzIw/HSjogJqZFA==", + "node_modules/@yuku-core/binding-win32-x64": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-core/binding-win32-x64/-/binding-win32-x64-0.19.0.tgz", + "integrity": "sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==", "cpu": [ "x64" ], @@ -1383,16 +1212,16 @@ ] }, "node_modules/@yuku-toolchain/types": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.7.3.tgz", - "integrity": "sha512-ezarjq3dcl8Nx3iJ9VeAc7vhzvHyRoSvr7bLX76T+ljRq73IbZ11X2RFCblfK6YxW2DsjkzU6MPnr3JWDYqYYQ==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.19.0.tgz", + "integrity": "sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==", "dev": true, "license": "MIT" }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -1429,16 +1258,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", - "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1450,9 +1269,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1472,9 +1291,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1485,32 +1304,39 @@ } }, "node_modules/cac": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", - "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.1.0.tgz", + "integrity": "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA==", "dev": true, "license": "MIT", "engines": { "node": ">=20.19.0" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1564,6 +1390,7 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -1590,9 +1417,9 @@ } }, "node_modules/empathic": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", - "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", "dev": true, "license": "MIT", "engines": { @@ -1620,9 +1447,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", "workspaces": [ @@ -1634,7 +1461,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1649,7 +1476,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -1834,16 +1661,13 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -1864,30 +1688,28 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", - "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, "node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { @@ -1910,14 +1732,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/get-tsconfig": { - "version": "5.0.0-beta.5", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", - "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==", + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", "dev": true, "license": "MIT", "dependencies": { @@ -1950,27 +1773,30 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/hookable": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", - "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.2.tgz", + "integrity": "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==", "dev": true, "license": "MIT" }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, "license": "MIT" }, @@ -1985,9 +1811,9 @@ } }, "node_modules/import-without-cache": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.0.tgz", - "integrity": "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.1.tgz", + "integrity": "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==", "dev": true, "license": "MIT", "engines": { @@ -2037,45 +1863,6 @@ "dev": true, "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2083,13 +1870,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2118,13 +1898,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -2147,6 +1927,7 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2184,6 +1965,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2205,6 +1987,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2226,6 +2009,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2247,6 +2031,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2268,6 +2053,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2292,6 +2078,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2316,6 +2103,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2340,6 +2128,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2364,6 +2153,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2385,6 +2175,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2406,6 +2197,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2431,43 +2223,27 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2492,9 +2268,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2503,6 +2279,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2518,9 +2295,9 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2601,24 +2378,18 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2629,9 +2400,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -2648,10 +2419,11 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2677,6 +2449,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/quansync": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", @@ -2705,13 +2497,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2721,39 +2513,39 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rolldown-plugin-dts": { - "version": "0.27.13", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.27.13.tgz", - "integrity": "sha512-DeVZJbbB0ajp5q6vABqC8ZCJzxftlxbiV60Bk96GFdQaysGVpgTTVjQu0lUt4Lb+aRCtejfOixtQKDRol7IuVQ==", + "version": "0.28.8", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.28.8.tgz", + "integrity": "sha512-FVhfqxorCWcc20BVT5eVRfRjzLLl6mgzjcMR4gSBf0K4oQMXT0qoHhhzfXy0L7y83fQy7Ug5ZDNk90xM3/LiCQ==", "dev": true, "license": "MIT", "dependencies": { "dts-resolver": "^3.0.0", - "get-tsconfig": "5.0.0-beta.5", - "obug": "^2.1.4", - "yuku-ast": "^0.7.0", - "yuku-codegen": "^0.7.0", - "yuku-parser": "^0.7.0" + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.19.0", + "yuku-codegen": "^0.19.0", + "yuku-parser": "^0.19.0" }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" @@ -2761,7 +2553,8 @@ "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", - "rolldown": "^1.0.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, @@ -2772,6 +2565,9 @@ "@volar/typescript": { "optional": true }, + "@vue/language-core": { + "optional": true + }, "typescript": { "optional": true }, @@ -2816,61 +2612,37 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=20.0.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -2895,9 +2667,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -2928,46 +2700,45 @@ } }, "node_modules/tsdown": { - "version": "0.22.14", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.14.tgz", - "integrity": "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.1.tgz", + "integrity": "sha512-MSC2WSOts3in95J334/VIS7KPfor3eCSvndJ6KdoVj9Aak9Kh1aNfz9ws5owhdukuTNSXRdfkPEzA/ihpDD6SA==", "dev": true, "license": "MIT", "dependencies": { - "ansis": "^4.3.1", - "cac": "^7.0.0", + "cac": "^7.0.1", "defu": "^6.1.7", - "empathic": "^2.0.1", - "hookable": "^6.1.1", - "import-without-cache": "^0.4.0", - "obug": "^2.1.4", - "picomatch": "^4.0.5", - "rolldown": "~1.2.0", - "rolldown-plugin-dts": "^0.27.13", - "tinyexec": "^1.2.4", + "empathic": "^2.1.0", + "hookable": "^6.1.2", + "import-without-cache": "^0.4.1", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "rolldown": "~1.2.13", + "rolldown-plugin-dts": "^0.28.7", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", - "verkit": "^0.3.0" + "verkit": "^0.5.0" }, "bin": { "tsdown": "dist/run.mjs" }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.22.14", - "@tsdown/exe": "0.22.14", + "@tsdown/css": "0.23.1", + "@tsdown/exe": "0.23.1", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", - "unplugin-unused": "^0.5.0", + "unplugin-unused": ">=0.5.0", "unrun": "*" }, "peerDependenciesMeta": { @@ -3028,16 +2799,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3066,9 +2837,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -3093,9 +2864,9 @@ } }, "node_modules/verkit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.1.tgz", - "integrity": "sha512-w2Eo8LSIIoW7qxNBzT7/17k+bh8plXo7G3dHjEIDqPlnluhzaxr9JX8F28VSYEtDvc1/a3WBDih6xNUZseebXg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", "dev": true, "license": "MIT", "engines": { @@ -3106,16 +2877,17 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -3132,7 +2904,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3184,38 +2956,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3223,16 +2988,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -3290,20 +3055,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { @@ -3330,60 +3091,70 @@ } }, "node_modules/yuku-ast": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.7.3.tgz", - "integrity": "sha512-occyAXtcU4hcl5UPEclWwLqz4J1ZAV8Us4LllmTqAe1YjL5aMbh3GCpzHt6O0sOF+dwBI3BZAcFQqbyX1RiaWg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.19.0.tgz", + "integrity": "sha512-PyCsiScaelhxBGzL8dyeJkLqvRZycoh78R31Vrt/WqYrm0hMFoKwIw/X7Dx5mBKLWAMlV9I9HEyBOHnj2gZVyw==", "dev": true, "license": "MIT", "dependencies": { - "@yuku-toolchain/types": "^0.7.3" + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" } }, "node_modules/yuku-codegen": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.7.3.tgz", - "integrity": "sha512-hhyJW0TIEwm4kex8XVCS4CZIXHS/3TWWNUum+95Ji5/4W+iaLfUnvwSxJwyNfTzS8cxmd1np+EZ4b8ObLguKEA==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.19.0.tgz", + "integrity": "sha512-uymAKHjDuaP4BEpkXC/BSLz4DUEF5oArZ5AuD3888nb2miChjWSgpPdojEJxm3pjpXkgUaCRJQyNAbnMN33wHg==", "dev": true, "license": "MIT", "dependencies": { - "@yuku-toolchain/types": "^0.7.3" + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/yuku-core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-core/-/yuku-core-0.19.0.tgz", + "integrity": "sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.19.0" + }, + "engines": { + "node": ">=14.18.0" }, "optionalDependencies": { - "@yuku-codegen/binding-darwin-arm64": "0.7.3", - "@yuku-codegen/binding-darwin-x64": "0.7.3", - "@yuku-codegen/binding-freebsd-x64": "0.7.3", - "@yuku-codegen/binding-linux-arm-gnu": "0.7.3", - "@yuku-codegen/binding-linux-arm-musl": "0.7.3", - "@yuku-codegen/binding-linux-arm64-gnu": "0.7.3", - "@yuku-codegen/binding-linux-arm64-musl": "0.7.3", - "@yuku-codegen/binding-linux-x64-gnu": "0.7.3", - "@yuku-codegen/binding-linux-x64-musl": "0.7.3", - "@yuku-codegen/binding-win32-arm64": "0.7.3", - "@yuku-codegen/binding-win32-x64": "0.7.3" + "@yuku-core/binding-android-arm64": "0.19.0", + "@yuku-core/binding-darwin-arm64": "0.19.0", + "@yuku-core/binding-darwin-x64": "0.19.0", + "@yuku-core/binding-freebsd-x64": "0.19.0", + "@yuku-core/binding-linux-arm-gnu": "0.19.0", + "@yuku-core/binding-linux-arm-musl": "0.19.0", + "@yuku-core/binding-linux-arm64-gnu": "0.19.0", + "@yuku-core/binding-linux-arm64-musl": "0.19.0", + "@yuku-core/binding-linux-x64-gnu": "0.19.0", + "@yuku-core/binding-linux-x64-musl": "0.19.0", + "@yuku-core/binding-win32-arm64": "0.19.0", + "@yuku-core/binding-win32-x64": "0.19.0" } }, "node_modules/yuku-parser": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.7.3.tgz", - "integrity": "sha512-5kZ7HR+W+OsNpVtZ9LHruQsgmcoJl4TETrffPq2GbliThsFiy+zHzbMLmSxQJrokzyJqfJ/TPfFkdUDOodJCgA==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.19.0.tgz", + "integrity": "sha512-XrBh9ZiQK5mJVRxZBAvD6LwRyAYrK3MFtH3+onyuxFL5hFD44EC3QOmIC1FjTj6H2MMwq46/NAI+NZxLZPZgxg==", "dev": true, "license": "MIT", "dependencies": { - "@yuku-toolchain/types": "^0.7.3", - "yuku-ast": "^0.7.3" + "@yuku-toolchain/types": "^0.19.0", + "yuku-core": "0.19.0" }, - "optionalDependencies": { - "@yuku-parser/binding-darwin-arm64": "0.7.3", - "@yuku-parser/binding-darwin-x64": "0.7.3", - "@yuku-parser/binding-freebsd-x64": "0.7.3", - "@yuku-parser/binding-linux-arm-gnu": "0.7.3", - "@yuku-parser/binding-linux-arm-musl": "0.7.3", - "@yuku-parser/binding-linux-arm64-gnu": "0.7.3", - "@yuku-parser/binding-linux-arm64-musl": "0.7.3", - "@yuku-parser/binding-linux-x64-gnu": "0.7.3", - "@yuku-parser/binding-linux-x64-musl": "0.7.3", - "@yuku-parser/binding-win32-arm64": "0.7.3", - "@yuku-parser/binding-win32-x64": "0.7.3" + "engines": { + "node": ">=14.18.0" } } } diff --git a/packages/chaptertool/package.json b/packages/chaptertool/package.json index afdac424..034f9808 100644 --- a/packages/chaptertool/package.json +++ b/packages/chaptertool/package.json @@ -1,6 +1,6 @@ { "name": "@chaptertool/node", - "version": "23.3.2", + "version": "23.31.0", "description": "Portable ChapterTool Core API for Node.js through .NET WebAssembly.", "type": "module", "main": "./dist/index.mjs", @@ -38,14 +38,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.11", - "eslint": "^10.8.1", - "fs-extra": "^11.4.0", - "tsdown": "^0.22.14", + "@types/node": "^26.6.5", + "@vitest/coverage-v8": "^5.0.4", + "eslint": "^10.12.0", + "fs-extra": "^11.4.1", + "tsdown": "^0.23.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0", - "vitest": "^4.1.11" + "typescript-eslint": "^8.71.1", + "vitest": "^5.0.4" }, "allowScripts": { "fsevents@2.3.3": true diff --git a/packages/chaptertool/scripts/verify-pack.mjs b/packages/chaptertool/scripts/verify-pack.mjs index 72abaf12..96f17118 100644 --- a/packages/chaptertool/scripts/verify-pack.mjs +++ b/packages/chaptertool/scripts/verify-pack.mjs @@ -74,11 +74,41 @@ try { if (!result.success || result.groups[0]?.entries[0]?.chapterSet.chapters.length !== 1) { throw new Error("The installed npm package did not execute the Core import API."); } + const tool = new ChapterTool(); + const exact = await tool.import("CHAPTER01=00:00:00.000\\nCHAPTER01NAME=Exact\\n"); + const content = exact.groups[0].entries[0].chapterSet; + content.chapters[0].startTicks = "9007199254740993"; + content.frameRate = { kind: "rational", numerator: "48000", denominator: "2002" }; + content.durationTicks = null; + const renamed = await tool.rename(content, 0, "Packed"); + if (!renamed.success || renamed.chapterSet.chapters[0].startTicks !== "9007199254740993" + || renamed.chapterSet.frameRate.numerator !== "24000" || renamed.chapterSet.durationTicks !== null) { + throw new Error("The installed exact facade lost ticks, authority, or duration availability."); + } + const converted = await tool.changeFrameRate(renamed.chapterSet, renamed.chapterSet.frameRate, + { kind: "approximate", value: "25.0" }); + const exported = await tool.serialize(converted.chapterSet, { format: "qpf" }); + if (!converted.success || converted.chapterSet.frameRate.kind !== "approximate" || !exported.success) { + throw new Error("The installed exact facade failed conversion and serialization."); + } `; execFileSync(process.execPath, ["--input-type=module", "--eval", checkScript], { cwd: consumerDirectory, stdio: "inherit", }); + writeFileSync(join(consumerDirectory, "consumer.ts"), ` + import { ChapterTool, type ExactChapterSet, type ExactFrameRate } from "@chaptertool/node"; + const rate: ExactFrameRate = { kind: "rational", numerator: "24000", denominator: "1001" }; + const content: ExactChapterSet = { title: "Types", sourceName: null, importFormat: "Ogm", + frameRate: rate, durationTicks: null, chapters: [{ displayNumber: 1, startTicks: "9007199254740993", + endTicks: null, name: "A", framesInfo: "", frameAccuracy: "Neutral", kind: "Marker" }] }; + const result = await new ChapterTool().editFrame(content, 0, "1.5", rate); + if (result.chapterSet) result.chapterSet.durationTicks satisfies string | null; + `); + execFileSync(process.execPath, [join(packageDirectory, "node_modules/typescript/bin/tsc"), + "--noEmit", "--strict", "--skipLibCheck", "--module", "NodeNext", "--moduleResolution", "NodeNext", + "--target", "ES2022", "--typeRoots", join(packageDirectory, "node_modules/@types"), "consumer.ts"], + { cwd: consumerDirectory, stdio: "inherit" }); } finally { rmSync(consumerDirectory, { recursive: true, force: true }); } diff --git a/packages/chaptertool/src/exact-types.ts b/packages/chaptertool/src/exact-types.ts new file mode 100644 index 00000000..d9a632e2 --- /dev/null +++ b/packages/chaptertool/src/exact-types.ts @@ -0,0 +1,100 @@ +import type { ChapterDiagnostic } from "./types.js"; + +/** Canonical authority. Integer components and decimals never pass through JS numbers. */ +export type ExactFrameRate = + | { kind: "rational"; numerator: string; denominator: string } + | { kind: "approximate"; value: string } + | { kind: "unknown" }; + +/** Signed Int64 ticks. Null ends represent absent ends. */ +export interface ExactChapter { + displayNumber: number; + startTicks: string; + endTicks: string | null; + name: string; + framesInfo: string; + frameAccuracy: string; + kind: string; +} + +export interface ExactSegment { + id: string; + name: string; + startTicks: string; + durationTicks: string | null; + frameRate: ExactFrameRate; + sourceFrameRate: ExactFrameRate; +} + +/** Null duration means Unknown. "0" means a known zero duration. */ +export interface ExactChapterSet { + title: string; + sourceName: string | null; + importFormat: string; + frameRate: ExactFrameRate; + durationTicks: string | null; + chapters: ExactChapter[]; + segments?: ExactSegment[] | null; +} + +export interface ExactImportEntry { + id: string; + displayName: string; + chapterSet: ExactChapterSet; + canCombine: boolean; + referencedMediaFiles: { displayName: string; relativePath: string; absolutePath: string | null }[] | null; + mediaTracks?: { kind: string; summary: string; codec: string | null; format: string | null; language: string | null; + channels: string | null; sampleRate: string | null; aspectRatio: string | null }[] | null; +} + +export interface ExactImportGroup { + sourcePath: string; + entries: ExactImportEntry[]; + defaultEntryIndex: number; +} + +export interface ExactImportResult { + success: boolean; + isPartial: boolean; + groups: ExactImportGroup[]; + diagnostics: ChapterDiagnostic[]; +} + +/** Parse failures have no content. Operation failures preserve the input where available. */ +export interface ExactContentResult { + success: boolean; + chapterSet: ExactChapterSet | null; + diagnostics: ChapterDiagnostic[]; +} + +export interface ExactFrameRateOption { + code: string; + displayName: string; + isValid: boolean; + mplsFrameRateCode: number; + rate: ExactFrameRate; +} + +export interface ExactFrameInfoResult extends ExactContentResult { + selectedOption: ExactFrameRateOption | null; + accuracy: string[]; +} + +export interface ExactDetectionResult { + success: boolean; + option: ExactFrameRateOption | null; + accurateChapterCount: number; + evaluatedChapterCount: number; + cumulativeDeviation: string; + confidence: string; + diagnostics: ChapterDiagnostic[]; +} + +export interface ExactOutputOptions { + format: string; + xmlLanguage?: string; + sourceFileName?: string; + textEncoding?: "Utf8" | "Utf16LittleEndian" | "Utf16BigEndian" | "Utf32LittleEndian" | "Utf32BigEndian"; + /** Rate for formats that serialize frame numbers. */ + computationRate?: ExactFrameRate; +} diff --git a/packages/chaptertool/src/exact.ts b/packages/chaptertool/src/exact.ts new file mode 100644 index 00000000..66ee745a --- /dev/null +++ b/packages/chaptertool/src/exact.ts @@ -0,0 +1,132 @@ +import { requireFileName, toBytes, MAX_INPUT_BYTES } from "./utils/input.js"; +import { encodeJson } from "./utils/json.js"; +import { requireString } from "./utils/validation.js"; +import type { ChapterConversionResult, ChapterExportResult, ChapterInput, ChapterFormat, ChapterImportFormat, + ChapterOutputEncoding, ChapterXmlLanguage, ChapterZonesResult } from "./types.js"; +import type { + ExactChapterSet, ExactContentResult, ExactDetectionResult, ExactFrameInfoResult, ExactFrameRate, + ExactFrameRateOption, ExactImportGroup, ExactImportResult, ExactOutputOptions, +} from "./exact-types.js"; + +type ExactInvoker = (operation: string, ...arguments_: unknown[]) => Promise; +const json = (value: unknown): string => encodeJson(value, "exact input"); +const rateJson = (rate?: ExactFrameRate): string | null => rate === undefined ? null : json(rate); + +/** Lossless content API. Rates and timestamps are validated by the canonical Core boundary. */ +export class ExactChapterTool { + constructor(private readonly invoke: ExactInvoker, private readonly load: () => Promise) {} + + async maxInputBytes(): Promise { + await this.load(); + return MAX_INPUT_BYTES; + } + + formats(): Promise { + return this.invoke("GetFormats"); + } + + importFormats(): Promise { + return this.invoke("GetImportFormats"); + } + + isBinaryExtension(extension: string): Promise { + return this.invoke("IsBinaryExtension", requireString(extension, "extension")); + } + + xmlLanguages(): Promise { + return this.invoke("GetXmlLanguages"); + } + + outputEncodings(): Promise { + return this.invoke("GetOutputEncodings"); + } + + async import(content: ChapterInput, options: { fileName?: string } = {}): Promise { + await this.load(); + return this.invoke("ExactImport", requireFileName(options.fileName), toBytes(content).toString("base64")); + } + + private edit(set: ExactChapterSet, operation: string, options: object): Promise { + return this.invoke("ExactEdit", json(set), operation, json(options)); + } + + editTime(set: ExactChapterSet, index: number, text: string): Promise { + return this.edit(set, "editTime", { index, text }); + } + + editStartTicks(set: ExactChapterSet, index: number, ticks: string): Promise { + return this.edit(set, "editStartTicks", { index, ticks }); + } + + editFrame(set: ExactChapterSet, index: number, text: string, computationRate?: ExactFrameRate): Promise { + return this.edit(set, "editFrame", { index, text, computationRate }); + } + + rename(set: ExactChapterSet, index: number, text: string): Promise { + return this.edit(set, "rename", { index, text }); + } + + delete(set: ExactChapterSet, indexes: number[]): Promise { + return this.edit(set, "delete", { indexes }); + } + + insertBefore(set: ExactChapterSet, index: number): Promise { + return this.edit(set, "insertBefore", { index }); + } + + applyOrderShift(set: ExactChapterSet, shift: number): Promise { + return this.edit(set, "applyOrderShift", { shift }); + } + + applyTemplate(set: ExactChapterSet, text: string): Promise { + return this.edit(set, "applyTemplate", { text }); + } + + shiftFramesForward(set: ExactChapterSet, frames: number, computationRate?: ExactFrameRate): Promise { + return this.edit(set, "shiftFramesForward", { frames, computationRate }); + } + + createZones(set: ExactChapterSet, indexes: number[], computationRate?: ExactFrameRate): Promise { + return this.invoke("ExactCreateZones", json(set), json(indexes), rateJson(computationRate)); + } + + combine(group: ExactImportGroup): Promise { + return this.invoke("ExactCombine", json(group)); + } + + append(existing: ExactImportGroup, appended: ExactImportGroup): Promise { + return this.invoke("ExactAppend", json(existing), json(appended)); + } + + frameRates(): Promise { + return this.invoke("ExactGetFrameRates"); + } + + detectFrameRate(set: ExactChapterSet, tolerance = 0.01): Promise { + return this.invoke("ExactDetectFrameRate", json(set), tolerance); + } + + updateFrames(set: ExactChapterSet, computationRate?: ExactFrameRate, decimalPlaces = 0, tolerance = 0.01): Promise { + return this.invoke("ExactUpdateFrames", json(set), rateJson(computationRate), decimalPlaces, tolerance); + } + + changeFrameRate(set: ExactChapterSet, sourceRate: ExactFrameRate, targetRate: ExactFrameRate): Promise { + return this.invoke("ExactChangeFrameRate", json(set), json(sourceRate), json(targetRate)); + } + + applyExpression(set: ExactChapterSet, enabled: boolean, expression: string, computationRate?: ExactFrameRate): Promise { + return this.invoke("ExactApplyExpression", json(set), enabled, expression, rateJson(computationRate)); + } + + serialize(set: ExactChapterSet, options: ExactOutputOptions): Promise { + return this.invoke("ExactSerialize", json(set), json(options)); + } + + toCelltimes(set: ExactChapterSet, computationRate?: ExactFrameRate): Promise { + return this.invoke("ExactToCelltimes", json(set), rateJson(computationRate)); + } + + chapterTextToQpfile(text: string, computationRate?: ExactFrameRate, timecodeText: string | null = null): Promise { + return this.invoke("ExactChapterTextToQpfile", text, rateJson(computationRate), timecodeText); + } +} diff --git a/packages/chaptertool/src/index.ts b/packages/chaptertool/src/index.ts index 85c80014..01d2a7c9 100644 --- a/packages/chaptertool/src/index.ts +++ b/packages/chaptertool/src/index.ts @@ -1,682 +1,49 @@ import { dotnet } from "./runtime/_framework/dotnet.js"; import { createRetryableLoader } from "./api-loader.js"; -import { applyMaxInputBytes, requireFileName, toBytes } from "./utils/input.js"; -import { encodeJson, invokeJson } from "./utils/json.js"; -import { - requireBoolean, - requireIndex, - requireIndexes, - requireInteger, - requireNumber, - requireObject, - requireString, -} from "./utils/validation.js"; -import type { +import { applyMaxInputBytes } from "./utils/input.js"; +import { ExactChapterTool } from "./exact.js"; + +export type { ChapterConversionResult, - ChapterEditResult, - ChapterExportFormat, - ChapterExportOptions, + ChapterDiagnostic, ChapterExportResult, + ChapterFormat, ChapterImportFormat, - ChapterImportGroup, - ChapterImportResult, ChapterInput, - ChapterProjectionResult, - ChapterSet, - ChapterTransformResult, + ChapterOutputEncoding, + ChapterXmlLanguage, ChapterZonesResult, - ExpressionAnalysisResult, - ExpressionPreset, - ExpressionSymbol, - FrameInfoResult, - FrameRateDetectionResult, - FrameRateOption, - OutputEncoding, - TimeParseResult, - XmlLanguage, } from "./types.js"; - -export type * from "./types.js"; +export type * from "./exact-types.js"; export { MAX_INPUT_BYTES } from "./utils/input.js"; interface NodeApi { [operation: string]: (...arguments_: unknown[]) => unknown; + GetMaxInputBytes(): number; } interface ChapterToolAssembly { - ChapterTool: { - Node: { - NodeApi: NodeApi; - }; - }; + ChapterTool: { Node: { NodeApi: NodeApi } }; } -/** - * Portable ChapterTool Core API for Node.js, backed by the .NET WebAssembly - * runtime. The underlying WASM module is loaded lazily on first use and - * shared across all {@link ChapterTool} instances. When the runtime fails to - * start (e.g. because of a transient resource limit), subsequent calls - * automatically retry the initialisation. - * - * @example - * ```ts - * const tool = new ChapterTool(); - * const { groups } = await tool.import( - * "CHAPTER01=00:00:00.000\\nCHAPTER01NAME=Opening\\n", - * { fileName: "chapters.txt" }, - * ); - * const chapterSet = groups[0].entries[0].chapterSet; - * const exported = await tool.export(chapterSet, { format: "xml" }); - * console.log(exported.content); - * ``` - */ -export class ChapterTool { - /** Shared, retryable loader that resolves once the .NET runtime is ready. */ - static readonly #loadApi = createRetryableLoader(async (): Promise => { - const runtime = await dotnet - .withMainAssembly("ChapterTool.Node") - .create(); +/** Exact tick and frame-rate API for ChapterTool's Node.js package. */ +export class ChapterTool extends ExactChapterTool { + private static readonly loadApi = createRetryableLoader(async (): Promise => { + const runtime = await dotnet.withMainAssembly("ChapterTool.Node").create(); const assembly = await runtime.getAssemblyExports("ChapterTool.Node"); const api = assembly.ChapterTool.Node.NodeApi; applyMaxInputBytes(Number(api.GetMaxInputBytes())); return api; }); - /** - * Invokes a named operation on the .NET API, casting the return value to - * {@link T}. Prefer {@link #invokeJson} for operations that marshal complex - * data through JSON strings. - * - * @typeParam T - Expected return type. - * @param operation - Method name on the .NET `NodeApi`. - * @param arguments_ - Positional arguments forwarded to the .NET method. - * @returns The value produced by the .NET export. - */ - async #invoke(operation: string, ...arguments_: unknown[]): Promise { - const api = await ChapterTool.#loadApi(); - return api[operation](...arguments_) as T; - } - - /** - * Invokes a named .NET operation that returns a JSON string, then parses it - * into the expected type. ChapterTool's .NET records are exposed as JSON - * because `JSExport` does not automatically marshal them to JavaScript - * objects. - * - * @typeParam T - Shape of the deserialised JSON value. - * @param operation - Method name on the .NET `NodeApi`. - * @param arguments_ - Positional arguments forwarded to the .NET method. - * @returns The deserialised value. - * @throws {Error} When the .NET method returns invalid JSON. - */ - async #invokeJson(operation: string, ...arguments_: unknown[]): Promise { - const api = await ChapterTool.#loadApi(); - return invokeJson(api, operation, ...arguments_); - } - - /** - * Imports chapter content from a UTF-8 string, a {@link Buffer}, or a - * `Uint8Array`, returning typed groups with per-entry diagnostics. - * - * Inputs larger than {@link MAX_INPUT_BYTES} are rejected before reaching the - * WASM boundary. - * - * @param content - Chapter source text or raw bytes. - * @param options - Optional file-name hint used to select the importer. - * @param options.fileName - Source file name including extension. Defaults - * to {@code "input.txt"} when omitted. - * @returns The parsed groups together with any diagnostics. - * @throws {TypeError} When {@code content} is not a string, Buffer, or - * `Uint8Array`, or when {@code options.fileName} is an empty string. - * @throws {RangeError} When the UTF-8 byte length exceeds - * {@link MAX_INPUT_BYTES}. - */ - async import(content: ChapterInput, options: { fileName?: string } = {}): Promise { - const fileName = requireFileName(requireObject(options, "options").fileName); - await ChapterTool.#loadApi(); - const bytes = toBytes(content); - return this.#invokeJson("Import", fileName, bytes.toString("base64")); - } - - /** - * Returns the live portable input byte limit from the .NET Core. - */ - async maxInputBytes(): Promise { - return Number(await this.#invoke("GetMaxInputBytes")); - } - - /** - * Exports a chapter set using the specified format and options. - * - * @param chapterSet - The chapter set to export (as returned by {@link import} - * or any edit operation). - * @param options - Export configuration; {@code options.format} must be a - * non-empty format code such as {@code "xml"} or {@code "qpf"}. - * @returns The serialised content, its file extension, and diagnostics. - * @throws {TypeError} When {@code options.format} is missing or empty. - */ - async export(chapterSet: ChapterSet, options: ChapterExportOptions): Promise { - requireObject(chapterSet, "chapterSet"); - - const normalizedOptions = requireObject(options, "options"); - if (typeof normalizedOptions.format !== "string" || normalizedOptions.format.length === 0) { - throw new TypeError("options.format must be an export format code, such as 'xml'."); - } - - return this.#invokeJson( - "Export", - encodeJson(chapterSet, "chapterSet"), - encodeJson(normalizedOptions, "options")); - } - - /** - * Returns descriptors for every export format supported by the Core. - * - * @returns An array of {@link ChapterExportFormat} entries, each with a - * unique {@code code} and default file extension. - */ - async formats(): Promise { - return this.#invokeJson("GetFormats"); - } - - /** - * Returns descriptors for every import format recognised by the Core. - * - * @returns An array of {@link ChapterImportFormat} entries. - */ - async importFormats(): Promise { - return this.#invokeJson("GetImportFormats"); - } - - /** - * Reports whether a file extension maps to a binary (non-text) chapter - * importer. - * - * @param extension - File extension including the leading dot - * (e.g. {@code ".mpls"}). - * @returns {@code true} when the extension triggers a binary reader. - */ - async isBinaryExtension(extension: string): Promise { - return this.#invoke("IsBinaryExtension", requireString(extension, "extension")); - } - - /** - * Re-parses the start time of one chapter from a time-text value. - * - * @param chapterSet - The chapter set to modify. - * @param index - Zero-based index of the chapter whose time will be replaced. - * @param text - Time text in a format understood by the Core (e.g. - * {@code "00:05:23.500"}). - * @returns The updated chapter set with any diagnostics. - * @throws {RangeError} When {@code index} is negative. - */ - async editTime(chapterSet: ChapterSet, index: number, text: string): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "editTime", - encodeJson({ index: requireIndex(index, "index"), text: requireString(text, "text") }, "options")); - } - - /** - * Replaces one chapter start time using a frame number and frame rate. - * - * @param chapterSet - The chapter set to modify. - * @param index - Zero-based index of the chapter to edit. - * @param text - Frame number as a string (e.g. {@code "1440"}). - * @param framesPerSecond - Frame rate used to convert the frame number. - * @returns The updated chapter set with any diagnostics. - * @throws {RangeError} When {@code index} is negative. - */ - async editFrame(chapterSet: ChapterSet, index: number, text: string, framesPerSecond: number): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "editFrame", - encodeJson({ - index: requireIndex(index, "index"), - text: requireString(text, "text"), - framesPerSecond: requireNumber(framesPerSecond, "framesPerSecond"), - }, "options")); - } - - /** - * Changes the display name of a single chapter. - * - * @param chapterSet - The chapter set to modify. - * @param index - Zero-based index of the chapter to rename. - * @param name - New chapter name (may be empty). - * @returns The updated chapter set with any diagnostics. - * @throws {RangeError} When {@code index} is negative. - */ - async rename(chapterSet: ChapterSet, index: number, name: string): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "rename", - encodeJson({ index: requireIndex(index, "index"), text: requireString(name, "name") }, "options")); - } - - /** - * Deletes one or more chapters by their zero-based indexes. - * - * @param chapterSet - The chapter set to modify. - * @param indexes - Array of zero-based chapter indexes to remove. Negative - * values are rejected. - * @returns The updated chapter set (with remaining chapters re-indexed) and - * any diagnostics. - * @throws {TypeError} When any index is not a safe, non-negative integer. - */ - async delete(chapterSet: ChapterSet, indexes: number[]): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "delete", - encodeJson({ indexes: requireIndexes(indexes) }, "options")); - } - - /** - * Inserts a new, empty chapter immediately before the given index. - * - * @param chapterSet - The chapter set to modify. - * @param index - Zero-based position where the new chapter will be inserted. - * After the operation the new chapter occupies this index. - * @returns The expanded chapter set with any diagnostics. - * @throws {RangeError} When {@code index} is negative. - */ - async insertBefore(chapterSet: ChapterSet, index: number): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "insertBefore", - encodeJson({ index: requireIndex(index, "index") }, "options")); - } - - /** - * Adds a constant integer offset to every chapter's display number. - * - * @param chapterSet - The chapter set to modify. - * @param shift - Integer value added to each chapter's {@code displayNumber}. - * May be negative. - * @returns The updated chapter set with renumbered display numbers. - */ - async applyOrderShift(chapterSet: ChapterSet, shift: number): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "applyOrderShift", - encodeJson({ shift: requireInteger(shift, "shift") }, "options")); - } - - /** - * Assigns chapter names from a template string where each line corresponds - * to one chapter in order. - * - * @param chapterSet - The chapter set to modify. - * @param templateText - Name template text (one name per line). Lines after - * the chapter count are ignored; fewer lines leave trailing chapters - * unchanged. - * @returns The updated chapter set with any diagnostics. - */ - async applyTemplate(chapterSet: ChapterSet, templateText: string): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "applyTemplate", - encodeJson({ text: requireString(templateText, "templateText") }, "options")); - } - - /** - * Moves every chapter start time forward by a given number of frames. - * When the shift is positive, the first chapter (at 0 s) is removed. - * - * @param chapterSet - The chapter set to modify. Must contain at least one - * chapter. - * @param frames - Number of frames to shift forward (must be a safe integer). - * @param framesPerSecond - Frame rate used to convert the frame count to - * seconds. - * @returns The updated chapter set. When the shift is positive, the chapter - * that previously was at time 0 is dropped. - */ - async shiftFramesForward(chapterSet: ChapterSet, frames: number, framesPerSecond: number): Promise { - return this.#invokeJson( - "Edit", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - "shiftFramesForward", - encodeJson({ - frames: requireInteger(frames, "frames"), - framesPerSecond: requireNumber(framesPerSecond, "framesPerSecond"), - }, "options")); - } - - /** - * Generates a keyframe / zones list for the selected chapters at the given - * frame rate. - * - * @param chapterSet - Source chapter set. - * @param indexes - Zero-based indexes of the chapters to include. - * @param framesPerSecond - Frame rate for the keyframe calculation. - * @returns Zone list text (one entry per line) with diagnostics. - */ - async createZones(chapterSet: ChapterSet, indexes: number[], framesPerSecond: number): Promise { - return this.#invokeJson( - "CreateZones", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - encodeJson(requireIndexes(indexes), "indexes"), - requireNumber(framesPerSecond, "framesPerSecond")); - } - - /** - * Merges all entries within one imported source group into a single chapter - * set (e.g. combining MPLS playlist items into one track). - * - * @param source - An import group typically obtained from - * {@link ChapterImportResult.groups}. - * @returns A single chapter set that combines every entry in the group. - */ - async combine(source: ChapterImportGroup): Promise { - return this.#invokeJson( - "Combine", - encodeJson(requireObject(source, "source"), "source")); - } - - /** - * Appends the entries of one imported source group to another (e.g. - * concatenating two MPLS playlists). - * - * @param existing - The base group. - * @param appended - The group whose entries are appended. - * @returns A single chapter set containing entries from both groups in order. - */ - async append(existing: ChapterImportGroup, appended: ChapterImportGroup): Promise { - return this.#invokeJson( - "Append", - encodeJson(requireObject(existing, "existing"), "existing"), - encodeJson(requireObject(appended, "appended"), "appended")); - } - - /** - * Returns the list of frame-rate presets known to the Core. - * - * @returns Supported frame-rate options, each with a unique {@code code} and - * exact numeric value. - */ - async frameRates(): Promise { - return this.#invokeJson("GetFrameRates"); - } - - /** - * Finds the closest supported frame-rate preset for a given numeric value. - * - * @param framesPerSecond - Target frames per second (e.g. 23.976). - * @returns The nearest {@link FrameRateOption} recognised by the Core. - */ - async findFrameRate(framesPerSecond: number): Promise { - return this.#invokeJson( - "FindFrameRate", - requireNumber(framesPerSecond, "framesPerSecond")); - } - - /** - * Heuristically detects the most likely frame rate for a chapter set by - * analysing how chapter start times align with known frame-rate grids. - * - * @param chapterSet - The chapter set to analyse. - * @param tolerance - Allowed per-chapter deviation ratio (default 0.15). - * @returns The best match together with accuracy and confidence metrics. - */ - async detectFrameRate(chapterSet: ChapterSet, tolerance = 0.15): Promise { - return this.#invokeJson( - "DetectFrameRate", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - requireNumber(tolerance, "tolerance")); - } - - /** - * Computes frame-level metadata for every chapter using the specified - * frame-rate option. - * - * @param chapterSet - The chapter set to process. - * @param options - Optional overrides: - * {@code optionCode} selects the frame-rate preset (defaults to - * {@code "Auto"}); {@code round} controls whether the frame count is - * rounded to the nearest integer (default {@code true}); {@code tolerance} - * sets the detection tolerance when using auto-detection (default 0.15). - * @returns The chapter set augmented with frame-accuracy metadata. - * @throws {TypeError} When {@code options.optionCode} is not a string. - */ - async updateFrames( - chapterSet: ChapterSet, - options: { optionCode?: string; round?: boolean; tolerance?: number } = {}, - ): Promise { - const normalizedOptions = requireObject(options, "options"); - const optionCode = normalizedOptions.optionCode ?? "Auto"; - const round = normalizedOptions.round ?? true; - if (typeof optionCode !== "string") { - throw new TypeError("options.optionCode must be a string."); - } - requireBoolean(round, "options.round"); - - return this.#invokeJson( - "UpdateFrames", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - optionCode, - round, - requireNumber(normalizedOptions.tolerance ?? 0.15, "options.tolerance")); - } - - /** - * Converts all chapter start times from one frame rate to another, - * recalculating the `framesPerSecond` metadata on the resulting set. - * - * @param chapterSet - The chapter set to convert. - * @param sourceFps - Original frame rate of the chapter set. - * @param targetFps - Desired output frame rate. - * @returns The converted chapter set and diagnostics. - */ - async changeFrameRate(chapterSet: ChapterSet, sourceFps: number, targetFps: number): Promise { - return this.#invokeJson( - "ChangeFrameRate", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - requireNumber(sourceFps, "sourceFps"), - requireNumber(targetFps, "targetFps")); - } - - /** - * Evaluates a time-arithmetic expression against every chapter start time. - * Expressions support variables ({@code t}, {@code n}), operators, and - * functions from the expression engine. - * - * @param chapterSet - The chapter set to modify. - * @param expression - Expression script text (e.g. {@code "t + 1"} adds one - * second to every chapter). - * @param enabled - When {@code false} the expression is not applied but still - * validated (default {@code true}). - * @returns The updated chapter set with any diagnostics. - * @see {@link analyzeExpression} to preview token spans and completions - * before applying. - * @see {@link expressionSymbols} for available variables and functions. - */ - async applyExpression(chapterSet: ChapterSet, expression: string, enabled = true): Promise { - return this.#invokeJson( - "ApplyExpression", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - requireBoolean(enabled, "enabled"), - requireString(expression, "expression")); - } - - /** - * Projects how a chapter set would appear after export — resolving name - * templates, order shifts, and encoding — without producing the final - * output text. - * - * @param chapterSet - The chapter set to project. - * @param options - Export-shaped options (primarily {@code format} and name - * generation flags). - * @returns The original set plus the projected chapter list as it would be - * written on export. - * @throws {TypeError} When {@code options.format} is missing or empty. - */ - async project(chapterSet: ChapterSet, options: ChapterExportOptions): Promise { - const normalizedOptions = requireObject(options, "options"); - if (typeof normalizedOptions.format !== "string" || normalizedOptions.format.length === 0) { - throw new TypeError("options.format must be an export format code, such as 'xml'."); - } - - return this.#invokeJson( - "Project", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - encodeJson(normalizedOptions, "options")); - } - - /** - * Lexes and analyses an expression string, returning token spans, completion - * suggestions at the caret position, and any diagnostics. - * - * @param expression - Expression script text to analyse. - * @param options - Optional context for the analysis. - * @param options.caretIndex - Zero-based caret offset for completion - * suggestions. Defaults to {@code expression.length}. - * @param options.timeSeconds - Current chapter time for variable resolution - * (default 0). - * @param options.framesPerSecond - Frame rate for frame-related functions - * (default 24). - * @returns Structured analysis with token spans, completions, and - * diagnostics. - */ - async analyzeExpression( - expression: string, - options: { caretIndex?: number; timeSeconds?: number; framesPerSecond?: number } = {}, - ): Promise { - const normalizedExpression = requireString(expression, "expression"); - const normalizedOptions = requireObject(options, "options"); - return this.#invokeJson( - "AnalyzeExpression", - normalizedExpression, - requireInteger(normalizedOptions.caretIndex ?? normalizedExpression.length, "options.caretIndex"), - requireNumber(normalizedOptions.timeSeconds ?? 0, "options.timeSeconds"), - requireNumber(normalizedOptions.framesPerSecond ?? 24, "options.framesPerSecond")); - } - - /** - * Returns the variables, functions, and operators available in the - * expression engine. - * - * @returns Symbols that can be referenced in expression scripts. - */ - async expressionSymbols(): Promise { - return this.#invokeJson("GetExpressionSymbols"); - } - - /** - * Returns the built-in expression presets shipped with the Core. - * - * @returns Preset definitions, each with an identifier, display name, and - * script text. - */ - async expressionPresets(): Promise { - return this.#invokeJson("GetExpressionPresets"); - } - - /** - * Parses a time-text string into fractional seconds, returning diagnostics - * when the input is ambiguous or invalid. - * - * @param text - Time text such as {@code "01:23:45.678"}. - * @returns The parsed duration together with any warnings or errors. - */ - async parseTime(text: string): Promise { - return this.#invokeJson("ParseTime", requireString(text, "text")); - } - - /** - * Lenient variant of {@link parseTime} that returns {@code 0} for any input - * that cannot be parsed. - * - * @param text - Time text to attempt parsing. - * @returns Parsed seconds, or 0 on failure. - */ - async parseTimeOrZero(text: string): Promise { - return this.#invoke("ParseTimeOrZero", requireString(text, "text")); - } - - /** - * Formats a duration in seconds as chapter time-text. - * - * @param seconds - Duration in fractional seconds. - * @returns Formatted time string (e.g. {@code "00:05:23.500"}). - */ - async formatTime(seconds: number): Promise { - return this.#invoke("FormatTime", requireNumber(seconds, "seconds")); - } - - /** - * Formats a duration in seconds as cue-sheet time-text (no sub-second - * precision). - * - * @param seconds - Duration in fractional seconds. - * @returns Time string in {@code HH:MM:SS} format. - */ - async formatCueTime(seconds: number): Promise { - return this.#invoke("FormatCueTime", requireNumber(seconds, "seconds")); - } - - /** - * Converts a chapter set to Celltimes format (frame-count lines for use with - * video encoders). - * - * @param chapterSet - The chapter set to convert. - * @param framesPerSecond - Frame rate for the frame-count calculation. - * @returns Celltimes text content and diagnostics. - */ - async toCelltimes(chapterSet: ChapterSet, framesPerSecond: number): Promise { - return this.#invokeJson( - "ToCelltimes", - encodeJson(requireObject(chapterSet, "chapterSet"), "chapterSet"), - requireNumber(framesPerSecond, "framesPerSecond")); - } - - /** - * Converts raw chapter-text content into QPFile format (keyframe index for - * x264 / x265). - * - * @param chapterText - OGM-style chapter text. - * @param framesPerSecond - Frame rate used for the keyframe calculation. - * @param timecodeText - Optional initial timecode text. When - * {@code null} or omitted, the first chapter starts at frame 0. - * @returns QPFile text content and diagnostics. - */ - async chapterTextToQpfile( - chapterText: string, - framesPerSecond: number, - timecodeText: string | null = null, - ): Promise { - if (timecodeText !== null && timecodeText !== undefined) { - requireString(timecodeText, "timecodeText"); - } - - return this.#invokeJson( - "ChapterTextToQpfile", - requireString(chapterText, "chapterText"), - requireNumber(framesPerSecond, "framesPerSecond"), - timecodeText); - } - - /** - * Returns the XML language options available for XML-based export formats. - * - * @returns Language descriptors with BCP 47 / ISO 639-1 codes. - */ - async xmlLanguages(): Promise { - return this.#invokeJson("GetXmlLanguages"); - } - - /** - * Returns the text-encoding options available for chapter output. - * - * @returns Encoding descriptors with internal identifiers and XML names. - */ - async outputEncodings(): Promise { - return this.#invokeJson("GetOutputEncodings"); + constructor() { + super( + async (operation: string, ...arguments_: unknown[]): Promise => { + const api = await ChapterTool.loadApi(); + const result = api[operation](...arguments_); + return (typeof result === "string" ? JSON.parse(result) : result) as T; + }, + async () => { await ChapterTool.loadApi(); }, + ); } } diff --git a/packages/chaptertool/src/types.ts b/packages/chaptertool/src/types.ts index 5c7eb63f..6474fc81 100644 --- a/packages/chaptertool/src/types.ts +++ b/packages/chaptertool/src/types.ts @@ -1,362 +1,61 @@ -/** Severity level and message produced by the .NET Core during processing. */ +/** Severity and message produced by ChapterTool Core. */ export interface ChapterDiagnostic { - /** Severity category: {@code "Information"}, {@code "Warning"}, or {@code "Error"}. */ severity: string; - /** Diagnostic code identifier, e.g. {@code "TimecodeText.Invalid"}. */ code: string; - /** Human-readable description of the issue. */ message: string; - /** Source location (file path or section name) when available. */ location: string | null; - /** Additional detail text, or {@code null} when no extra context is available. */ details: string | null; } -/** A single chapter entry with its timing, display name, and frame metadata. */ -export interface Chapter { - /** Chapter number as displayed to the user (1-based). */ - displayNumber: number; - /** Start time in fractional seconds. */ - startTimeSeconds: number; - /** Chapter display name / title text. */ - name: string; - /** Frame-accurate representation of the start time as a human-readable string. */ - framesInfo: string; - /** End time in fractional seconds. {@code null} for the final chapter. */ - endTimeSeconds: number | null; - /** Describes how precisely the start time aligns to frame boundaries. */ - frameAccuracy: string; - /** Chapter kind: {@code "Marker"} for standard chapters, or a format-specific label. */ - kind: string; -} - -/** - * A self-contained collection of chapters with source and timing metadata. - * Returned by import, editing, and transform operations. - */ -export interface ChapterSet { - /** Title of this chapter set, typically derived from the source file name. */ - title: string; - /** Original source file name, or {@code null} for generated / anonymous sets. */ - sourceName: string | null; - /** Code of the import format that produced this set. */ - importFormat: string; - /** Frame rate used for frame-accurate calculations. */ - framesPerSecond: number; - /** Total duration of the chapter timeline in seconds. */ - durationSeconds: number; - /** Ordered array of chapters in this set. */ - chapters: Chapter[]; -} - -/** - * One item within a {@link ChapterImportGroup}, representing a single parsed - * source (e.g. a playlist item or a track). - */ -export interface ChapterImportEntry { - /** Opaque identifier assigned by the importer. */ - id: string; - /** Label shown to the user for this entry. */ - displayName: string; - /** The chapter set extracted from this entry. */ - chapterSet: ChapterSet; - /** Whether this entry can be combined with others into a single track. */ - canCombine: boolean; - /** Media files referenced by the source, or {@code null} when not applicable. */ - referencedMediaFiles: ReferencedMediaFile[] | null; -} - -/** A group of chapter entries belonging to the same source container (file or disc). */ -export interface ChapterImportGroup { - /** Path or identifier of the source container. */ - sourcePath: string; - /** Entries parsed from this source. */ - entries: ChapterImportEntry[]; - /** Index of the entry that should be selected by default in a UI. */ - defaultEntryIndex: number; -} - -/** A media file referenced by a chapter source (e.g. an M2TS clip inside an MPLS). */ -export interface ReferencedMediaFile { - /** File name shown to the user. */ - displayName: string; - /** Path relative to the source container root. */ - relativePath: string; - /** Resolved absolute path on disk, or {@code null} when unavailable. */ - absolutePath: string | null; -} - -/** Top-level result returned by {@code ChapterTool.import}. */ -export interface ChapterImportResult { - /** Whether the import completed without fatal errors. */ - success: boolean; - /** Whether only a subset of the input could be parsed. */ - isPartial: boolean; - /** Groups produced from the input source. */ - groups: ChapterImportGroup[]; - /** Diagnostics collected during import. */ - diagnostics: ChapterDiagnostic[]; -} - -/** Result returned by {@code ChapterTool.export}. */ +/** Result returned by serialization. */ export interface ChapterExportResult { - /** Whether the export completed without fatal errors. */ success: boolean; - /** Serialised chapter text / data. */ content: string; - /** File extension for the exported format (including the leading dot). */ fileExtension: string; - /** Diagnostics collected during export. */ - diagnostics: ChapterDiagnostic[]; -} - -/** Options controlling chapter export behaviour and output formatting. */ -export interface ChapterExportOptions { - /** Export format code, e.g. {@code "xml"}, {@code "txt"}, {@code "vtt"}. */ - format: string; - /** XML language code for XML-based formats (ISO 639-1 or IETF BCP 47). */ - xmlLanguage?: string; - /** Override the source file name embedded in the exported content. */ - sourceFileName?: string; - /** When {@code true}, auto-generate chapter names if the source has none. */ - autoGenerateNames?: boolean; - /** When {@code true}, use chapter name templates instead of source names. */ - useTemplateNames?: boolean; - /** Template text for generating chapter names (one name per line). */ - chapterNameTemplateText?: string; - /** Integer shift applied to chapter display numbers before export. */ - orderShift?: number; - /** When {@code true}, apply the configured expression before export. */ - applyExpression?: boolean; - /** Expression script text applied to chapter start times. */ - expression?: string; - /** Pre-configured expression preset identifier. */ - expressionPresetId?: string; - /** Source name used inside expression evaluation. */ - expressionSourceName?: string; - /** Text encoding for the exported output. */ - textEncoding?: "Utf8" | "Utf16LittleEndian" | "Utf16BigEndian" | "Utf32LittleEndian" | "Utf32BigEndian"; - /** Whether to emit a byte-order mark (BOM) at the beginning of the output. */ - emitBom?: boolean; - /** When {@code true}, run chapter projection prior to export. */ - projectOutput?: boolean; -} - -/** Descriptor for one supported export format. */ -export interface ChapterExportFormat { - /** Stable sort index used by the .NET Core. */ - index: number; - /** Unique format code, e.g. {@code "xml"} or {@code "qpf"}. */ - code: string; - /** Human-readable format name. */ - displayName: string; - /** Default file extension for this format (including the leading dot). */ - extension: string; - /** Short description of the format and its typical use. */ - description: string; -} - -/** Descriptor for one supported import format. */ -export interface ChapterImportFormat { - /** Unique format code, e.g. {@code "hddvd-xpl"}. */ - code: string; - /** Human-readable format name. */ - displayName: string; -} - -/** Result returned by edit operations (rename, delete, insert, etc.). */ -export interface ChapterEditResult { - /** The chapter set after the edit was applied. */ - chapterSet: ChapterSet; - /** Diagnostics produced during the edit. */ diagnostics: ChapterDiagnostic[]; } -/** Zones / keyframe list generated from selected chapters. */ +/** Result returned by a zone conversion. */ export interface ChapterZonesResult { - /** Zone list text (one zone or keyframe per line). */ zones: string; - /** Diagnostics collected during zone generation. */ diagnostics: ChapterDiagnostic[]; } -/** A supported frame-rate preset. */ -export interface FrameRateOption { - /** Unique code, e.g. {@code "Fps23_976"} or {@code "Fps24"}. */ - code: string; - /** Human-readable label for the frame rate. */ - displayName: string; - /** Exact frame-per-second value (may be fractional for drop-frame rates). */ - value: number; - /** Whether this frame rate is considered valid for processing. */ - isValid: boolean; - /** Legacy MPLS code used for compatibility with older tools. */ - legacyMplsCode: number; -} - -/** Result of automatic frame-rate detection on a chapter set. */ -export interface FrameRateDetectionResult { - /** The best-matching frame-rate option detected. */ - option: FrameRateOption; - /** How many chapters matched the detected rate with high accuracy. */ - accurateChapterCount: number; - /** Total number of chapters evaluated. */ - evaluatedChapterCount: number; - /** Sum of timing deviations (lower is better). */ - cumulativeDeviation: number; - /** Confidence level label, e.g. {@code "High"} or {@code "Low"}. */ - confidence: string; -} - -/** Result of updating frame metadata for a chapter set. */ -export interface FrameInfoResult { - /** The chapter set after frame metadata was computed. */ - chapterSet: ChapterSet; - /** Chapter list with updated frame information. */ - chapters: Chapter[]; - /** The frame-rate option that was selected for the computation. */ - selectedOption: FrameRateOption; - /** Effective frame rate used. */ - framesPerSecond: number; - /** Frame-accuracy label for each chapter. */ - accuracy: string[]; -} - -/** Result of a chapter transform operation (e.g. frame-rate conversion). */ -export interface ChapterTransformResult { - /** Whether the transform completed without fatal errors. */ +/** Result returned by a text conversion. */ +export interface ChapterConversionResult { success: boolean; - /** The chapter set after transformation. */ - chapterSet: ChapterSet; - /** Diagnostics collected during the transform. */ - diagnostics: ChapterDiagnostic[]; -} - -/** Result of projecting chapter names and timing before export. */ -export interface ChapterProjectionResult { - /** The original chapter set (unchanged). */ - chapterSet: ChapterSet; - /** Chapters as they will appear in the exported output. */ - outputChapters: Chapter[]; - /** Diagnostics collected during projection. */ + content: string; + extension: string; diagnostics: ChapterDiagnostic[]; } -/** A classified span of text within an expression script. */ -export interface ExpressionTokenSpan { - /** Zero-based start offset in the expression text. */ - start: number; - /** Length of the span in characters. */ - length: number; - /** Raw text content of this span. */ - text: string; - /** Classification kind, e.g. {@code "Identifier"}, {@code "Number"}, {@code "Operator"}. */ - kind: string; -} - -/** A completion suggestion for the expression editor at a specific caret position. */ -export interface ExpressionCompletion { - /** Display text shown in the completion list. */ - text: string; - /** Symbol kind, e.g. {@code "Function"} or {@code "Variable"}. */ - kind: string; - /** Human-readable label for the symbol kind. */ - kindLabel: string; - /** Description of the symbol's purpose and usage. */ - description: string; - /** Start offset in the expression where the replacement begins. */ - replacementStart: number; - /** Number of characters to replace. */ - replacementLength: number; - /** Text to insert when the completion is accepted. */ - insertText: string; -} - -/** A diagnostic anchored to a specific span within an expression script. */ -export interface ExpressionDiagnostic { - /** The underlying chapter diagnostic. */ - diagnostic: ChapterDiagnostic; - /** Suggested fix for this diagnostic, if available. */ - suggestion: { code: string; message: string }; - /** Zero-based start offset of the problematic span. */ - start: number; - /** Length of the problematic span in characters. */ - length: number; -} - -/** Complete result of analysing an expression script. */ -export interface ExpressionAnalysisResult { - /** Token spans identified in the expression. */ - spans: ExpressionTokenSpan[]; - /** Completion suggestions for the current caret position. */ - completions: ExpressionCompletion[]; - /** Diagnostics for errors or warnings in the expression. */ - diagnostics: ExpressionDiagnostic[]; -} - -/** A symbol (variable, function, or operator) available in the expression editor. */ -export interface ExpressionSymbol { - /** Symbol text as it appears in expressions. */ - text: string; - /** Classification kind, e.g. {@code "Function"}, {@code "Variable"}, {@code "Operator"}. */ - kind: string; - /** Human-readable description of the symbol. */ - description: string; - /** Function arity (expected argument count), or {@code null} for non-function symbols. */ - arity: number | null; - /** Snippet text inserted when the symbol is selected from the completion list. */ - insertText: string; -} +/** UTF-8 text or raw chapter bytes accepted by the import API. */ +export type ChapterInput = string | Uint8Array; -/** A built-in expression preset available in the expression editor. */ -export interface ExpressionPreset { - /** Unique preset identifier. */ - id: string; - /** Name shown in the preset picker. */ +/** Export format metadata from Core. */ +export interface ChapterFormat { + index: number; + code: string; displayName: string; - /** Short description of what the preset does. */ + extension: string; description: string; - /** Expression script text for this preset. */ - scriptText: string; } -/** Result of parsing a time text string into seconds. */ -export interface TimeParseResult { - /** Parsed time value in fractional seconds. */ - seconds: number; - /** Diagnostics for invalid or ambiguous input. */ - diagnostics: ChapterDiagnostic[]; -} - -/** Result of converting a chapter set to another text format. */ -export interface ChapterConversionResult { - /** Whether the conversion completed without fatal errors. */ - success: boolean; - /** Converted content text. */ - content: string; - /** File extension for the converted format (including the leading dot). */ - extension: string; - /** Diagnostics collected during conversion. */ - diagnostics: ChapterDiagnostic[]; +/** Import format metadata from Core. */ +export interface ChapterImportFormat { + code: string; + displayName: string; } -/** An XML language option available for XML-based export formats. */ -export interface XmlLanguage { - /** Language code (ISO 639-1 or IETF BCP 47). */ +/** XML chapter language metadata. */ +export interface ChapterXmlLanguage { code: string; - /** Human-readable language name. */ displayName: string; } -/** A text-encoding option available for chapter output. */ -export interface OutputEncoding { - /** Encoding identifier used by the .NET Core. */ +/** Supported output encoding metadata. */ +export interface ChapterOutputEncoding { id: string; - /** Human-readable encoding name. */ displayName: string; - /** XML declaration encoding name (e.g. {@code "UTF-8"}). */ xmlName: string; } - -/** UTF-8 text or raw chapter bytes accepted by the import API. */ -export type ChapterInput = string | Uint8Array; diff --git a/packages/chaptertool/test/chaptertool.test.ts b/packages/chaptertool/test/chaptertool.test.ts index 32cb98af..ed5f029b 100644 --- a/packages/chaptertool/test/chaptertool.test.ts +++ b/packages/chaptertool/test/chaptertool.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest"; import { ChapterTool, MAX_INPUT_BYTES } from "../src/index.ts"; -import { encodeJson, decodeJson } from "../src/utils/json.ts"; -import { requireBoolean, requireInteger, requireIndex, requireNumber } from "../src/utils/validation.ts"; +import type { ExactChapterSet, ExactFrameRate } from "../src/exact-types.ts"; const chapterText = `CHAPTER01=00:00:00.000 CHAPTER01NAME=Opening CHAPTER02=00:01:00.000 CHAPTER02NAME=Middle `; +const rate: ExactFrameRate = { kind: "rational", numerator: "24", denominator: "1" }; describe("ChapterTool package entry point", () => { - it("imports UTF-8 text and exports XML", async () => { + it("imports UTF-8 text and serializes XML", async () => { const tool = new ChapterTool(); const imported = await tool.import(chapterText, { fileName: "sample.txt" }); @@ -20,12 +20,28 @@ describe("ChapterTool package entry point", () => { expect(chapterSet.chapters).toHaveLength(2); expect(chapterSet.chapters.map(({ name }) => name)).toEqual(["Opening", "Middle"]); - const exported = await tool.export(chapterSet, { format: "xml" }); + const exported = await tool.serialize(chapterSet, { format: "xml" }); expect(exported.success).toBe(true); expect(exported.fileExtension).toBe(".xml"); expect(exported.content).toMatch(//); }); + it("applies an expression explicitly before serialization", async () => { + const tool = new ChapterTool(); + const imported = await tool.import(chapterText, { fileName: "expression.txt" }); + const chapterSet = imported.groups[0].entries[0].chapterSet; + const before = chapterSet.chapters[0].startTicks; + + const applied = await tool.applyExpression(chapterSet, true, "t + 1", rate); + expect(applied.success).toBe(true); + expect(applied.chapterSet!.chapters[0].startTicks).toBe("10000000"); + expect(chapterSet.chapters[0].startTicks).toBe(before); + + const exported = await tool.serialize(applied.chapterSet!, { format: "timecodes" }); + expect(exported.success).toBe(true); + expect(exported.content).toContain("00:00:01.000"); + }); + it("imports Buffer and Uint8Array content", async () => { const tool = new ChapterTool(); const bufferResult = await tool.import(Buffer.from(chapterText), { fileName: "buffer.txt" }); @@ -35,25 +51,16 @@ describe("ChapterTool package entry point", () => { expect(bytesResult.groups[0].entries[0].chapterSet.chapters).toHaveLength(2); }); - it("lists and exports every Core format code", async () => { + it("exposes Core format and import catalogs", async () => { const tool = new ChapterTool(); const formats = await tool.formats(); - expect( - formats.map(({ code }) => code), - ).toEqual(["txt", "xml", "qpf", "timecodes", "tsmuxer", "cue", "json", "vtt", "celltimes"]); - - const imported = await tool.import(chapterText, { fileName: "formats.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; - chapterSet.framesPerSecond = 24; - - for (const { code } of formats) { - const exported = await tool.export(chapterSet, { - format: code, - sourceFileName: "source.wav", - }); - expect(exported.success, `${code}: ${JSON.stringify(exported.diagnostics)}`).toBe(true); - } + expect(formats.map(({ code }) => code)).toEqual(["txt", "xml", "qpf", "timecodes", "tsmuxer", "cue", "json", "vtt", "celltimes"]); + expect((await tool.importFormats()).find(({ code }) => code === "hddvd-xpl")?.displayName).toBe("HD-DVD XPL"); + expect((await tool.xmlLanguages()).some(({ code }) => code === "und")).toBe(true); + expect((await tool.outputEncodings()).some(({ id }) => id === "utf8")).toBe(true); + expect(await tool.isBinaryExtension(".mpls")).toBe(true); + expect(await tool.isBinaryExtension(".xpl")).toBe(false); }); it("rejects unsupported JavaScript input", async () => { @@ -77,147 +84,36 @@ describe("ChapterTool package entry point", () => { }); }); - it("rejects invalid dynamic options before the Core boundary", async () => { - const tool = new ChapterTool(); - const imported = await tool.import(chapterText, { fileName: "validation.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; - - await expect(tool.import(chapterText, null as any)).rejects.toMatchObject({ - name: "TypeError", - message: "options must be an object.", - }); - await expect(tool.updateFrames(chapterSet, null as any)).rejects.toMatchObject({ - name: "TypeError", - message: "options must be an object.", - }); - await expect(tool.rename(chapterSet, 0, undefined as any)).rejects.toMatchObject({ - name: "TypeError", - message: "name must be a string.", - }); - await expect(tool.delete(chapterSet, [0, -1])).rejects.toMatchObject({ - name: "TypeError", - message: "indexes must be an array of non-negative chapter indexes.", - }); - - expect(chapterSet.chapters[0].name).toBe("Opening"); - }); - it("preserves Unicode chapter names through the JSON boundary", async () => { const tool = new ChapterTool(); const imported = await tool.import(chapterText, { fileName: "unicode.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; - const result = await tool.rename(chapterSet, 0, "第一章"); + const result = await tool.rename(imported.groups[0].entries[0].chapterSet, 0, "第一章"); - expect(result.chapterSet.chapters[0].name).toBe("第一章"); + expect(result.chapterSet!.chapters[0].name).toBe("第一章"); }); - it("rejects export and project with an empty format code", async () => { + it("reports unsupported serialization formats", async () => { const tool = new ChapterTool(); const imported = await tool.import(chapterText, { fileName: "empty.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; - - await expect(tool.export(chapterSet, { format: "" })).rejects.toMatchObject({ - name: "TypeError", - message: "options.format must be an export format code, such as 'xml'.", - }); - await expect(tool.project(chapterSet, { format: "" })).rejects.toMatchObject({ - name: "TypeError", - message: "options.format must be an export format code, such as 'xml'.", - }); - }); - it("rejects updateFrames with a non-string optionCode", async () => { - const tool = new ChapterTool(); - const imported = await tool.import(chapterText, { fileName: "frames.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; + const result = await tool.serialize(imported.groups[0].entries[0].chapterSet, { format: "" }); - await expect(tool.updateFrames(chapterSet, { optionCode: 123 } as any)).rejects.toMatchObject({ - name: "TypeError", - message: "options.optionCode must be a string.", - }); + expect(result.success).toBe(false); + expect(result.diagnostics).toHaveLength(1); }); - it("validates timecodeText when passed to chapterTextToQpfile", async () => { + it("keeps exact frame rates and time ticks as strings", async () => { const tool = new ChapterTool(); - // The JS‑side requireString should be exercised even when the .NET parser - // rejects the timecode format — a TypeError on the JS side proves the path. - await expect(tool.chapterTextToQpfile(chapterText, 24, 123 as any)).rejects.toMatchObject({ - name: "TypeError", - message: "timecodeText must be a string.", - }); - }); - - it("rejects a blank fileName", async () => { - const tool = new ChapterTool(); - await expect(tool.import(chapterText, { fileName: " " })).rejects.toMatchObject({ - name: "TypeError", - message: "fileName must be a non-empty string.", - }); - }); - - it("rejects invalid validation primitives before the Core boundary", async () => { - const tool = new ChapterTool(); - const imported = await tool.import(chapterText, { fileName: "primitives.txt" }); - const chapterSet = imported.groups[0].entries[0].chapterSet; - - await expect(tool.updateFrames(chapterSet, { round: "yes" } as any)).rejects.toMatchObject({ - name: "TypeError", - message: "options.round must be a boolean.", - }); - await expect(tool.shiftFramesForward(chapterSet, 24.5, 24)).rejects.toMatchObject({ - name: "TypeError", - message: "frames must be a safe integer.", - }); - await expect(tool.changeFrameRate(chapterSet, NaN, 24)).rejects.toMatchObject({ - name: "TypeError", - message: "sourceFps must be a finite number.", - }); - await expect(tool.editTime(chapterSet, -1, "00:00:05.000")).rejects.toMatchObject({ - name: "RangeError", - message: "index must be a non-negative integer.", - }); - }); -}); - -describe("JSON encoding utilities", () => { - it("rejects values that JSON.stringify cannot serialise", () => { - const circular: Record = {}; - circular.self = circular; - expect(() => encodeJson(circular, "circular")).toThrow(TypeError); - expect(() => encodeJson(circular, "circular")).toThrow("circular must be serializable as JSON."); - }); - - it("rejects values that JSON.stringify returns undefined for", () => { - expect(() => encodeJson(undefined, "value")).toThrow(TypeError); - expect(() => encodeJson(undefined, "value")).toThrow("value must be serializable as JSON."); - }); - - it("rejects invalid JSON text", () => { - expect(() => decodeJson("not json", "TestOp")).toThrow(Error); - expect(() => decodeJson("not json", "TestOp")).toThrow("ChapterTool TestOp returned invalid JSON."); - }); -}); - -describe("validation primitives", () => { - it("requireBoolean rejects non-boolean values", () => { - expect(() => requireBoolean("true", "flag")).toThrow(TypeError); - expect(() => requireBoolean("true", "flag")).toThrow("flag must be a boolean."); - }); - - it("requireInteger rejects non-safe-integer values", () => { - expect(() => requireInteger(3.14, "count")).toThrow(TypeError); - expect(() => requireInteger(3.14, "count")).toThrow("count must be a safe integer."); - }); - - it("requireIndex rejects negative values", () => { - expect(() => requireIndex(-1, "pos")).toThrow(RangeError); - expect(() => requireIndex(-1, "pos")).toThrow("pos must be a non-negative integer."); - }); - - it("requireNumber rejects NaN and Infinity", () => { - expect(() => requireNumber(NaN, "value")).toThrow(TypeError); - expect(() => requireNumber(NaN, "value")).toThrow("value must be a finite number."); - expect(() => requireNumber(Infinity, "value")).toThrow(TypeError); + const imported = await tool.import(chapterText, { fileName: "frames.txt" }); + const set: ExactChapterSet = { + ...imported.groups[0].entries[0].chapterSet, + frameRate: rate, + }; + const framed = await tool.updateFrames(set, rate); + + expect(framed.chapterSet!.frameRate).toEqual(rate); + expect(framed.chapterSet!.chapters[1].framesInfo).toBe("1440"); + expect(framed.chapterSet!.chapters[1].startTicks).toBe("600000000"); }); }); diff --git a/packages/chaptertool/test/core-api.test.ts b/packages/chaptertool/test/core-api.test.ts index 0ae61194..63a6bb46 100644 --- a/packages/chaptertool/test/core-api.test.ts +++ b/packages/chaptertool/test/core-api.test.ts @@ -1,179 +1,36 @@ -import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { ChapterTool } from "../src/index.ts"; -import type { ChapterSet } from "../src/types.ts"; -const chapterText = `CHAPTER01=00:00:00.000 -CHAPTER01NAME=Opening -CHAPTER02=00:01:00.000 -CHAPTER02NAME=Middle -`; +import type { ExactFrameRate } from "../src/exact-types.ts"; -async function sample(tool: ChapterTool): Promise { - const imported = await tool.import(chapterText, { fileName: "core-api.txt" }); - return imported.groups[0].entries[0].chapterSet; -} +const chapterText = "CHAPTER01=00:00:00.000\nCHAPTER01NAME=Opening\n"; +const rate: ExactFrameRate = { kind: "rational", numerator: "24", denominator: "1" }; -function mplsGroup(id: string, durationSeconds: number) { - return { - sourcePath: `${id}.mpls`, - defaultEntryIndex: 0, - entries: [{ - id, - displayName: id, - canCombine: true, - referencedMediaFiles: null, - chapterSet: { - title: id, - sourceName: `${id}.m2ts`, - importFormat: "Mpls", - framesPerSecond: 24, - durationSeconds, - chapters: [{ - displayNumber: 1, - startTimeSeconds: 0, - name: id, - framesInfo: "", - endTimeSeconds: null, - frameAccuracy: "Neutral", - kind: "Marker", - }], - }, - }], - }; -} - -describe("ChapterTool Core API mapping", () => { - it("exposes only the portable Core API surface", async () => { +describe("ChapterTool exact Core API mapping", () => { + it("uses the exact API as the package entry point", () => { const tool = new ChapterTool(); expect(tool).toBeInstanceOf(ChapterTool); - expect(Object.getOwnPropertyNames(Object.getPrototypeOf(tool)).filter((name) => name !== "constructor").sort()).toEqual([ - "analyzeExpression", - "append", - "applyExpression", - "applyOrderShift", - "applyTemplate", - "changeFrameRate", - "chapterTextToQpfile", - "combine", - "createZones", - "delete", - "detectFrameRate", - "editFrame", - "editTime", - "export", - "expressionPresets", - "expressionSymbols", - "findFrameRate", - "formatCueTime", - "formatTime", - "formats", - "frameRates", - "import", - "importFormats", - "insertBefore", - "isBinaryExtension", - "maxInputBytes", - "outputEncodings", - "parseTime", - "parseTimeOrZero", - "project", - "rename", - "shiftFramesForward", - "toCelltimes", - "updateFrames", - "xmlLanguages", - ]); - }); - - it("exposes Core editing and segment operations", async () => { - const tool = new ChapterTool(); - const chapterSet = await sample(tool); - - expect((await tool.editTime(chapterSet, 1, "00:00:05.000")).chapterSet.chapters[1].startTimeSeconds).toBe(5); - expect((await tool.editFrame(chapterSet, 1, "48", 24)).chapterSet.chapters[1].startTimeSeconds).toBe(2); - expect((await tool.rename(chapterSet, 0, "Renamed")).chapterSet.chapters[0].name).toBe("Renamed"); - expect((await tool.delete(chapterSet, [1])).chapterSet.chapters).toHaveLength(1); - expect((await tool.insertBefore(chapterSet, 1)).chapterSet.chapters).toHaveLength(3); - expect((await tool.applyOrderShift(chapterSet, 2)).chapterSet.chapters[0].displayNumber).toBe(3); - expect((await tool.applyTemplate(chapterSet, "Alpha\nBeta")).chapterSet.chapters[1].name).toBe("Beta"); - const shifted = await tool.shiftFramesForward(chapterSet, 24, 24); - expect(shifted.chapterSet.chapters).toHaveLength(1); - expect(shifted.chapterSet.chapters[0].startTimeSeconds).toBe(59); - expect((await tool.createZones(chapterSet, [0, 1], 24)).zones).toMatch(/0,/); - - const first = mplsGroup("first", 10); - const second = mplsGroup("second", 20); - expect((await tool.combine(first)).chapterSet.chapters).toHaveLength(1); - expect((await tool.append(first, second)).chapterSet.chapters).toHaveLength(2); - }); - - it("exposes Core frame and expression operations", async () => { - const tool = new ChapterTool(); - const chapterSet = await sample(tool); - - const frameRates = await tool.frameRates(); - expect(frameRates).toHaveLength(8); - expect((await tool.findFrameRate(24)).code).toBe("Fps24"); - expect((await tool.detectFrameRate(chapterSet)).evaluatedChapterCount).toBe(2); - - const framed = await tool.updateFrames(chapterSet, { optionCode: "Fps24" }); - expect(framed.framesPerSecond).toBe(24); - expect(framed.chapters[1].framesInfo).toBe("1440"); - - const changed = await tool.changeFrameRate(framed.chapterSet, 24, 25); - expect(changed.success).toBe(true); - expect(changed.chapterSet.framesPerSecond).toBe(25); - - const expressed = await tool.applyExpression(framed.chapterSet, "t + 1"); - expect(expressed.chapterSet.chapters[0].startTimeSeconds).toBe(1); - - const analysis = await tool.analyzeExpression("t + ", { caretIndex: 4 }); - expect(analysis.diagnostics.length).toBeGreaterThan(0); - // Without caretIndex the default falls back to expression.length. - expect((await tool.analyzeExpression("t + ")).spans.length).toBeGreaterThan(0); - expect((await tool.expressionSymbols()).some(({ text }) => text === "t")).toBe(true); - expect((await tool.expressionPresets()).length).toBeGreaterThan(0); - - const projected = await tool.project(chapterSet, { format: "txt", autoGenerateNames: true }); - expect(projected.outputChapters[0].name).toBe("Chapter 01"); - }); - - it("exposes Core time, conversion, and metadata operations", async () => { - const tool = new ChapterTool(); - const chapterSet = await sample(tool); - - expect((await tool.parseTime("00:00:01.500")).seconds).toBe(1.5); - expect(await tool.parseTimeOrZero("invalid")).toBe(0); - expect(await tool.formatTime(1.5)).toBe("00:00:01.500"); - expect(await tool.formatCueTime(60)).toBe("01:00:00"); - - const celltimes = await tool.toCelltimes(chapterSet, 24); - expect(celltimes.success).toBe(true); - expect(celltimes.content).toMatch(/1440/); - - const qpfile = await tool.chapterTextToQpfile(chapterText, 24); - expect(qpfile.success).toBe(true); - expect(qpfile.content).toMatch(/1440 I/); - - expect((await tool.xmlLanguages()).some(({ code }) => code === "und")).toBe(true); - expect((await tool.outputEncodings()).some(({ id }) => id === "utf8")).toBe(true); - const importFormats = await tool.importFormats(); - expect(importFormats.find(({ code }) => code === "hddvd-xpl")!.displayName).toBe("HD-DVD XPL"); - expect(importFormats.some(({ code }) => code === "media" || code === "bdmv")).toBe(false); - expect(await tool.isBinaryExtension(".mpls")).toBe(true); - expect(await tool.isBinaryExtension(".xpl")).toBe(false); + expect("exact" in tool).toBe(false); + expect("export" in tool).toBe(false); + expect(typeof tool.serialize).toBe("function"); + expect(typeof tool.applyExpression).toBe("function"); }); - it("imports the pure Core XPL format", async () => { + it("serializes transformed snapshots without applying the transform again", async () => { const tool = new ChapterTool(); - const content = await readFile(new URL("../../../tests/ChapterTool.Core.Tests/Fixtures/Importing/Disc/Xpl/VPLST001.XPL", import.meta.url)); - - const imported = await tool.import(content, { fileName: "VPLST001.XPL" }); - - expect(imported.success).toBe(true); - expect(imported.groups[0].entries[0].chapterSet.importFormat).toBe("HdDvdXpl"); - expect(imported.groups[0].entries[0].chapterSet.chapters).toHaveLength(29); + const imported = await tool.import(chapterText, { fileName: "core-api.txt" }); + const chapterSet = imported.groups[0].entries[0].chapterSet; + const originalTicks = chapterSet.chapters[0].startTicks; + + const transformed = await tool.applyExpression(chapterSet, true, "t + 1", rate); + const serialized = await tool.serialize(transformed.chapterSet!, { format: "timecodes" }); + const repeated = await tool.serialize(transformed.chapterSet!, { format: "timecodes" }); + + expect(serialized.success).toBe(true); + expect(serialized.content).toBe("00:00:01.000"); + expect(repeated.content).toBe(serialized.content); + expect(chapterSet.chapters[0].startTicks).toBe(originalTicks); + expect(transformed.chapterSet!.chapters[0].startTicks).toBe("10000000"); }); }); diff --git a/packages/chaptertool/test/exact-api.test.ts b/packages/chaptertool/test/exact-api.test.ts new file mode 100644 index 00000000..7dbc328b --- /dev/null +++ b/packages/chaptertool/test/exact-api.test.ts @@ -0,0 +1,142 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { ChapterTool } from "@chaptertool/node"; +import type { ExactChapterSet, ExactFrameRate, ExactImportGroup } from "@chaptertool/node"; + +const rational: ExactFrameRate = { kind: "rational", numerator: "48000", denominator: "2002" }; +const approximate: ExactFrameRate = { kind: "approximate", value: "23.976" }; +const unknown: ExactFrameRate = { kind: "unknown" }; +const tool = new ChapterTool(); +function sample(rate: ExactFrameRate = rational, duration: string | null = "100000000"): ExactChapterSet { + return { + title: "Exact", sourceName: null, importFormat: "Mpls", frameRate: rate, durationTicks: duration, + chapters: [{ displayNumber: 1, startTicks: "0", endTicks: null, name: "A", framesInfo: "", frameAccuracy: "Neutral", kind: "Marker" }], + }; +} +function group(set: ExactChapterSet): ExactImportGroup { + return { sourcePath: "source.mpls", defaultEntryIndex: 0, entries: [ + { id: "one", displayName: "One", chapterSet: set, canCombine: true, referencedMediaFiles: null }, + ] }; +} + +describe("packaged exact facade", () => { + it("imports rational IFO metadata and unknown text without numeric downgrade", async () => { + const bytes = await readFile(new URL("../../../tests/ChapterTool.Core.Tests/Fixtures/Importing/Disc/Ifo/VTS_05_0.IFO", import.meta.url)); + const imported = await tool.import(bytes, { fileName: "VTS_05_0.IFO" }); + expect(imported.success).toBe(true); + const set = imported.groups[0].entries[0].chapterSet; + expect(set.frameRate.kind).toBe("rational"); + const updated = await tool.editFrame(set, 0, "1.5"); + expect(updated.success).toBe(true); + expect(updated.chapterSet!.frameRate).toEqual(set.frameRate); + expect(BigInt(updated.chapterSet!.chapters[0].startTicks)).toBeGreaterThan(0n); + expect((await tool.serialize(updated.chapterSet!, { format: "qpf" })).success).toBe(true); + const text = await tool.import("CHAPTER01=00:00:00.000\nCHAPTER01NAME=A\n"); + expect(text.groups[0].entries[0].chapterSet.frameRate).toEqual(unknown); + }); + + it("reduces ratios and preserves tick strings above the JS safe-integer limit", async () => { + const set = sample(rational, null); + set.chapters[0].startTicks = "9007199254740993"; + set.chapters[0].endTicks = "9007199254741093"; + const renamed = await tool.rename(set, 0, "B"); + expect(renamed.success).toBe(true); + expect(renamed.chapterSet!.frameRate).toEqual({ kind: "rational", numerator: "24000", denominator: "1001" }); + expect(renamed.chapterSet!.durationTicks).toBeNull(); + expect(renamed.chapterSet!.chapters[0].startTicks).toBe(set.chapters[0].startTicks); + expect(renamed.chapterSet!.chapters[0].endTicks).toBe(set.chapters[0].endTicks); + const unchanged = await tool.applyExpression(renamed.chapterSet!, true, "chapter.time"); + expect(unchanged.success).toBe(true); + expect(unchanged.chapterSet!.chapters[0].startTicks).toBe(set.chapters[0].startTicks); + }); + + it("preserves huge rational components as strings and only exposes safe Lua numeric components", async () => { + const rate: ExactFrameRate = { kind: "rational", numerator: "9007199254740993", denominator: "1001" }; + const result = await tool.applyExpression(sample(rate), true, + 'fpsNumerator == nil and fpsDenominator == nil and fpsNumeratorText == "9007199254740993" and t or nil'); + expect(result.success).toBe(true); + expect(result.chapterSet!.frameRate).toEqual(rate); + }); + + it.each([null, "0"])("preserves duration availability %s and nullable ends across edits", async (duration) => { + const result = await tool.rename(sample(approximate, duration), 0, "B"); + expect(result.chapterSet!.durationTicks).toBe(duration); + expect(result.chapterSet!.chapters[0].endTicks).toBeNull(); + expect(result.chapterSet!.frameRate).toEqual(approximate); + const changed = await tool.changeFrameRate(result.chapterSet!, approximate, { kind: "rational", numerator: "25", denominator: "1" }); + expect(changed.success).toBe(true); + expect(changed.chapterSet!.durationTicks).toBe(duration); + }); + + it.each([ + {}, { kind: "other" }, { kind: "unknown", value: "0" }, + { kind: "rational", numerator: "1", denominator: "0" }, + { kind: "rational", numerator: "9223372036854775808", denominator: "1" }, + { kind: "approximate", value: "-24" }, { kind: "approximate", value: "1,000" }, + { kind: "approximate", value: "0.00000000000000000000000000001" }, + { kind: "approximate", value: "23.97600000000000000000000000001" }, + ])("rejects malformed rate %j with structured diagnostics", async (payload) => { + const result = await tool.rename(sample(payload as ExactFrameRate), 0, "B"); + expect(result.success).toBe(false); + expect(result.chapterSet).toBeNull(); + expect(result.diagnostics[0].code).toBe("FrameRate.Invalid"); + }); + + it("keeps Auto detection display-only and serializes an explicitly transformed snapshot", async () => { + const set = sample(unknown); + const framed = await tool.updateFrames(set); + expect(framed.success).toBe(true); + expect(framed.selectedOption!.rate.kind).toBe("rational"); + expect(framed.chapterSet!.frameRate).toEqual(unknown); + expect((await tool.serialize(set, { format: "txt" })).success).toBe(true); + expect((await tool.serialize(set, { format: "qpf" })).success).toBe(false); + const options = { format: "qpf", computationRate: rational }; + const transformed = await tool.applyExpression(set, true, "t + 1 / fps", rational); + expect(transformed.success).toBe(true); + const serialized = await tool.serialize(transformed.chapterSet!, options); + expect(serialized.success).toBe(true); + expect(serialized.content).toContain("1 I"); + expect(set.chapters[0].startTicks).toBe("0"); + expect(transformed.chapterSet!.frameRate).toEqual(unknown); + }); + + it("converts both rate kinds and exposes shifts, zones, detection and conversion tools", async () => { + const edited = await tool.editFrame(sample(rational), 0, "240"); + const changed = await tool.changeFrameRate(edited.chapterSet!, rational, approximate); + expect(changed.success).toBe(true); + expect(changed.chapterSet!.frameRate).toEqual(approximate); + const shifted = await tool.shiftFramesForward(changed.chapterSet!, -1); + expect(shifted.success).toBe(true); + expect((await tool.createZones(shifted.chapterSet!, [0])).zones).toContain("--zones"); + expect((await tool.detectFrameRate(shifted.chapterSet!)).success).toBe(true); + expect((await tool.toCelltimes(shifted.chapterSet!)).success).toBe(true); + expect((await tool.frameRates())[1].rate).toEqual({ kind: "rational", numerator: "24000", denominator: "1001" }); + const mapped = await tool.chapterTextToQpfile("CHAPTER01=00:00:00.000\nCHAPTER01NAME=A\n", undefined, + "# timecode format v2\n0\n40\n80\n"); + expect(mapped.success).toBe(true); + }); + + it("rejects scalar mixed-rate combine and append", async () => { + const combined = await tool.combine({ ...group(sample(rational)), entries: [ + ...group(sample(rational)).entries, ...group(sample(approximate)).entries, + ] }); + expect(combined.success).toBe(false); + expect(combined.diagnostics[0].code).toBe("FrameRate.Invalid"); + expect((await tool.append(group(sample(rational)), group(sample(approximate)))).success).toBe(false); + const same = await tool.combine(group(sample(rational, "0"))); + expect(same.success).toBe(true); + expect(same.chapterSet!.durationTicks).toBe("0"); + }); + + it("rejects a combine when an entry duration is unknown", async () => { + const unknownDuration = await tool.combine({ ...group(sample(rational)), entries: [ + ...group(sample(rational)).entries, ...group(sample(rational, null)).entries, + ] }); + expect(unknownDuration.success).toBe(false); + expect(unknownDuration.diagnostics[0].code).toBe("Structure.Invalid"); + + const appended = await tool.append(group(sample(rational)), group(sample(rational, null))); + expect(appended.success).toBe(false); + expect(appended.diagnostics[0].code).toBe("Structure.Invalid"); + }); +}); diff --git a/scripts/README.md b/scripts/README.md index 84ccea04..43c0a04b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,47 +1,206 @@ # Repository Scripts -Each script name identifies one repository task. The file extension identifies the runtime. Platform-specific names identify a required host platform. +These scripts support the repository tasks that users and maintainers run around ChapterTool: verify a change, produce a release artifact, keep generated localization files aligned, and inspect resource or analyzer output. Run them from the repository root unless a script says otherwise. + +The tables below show what each script helps you do and what it needs to run. + +## Script Layout + +Script entry points use lowercase kebab-case names. Their parent directory identifies the task area. + +| Directory | Purpose | Main entry points | +| --- | --- | --- | +| `ci/` | Shared CI commands, planning, release checks, and their tests. | `run.py`, `plan.py`, `verify-nuget.py`, `resolve-run.py` | +| `diagnostics/` | Build diagnostics and resource audits. | `report-analyzers.py`, `audit-ui-resources.py` | +| `localization/` | Generate Wasm locale JSON from shared Avalonia locales. | `axaml-to-json.py` | +| `maintenance/` | Manual repository and asset maintenance. | `normalize-changed-text-files.py`, `generate-app-icons-macos.sh`, `subset-shortcut-font.py` | +| `packaging/` | Publish desktop artifacts. | `publish-desktop.sh`, `publish-desktop.ps1` | +| `testing/` | Coverage collection and package compatibility checks. | `collect-coverage.py`, `check-headless-xunit.py` | + +Keep shared build, test, browser, and packaging commands in `ci/run.py`. GitHub Actions workflows select its stages and step keys. Other directories must not duplicate those commands. ## Verification And Release | Script | Runtime and platform | Main dependencies | Use | | --- | --- | --- | --- | -| `test-coverage.py` | Python 3; cross-platform | Python standard library, `defusedxml` (uv-managed), .NET SDK; optional `reportgenerator` | Build test projects, run their assemblies through VSTest, and collect coverage. | -| `report-analyzers.py` | Python 3; cross-platform | Python standard library, .NET SDK | Build the solution and summarize SARIF diagnostics. | -| `publish.sh` | Bash; Unix-like hosts or Git Bash | .NET SDK | Publish and validate Linux, macOS, or Windows runtime artifacts. macOS bundles require a macOS host. | -| `publish.ps1` | PowerShell 7; Windows | .NET SDK | Publish and validate Windows runtime artifacts. | +| `ci/run.py` | Python 3.9+; cross-platform | See prerequisites below | Run the checks shared with GitHub Actions. | +| `ci/plan.py` | Python 3.9+; cross-platform | Git; Python standard library | Select affected project consumers. Verify required CI job results. | +| `ci/verify-nuget.py` | Python 3.9+; cross-platform | .NET SDK | Install and run packed Core and CLI consumers. Verify release package versions. | +| `ci/resolve-run.py` | Python 3.9+; cross-platform | Git; GitHub CLI | Resolve a successful tag CI run for the checked-out commit. | +| `testing/collect-coverage.py` | Python 3; cross-platform | .NET SDK; uv dependencies; optional `reportgenerator` | Collect test coverage. | +| `diagnostics/report-analyzers.py` | Python 3; cross-platform | .NET SDK | Build the solution and summarize analyzer diagnostics. | +| `packaging/publish-desktop.sh` | Bash; Unix-like hosts or Git Bash | .NET SDK | Publish and validate desktop artifacts. macOS bundles require macOS. | +| `packaging/publish-desktop.ps1` | PowerShell 7; Windows | .NET SDK | Publish and validate Windows artifacts. | + +`ci/run.py` owns build, test, browser, and packaging commands. CI workflows must select its stages and step keys. Publish jobs use the tested CI artifacts. + +### Check a change before pushing + +Run from the repository root: + +```powershell +python scripts/ci/run.py +``` + +The script uses `CI=true`, Release output, and locked Python and npm dependencies. Local checks run sequentially. The script stops at the first failed check and returns a nonzero exit code. + +The default checks run in this order: + +1. Check generated translations and Python lint. Run CI script tests. Validate PowerShell publish syntax. +2. Restore and build `ChapterTool.slnx`. Pack both NuGet packages. Install and run isolated package consumers. Run all .NET test projects in separate processes. +3. Build the Node.js WASM package. Run type checks and built-package tests. +4. Publish browser WASM. Prepare the `/ChapterTool/` site. Run Chromium workflows and WebKit modal, editing, and layout regressions. +5. Pack the npm tarball. Install it in a temporary consumer and run its Core API. +6. Publish and validate desktop artifacts for the current host. + +The script does not regenerate translations. Browser reports use `artifacts/wasm-e2e/`. Packages use `artifacts/nuget/`, `artifacts/cli-nuget/`, `artifacts/npm/`, and `artifacts/publish/`. + +### Prerequisites + +The full local gate requires Python 3.9+, .NET SDK 10.x, Node.js 22.x, uv, PowerShell 7, ffmpeg, and mkvtoolnix. These tools must be on `PATH`. Selected stages require only their own tools. Infrastructure and Avalonia service tests require real media tools. + +On Windows, the script also finds MKVToolNix under `ProgramFiles`, `ProgramFiles(x86)`, and `LOCALAPPDATA`. It changes only the child-process `PATH`. Linux cross-publishing on Windows requires Git Bash. DMG creation requires macOS. + +The full solution and Node package builds require the native WASM workload: + +```powershell +dotnet workload install wasm-tools +``` + +Isolated .NET test projects and browser publishing do not require this workload. The Python sync uses `--locked --no-build --no-install-project`. Linux browser checks may use `--install-browser-deps` to install system packages. Missing tools fail the selected check. + +### Run selected checks + +```powershell +# Print commands without running them. +python scripts/ci/run.py --plan + +# Restore, build, and test one solution test project. +python scripts/ci/run.py --stage test-dotnet --test-project ChapterTool.Core.Tests + +# Run one prepared check. +python scripts/ci/run.py --stage test-dotnet --test-project ChapterTool.Core.Tests --step run-tests + +# Build packages and run installed consumers. +python scripts/ci/run.py --stage pack-nuget + +# Build packages with one release version. +python scripts/ci/run.py --stage pack-nuget --package-version 23.3.2-rc.1 + +python scripts/ci/run.py --stage resources +python scripts/ci/run.py --stage node +python scripts/ci/run.py --stage browser +python scripts/ci/run.py --stage browser --browser-suite full --browser-engine webkit +python scripts/ci/run.py --stage pack-desktop --runtime win-x64 +``` + +`--step` runs only the selected keys. It does not run prerequisites. Unknown keys fail before execution. `--stage pack-node` requires the `dist` output from `node` or `build-test`. + +### CI scheduling + +`.NET 10 CI` starts for every push and pull request. It also supports a weekly schedule and manual dispatch. The planning job reads `ChapterTool.slnx` and project references. It maps changed paths to test and host consumers. Shared Core fixtures also select the browser and Node consumers. Renames include both paths. Unknown inputs, CI scripts, and shared build configuration select all checks. -The CI workflow calls `axaml-to-json.py --check` and `publish.sh` directly. Release jobs use the same publish entry point. `publish.ps1` remains the Windows-native entry point for local release work and for PowerShell validation in CI. +| Event or change | Checks | +| --- | --- | +| Documentation only | Planning and `CI ready`. | +| Pull request or feature branch | Affected tests, resources, browser, Node, and NuGet checks. | +| Desktop dependency project files or shared publish configuration | Desktop runtime matrix. | +| Non-documentation push to `master` | All consumers and distribution artifacts. | +| Version tag, weekly schedule, or manual dispatch | Full solution build, all consumers, all artifacts, and three browser engines. | + +Each .NET matrix job restores and builds one test project. Headless uses its own process and checkout. A failure does not cancel the other test projects. Only the Infrastructure job installs media tools. Native WASM tools belong to the Node and full solution jobs. + +`CI ready` runs after the selected jobs. It must reject failed, canceled, missing, or unexpectedly skipped required checks. Branch protection can require this fixed check name. + +The reusable browser workflow publishes one Release site. Chromium runs the behavior suite. WebKit runs modal, editing, and layout regressions. Full acceptance adds Firefox and runs the complete suite in each engine. Tests use zero retries. CI stops after three failures or ten minutes per browser suite. Each engine uploads its diagnostics. + +Browser publishing sets `WasmBuildNative=false` and `WasmRunWasmOpt=false`. A locally installed native workload must not change the browser artifact. The publish step clears its own output directory before building. + +### Distribution and deployment + +The NuGet job builds and packs both packages with the same tag version. It installs Core in a temporary .NET consumer. It installs the CLI tool and runs `--help`. Package source mapping and a fresh package cache ensure these consumers use the local artifacts. + +The NuGet and npm publishers resolve a successful tag push for the exact checked-out SHA. They download its artifacts. They do not rebuild packages. GitHub Release downloads only the six named distribution artifacts. Browser reports and test diagnostics are not release attachments. + +Pages deployment runs after `CI ready` on `master` pushes and manual CI runs. The browser job builds the Core API site, adds it under `/docs/` beside the WASM app, and uploads the same `wasm-prepared-site` tested by the browser jobs. To deploy manually, dispatch `.NET 10 CI` on `master`. + +### Layout verification and platform limits + +Browser E2E and Avalonia Headless tests verify layout and interaction behavior. E2E checks include responsive geometry, focus, real clicks, and long-content scrolling. Screenshots support failure diagnosis or manual review. No pixel baseline generation or comparison is maintained. + +`specs/expression-preview-screenshots.spec.ts` always checks English and Chinese layout behavior. Set `E2E_CAPTURE_REVIEW=1` when manual review images are needed. The images use `artifacts/expression-preview/`. + +Local checks cannot establish GitHub upload or deployment permissions. Linux filename behavior requires Linux. DMG creation requires macOS. Physical mobile keyboard behavior requires a device check. + +### Evidence from recent failures + +- Run [37161159868](https://github.com/tautcony/ChapterTool/actions/runs/37161159868) failed translation verification and WASM site preparation. Commits `aab34ce`, `9255568`, and `686d7d2` corrected generated resources, repository-relative paths, and the execution directory. +- Runs [33588611328](https://github.com/tautcony/ChapterTool/actions/runs/33588611328) and [33594442570](https://github.com/tautcony/ChapterTool/actions/runs/33594442570) also failed translation verification. +- Run [37167108459](https://github.com/tautcony/ChapterTool/actions/runs/37167108459) failed the .NET test step. A successful test run for one project does not cover the full solution. +- Commit `dc666da` corrected platform-specific newline expectations. Commits `5997fb3` and `1c58b1b` corrected filename extraction across platforms. + +The shared entry point checks resources and workflows before a push. Platform CI jobs also verify operating system behavior. Keep shared build, test, and packaging commands in `ci/run.py`. CI workflows must select those stages and step keys instead of duplicating commands. ## Repository Maintenance | Script | Runtime and platform | Main dependencies | Use | | --- | --- | --- | --- | -| `axaml-to-json.py` | Python 3; cross-platform | Python standard library | Generate Wasm locale JSON files from Avalonia AXAML files. Use `--check` to detect drift without writing files. | -| `audit-ui-resources.py` | Python 3; cross-platform | Python standard library | Audit Avalonia resource definitions and references. | -| `normalize-changed-text-files.py` | Python 3; cross-platform | Python standard library, Git | Normalize line endings and UTF-8 BOMs in changed text files. Use `--what-if` for a read-only check. | -| `generate-app-icons-macos.sh` | Bash; macOS only | ImageMagick, `iconutil` | Generate ICNS and ICO files from the SVG icon source. | +| `localization/axaml-to-json.py` | Python 3; cross-platform | Python standard library | Generate Wasm locale JSON files from Avalonia AXAML files. Use `--check` to detect drift without writing files. | +| `diagnostics/audit-ui-resources.py` | Python 3; cross-platform | Python standard library | Audit Avalonia resource definitions against repository references and the pinned Fluent source inventory. | +| `diagnostics/update-avalonia-fluent-resource-references.py` | Python 3; cross-platform | Python standard library and network access | Read NuGet metadata and scan its matching Avalonia source commit. Update the full Fluent resource-reference manifest. | +| `maintenance/normalize-changed-text-files.py` | Python 3; cross-platform | Python standard library, Git | Normalize line endings and UTF-8 BOMs in changed text files. Use `--what-if` for a read-only check. | +| `maintenance/generate-app-icons-macos.sh` | Bash; macOS only | ImageMagick, `iconutil` | Generate ICNS and ICO files from the SVG icon source. | +| `maintenance/subset-shortcut-font.py` | Python 3; cross-platform | FontTools (`pyftsubset`) | Keep only the ASCII gesture text and platform-neutral shortcut symbols in the bundled Avalonia font. | + +Maintenance scripts are manual entry points. They do not run as hidden build steps. + +The audit stops if the Avalonia Fluent package version differs from the manifest. + +Run `python3 scripts/diagnostics/update-avalonia-fluent-resource-references.py` after changing the pinned package version. Use `--check` to compare the generated inventory with the committed file. + +### Subset the Avalonia shortcut font + +Run this command from the repository root after changing shortcut display text: + +```bash +uv run --project scripts subset-shortcut-font +``` -Maintenance scripts are manual entry points. They do not run as hidden build steps. Run them from the repository root, except when a script documents another working directory. +The script invokes `pyftsubset` with a UTF-8 character file, preserves the +`vert`, `vrtr`, `vrt2`, and `vkna` layout features, and writes the result to +`src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf` from the +original font at `src/ChapterTool.Avalonia.UI/Assets/Fonts/source/`. Use +`--check` to verify that the checked-in font is current. Use `--extra-text` +when a new shortcut symbol is added. + +The equivalent verification commands are `uv run --project scripts +check-shortcut-font` and `uv run --project scripts lint-scripts`. -`audit-ui-resources.py` reports unqualified PascalCase keys as possible framework-template resources and reports namespaced application keys separately. Avalonia Fluent has many implicit resource families, including buttons, check boxes, combo boxes, text boxes, calendars, menus, flyouts, sliders, tabs, and scroll bars. The structural rule avoids a fixed list, but it is not proof of runtime use. Confirm possible framework keys against the active theme and review application candidates before removal. ## Configuration | Script | Runtime and platform | Main dependencies | Use | | --- | --- | --- | --- | -| `coverage.runsettings` | .NET test configuration | Coverlet | Configure coverage collection. | - -The two publish scripts are separate entry points because macOS bundle creation and Windows publishing use different platform APIs. Their output layout and option names must remain aligned. - -Each publish script rejects debug symbols and development diagnostics. Single-file publication also rejects duplicate top-level assemblies. +| `testing/coverage.runsettings` | .NET test configuration | Coverlet | Configure coverage collection. | The scripts under `packages/chaptertool/scripts/` belong to the npm package. They use Node.js modules and are invoked through the package scripts in `packages/chaptertool/package.json`. Run Python scripts with Python 3. Run Bash scripts with Bash. Run PowerShell scripts with PowerShell 7 (`pwsh`). -`scripts/pyproject.toml` pins Python dependencies for scripts that need third-party packages (`test-coverage.py`, `axaml-to-json.py`). Install them once with `uv sync --project scripts`, then run the script through `uv run --project scripts scripts/.py ...` so the virtual environment is used. CI installs `uv` and invokes `axaml-to-json.py` through `uv run`. +`scripts/pyproject.toml` pins Python dependencies for scripts that need third-party packages (`testing/collect-coverage.py`, `localization/axaml-to-json.py`). Install them once with `uv sync --project scripts`, then run the script through `uv run --project scripts scripts//.py ...` so the virtual environment is used. CI installs `uv` and invokes `localization/axaml-to-json.py` through `uv run`. -When `test-coverage.py` needs an HTML report and `reportgenerator` is missing, it asks before coverage starts whether it should install the global tool. Non-interactive runs skip HTML generation unless the tool is already available. Use `-SkipHtml` to intentionally produce XML only. +Use `-SkipHtml` with `collect-coverage.py` when you only need XML coverage output. `ruff` is a dev dependency of the same environment. Run `uv run --project scripts ruff check scripts/` to lint the Python scripts. Rule `C901` keeps function cyclomatic complexity under 16. This matches the `CA1502: 16` threshold in `CodeMetricsConfig.txt`. Rules `E701` through `E703` require one statement per line. CI runs the same check. + +### Check Avalonia Headless xUnit compatibility + +Run this command to inspect the latest stable `Avalonia.Headless.XUnit` package +and its declared xUnit extensibility dependency: + +```bash +python3 scripts/testing/check-headless-xunit.py \ + --project tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj +``` + +Use `--version ` to inspect a specific NuGet version. diff --git a/scripts/audit-ui-resources.py b/scripts/audit-ui-resources.py deleted file mode 100755 index 759bdff5..00000000 --- a/scripts/audit-ui-resources.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -"""Audit Avalonia resource definitions and references.""" -import argparse -import re -from pathlib import Path - -SYSTEM_PREFIXES = ("System", "AccentButton", "Theme", "ContentControl", "TextControl", "ComboBox", "CheckBox", "RadioButton", "Slider", "TabView", "ScrollBar", "DataGrid", "Flyout", "MenuFlyout", "ToolTip", "ButtonBackground", "ButtonForeground", "ButtonBorder", "ButtonPadding") -FRAMEWORK_KEYS = {"MenuInputGestureTextMargin", "OverlayCornerRadius", "TooltipDataValidationErrors"} -# Fluent template resources use unqualified PascalCase keys. Application -# resources in this repository use a dotted namespace such as ChapterTool.* or -# Brush.*. Keep this structural rule broad so new Fluent controls do not need a -# maintained key list. Results are reported as possible framework keys, not as -# proof of runtime reachability. -IMPLICIT_FRAMEWORK_PATTERN = re.compile(r"^[A-Z][A-Za-z0-9]*$") -KEY = re.compile(r'x:Key\s*=\s*"([^"]+)"') -REF = re.compile(r"\{(?:DynamicResource|StaticResource)\s+([^}\s,]+)") -IMPORTED = re.compile(r"ImportedThemeColorKeys\s*\{\s*get;\s*\}\s*=\s*\[(.*?)\];", re.S) -QUOTED = re.compile(r'"([^"]+)"') -CONST = re.compile(r'public const string \w+ = "([^"]+)";') - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo-root", type=Path) - root = (parser.parse_args().repo_root or Path(__file__).resolve().parents[1]).resolve() - src = root / "src" - definitions = [src / "ChapterTool.Avalonia.UI/Resources" / name for name in ("Themes.axaml", "SharedResources.axaml", "Styles.axaml")] - service = src / "ChapterTool.Avalonia/Services/AvaloniaThemeApplicationService.cs" - for path in [*definitions, service]: - if not path.is_file(): - raise FileNotFoundError(f"file not found: {path}") - defined, referenced, local, service_keys = set(), set(), set(), set() - definition_sources = {} - for path in definitions: - text = path.read_text(encoding="utf-8") - for match in KEY.finditer(text): - key = match.group(1) - if key not in {"Light", "Dark"}: - defined.add(key) - definition_sources.setdefault(key, path.relative_to(root).as_posix()) - for block in re.findall(r"(.*?)", text, re.S): - referenced.update(m.group(1) for m in KEY.finditer(block)) - text = service.read_text(encoding="utf-8") - imported = IMPORTED.search(text) - if imported: - service_keys.update(m.group(1) for m in QUOTED.finditer(imported.group(1))) - service_keys.update(m.group(1) for m in CONST.finditer(text)) - defined.update(service_keys) - for key in service_keys: - definition_sources.setdefault(key, service.relative_to(root).as_posix()) - for path in src.rglob("*.axaml"): - text = path.read_text(encoding="utf-8") - local.update(m.group(1) for m in KEY.finditer(text)) - referenced.update(m.group(1) for m in REF.finditer(text)) - for path in src.rglob("*.cs"): - if path.resolve() == service.resolve(): - continue - referenced.update(m.group(1) for m in QUOTED.finditer(path.read_text(encoding="utf-8")) if m.group(1) in defined) - unresolved = sorted(k for k in referenced if k not in defined and k not in local and k not in FRAMEWORK_KEYS and not any(k.startswith(p) for p in SYSTEM_PREFIXES)) - unconsumed = sorted(defined - referenced) - implicit = sorted(k for k in unconsumed if IMPLICIT_FRAMEWORK_PATTERN.fullmatch(k)) - explicit_candidates = sorted(set(unconsumed) - set(implicit)) - print("UI resource audit") - print(f"Repo: {root}\nDefined keys: {len(defined)}\nReferenced keys: {len(referenced)}\nService-written keys: {len(service_keys)}\n") - print(f"Unresolved references ({len(unresolved)}):\n" + (" (none)" if not unresolved else "\n".join(f" {k}" for k in unresolved))) - print(f"\nPossible framework-template definitions ({len(implicit)}):") - if implicit: - print("\n".join(f" {key} ({definition_sources.get(key, '')})" for key in implicit)) - else: - print(" (none)") - print("These unqualified keys may be resolved by Avalonia Fluent templates or other framework resources. Confirm against the active theme before removal.") - print(f"\nApplication definitions without a detected consumer ({len(explicit_candidates)}):") - if explicit_candidates: - print("\n".join(f" {key} ({definition_sources.get(key, '')})" for key in explicit_candidates)) - else: - print(" (none)") - print("Review these candidates before removal. Static analysis cannot prove that a resource is unreachable at runtime.") - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/consumers/core-frame-rate.cs b/scripts/ci/consumers/core-frame-rate.cs new file mode 100644 index 00000000..4b63b83b --- /dev/null +++ b/scripts/ci/consumers/core-frame-rate.cs @@ -0,0 +1,54 @@ +using ChapterTool.Core.Editing; +using ChapterTool.Core.Exporting; +using ChapterTool.Core.Models; +using ChapterTool.Core.Transform; +using ChapterTool.Core.Transform.Expressions; + +namespace ChapterTool.Ci.Consumers; + +internal static class Program +{ + private static void Main() + { + var formatter = new ChapterTimeFormatter(); + Require(formatter.Format(formatter.ParseOrZero("00:00:12.500")) == "00:00:12.500"); + + var chapter = new Chapter(1, TimeSpan.FromTicks(9007199254740993), "External"); + var rate = FrameRateValue.Rational(24000, 1001); + var info = new ChapterSet("External", null, ChapterImportFormat.Ogm, rate, TimeSpan.Zero, [chapter]); + var (title, source, format, frameRate, duration, chapters) = info; + Require(title == "External" && source is null && format == ChapterImportFormat.Ogm + && frameRate == rate && duration == TimeSpan.Zero && chapters.Count == 1); + + var editing = new ChapterEditingService(formatter); + Require(editing.EditFrame(info, index: 0, text: "24", framesPerSecond: rate).Diagnostics.Count == 0); + var changed = ChapterFpsTransformService.ChangeFps(info, sourceFps: rate, + targetFps: FrameRateValue.Approximate(25m)); + Require(changed.Success && changed.Info.FrameRate == FrameRateValue.Approximate(25m)); + + var catalog = new FrameRateService(); + var option = catalog.FindByCode("Fps23976"); + var (code, displayName, optionRate, valid, mplsCode) = option; + Require(code == "Fps23976" && displayName.Length > 0 && optionRate == rate && valid && mplsCode == 1); + var frameResult = catalog.UpdateFrames(info, option, round: true, tolerance: 0.01m); + var (_, displayedChapters, selectedOption, accuracy) = frameResult; + Require(displayedChapters.Count == 1 && selectedOption.Rate == rate && accuracy.Count == 1); + + var context = new ChapterExpressionContext(chapter, Index: 1, Count: 1, + TimeSeconds: chapter.StartTime.Ticks / (decimal)TimeSpan.TicksPerSecond, + FramesPerSecond: rate.ToDecimal(), Chapters: [chapter]); + Require(context.FramesPerSecond > 23 && context.Chapters.Count == 1); + + var serializer = new ChapterExportService(formatter); + var unknown = info with { FrameRate = FrameRateValue.Unknown }; + Require(serializer.Serialize(unknown, new ChapterSerializationOptions(ChapterExportFormat.Txt)).Success); + Require(serializer.Serialize(unknown, new ChapterSerializationOptions(ChapterExportFormat.Qpfile) + { ComputationRate = FrameRateValue.Rational(24, 1) }).Success); + Console.WriteLine("Installed canonical Core frame-rate contracts passed."); + } + + private static void Require(bool condition) + { + if (!condition) throw new Exception("Installed Core frame-rate contract failed."); + } +} diff --git a/scripts/ci/plan.py b/scripts/ci/plan.py new file mode 100644 index 00000000..6769f61e --- /dev/null +++ b/scripts/ci/plan.py @@ -0,0 +1,148 @@ +"""Select CI consumers from changed paths and verify their final job results.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[2] +LOCALES = "src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/" +JOBS = ("resources", "dotnet-tests", "solution-build", "wasm-browser-e2e", "pack-nuget", "pack-nodejs", "pack-dotnet") + + +def project_graph(root: Path) -> dict[str, set[str]]: + paths = [p.attrib["Path"].replace("\\", "/") for p in ET.parse(root / "ChapterTool.slnx").iter("Project")] # nosec B314 + graph = {} + for path in paths: + file = root / path + references = ET.parse(file).iter("ProjectReference") # nosec B314 + graph[path] = { + (file.parent / p.attrib["Include"].replace("\\", "/")).resolve().relative_to(root.resolve()).as_posix() + for p in references + } + for references in list(graph.values()): + if not references <= graph.keys(): + raise ValueError("CI project references must be present in ChapterTool.slnx.") + return graph + + +def dependencies(project: str, graph: dict[str, set[str]]) -> set[str]: + found = set() + pending = [project] + while pending: + current = pending.pop() + if current not in found: + found.add(current) + pending.extend(graph[current]) + return found + + +def documentation(path: str) -> bool: + return path.startswith(("docs/", "openspec/", ".agents/", ".codex/", ".codegraph/", "libbluray/")) or ( + "/" not in path and (path.endswith(".md") or path.startswith("LICENSE")) + ) + + +def select_jobs(root: Path, changed: list[str], full: bool = False, master: bool = False) -> dict: + graph = project_graph(root) + directories = {path: path.rsplit("/", 1)[0] + "/" for path in graph} + substantive = [path for path in changed if not documentation(path)] + known = (*directories.values(), "tests/ChapterTool.Wasm.E2E/", "packages/chaptertool/", LOCALES) + # Unknown inputs, shared build configuration, and CI edits use the full gate. + shared = any(not path.startswith(known) for path in substantive) + # Browser, Node, and other .NET suites load Core fixtures outside ProjectReference. + shared_fixtures = any(path.startswith("tests/ChapterTool.Core.Tests/Fixtures/") for path in substantive) + all_consumers = full or shared or shared_fixtures or (master and bool(substantive)) + affected = {project for project, directory in directories.items() if any(path.startswith(directory) for path in substantive)} + + def consumes(project: str) -> bool: + return all_consumers or bool(dependencies(project, graph) & affected) + + tests = [project for project in graph if project.startswith("tests/") and project.endswith(".Tests.csproj") and consumes(project)] + browser = consumes("src/ChapterTool.Wasm/ChapterTool.Wasm.csproj") or any( + path.startswith(("tests/ChapterTool.Wasm.E2E/", LOCALES)) for path in substantive + ) + node = consumes("src/ChapterTool.Node/ChapterTool.Node.csproj") or any(path.startswith("packages/chaptertool/") for path in substantive) + nuget = any(consumes(f"src/{name}/{name}.csproj") for name in ("ChapterTool.Core", "ChapterTool.CommandLine")) + desktop_projects = dependencies("src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj", graph) + desktop = full or (master and bool(substantive)) or shared or any(path in desktop_projects for path in substantive) + selected = dict(zip(JOBS, ( + all_consumers or any(path.startswith(LOCALES) for path in substantive), + bool(tests), full or shared, browser, nuget, node, desktop, + ))) + return { + "required": [job for job in JOBS if selected[job]], + "jobs": selected, + "tests": {"include": [{ + "project": Path(project).stem, + "label": Path(project).stem.removeprefix("ChapterTool.").removesuffix(".Tests"), + "media": Path(project).stem in ("ChapterTool.Infrastructure.Tests", "ChapterTool.Avalonia.Tests"), + } for project in tests]}, + } + + +def changed_paths(root: Path, event_name: str, event: dict) -> list[str]: + if event_name == "pull_request": + base = event["pull_request"]["base"]["sha"] + head = event["pull_request"]["head"]["sha"] + revision = f"{base}...{head}" + elif event_name == "push" and event.get("before", "").strip("0"): + revisions = [event["before"], event["after"]] + else: + # New branches and missing comparison commits must not reduce coverage. + return ["global.json"] + if event_name == "pull_request": + revisions = [revision] + args = ["git", "diff", "--name-only", "--no-renames", "-z", *revisions, "--"] + try: + result = subprocess.check_output(args, cwd=root, stderr=subprocess.PIPE) + except subprocess.CalledProcessError: + # Force pushes can make the previous commit unavailable in a fresh checkout. + return ["global.json"] + return [path for path in result.decode("utf-8").split("\0") if path] + + +def verify_jobs(plan: dict, needs: dict) -> None: + if needs.get("plan", {}).get("result") != "success": + raise ValueError("CI planning did not succeed.") + for job in JOBS: + result = needs.get(job, {}).get("result") + allowed = {"success"} if job in plan["required"] else {"skipped"} + if result not in allowed: + raise ValueError(f"CI job {job}: expected {sorted(allowed)}, received {result}.") + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--verify", action="store_true") + options = parser.parse_args(arguments) + try: + if options.verify: + verify_jobs(json.loads(os.environ["CI_PLAN"]), json.loads(os.environ["CI_NEEDS"])) + print("All required CI jobs passed.") + return 0 + event_name = os.environ["GITHUB_EVENT_NAME"] + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + full = event_name in ("schedule", "workflow_dispatch") or os.environ["GITHUB_REF"].startswith("refs/tags/") + changed = [] if full else changed_paths(ROOT, event_name, event) + plan = select_jobs(ROOT, changed, full, os.environ["GITHUB_REF"] == "refs/heads/master") + print(json.dumps(plan, indent=2)) + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"plan={json.dumps(plan, separators=(',', ':'))}\n") + output.write(f"tests={json.dumps(plan['tests'], separators=(',', ':'))}\n") + for job, selected in plan["jobs"].items(): + output.write(f"{job}={str(selected).lower()}\n") + except (KeyError, OSError, ValueError, subprocess.SubprocessError) as error: + print(f"CI planning failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/resolve-run.py b/scripts/ci/resolve-run.py new file mode 100644 index 00000000..16df93d7 --- /dev/null +++ b/scripts/ci/resolve-run.py @@ -0,0 +1,65 @@ +"""Resolve a successful tag CI run for the exact checked-out release commit.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] + + +def gh_json(*arguments: str): + return json.loads(subprocess.check_output(["gh", *arguments], cwd=ROOT, text=True, encoding="utf-8", timeout=60)) + + +def validate_run(run: dict, repository: str, tag: str, sha: str) -> None: + expected = {"event": "push", "status": "completed", "conclusion": "success", "head_branch": tag, "head_sha": sha} + if any(run.get(key) != value for key, value in expected.items()): + raise ValueError("Release CI must be a successful tag push for the checked-out commit.") + if run.get("head_repository", {}).get("full_name") != repository or run.get("repository", {}).get("full_name") != repository: + raise ValueError("Release CI must belong to this repository.") + if run.get("path", "").split("@", 1)[0] != ".github/workflows/dotnet-ci.yml": + raise ValueError("Release artifacts must come from dotnet-ci.yml.") + + +def resolve_run(repository: str, tag: str, sha: str, run_id: str | None = None) -> int: + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", tag): + raise ValueError("Expected a version tag such as v23.3.2 or v23.3.2-rc.1.") + if not run_id: + candidates = gh_json("run", "list", "--repo", repository, "--workflow", "dotnet-ci.yml", "--branch", tag, + "--commit", sha, "--event", "push", "--status", "success", "--limit", "100", "--json", "databaseId") + if not candidates: + raise ValueError(f"No successful tag CI run found for {tag} at {sha}.") + run_id = str(candidates[0]["databaseId"]) + if not run_id.isdecimal(): + raise ValueError("CI run ID must be an integer.") + run = gh_json("api", f"repos/{repository}/actions/runs/{run_id}") + validate_run(run, repository, tag, sha) + return int(run_id) + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--run-id", default=os.environ.get("TRIGGER_RUN_ID") or None) + options = parser.parse_args(arguments) + try: + sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + run_id = resolve_run(os.environ["GITHUB_REPOSITORY"], options.tag, sha, options.run_id) + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"run-id={run_id}\n") + print(f"Using tested CI artifacts from run {run_id} ({options.tag}, {sha}).") + except (KeyError, OSError, ValueError, subprocess.SubprocessError) as error: + print(f"Release CI resolution failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/run.py b/scripts/ci/run.py new file mode 100644 index 00000000..e715b985 --- /dev/null +++ b/scripts/ci/run.py @@ -0,0 +1,296 @@ +"""Run the host checks shared by local development and GitHub Actions.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[2] +NODE = "packages/chaptertool" +E2E = "tests/ChapterTool.Wasm.E2E" +STAGES = ("resources", "dotnet", "test-dotnet", "pack-nuget", "node", "build-test", "browser", "pack-node", "pack-desktop") +DEFAULT_STAGES = ("resources", "dotnet", "node", "browser", "pack-node", "pack-desktop") + + +@dataclass +class Step: + name: str + args: list[str] + cwd: str = "." + env: dict[str, str] = field(default_factory=dict) + + @property + def key(self) -> str: + return re.sub(r"[^a-z0-9]+", "-", self.name.lower()).strip("-") + + +def dotnet_tests(root: Path) -> list[str]: + projects = ET.parse(root / "ChapterTool.slnx").iter("Project") # nosec B314 + return [p.attrib["Path"].replace("\\", "/") for p in projects + if p.attrib["Path"].replace("\\", "/").startswith("tests/") + and p.attrib["Path"].endswith(".Tests.csproj")] + + +def resource_steps() -> list[Step]: + return [ + Step("Sync locked Python tools", ["uv", "sync", "--locked", "--no-build", "--no-install-project", "--project", "scripts"]), + Step("Verify localization resources", ["uv", "run", "--no-sync", "--project", "scripts", "python", "scripts/localization/axaml-to-json.py", "--check"]), + Step("Audit UI resources", ["uv", "run", "--no-sync", "--project", "scripts", "python", "scripts/diagnostics/audit-ui-resources.py"]), + Step("Lint Python scripts", ["uv", "run", "--no-sync", "--project", "scripts", "ruff", "check", "scripts/"]), + Step("Test CI runner", [sys.executable, "-m", "unittest", "discover", "-s", "scripts/ci/tests", "-v"]), + Step("Validate PowerShell publish script", ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-File", "scripts/ci/validate-publish.ps1"]), + ] + + +def dotnet_steps(root: Path) -> list[Step]: + steps = [ + Step("Restore", ["dotnet", "restore", "ChapterTool.slnx"]), + Step("Build Release", ["dotnet", "build", "ChapterTool.slnx", "--configuration", "Release", "--no-restore"]), + ] + for project, output in (("ChapterTool.Core", "nuget"), ("ChapterTool.CommandLine", "cli-nuget")): + steps.append(Step(f"Pack {project}", ["dotnet", "pack", f"src/{project}/{project}.csproj", "--configuration", "Release", "--no-build", "--no-restore", "--output", f"artifacts/{output}", "/p:ContinuousIntegrationBuild=true"])) + steps.append(Step("Verify NuGet consumers", [sys.executable, "scripts/ci/verify-nuget.py"])) + for project in dotnet_tests(root): + steps.append(Step(f"Test {Path(project).stem}", ["dotnet", "test", project, "--configuration", "Release", "--no-build", "--no-restore", "--timeout", "10m"])) + return steps + + +def test_dotnet_steps(root: Path, project: str | None) -> list[Step]: + projects = {Path(path).stem: path for path in dotnet_tests(root)} + if project not in projects: + raise ValueError("--stage test-dotnet requires --test-project from ChapterTool.slnx.") + path = projects[project] + return [ + Step("Restore test project", ["dotnet", "restore", path]), + Step("Build test project", ["dotnet", "build", path, "--configuration", "Release", "--no-restore"]), + Step("Run tests", ["dotnet", "test", path, "--configuration", "Release", "--no-build", "--no-restore", "--timeout", "10m"]), + ] + + +def nuget_steps(version: str | None) -> list[Step]: + if version and not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version): + raise ValueError("Invalid NuGet package version.") + properties = [f"-p:Version={version}", f"-p:PackageVersion={version}"] if version else [] + steps = [] + for project, output in (("ChapterTool.Core", "nuget"), ("ChapterTool.CommandLine", "cli-nuget")): + path = f"src/{project}/{project}.csproj" + steps.extend([ + Step(f"Restore {project}", ["dotnet", "restore", path]), + Step(f"Build {project}", ["dotnet", "build", path, "--configuration", "Release", "--no-restore", *properties]), + Step(f"Pack {project}", ["dotnet", "pack", path, "--configuration", "Release", "--no-build", "--no-restore", "--output", f"artifacts/{output}", *properties, "/p:ContinuousIntegrationBuild=true"]), + ]) + steps.append(Step("Verify NuGet consumers", [sys.executable, "scripts/ci/verify-nuget.py"])) + return steps + + +def node_steps() -> list[Step]: + return [ + Step("Install npm dependencies", ["npm", "ci", "--ignore-scripts"], NODE), + Step("Build Node.js package", ["npm", "run", "build"], NODE), + Step("Type check Node.js package", ["npm", "run", "typecheck"], NODE), + Step("Test built Node.js package", ["npm", "run", "test:built"], NODE), + ] + + +def build_steps(root: Path) -> list[Step]: + return resource_steps() + dotnet_steps(root) + node_steps() + + +def browser_steps(root: Path, suite: str, install_deps: bool, engine: str | None = None) -> list[Step]: + project = "src/ChapterTool.Wasm/ChapterTool.Wasm.csproj" + steps = [ + Step("Restore WASM", ["dotnet", "restore", project]), + Step("Publish Release WASM", ["dotnet", "publish", project, "--configuration", "Release", "--no-restore", "--output", "artifacts/wasm-e2e/publish", "-p:WasmBuildNative=false", "-p:WasmRunWasmOpt=false"]), + Step("Restore DocFX tool", ["dotnet", "tool", "restore"]), + Step("Generate Core API Markdown", ["dotnet", "tool", "run", "docfx", "metadata", "src/ChapterTool.Core/docfx.json"]), + Step("Install VitePress dependencies", ["npm", "ci"], root / "docs-site"), + Step("Build VitePress documentation", ["npm", "run", "docs:build"], root / "docs-site"), + Step("Install browser test dependencies", ["npm", "ci"], E2E), + Step("Prepare Pages-shaped site", ["npm", "run", "prepare:site"], E2E, {"CHAPTERTOOL_E2E_PUBLISH": "artifacts/wasm-e2e/publish/wwwroot"}), + Step("Type check browser tests", ["npm", "run", "typecheck"], E2E), + ] + engines = ["chromium", "webkit"] if suite == "pr" else ["chromium"] + if suite == "full": + engines = ["chromium", "firefox", "webkit"] + if engine: + if engine not in engines: + raise ValueError(f"{engine} is not part of the {suite} browser suite.") + engines = [engine] + steps.append(Step("Install browser engines", ["npm", "exec", "--", "playwright", "install", *(["--with-deps"] if install_deps else []), *engines], E2E)) + args = ["npm", "run", "test:e2e", "--"] + if suite == "full": + steps.append(Step("Run full browser acceptance", [*args, *([f"--project={engine}"] if engine else [])], E2E, {"E2E_RUN_NAME": engine} if engine else {})) + elif "chromium" in engines: + steps.append(Step("Run Chromium E2E", [*args, "--project=chromium"], E2E)) + if suite == "pr" and "webkit" in engines: + steps.append(Step("Run WebKit regressions", [*args, "--project=webkit", "modal-layout.spec.ts", "unified-editing.spec.ts", "layout-behavior.spec.ts"], E2E, {"E2E_RUN_NAME": "webkit"})) + return steps + + +def bash_executable() -> str: + if os.name == "nt": + # Avoid System32/bash.exe, which starts WSL instead of Git Bash. + git = shutil.which("git") + if git: + for candidate in (Path(git).parent.parent / "bin/bash.exe", Path(git).parent / "bash.exe"): + if candidate.is_file(): + return str(candidate) + raise ValueError("Linux cross-publishing on Windows requires Git for Windows with Git Bash.") + return "bash" + + +def desktop_steps(runtimes: list[str]) -> list[Step]: + steps = [] + project = "src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj" + for runtime in runtimes: + if runtime == "osx-arm64" and platform.system() != "Darwin": + raise ValueError("macOS DMG packaging requires a macOS host.") + steps.append(Step(f"Restore desktop {runtime}", ["dotnet", "restore", project, "--runtime", runtime])) + command = [bash_executable(), "scripts/packaging/publish-desktop.sh"] if os.name != "nt" or runtime != "win-x64" else ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-File", "scripts/packaging/publish-desktop.ps1"] + steps.append(Step(f"Publish desktop {runtime}", [*command, "-Configuration", "Release", "-Runtime", runtime, "-NoRestore", "-PublishSingleFile"])) + return steps + + +def plan(options: argparse.Namespace, root: Path) -> list[Step]: + stages = DEFAULT_STAGES if options.stage == "all" else (options.stage,) + steps = [] + for stage in stages: + if stage == "resources": + steps.extend(resource_steps()) + elif stage == "dotnet": + steps.extend(dotnet_steps(root)) + elif stage == "test-dotnet": + steps.extend(test_dotnet_steps(root, options.test_project)) + elif stage == "pack-nuget": + steps.extend(nuget_steps(options.package_version)) + elif stage == "node": + steps.extend(node_steps()) + elif stage == "build-test": + steps.extend(build_steps(root)) + elif stage == "browser": + steps.extend(browser_steps(root, options.browser_suite, options.install_browser_deps, options.browser_engine)) + elif stage == "pack-node": + steps.append(Step("Pack and verify npm tarball", ["npm", "run", "pack:verify"], NODE)) + elif stage == "pack-desktop": + runtimes = options.runtime or (["osx-arm64"] if platform.system() == "Darwin" else ["win-x64", "linux-x64"]) + steps.extend(desktop_steps(runtimes)) + if options.step: + unknown = set(options.step) - {step.key for step in steps} + if unknown: + raise ValueError(f"Unknown step(s) for {options.stage}: {', '.join(sorted(unknown))}. Use --plan to list step keys.") + steps = [step for step in steps if step.key in options.step] + return steps + + +def tool_environment() -> dict[str, str]: + environment = dict(os.environ) + if platform.system() == "Windows": + for name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"): + root = os.environ.get(name) + if not root: + continue + directory = Path(root) / "MKVToolNix" + if all((directory / f"{tool}.exe").is_file() for tool in ("mkvextract", "mkvmerge")): + environment["PATH"] = os.pathsep.join((environment.get("PATH", ""), str(directory))) + break + return environment + + +def run_step(step: Step, root: Path, dry_run: bool = False) -> None: + print(f"\n[{step.key}] {step.name} ({step.cwd})\n {subprocess.list2cmdline(step.args)}", flush=True) + if dry_run: + return + if step.key == "publish-release-wasm": + output = (root / "artifacts/wasm-e2e/publish").resolve() + if output != root.resolve() / "artifacts/wasm-e2e/publish": + raise ValueError("WASM publish output must stay in the workspace artifact directory.") + if output.exists(): + shutil.rmtree(output) + if step.key in ("pack-chaptertool-core", "pack-chaptertool-commandline"): + directory = root / ("artifacts/nuget" if step.key == "pack-chaptertool-core" else "artifacts/cli-nuget") + for pattern in ("*.nupkg", "*.snupkg"): + for package in directory.glob(pattern): + package.unlink() + args = step.args.copy() + args[0] = shutil.which(args[0]) or args[0] + environment = {**tool_environment(), "CI": "true", "BINARYEN_CORES": "1", **step.env} + subprocess.run(args, cwd=root / step.cwd, env=environment, check=True, timeout=900) + + +def preflight(options: argparse.Namespace) -> None: + steps = plan(options, ROOT) + commands = {step.args[0] for step in steps} + if "npm" in commands: + version = subprocess.check_output(["node", "--version"], text=True, timeout=30).strip() + if not version.startswith("v22."): + raise ValueError(f"CI requires Node.js 22.x; found {version}.") + if "dotnet" in commands or any(step.key.startswith(("build-node", "publish-desktop")) for step in steps): + sdk = subprocess.check_output(["dotnet", "--version"], text=True, timeout=30).strip() + if not sdk.startswith("10."): + raise ValueError(f"CI requires .NET SDK 10.x; found {sdk}.") + required = commands.copy() + if any(step.key in ("test-chaptertool-infrastructure-tests", "test-chaptertool-avalonia-tests") or + (step.key == "run-tests" and options.test_project in + ("ChapterTool.Infrastructure.Tests", "ChapterTool.Avalonia.Tests")) for step in steps): + required.update(("ffmpeg", "ffprobe", "mkvextract", "mkvmerge")) + environment = tool_environment() + missing = sorted(name for name in required if not shutil.which(name, path=environment.get("PATH"))) + if missing: + raise ValueError(f"Missing CI tools on PATH: {', '.join(missing)}. See scripts/README.md for setup.") + if any(step.key in ("build-release", "build-node-js-package") for step in steps): + workloads = subprocess.check_output(["dotnet", "workload", "list"], encoding="utf-8", timeout=30) + if "wasm-tools" not in workloads: + raise ValueError("CI requires wasm-tools. Run 'dotnet workload install wasm-tools' once, then retry.") + if options.stage == "pack-node" and not (ROOT / NODE / "dist/index.mjs").is_file(): + raise ValueError("pack-node needs the dist output from --stage node or --stage build-test.") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + result.add_argument("--stage", choices=("all", *STAGES), default="all") + result.add_argument("--step", action="append", help="Run a step key from --plan. Dependencies must already be prepared. May be repeated.") + result.add_argument("--test-project", help="Select one solution test project by name for --stage test-dotnet.") + result.add_argument("--package-version", help="Version to build and pack for --stage pack-nuget.") + result.add_argument("--browser-suite", choices=("chromium", "pr", "full"), default="pr") + result.add_argument("--browser-engine", choices=("chromium", "firefox", "webkit"), help="Select one engine from the browser suite for an isolated CI job.") + result.add_argument("--runtime", action="append", choices=("win-x64", "linux-x64", "osx-arm64")) + result.add_argument("--install-browser-deps", action="store_true", help="Install Playwright system packages on Linux.") + result.add_argument("--plan", action="store_true", help="Print commands without running checks.") + return result + + +def main(arguments: list[str] | None = None) -> int: + options = parser().parse_args(arguments) + started = time.monotonic() + current_step = "prerequisites" + try: + steps = plan(options, ROOT) + if not options.plan: + preflight(options) + for step in steps: + current_step = step.key + run_step(step, ROOT, options.plan) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f"\nCI check failed [{current_step}]: {error}", file=sys.stderr, flush=True) + if os.environ.get("GITHUB_ACTIONS") == "true": + message = str(error).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error title=CI check {current_step}::{message}", flush=True) + return 1 + print(f"\n{'Plan printed' if options.plan else 'Selected host CI checks passed'} ({time.monotonic() - started:.1f}s).", flush=True) + if options.stage in ("all", "pack-desktop") and platform.system() != "Darwin": + print("macOS DMG must still be verified on a macOS host.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/tests/test_check_ci.py b/scripts/ci/tests/test_check_ci.py new file mode 100644 index 00000000..724eb2f9 --- /dev/null +++ b/scripts/ci/tests/test_check_ci.py @@ -0,0 +1,222 @@ +"""Behavior checks for the shared cross-platform CI runner.""" + +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("check_ci", Path(__file__).parents[1] / "run.py") +ci = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ci +SPEC.loader.exec_module(ci) + + +class RunnerTests(unittest.TestCase): + def test_failure_stops_checks_and_returns_failure(self): + steps = [ci.Step("first", [sys.executable, "-c", "raise SystemExit(7)"]), + ci.Step("second", [sys.executable, "-c", "raise AssertionError('must not run')"])] + with patch.object(ci, "plan", return_value=steps), patch.object(ci, "preflight"), \ + patch.object(ci, "run_step", wraps=ci.run_step) as run, \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(1, ci.main(["--stage", "pack-node"])) + self.assertEqual(1, run.call_count) + + def test_plan_runs_no_commands_or_preflight(self): + with patch.object(ci, "preflight") as preflight, patch.object(ci.subprocess, "run") as run, \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(0, ci.main(["--plan"])) + preflight.assert_not_called() + run.assert_not_called() + + def test_selected_step_runs_only_that_check(self): + with patch.object(ci, "preflight"), patch.object(ci, "run_step") as run, \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(0, ci.main(["--stage", "resources", "--step", "lint-python-scripts"])) + self.assertEqual(["lint-python-scripts"], [call.args[0].key for call in run.call_args_list]) + + def test_unknown_step_fails_without_running_commands(self): + with patch.object(ci, "preflight") as preflight, patch.object(ci, "run_step") as run, \ + patch.dict(ci.os.environ, {"GITHUB_ACTIONS": "false"}), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(1, ci.main(["--stage", "resources", "--step", "typo"])) + preflight.assert_not_called() + run.assert_not_called() + + def test_resource_step_does_not_require_dotnet_node_or_media_tools(self): + options = ci.parser().parse_args(["--stage", "resources", "--step", "lint-python-scripts"]) + with patch.object(ci.shutil, "which", side_effect=lambda name, **kwargs: "/bin/uv" if name == "uv" else None), \ + patch.object(ci.subprocess, "check_output") as check: + ci.preflight(options) + check.assert_not_called() + + def test_full_solution_build_requires_native_workload(self): + options = ci.parser().parse_args(["--stage", "dotnet", "--step", "build-release"]) + with patch.object(ci.shutil, "which", return_value="dotnet"), \ + patch.object(ci.subprocess, "check_output", side_effect=["10.0.401", "No workloads"]): + with self.assertRaisesRegex(ValueError, "wasm-tools"): + ci.preflight(options) + + def test_node_build_requires_workload_instead_of_reducing_coverage(self): + options = ci.parser().parse_args(["--stage", "node", "--step", "build-node-js-package"]) + with patch.object(ci.shutil, "which", return_value="dotnet"), \ + patch.object(ci.subprocess, "check_output", side_effect=["v22.16.0", "10.0.401", "No workloads"]): + with self.assertRaisesRegex(ValueError, "wasm-tools"): + ci.preflight(options) + + def test_browser_publish_uses_same_runtime_without_native_workload(self): + options = ci.parser().parse_args(["--stage", "browser", "--step", "publish-release-wasm"]) + with patch.object(ci.shutil, "which", return_value="dotnet"), \ + patch.object(ci.subprocess, "check_output", return_value="10.0.401") as check: + ci.preflight(options) + check.assert_called_once_with(["dotnet", "--version"], text=True, timeout=30) + + def test_timeout_returns_failure(self): + step = ci.Step("typecheck", ["npm", "run", "typecheck"]) + with patch.object(ci, "plan", return_value=[step]), patch.object(ci, "preflight"), \ + patch.object(ci.subprocess, "run", side_effect=subprocess.TimeoutExpired(step.args, 900)), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(1, ci.main(["--stage", "pack-node"])) + + def test_actions_error_annotation_names_the_failed_step(self): + output = io.StringIO() + errors = io.StringIO() + step = ci.Step("Browser type check", ["npm", "run", "typecheck"]) + with patch.object(ci, "plan", return_value=[step]), patch.object(ci, "preflight"), \ + patch.object(ci, "run_step", side_effect=ValueError("first line\nsecond line%")), \ + patch.dict(ci.os.environ, {"GITHUB_ACTIONS": "true"}), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + self.assertEqual(1, ci.main(["--stage", "browser"])) + self.assertIn("CI check failed [browser-type-check]", errors.getvalue()) + self.assertIn("::error title=CI check browser-type-check::first line%0Asecond line%25", output.getvalue()) + + def test_step_sets_ci_and_scoped_environment_without_changing_parent(self): + before = dict(os.environ) + with patch.object(ci.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()): + ci.run_step(ci.Step("prepare", [sys.executable, "--version"], ci.E2E, + {"CHAPTERTOOL_E2E_PUBLISH": "artifacts/wasm-e2e/publish/wwwroot"}), ci.ROOT) + self.assertEqual(ci.ROOT / ci.E2E, run.call_args.kwargs["cwd"]) + self.assertEqual("true", run.call_args.kwargs["env"]["CI"]) + self.assertEqual("1", run.call_args.kwargs["env"]["BINARYEN_CORES"]) + self.assertEqual("artifacts/wasm-e2e/publish/wwwroot", run.call_args.kwargs["env"]["CHAPTERTOOL_E2E_PUBLISH"]) + self.assertEqual(before, dict(os.environ)) + + def test_windows_mkvtoolnix_install_is_visible_to_child_processes(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "MKVToolNix" + directory.mkdir() + for tool in ("mkvextract", "mkvmerge"): + (directory / f"{tool}.exe").touch() + with patch.object(ci.platform, "system", return_value="Windows"), \ + patch.dict(os.environ, {"ProgramFiles": temporary, "PATH": "original"}), \ + patch.object(ci.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()): + ci.run_step(ci.Step("test", [sys.executable, "--version"]), ci.ROOT) + self.assertEqual("original", os.environ["PATH"]) + self.assertEqual(os.pathsep.join(("original", str(directory))), run.call_args.kwargs["env"]["PATH"]) + + def test_browser_publish_removes_stale_output_and_keeps_diagnostics(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "artifacts/wasm-e2e/publish" + output.mkdir(parents=True) + (output / "stale.wasm").touch() + diagnostics = output.parent / "report" + diagnostics.mkdir() + with patch.object(ci.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()): + ci.run_step(ci.browser_steps(root, "pr", False)[1], root) + self.assertFalse(output.exists()) + self.assertTrue(diagnostics.exists()) + run.assert_called_once() + + def test_solution_membership_drives_separate_test_processes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "ChapterTool.slnx").write_text( + '' + '' + '', encoding="utf-8") + tests = [step for step in ci.build_steps(root) if step.args[:2] == ["dotnet", "test"]] + self.assertEqual(["tests/One.Tests/One.Tests.csproj", "tests/Two.Tests/Two.Tests.csproj"], [step.args[2] for step in tests]) + self.assertTrue(all("--no-build" in step.args and "--no-restore" in step.args for step in tests)) + + def test_full_acceptance_installs_all_engines(self): + steps = ci.browser_steps(ci.ROOT, "full", True) + install = next(step for step in steps if step.name == "Install browser engines") + self.assertEqual(["--with-deps", "chromium", "firefox", "webkit"], install.args[-4:]) + self.assertEqual({}, steps[-1].env) + + def test_engine_selection_keeps_full_coverage_in_isolated_jobs(self): + for engine in ("chromium", "firefox", "webkit"): + with self.subTest(engine=engine): + steps = ci.browser_steps(ci.ROOT, "full", True, engine) + install = next(step for step in steps if step.key == "install-browser-engines") + self.assertEqual(["--with-deps", engine], install.args[-2:]) + self.assertEqual(["npm", "run", "test:e2e", "--", f"--project={engine}"], steps[-1].args) + self.assertEqual({"E2E_RUN_NAME": engine}, steps[-1].env) + + def test_pr_webkit_selection_does_not_run_chromium(self): + steps = ci.browser_steps(ci.ROOT, "pr", False, "webkit") + tests = [step for step in steps if "test:e2e" in step.args] + self.assertEqual(1, len(tests)) + self.assertEqual("run-webkit-regressions", tests[0].key) + self.assertEqual(["modal-layout.spec.ts", "unified-editing.spec.ts", "layout-behavior.spec.ts"], tests[0].args[-3:]) + self.assertEqual("webkit", tests[0].env["E2E_RUN_NAME"]) + + def test_isolated_project_build_needs_no_native_workload(self): + options = ci.parser().parse_args(["--stage", "test-dotnet", "--test-project", "ChapterTool.Core.Tests"]) + steps = ci.plan(options, ci.ROOT) + self.assertEqual(["restore-test-project", "build-test-project", "run-tests"], [step.key for step in steps]) + self.assertTrue(all(step.args[2].endswith("ChapterTool.Core.Tests.csproj") for step in steps)) + with patch.object(ci.shutil, "which", return_value="dotnet"), \ + patch.object(ci.subprocess, "check_output", return_value="10.0.401") as check: + ci.preflight(options) + check.assert_called_once_with(["dotnet", "--version"], text=True, timeout=30) + + def test_unknown_test_project_fails_before_running_commands(self): + with patch.object(ci, "run_step") as run, \ + patch.dict(ci.os.environ, {"GITHUB_ACTIONS": "false"}), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(1, ci.main(["--stage", "test-dotnet", "--test-project", "../Other"])) + run.assert_not_called() + + def test_isolated_media_consumers_require_real_tools(self): + for project in ("ChapterTool.Infrastructure.Tests", "ChapterTool.Avalonia.Tests"): + with self.subTest(project=project): + options = ci.parser().parse_args(["--stage", "test-dotnet", "--test-project", project, "--step", "run-tests"]) + with patch.object(ci.shutil, "which", side_effect=lambda name, **kwargs: "dotnet" if name == "dotnet" else None), \ + patch.object(ci.subprocess, "check_output", return_value="10.0.401"): + with self.assertRaisesRegex(ValueError, "ffmpeg.*ffprobe.*mkvextract.*mkvmerge"): + ci.preflight(options) + + def test_release_nuget_version_applies_to_build_and_pack(self): + steps = ci.nuget_steps("23.3.2-rc.1") + versioned = [step for step in steps if step.args[:2] in (["dotnet", "build"], ["dotnet", "pack"])] + self.assertEqual(4, len(versioned)) + self.assertTrue(all("-p:Version=23.3.2-rc.1" in step.args and "-p:PackageVersion=23.3.2-rc.1" in step.args for step in versioned)) + self.assertEqual("verify-nuget-consumers", steps[-1].key) + with self.assertRaisesRegex(ValueError, "Invalid NuGet"): + ci.nuget_steps("--invalid") + + def test_invalid_engine_for_suite_fails_before_commands(self): + with self.assertRaisesRegex(ValueError, "not part of"): + ci.browser_steps(ci.ROOT, "pr", False, "firefox") + + def test_unsupported_macos_packaging_fails_before_commands_run(self): + with patch.object(ci.platform, "system", return_value="Windows"): + with self.assertRaisesRegex(ValueError, "macOS host"): + ci.desktop_steps(["osx-arm64"]) + + def test_windows_linux_publish_uses_git_bash(self): + with patch.object(ci.os, "name", "nt"), patch.object(ci, "bash_executable", return_value="git-bash"): + steps = ci.desktop_steps(["linux-x64"]) + self.assertEqual(["git-bash", "scripts/packaging/publish-desktop.sh"], steps[-1].args[:2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/tests/test_fluent_resource_references.py b/scripts/ci/tests/test_fluent_resource_references.py new file mode 100644 index 00000000..c6fba8c1 --- /dev/null +++ b/scripts/ci/tests/test_fluent_resource_references.py @@ -0,0 +1,49 @@ +"""Behavior checks for Fluent resource-reference extraction.""" + +import importlib.util +from pathlib import Path +import subprocess +import sys +import unittest + + +SCRIPT = Path(__file__).resolve().parents[2] / "diagnostics" / "update-avalonia-fluent-resource-references.py" +SPEC = importlib.util.spec_from_file_location("fluent_resource_references", SCRIPT) +references = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(references) + + +class ResourceReferenceTests(unittest.TestCase): + def test_positional_and_named_keys_preserve_reference_syntax(self): + source = """ + {DynamicResource AccentBrush} + {StaticResource ResourceKey = ControlTheme, Other = value} + {DynamicResource Other=value, ResourceKey=ForegroundBrush} + {StaticResource\tTabKey, Other=value} + {DynamicResource\nMultilineKey} + {Binding Path=Name} {StaticResourceNoSpace} + """ + self.assertEqual( + {"AccentBrush", "ControlTheme", "ForegroundBrush", "TabKey", "MultilineKey"}, + references.resource_key_references(source), + ) + + def test_empty_or_incomplete_references_do_not_hide_later_keys(self): + source = "{DynamicResource } {StaticResource} {StaticResource Missing {DynamicResource ValidKey}" + self.assertEqual({"ValidKey"}, references.resource_key_references(source)) + + def test_long_unterminated_whitespace_completes_without_backtracking_explosion(self): + code = """ +import importlib.util +import sys +spec = importlib.util.spec_from_file_location("references", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +source = "{DynamicResource " + " " * 100_000 + "MissingClose\\n{StaticResource After}" +assert module.resource_key_references(source) == {"After"} +""" + subprocess.run([sys.executable, "-c", code, str(SCRIPT)], check=True, timeout=10, capture_output=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/tests/test_plan_ci.py b/scripts/ci/tests/test_plan_ci.py new file mode 100644 index 00000000..744086d5 --- /dev/null +++ b/scripts/ci/tests/test_plan_ci.py @@ -0,0 +1,152 @@ +"""Verify dependency selection, comparison ranges, and the required CI gate.""" + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("plan_ci", Path(__file__).parents[1] / "plan.py") +planner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(planner) +ROOT = Path(__file__).resolve().parents[3] + + +class PlanTests(unittest.TestCase): + def labels(self, paths, **options): + plan = planner.select_jobs(ROOT, paths, **options) + return {item["label"] for item in plan["tests"]["include"]}, plan + + def test_core_reaches_every_host_and_test_project(self): + labels, plan = self.labels(["src/ChapterTool.Core/Models/Chapter.cs"]) + self.assertEqual({"Core", "CommandLine", "Infrastructure", "Avalonia", "Avalonia.Headless", "Wasm"}, labels) + self.assertTrue(plan["jobs"]["wasm-browser-e2e"]) + self.assertTrue(plan["jobs"]["pack-nodejs"]) + self.assertTrue(plan["jobs"]["pack-nuget"]) + self.assertFalse(plan["jobs"]["pack-dotnet"]) + self.assertFalse(plan["jobs"]["solution-build"]) + + def test_infrastructure_selects_its_actual_consumers(self): + labels, plan = self.labels(["src/ChapterTool.Infrastructure/Services/Settings.cs"]) + self.assertEqual({"Infrastructure", "CommandLine", "Avalonia", "Avalonia.Headless"}, labels) + self.assertFalse(plan["jobs"]["pack-nodejs"]) + self.assertFalse(plan["jobs"]["wasm-browser-e2e"]) + + def test_media_consumers_receive_integration_tools(self): + _, plan = self.labels([], full=True) + media = {item["label"] for item in plan["tests"]["include"] if item["media"]} + self.assertEqual({"Infrastructure", "Avalonia"}, media) + + def test_test_change_runs_only_its_project(self): + labels, plan = self.labels(["tests/ChapterTool.Core.Tests/Editing/ChangedTests.cs"]) + self.assertEqual({"Core"}, labels) + self.assertEqual(["dotnet-tests"], plan["required"]) + + def test_shared_test_support_reaches_all_referencing_tests(self): + labels, _ = self.labels(["tests/ChapterTool.TestSupport/Fakes.cs"]) + self.assertEqual({"Core", "CommandLine", "Infrastructure", "Avalonia", "Avalonia.Headless", "Wasm"}, labels) + + def test_shared_disc_fixtures_reach_browser_node_and_dotnet_consumers(self): + labels, plan = self.labels(["tests/ChapterTool.Core.Tests/Fixtures/Importing/Disc/Xpl/VPLST001.XPL"]) + self.assertEqual({"Core", "CommandLine", "Infrastructure", "Avalonia", "Avalonia.Headless", "Wasm"}, labels) + self.assertTrue(plan["jobs"]["wasm-browser-e2e"]) + self.assertTrue(plan["jobs"]["pack-nodejs"]) + self.assertFalse(plan["jobs"]["pack-dotnet"]) + + def test_browser_test_change_does_not_run_desktop_or_node(self): + labels, plan = self.labels(["tests/ChapterTool.Wasm.E2E/specs/smoke.spec.ts"]) + self.assertEqual(set(), labels) + self.assertEqual(["wasm-browser-e2e"], plan["required"]) + + def test_locales_select_shared_ui_resources_and_browser(self): + labels, plan = self.labels([planner.LOCALES + "en-US.axaml"]) + self.assertEqual({"Avalonia", "Avalonia.Headless"}, labels) + self.assertTrue(plan["jobs"]["resources"]) + self.assertTrue(plan["jobs"]["wasm-browser-e2e"]) + self.assertFalse(plan["jobs"]["pack-nodejs"]) + + def test_desktop_dependency_project_changes_verify_runtime_packaging(self): + _, plan = self.labels(["src/ChapterTool.Avalonia.UI/ChapterTool.Avalonia.UI.csproj"]) + self.assertTrue(plan["jobs"]["pack-dotnet"]) + _, plan = self.labels(["src/ChapterTool.Avalonia.UI/Views/MainView.axaml"]) + self.assertFalse(plan["jobs"]["pack-dotnet"]) + + def test_docs_and_empty_changes_need_only_the_fixed_gate(self): + for paths in ([], ["docs/code-map/testing.md", "AGENTS.md"], ["CHANGELOG.md"]): + with self.subTest(paths=paths): + _, plan = self.labels(paths, master=True) + self.assertEqual([], plan["required"]) + + def test_global_unknown_and_ci_inputs_use_full_gate(self): + for path in ("Directory.Build.props", "ChapterTool.slnx", ".github/workflows/dotnet-ci.yml", "scripts/ci/run.py", "tests/TestFiles/minimal.txt", "new-build-input.json"): + with self.subTest(path=path): + _, plan = self.labels([path]) + self.assertEqual(list(planner.JOBS), plan["required"]) + + def test_master_and_tag_checks_produce_all_release_artifacts(self): + for options in ({"master": True}, {"full": True}): + with self.subTest(options=options): + _, plan = self.labels(["src/ChapterTool.Core/Models/Chapter.cs"], **options) + self.assertTrue(all(plan["jobs"][job] for job in ("pack-dotnet", "pack-nodejs", "pack-nuget"))) + self.assertEqual(bool(options.get("full")), plan["jobs"]["solution-build"]) + + def test_gate_rejects_failed_cancelled_missing_or_skipped_required_jobs(self): + _, plan = self.labels(["tests/ChapterTool.Core.Tests/Changed.cs"]) + needs = {job: {"result": "success" if job in plan["required"] else "skipped"} for job in planner.JOBS} + needs["plan"] = {"result": "success"} + planner.verify_jobs(plan, needs) + for result in ("failure", "cancelled", "skipped", None): + with self.subTest(result=result): + needs["dotnet-tests"] = {"result": result} + with self.assertRaisesRegex(ValueError, "dotnet-tests"): + planner.verify_jobs(plan, needs) + needs["plan"] = {"result": "failure"} + with self.assertRaisesRegex(ValueError, "planning"): + planner.verify_jobs(plan, needs) + + def test_planner_writes_actions_outputs_for_docs_only_pr(self): + with tempfile.TemporaryDirectory() as temporary: + event = Path(temporary) / "event.json" + output = Path(temporary) / "output.txt" + event.write_text(json.dumps({"pull_request": {"base": {"sha": "base"}, "head": {"sha": "head"}}}), encoding="utf-8") + with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request", "GITHUB_EVENT_PATH": str(event), "GITHUB_REF": "refs/pull/1/merge", "GITHUB_OUTPUT": str(output)}), \ + patch.object(planner, "changed_paths", return_value=["docs/README.md"]): + self.assertEqual(0, planner.main([])) + values = dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) + self.assertEqual([], json.loads(values["plan"])["required"]) + self.assertEqual("false", values["dotnet-tests"]) + + def test_git_diff_includes_deleted_files_and_both_rename_paths(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + + def git(*args): + return subprocess.check_output(["git", *args], cwd=root).decode().strip() + + git("init", "-q") + git("config", "user.name", "CI Test") + git("config", "user.email", "ci@example.invalid") + (root / "old.txt").write_text("old", encoding="utf-8") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + (root / "old.txt").rename(root / "new.txt") + git("add", "-A") + git("commit", "-qm", "rename") + head = git("rev-parse", "HEAD") + self.assertEqual({"old.txt", "new.txt"}, set(planner.changed_paths(root, "push", {"before": base, "after": head}))) + self.assertEqual([], planner.changed_paths(root, "push", {"before": head, "after": head})) + + def test_missing_comparison_history_uses_full_gate(self): + with patch.object(planner.subprocess, "check_output", side_effect=subprocess.CalledProcessError(128, ["git", "diff"])): + changed = planner.changed_paths(ROOT, "push", {"before": "old-sha", "after": "new-sha"}) + _, plan = self.labels(changed) + self.assertEqual(list(planner.JOBS), plan["required"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/tests/test_resolve_ci_run.py b/scripts/ci/tests/test_resolve_ci_run.py new file mode 100644 index 00000000..3a68762f --- /dev/null +++ b/scripts/ci/tests/test_resolve_ci_run.py @@ -0,0 +1,52 @@ +"""Verify release artifact provenance before any publisher can use a CI run.""" + +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("resolve_ci_run", Path(__file__).parents[1] / "resolve-run.py") +resolver = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resolver) + + +class ReleaseRunTests(unittest.TestCase): + def valid_run(self): + return { + "event": "push", "status": "completed", "conclusion": "success", "head_branch": "v23.3.2", "head_sha": "tested-sha", + "head_repository": {"full_name": "tautcony/ChapterTool"}, "repository": {"full_name": "tautcony/ChapterTool"}, + "path": ".github/workflows/dotnet-ci.yml", + } + + def test_triggered_run_requires_exact_commit_and_successful_tag_gate(self): + with patch.object(resolver, "gh_json", return_value=self.valid_run()): + self.assertEqual(123, resolver.resolve_run("tautcony/ChapterTool", "v23.3.2", "tested-sha", "123")) + for key, value in (("head_sha", "other-sha"), ("head_branch", "master"), ("event", "pull_request"), + ("conclusion", "failure"), ("status", "in_progress"), ("path", ".github/workflows/other.yml"), + ("head_repository", {"full_name": "fork/ChapterTool"})): + with self.subTest(key=key), patch.object(resolver, "gh_json", return_value={**self.valid_run(), key: value}): + with self.assertRaises(ValueError): + resolver.resolve_run("tautcony/ChapterTool", "v23.3.2", "tested-sha", "123") + + def test_manual_lookup_selects_a_successful_run_of_the_checked_out_commit(self): + with patch.object(resolver, "gh_json", side_effect=[[{"databaseId": 456}], self.valid_run()]) as gh: + self.assertEqual(456, resolver.resolve_run("tautcony/ChapterTool", "v23.3.2", "tested-sha")) + self.assertIn("tested-sha", gh.call_args_list[0].args) + self.assertEqual(("api", "repos/tautcony/ChapterTool/actions/runs/456"), gh.call_args_list[1].args) + + def test_manual_lookup_fails_when_no_successful_tag_run_exists(self): + with patch.object(resolver, "gh_json", return_value=[]): + with self.assertRaisesRegex(ValueError, "No successful"): + resolver.resolve_run("tautcony/ChapterTool", "v23.3.2", "tested-sha") + + def test_invalid_tag_or_run_id_cannot_reach_github(self): + with patch.object(resolver, "gh_json") as gh: + for tag, run_id in (("master", "123"), ("v23.3.2", "../../other")): + with self.subTest(tag=tag, run_id=run_id), self.assertRaises(ValueError): + resolver.resolve_run("tautcony/ChapterTool", tag, "tested-sha", run_id) + gh.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/tests/test_verify_nuget.py b/scripts/ci/tests/test_verify_nuget.py new file mode 100644 index 00000000..fbba4f6f --- /dev/null +++ b/scripts/ci/tests/test_verify_nuget.py @@ -0,0 +1,57 @@ +"""Verify that release metadata cannot select a stale or mismatched package.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + + +SPEC = importlib.util.spec_from_file_location("verify_nuget", Path(__file__).parents[1] / "verify-nuget.py") +verifier = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(verifier) + + +class PackageTests(unittest.TestCase): + def package(self, root, directory, name, version): + feed = root / "artifacts" / directory + feed.mkdir(parents=True, exist_ok=True) + archive = feed / f"{name}.{version}.nupkg" + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr(f"{name}.nuspec", '' + f'{name}{version}') + return archive + + def test_metadata_accepts_matching_tag_without_installing_again(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.package(root, "nuget", "ChapterTool.Core", "23.3.2") + self.package(root, "cli-nuget", "ChapterTool", "23.3.2") + with patch.object(verifier.subprocess, "run") as run: + verifier.verify(root, "23.3.2", metadata_only=True) + run.assert_not_called() + + def test_version_mismatch_fails_before_install_or_publication(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.package(root, "nuget", "ChapterTool.Core", "23.3.2") + self.package(root, "cli-nuget", "ChapterTool", "23.3.1") + with patch.object(verifier.subprocess, "run") as run, self.assertRaisesRegex(ValueError, "versions differ"): + verifier.verify(root, "23.3.2") + run.assert_not_called() + + def test_stale_packages_cannot_pass_release_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.package(root, "nuget", "ChapterTool.Core", "23.3.1") + self.package(root, "cli-nuget", "ChapterTool", "23.3.1") + with self.assertRaisesRegex(ValueError, "does not match"): + verifier.verify(root, "23.3.2", metadata_only=True) + self.package(root, "nuget", "ChapterTool.Core", "23.3.2") + with self.assertRaisesRegex(ValueError, "Expected one"): + verifier.verify(root, "23.3.2", metadata_only=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/validate-publish.ps1 b/scripts/ci/validate-publish.ps1 new file mode 100644 index 00000000..5d182821 --- /dev/null +++ b/scripts/ci/validate-publish.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Stop' +$parseErrors = $null +$null = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot '../packaging/publish-desktop.ps1'), + [ref]$null, + [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { + throw ($parseErrors | Out-String) +} diff --git a/scripts/ci/verify-nuget.py b/scripts/ci/verify-nuget.py new file mode 100644 index 00000000..dcd96e5e --- /dev/null +++ b/scripts/ci/verify-nuget.py @@ -0,0 +1,80 @@ +"""Install and execute the packed NuGet library and CLI in isolated consumers.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import tempfile +import zipfile +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[2] + + +def package_metadata(directory: Path, package_id: str) -> tuple[Path, str]: + archives = list(directory.glob("*.nupkg")) + if len(archives) != 1: + raise ValueError(f"Expected one {package_id} package in {directory}.") + with zipfile.ZipFile(archives[0]) as archive: + specs = [name for name in archive.namelist() if name.endswith(".nuspec")] + if len(specs) != 1: + raise ValueError("Expected one NuGet package manifest.") + metadata = ET.fromstring(archive.read(specs[0])) # nosec B314 + fields = {node.tag.rsplit("}", 1)[-1]: node.text for node in metadata.iter()} + if fields.get("id") != package_id or not fields.get("version"): + raise ValueError(f"Unexpected NuGet package metadata: {archives[0]}.") + return archives[0], fields["version"] + + +def verify(root: Path, expected_version: str | None = None, metadata_only: bool = False) -> None: + core, version = package_metadata(root / "artifacts/nuget", "ChapterTool.Core") + cli, cli_version = package_metadata(root / "artifacts/cli-nuget", "ChapterTool") + if version != cli_version: + raise ValueError("Core and CLI NuGet versions differ.") + if expected_version and version != expected_version: + raise ValueError(f"NuGet version {version} does not match release version {expected_version}.") + if metadata_only: + print(f"Core and CLI package metadata passed ({version}).") + return + with tempfile.TemporaryDirectory(prefix="chaptertool-nuget-") as temporary: + consumer = Path(temporary) + environment = {**os.environ, "NUGET_PACKAGES": str(consumer / "packages")} + configuration = ET.Element("configuration") + sources = ET.SubElement(configuration, "packageSources") + ET.SubElement(sources, "clear") + for name, source in (("core", str(core.parent)), ("cli", str(cli.parent)), ("nuget", "https://api.nuget.org/v3/index.json")): + ET.SubElement(sources, "add", key=name, value=source) + mappings = ET.SubElement(configuration, "packageSourceMapping") + for name, pattern in (("core", "ChapterTool.Core"), ("cli", "ChapterTool"), ("nuget", "*")): + source = ET.SubElement(mappings, "packageSource", key=name) + ET.SubElement(source, "package", pattern=pattern) + config = consumer / "NuGet.config" + ET.ElementTree(configuration).write(config, encoding="utf-8", xml_declaration=True) + (consumer / "Consumer.csproj").write_text( + 'Exe' + 'net10.0enable' + '' + f'' + '', encoding="utf-8") + (consumer / "Program.cs").write_text( + (ROOT / "scripts/ci/consumers/core-frame-rate.cs").read_text(encoding="utf-8"), encoding="utf-8") + commands = [ + ["dotnet", "restore", "Consumer.csproj", "--configfile", str(config)], + ["dotnet", "run", "--project", "Consumer.csproj", "--configuration", "Release", "--no-restore"], + ["dotnet", "tool", "install", "ChapterTool", "--version", cli_version, "--tool-path", str(consumer / "tools"), "--configfile", str(config)], + [str(consumer / "tools" / ("chaptertool.exe" if os.name == "nt" else "chaptertool")), "--help"], + ] + for command in commands: + subprocess.run(command, cwd=consumer, env=environment, check=True, timeout=180) + print(f"Installed Core and CLI consumers passed ({version}).") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected-version") + parser.add_argument("--metadata-only", action="store_true") + options = parser.parse_args() + verify(ROOT, options.expected_version, options.metadata_only) diff --git a/scripts/command.py b/scripts/command.py new file mode 100644 index 00000000..a54239b8 --- /dev/null +++ b/scripts/command.py @@ -0,0 +1,28 @@ +"""Entry points for repository scripts.""" + +from __future__ import annotations + +import runpy +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parent +SUBSET_SCRIPT = ROOT / "maintenance" / "subset-shortcut-font.py" + + +def _subset(arguments: list[str]) -> int: + namespace = runpy.run_path(str(SUBSET_SCRIPT), run_name="chaptertool_subset_shortcut_font") + return int(namespace["main"](arguments)) + + +def subset_shortcut_font() -> int: + return _subset([]) + + +def check_shortcut_font() -> int: + return _subset(["--check"]) + + +def lint_scripts() -> int: + return subprocess.run(["ruff", "check", "scripts/"], cwd=ROOT.parent, check=False).returncode diff --git a/scripts/diagnostics/audit-ui-resources.py b/scripts/diagnostics/audit-ui-resources.py new file mode 100755 index 00000000..1c64e94c --- /dev/null +++ b/scripts/diagnostics/audit-ui-resources.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Audit Avalonia resource definitions against repository and verified Fluent references.""" +import argparse +import json +import re +from pathlib import Path +import xml.etree.ElementTree as ET + +KEY = re.compile(r'x:Key\s*=\s*"([^"]+)"') +REF = re.compile(r"\{(?:DynamicResource|StaticResource)\s+([^}\s,]+)") +IMPORTED = re.compile(r"ImportedThemeColorKeys\s*\{\s*get;\s*\}\s*=\s*\[(.*?)\];", re.S) +QUOTED = re.compile(r'"([^"]+)"') +CONST = re.compile(r'public const string \w+ = "([^"]+)";') +NONE_LABEL = " (none)" +FLUENT_REFERENCE_MANIFEST = Path(__file__).with_name("avalonia-fluent-resource-references.json") + + +def fluent_resource_evidence(root: Path) -> tuple[str, str, dict[str, set[str]]]: + manifest = json.loads(FLUENT_REFERENCE_MANIFEST.read_text(encoding="utf-8")) + package_name = manifest["package"]["id"] + expected_version = manifest["package"]["version"] + versions = set() + for path in (root / "src").rglob("*.csproj"): + project = ET.parse(path).getroot() # nosec B314 + for reference in project.iter("PackageReference"): + if reference.attrib.get("Include", "").casefold() == package_name.casefold(): + version = reference.attrib.get("Version") + if version: + versions.add(version) + + if versions != {expected_version}: + found = ", ".join(sorted(versions)) or "no explicit version" + raise ValueError( + f"{package_name} resource evidence is pinned to {expected_version}, " + f"but project references specify {found}. Update the evidence from " + "the matching upstream source before running this audit." + ) + + references: dict[str, set[str]] = {} + for key, sources in manifest["references"].items(): + references[key] = set(sources) + return package_name, expected_version, references + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path) + root = (parser.parse_args().repo_root or Path(__file__).resolve().parents[2]).resolve() + src = root / "src" + definitions = [src / "ChapterTool.Avalonia.UI/Resources" / name for name in ("Themes.axaml", "SharedResources.axaml", "Styles.axaml")] + service = src / "ChapterTool.Avalonia/Services/AvaloniaThemeApplicationService.cs" + for path in [*definitions, service]: + if not path.is_file(): + raise FileNotFoundError(f"file not found: {path}") + + package_name, package_version, fluent_evidence = fluent_resource_evidence(root) + fluent_references = set(fluent_evidence) + defined, referenced, service_keys = set(), set(), set() + definition_sources = {} + for path in definitions: + text = path.read_text(encoding="utf-8") + for match in KEY.finditer(text): + key = match.group(1) + if key not in {"Light", "Dark"}: + defined.add(key) + definition_sources.setdefault(key, path.relative_to(root).as_posix()) + for block in re.findall(r"(.*?)", text, re.S): + referenced.update(m.group(1) for m in KEY.finditer(block)) + + text = service.read_text(encoding="utf-8") + imported = IMPORTED.search(text) + if imported: + service_keys.update(m.group(1) for m in QUOTED.finditer(imported.group(1))) + service_keys.update(m.group(1) for m in CONST.finditer(text)) + defined.update(service_keys) + for key in service_keys: + definition_sources.setdefault(key, service.relative_to(root).as_posix()) + + for path in src.rglob("*.axaml"): + text = path.read_text(encoding="utf-8") + referenced.update(m.group(1) for m in REF.finditer(text)) + for path in src.rglob("*.cs"): + if path.resolve() == service.resolve(): + continue + referenced.update(m.group(1) for m in QUOTED.finditer(path.read_text(encoding="utf-8")) if m.group(1) in defined) + + unconsumed = sorted(defined - referenced) + externally_consumed = sorted(set(unconsumed) & fluent_references) + candidates = sorted(set(unconsumed) - fluent_references) + print("UI resource audit") + print(f"Repo: {root}\nDefined keys: {len(defined)}\nRepository references: {len(referenced)}\nService-written keys: {len(service_keys)}\n") + print(f"Definitions consumed by verified {package_name} {package_version} templates ({len(externally_consumed)}):") + print( + "\n".join( + f" {key} (defined in {definition_sources.get(key, '')}; " + f"referenced by {', '.join(sorted(fluent_evidence[key]))})" + for key in externally_consumed + ) + if externally_consumed + else NONE_LABEL + ) + print(f"\nApplication definitions with no detected repository or verified Fluent consumer ({len(candidates)}):") + print("\n".join(f" {key} ({definition_sources.get(key, '')})" for key in candidates) if candidates else NONE_LABEL) + print("This report lists source-reference evidence. Review dynamic or reflection-based consumers before removal.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as error: + raise SystemExit(f"UI resource audit failed: {error}") from error diff --git a/scripts/diagnostics/avalonia-fluent-resource-references.json b/scripts/diagnostics/avalonia-fluent-resource-references.json new file mode 100644 index 00000000..f16063d9 --- /dev/null +++ b/scripts/diagnostics/avalonia-fluent-resource-references.json @@ -0,0 +1,3351 @@ +{ + "package": { + "id": "Avalonia.Themes.Fluent", + "version": "12.1.4" + }, + "repository": "https://github.com/AvaloniaUI/Avalonia/", + "commit": "2d79ba55fae1b1e71c69f93a668080c9029e6a31", + "sourceDirectory": "src/Avalonia.Themes.Fluent", + "sourceTreeApi": "https://api.github.com/repos/AvaloniaUI/Avalonia/git/trees/2d79ba55fae1b1e71c69f93a668080c9029e6a31?recursive=1", + "scannedFiles": [ + "src/Avalonia.Themes.Fluent/Accents/BaseColorsPalette.xaml", + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml", + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/Calendar.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/Carousel.xaml", + "src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContentPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/DataValidationErrors.xaml", + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml", + "src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml", + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/GridSplitter.xaml", + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/HeaderedContentControl.xaml", + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml", + "src/Avalonia.Themes.Fluent/Controls/Label.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml", + "src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml", + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml", + "src/Avalonia.Themes.Fluent/Controls/PopupRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/RefreshContainer.xaml", + "src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/SelectableTextBlock.xaml", + "src/Avalonia.Themes.Fluent/Controls/Separator.xaml", + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabControl.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStrip.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableViewCell.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml", + "src/Avalonia.Themes.Fluent/Controls/ThemeVariantScope.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml", + "src/Avalonia.Themes.Fluent/Controls/TransitioningContentControl.xaml", + "src/Avalonia.Themes.Fluent/Controls/TreeView.xaml", + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowNotificationManager.xaml", + "src/Avalonia.Themes.Fluent/DensityStyles/Compact.xaml", + "src/Avalonia.Themes.Fluent/FluentTheme.xaml", + "src/Avalonia.Themes.Fluent/Strings/InvariantResources.xaml" + ], + "scannedFileExtensions": [ + ".xaml", + ".axaml" + ], + "scannedReferenceKinds": [ + "DynamicResource", + "StaticResource" + ], + "scannedReferenceCount": 1262, + "references": { + "AccentButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AccentButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml" + ], + "AutoCompleteBoxSuggestionsListBackground": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "AutoCompleteBoxSuggestionsListBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "AutoCompleteListBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "AutoCompleteListMargin": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "AutoCompleteListMaxHeight": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "AutoCompleteListPadding": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml" + ], + "BorderGapMaskConverter": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "BottomCornerRadiusFilterConverter": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "ButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ButtonPadding": [ + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ButtonSpinnerDecreaseButtonIcon": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "ButtonSpinnerIncreaseButtonIcon": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "ButtonSpinnerLeftThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "ButtonSpinnerRightThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "CalendarDatePickerBackground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerCalendarGlyphForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerCalendarGlyphForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerCurrentDayFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDatePickerTextForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "CalendarDayButtonFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/Calendar.xaml" + ], + "CalendarViewBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Calendar.xaml" + ], + "CalendarViewBlackoutForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Calendar.xaml" + ], + "CalendarViewCalendarItemForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewCalendarItemRevealBackground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewCalendarItemRevealBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Calendar.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewHoverBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewNavigationButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewNavigationButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewNavigationButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewNavigationButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewNavigationButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CalendarViewOutOfScopeBackground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewOutOfScopeForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewPressedBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewSelectedBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewSelectedHoverBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewSelectedPressedBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewTodayFontWeight": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml" + ], + "CalendarViewTodayForeground": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "CalendarViewWeekDayForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "CaptionButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "CaptionButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "CaptionButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "CaptionButtonHeight": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "CaptionButtonWidth": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "CheckBoxBackgroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBackgroundUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushChecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderBrushUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillChecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundFillUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckBackgroundStrokeUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxCheckGlyphForegroundUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundUnchecked": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundUncheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundUncheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxForegroundUncheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckBoxMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml" + ], + "CheckMarkPathData": [ + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "ComboBoxBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundBorderBrushFocused": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundBorderBrushUnfocused": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBackgroundUnfocused": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxDropDownBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxDropDownBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxDropDownGlyphForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ComboBoxDropDownGlyphForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxDropDownGlyphForegroundFocused": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxDropDownGlyphForegroundFocusedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxDropdownBorderPadding": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxDropdownBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxDropdownContentMargin": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxForegroundFocused": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxForegroundFocusedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxItemBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBackgroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushSelected": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemBorderBrushSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemForegroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxItemThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml" + ], + "ComboBoxMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxPadding": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxPlaceHolderForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxPlaceHolderForegroundFocusedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ComboBoxThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml" + ], + "ContentControlThemeFontFamily": [ + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml", + "src/Avalonia.Themes.Fluent/Controls/PopupRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml" + ], + "ControlContentThemeFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml", + "src/Avalonia.Themes.Fluent/Controls/PopupRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml" + ], + "ControlCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/Button.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/CheckBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml", + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "DatePickerButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterAcceptDismissHostGridHeight": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterBackground": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterHighlightFill": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterHighlightHeight": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterItemHeight": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerFlyoutPresenterItemPadding": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DatePickerFlyoutPresenterMonthPadding": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DatePickerFlyoutPresenterSpacerFill": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerHostMonthPadding": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerHostPadding": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerSpacerFill": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerSpacerFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerSpacerThemeWidth": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerThemeMaxWidth": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DatePickerThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "DateTimeFlyoutBorderPadding": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "DateTimeFlyoutBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "DateTimeFlyoutButtonBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DateTimePickerFlyoutButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml" + ], + "DefaultTextBoxContextFlyout": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "DrawerPaneButtonIcon": [ + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml" + ], + "DropDownButtonMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/DropDownButton.xaml" + ], + "ExpanderChevronBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronButtonSize": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderChevronMargin": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentDownBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentLeftBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentPadding": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentRightBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderContentUpBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderHorizontalContentAlignment": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderPadding": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderHeaderVerticalContentAlignment": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "ExpanderMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentButtonSpinnerRepeatButton": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "FluentCalendarButton": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarItem.xaml" + ], + "FluentCalendarDatePickerButton": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml" + ], + "FluentDatePickerFlyoutButton": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "FluentDateTimePickerButton": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "FluentDateTimePickerDownButton": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "FluentDateTimePickerItem": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "FluentDateTimePickerUpButton": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "FluentDrawnCaptionButton": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "FluentExpanderToggleButtonDownTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentExpanderToggleButtonLeftTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentExpanderToggleButtonRightTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentExpanderToggleButtonTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentExpanderToggleButtonUpTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "FluentMenuItemIconTheme": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "FluentMenuScrollViewer": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml" + ], + "FluentScrollBarLineButton": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "FluentScrollBarPageButton": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "FluentScrollBarThumb": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "FluentSliderHorizontalRepeatButton": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "FluentSliderThumbTheme": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "FluentSliderVerticalRepeatButton": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "FluentSplitButtonComponent": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "FluentTextBoxButton": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "FluentTextBoxToggleButton": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "FluentTimePickerFlyoutButton": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "FluentTopLevelMenuItem": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "FluentTreeViewExpandCollapseChevron": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "FlyoutBorderThemeBrush": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutBorderThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml" + ], + "FlyoutBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutContentThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutPresenterBackground": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutThemeMaxHeight": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutThemeMaxWidth": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "FlyoutThemeMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "FlyoutThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml", + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml" + ], + "GroupBoxBackground": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "GroupBoxBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "GroupBoxBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "GroupBoxHeaderFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "GroupBoxHeaderForeground": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "GroupBoxPadding": [ + "src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml" + ], + "HorizontalMenuFlyoutItemMargin": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "HorizontalMenuFlyoutItemThemePaddingNarrow": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "HorizontalMenuFlyoutPresenter": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "HorizontalMenuFlyoutThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml" + ], + "HorizontalMenuItem": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "HorizontalScrollBarContextFlyout": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "HorizontalSmallScrollThumbScaleTransform": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "HorizontalTextBoxContextFlyout": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "HyperlinkButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundVisited": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundVisitedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkButtonForegroundVisitedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/HyperlinkButton.xaml" + ], + "HyperlinkVisitedColor": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "IconElementThemeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml" + ], + "IconElementThemeWidth": [ + "src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml" + ], + "Icons": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "KeyGestureConverter": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "LeftCornerRadiusFilterConverter": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "ListBoxBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ListBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml" + ], + "ListBoxItemPadding": [ + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "MenuBarHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml" + ], + "MenuBarItemPadding": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml" + ], + "MenuFlyoutItemBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemChevronMargin": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemForeground": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemKeyboardAcceleratorTextForeground": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemKeyboardAcceleratorTextForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemKeyboardAcceleratorTextForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemKeyboardAcceleratorTextForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutItemThemePaddingNarrow": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutPresenterBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutPresenterBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutPresenterBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutPresenterThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutScrollerMargin": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSeparatorThemeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Separator.xaml" + ], + "MenuFlyoutSeparatorThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/Separator.xaml" + ], + "MenuFlyoutSubItemChevron": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSubItemChevronDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSubItemChevronPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSubItemChevronPressed": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSubItemChevronSubMenuOpened": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutSubItemPopupHorizontalOffset": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuFlyoutThemeMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuIconPresenterMargin": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuInputGestureTextMargin": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "MenuItemChevronPathData": [ + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml" + ], + "NavigationBarBackground": [ + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "NavigationBarForeground": [ + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "NavigationControlNavBarBackground": [ + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml" + ], + "NavigationControlNavBarHeight": [ + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml" + ], + "NavigationPageBackIcon": [ + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "NotificationCardBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardErrorBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardForegroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardInformationBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardProgressBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardSuccessBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "NotificationCardWarningBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/NotificationCard.xaml" + ], + "OverlayCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBoxItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/FlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/Menu.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml", + "src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "PasswordBoxHideButtonData": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "PasswordBoxRevealButtonData": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "PipsPagerItemTheme": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNavigationButtonTheme": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNextButtonTheme": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNextPageButtonData": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerNextPageButtonVerticalData": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerPreviousButtonTheme": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerPreviousPageButtonData": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerPreviousPageButtonVerticalData": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBackground": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorBorderBrushSelected": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorForeground": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PipsPagerSelectionIndicatorForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml" + ], + "PrimaryButtonBorderMultiplier": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "ProgressBarBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "ProgressBarThemeMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "RadioButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphFill": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphStroke": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphStrokeDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphStrokePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonCheckGlyphStrokePressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedFill": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedStroke": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedStrokeDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedStrokePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseCheckedStrokePressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseFill": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseStroke": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseStrokeDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseStrokePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RadioButtonOuterEllipseStrokePressed": [ + "src/Avalonia.Themes.Fluent/Controls/RadioButton.xaml" + ], + "RefreshVisualizerBackground": [ + "src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml" + ], + "RefreshVisualizerForeground": [ + "src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml" + ], + "RefreshVisualizerIndicatorSize": [ + "src/Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml" + ], + "RepeatButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RepeatButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" + ], + "RightCornerRadiusFilterConverter": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "ScrollBarBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonArrowForeground": [ + "src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonArrowForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonArrowForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonArrowForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonArrowIconFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/MenuScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarPanningThumbBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarPanningThumbBackgroundColor": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "ScrollBarSize": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarThumbBackgroundColor": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarThumbFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarThumbFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarThumbFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarTrackBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarTrackFill": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarTrackFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarTrackStroke": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollBarTrackStrokePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "ScrollViewerScrollBarsSeparatorBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml" + ], + "SecondaryButtonBorderMultiplier": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SelectableTextBlockContextFlyout": [ + "src/Avalonia.Themes.Fluent/Controls/SelectableTextBlock.xaml" + ], + "SeparatorBorderMultiplier": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SliderBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderContainerBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderContainerBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderContainerBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderContainerBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderHorizontalHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderHorizontalThumbHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderHorizontalThumbWidth": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderOutsideTickBarThemeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderPostContentMargin": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderPreContentMargin": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderThumbBackground": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderThumbBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderThumbBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderThumbBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderThumbCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTickBarFill": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTickBarFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackFill": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackThemeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackValueFill": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackValueFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackValueFillPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderTrackValueFillPressed": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderVerticalThumbHeight": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderVerticalThumbWidth": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SliderVerticalWidth": [ + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml" + ], + "SplitButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushChecked": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonPrimaryButtonSize": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonSecondaryButtonSize": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitButtonSeparatorWidth": [ + "src/Avalonia.Themes.Fluent/Controls/SplitButton.xaml" + ], + "SplitViewCompactPaneThemeLength": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SplitViewLightDismissOverlayBackground": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SplitViewOpenPaneThemeLength": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SplitViewPaneAnimationCloseDuration": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SplitViewPaneAnimationEasing": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SplitViewPaneAnimationOpenDuration": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "StringDatePickerDayText": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "StringDatePickerMonthText": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "StringDatePickerYearText": [ + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml" + ], + "StringFormatConverter": [ + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "StringManagedFileChooserCancelText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserDateModifiedColumn": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserFileNamePlaceholder": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserNameColumn": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserOkText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserOverwritePromptCancelText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserOverwritePromptConfirmText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserOverwritePromptFileAlreadyExistsText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserShowHiddenFilesText": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserSizeColumn": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringManagedFileChooserTypeColumn": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "StringScrollBarBottom": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarLeftEdge": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarPageDown": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarPageLeft": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarPageRight": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarPageUp": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarRightEdge": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarScrollDown": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarScrollHere": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarScrollLeft": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarScrollRight": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarScrollUp": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringScrollBarTop": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "StringTextFlyoutCopyText": [ + "src/Avalonia.Themes.Fluent/Controls/SelectableTextBlock.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "StringTextFlyoutCutText": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "StringTextFlyoutPasteText": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "StringTimePickerHourText": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "StringTimePickerMinuteText": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "StringTimePickerSecondText": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "SystemAccentColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContextMenu.xaml", + "src/Avalonia.Themes.Fluent/Controls/GridSplitter.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemAccentColorDark1": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "SystemAccentColorDark2": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarButton.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDayButton.xaml" + ], + "SystemAccentColorLight1": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemAltHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemAltMediumColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemAltMediumHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemAltMediumLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/GridSplitter.xaml" + ], + "SystemBaseHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemBaseLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemBaseMediumColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemBaseMediumHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemBaseMediumLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeAltLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeBlackHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeBlackLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeBlackMediumColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeBlackMediumLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeDisabledHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeDisabledLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemChromeGrayColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeHighColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemChromeMediumColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "SystemChromeMediumLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemChromeWhiteColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "SystemControlBackgroundAccentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundAltHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml" + ], + "SystemControlBackgroundAltMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "SystemControlBackgroundAltMediumHighBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "SystemControlBackgroundAltMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundBaseLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "SystemControlBackgroundBaseMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundBaseMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundChromeMediumBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml" + ], + "SystemControlBackgroundChromeMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "SystemControlBackgroundChromeWhiteBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundListLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundListMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlBackgroundTransparentRevealBorderBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlDisabledBaseLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlDisabledBaseMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "SystemControlDisabledChromeDisabledHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlDisabledChromeDisabledLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlDisabledChromeMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlDisabledTransparentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlErrorTextForegroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/DataValidationErrors.xaml", + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/Slider.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "SystemControlFocusVisualMargin": [ + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml" + ], + "SystemControlFocusVisualPrimaryBrush": [ + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml" + ], + "SystemControlFocusVisualPrimaryThickness": [ + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml" + ], + "SystemControlFocusVisualSecondaryBrush": [ + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml" + ], + "SystemControlFocusVisualSecondaryThickness": [ + "src/Avalonia.Themes.Fluent/Controls/AdornerLayer.xaml" + ], + "SystemControlForegroundAccentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundAltHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundBaseHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContentPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml", + "src/Avalonia.Themes.Fluent/Controls/PopupRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml", + "src/Avalonia.Themes.Fluent/Controls/ThemeVariantScope.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowNotificationManager.xaml" + ], + "SystemControlForegroundBaseLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundBaseMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "SystemControlForegroundBaseMediumHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundBaseMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/Separator.xaml" + ], + "SystemControlForegroundChromeMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundChromeWhiteBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlForegroundTransparentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SystemControlHighlightAccent2RevealBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAccent3RevealBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAccentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "SystemControlHighlightAltAccentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAltBaseHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/DateTimePickerShared.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "SystemControlHighlightAltBaseMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAltBaseMediumHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAltBaseMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" + ], + "SystemControlHighlightAltChromeWhiteBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAltTransparentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightAltTransparentRevealBorderBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightBaseHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightBaseMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightBaseMediumHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightBaseMediumLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightChromeWhiteBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightListAccentHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "SystemControlHighlightListAccentLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "SystemControlHighlightListAccentMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml" + ], + "SystemControlHighlightListLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "SystemControlHighlightListLowRevealBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightListMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml", + "src/Avalonia.Themes.Fluent/Controls/ListBoxItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml" + ], + "SystemControlHighlightListMediumRevealBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHighlightTransparentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ProgressBar.xaml" + ], + "SystemControlHyperlinkBaseHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlHyperlinkBaseMediumHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlPageBackgroundAltHighBrush": [ + "src/Avalonia.Themes.Fluent/Controls/CarouselPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/ContentPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemControlPageBackgroundAltMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlPageBackgroundBaseLowBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlPageBackgroundChromeLowBrush": [ + "src/Avalonia.Themes.Fluent/Controls/SplitView.xaml" + ], + "SystemControlPageBackgroundMediumAltMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlPageTextBaseHighBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlPageTextBaseMediumBrush": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlTransientBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlTransientBorderBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlTransparentBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "SystemControlTransparentRevealBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemControlTransparentRevealBorderBrush": [ + "src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml" + ], + "SystemErrorTextColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml" + ], + "SystemListLowColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemListMediumColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "SystemRegionBrush": [ + "src/Avalonia.Themes.Fluent/Controls/EmbeddableControlRoot.xaml", + "src/Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml", + "src/Avalonia.Themes.Fluent/Controls/Window.xaml" + ], + "SystemRegionColor": [ + "src/Avalonia.Themes.Fluent/Accents/BaseResources.xaml" + ], + "TabControlBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TabControl.xaml" + ], + "TabControlTopPlacementItemMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabControl.xaml" + ], + "TabItemHeaderBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundUnselected": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundUnselectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderBackgroundUnselectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabItemHeaderForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundUnselected": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundUnselectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderForegroundUnselectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderSelectedPipeFill": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabItemHeaderThemeFontWeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml", + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabItemMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabControl.xaml" + ], + "TabItemMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml" + ], + "TabItemPipeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml" + ], + "TabItemVerticalPipeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabItem.xaml" + ], + "TabStripItemMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabStripItemPipeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TabStripItem.xaml" + ], + "TabbedPageTabControlTheme": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundUnselected": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundUnselectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderBackgroundUnselectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderForegroundUnselected": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderIconSize": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderIndicatorCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderIndicatorMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderPadding": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderPipeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderSpacing": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemHeaderVerticalPipeHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabItemTheme": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripBorderThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripCornerRadius": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TabbedPageTabStripPadding": [ + "src/Avalonia.Themes.Fluent/Controls/TabbedPage.xaml" + ], + "TableViewCellPadding": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewCell.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml" + ], + "TableViewColumnHeaderResizerTemplate": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml" + ], + "TableViewColumnHeaderSeparatorBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml" + ], + "TableViewColumnResizerMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml" + ], + "TableViewColumnResizerWidth": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewColumnHeader.xaml" + ], + "TableViewRowBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowBackgroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowForegroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewRowPadding": [ + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml", + "src/Avalonia.Themes.Fluent/Controls/TableViewRow.xaml" + ], + "TableViewScrollViewerTheme": [ + "src/Avalonia.Themes.Fluent/Controls/TableView.xaml" + ], + "TextBoxClearButtonData": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextCaretHandleDrawing": [ + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TextCaretHandlePath": [ + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TextControlBackground": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBackgroundFocused": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderBrushFocused": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlBorderThemeThicknessFocused": [ + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlForeground": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlForegroundFocused": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlPlaceholderForeground": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/CalendarDatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/DatePicker.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TextControlPlaceholderOpacity": [ + "src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlSelectionHighlightColor": [ + "src/Avalonia.Themes.Fluent/Controls/SelectableTextBlock.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TextControlThemeMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/ButtonSpinner.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextControlThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml", + "src/Avalonia.Themes.Fluent/Controls/NumericUpDown.xaml", + "src/Avalonia.Themes.Fluent/Controls/TextBox.xaml" + ], + "TextSelectHandleSize": [ + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TextSelectionHandleDrawing": [ + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TextSelectionHandlePath": [ + "src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" + ], + "TimePickerBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterAcceptDismissHostGridHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterHighlightFill": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterHighlightHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterItemHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerFlyoutPresenterSpacerFill": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerHostPadding": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerSpacerFill": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerSpacerFillDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerSpacerThemeWidth": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerThemeMaxWidth": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TimePickerThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/TimePicker.xaml" + ], + "TitleBarBackgroundBrush": [ + "src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml" + ], + "ToggleButtonBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushChecked": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundChecked": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundCheckedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundCheckedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundCheckedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundIndeterminate": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundIndeterminateDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundIndeterminatePointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundIndeterminatePressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleButtonForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleButton.xaml" + ], + "ToggleSwitchContainerBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchContainerBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchContainerBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchContentForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOff": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOffDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOffPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOffPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOn": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOnDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOnPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchFillOnPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchHeaderForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOff": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOffDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOffPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOffPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOn": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOnDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOnPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchKnobFillOnPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchOnStrokeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchOuterBorderStrokeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchPostContentMargin": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchPreContentMargin": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOff": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOffDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOffPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOffPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOn": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOnDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOnPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchStrokeOnPressed": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchThemeMinWidth": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToggleSwitchTopHeaderMargin": [ + "src/Avalonia.Themes.Fluent/Controls/ToggleSwitch.xaml" + ], + "ToolTipBackground": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipBorderThemePadding": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipContentMaxWidth": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipContentThemeFontSize": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "ToolTipForeground": [ + "src/Avalonia.Themes.Fluent/Controls/ToolTip.xaml" + ], + "TooltipDataValidationErrors": [ + "src/Avalonia.Themes.Fluent/Controls/DataValidationErrors.xaml" + ], + "TopCornerRadiusFilterConverter": [ + "src/Avalonia.Themes.Fluent/Controls/Expander.xaml" + ], + "TreeViewItemBackground": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBackgroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrush": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderBrushSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemBorderThemeThickness": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemCollapsedChevronPathData": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemExpandCollapseChevronMargin": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemExpandCollapseChevronSize": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemExpandedChevronPathData": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForeground": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundSelected": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundSelectedDisabled": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundSelectedPointerOver": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemForegroundSelectedPressed": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemIndent": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "TreeViewItemMinHeight": [ + "src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml" + ], + "VerticalScrollBarContextFlyout": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "VerticalSmallScrollThumbScaleTransform": [ + "src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" + ], + "settings_regular": [ + "src/Avalonia.Themes.Fluent/Controls/PathIcon.xaml" + ] + } +} diff --git a/scripts/report-analyzers.py b/scripts/diagnostics/report-analyzers.py similarity index 99% rename from scripts/report-analyzers.py rename to scripts/diagnostics/report-analyzers.py index 8a81ab37..4ac4946c 100755 --- a/scripts/report-analyzers.py +++ b/scripts/diagnostics/report-analyzers.py @@ -220,7 +220,7 @@ def print_report(diagnostics, report, prefix, last_code): for item in diagnostics: target = item['type'] or "" print(f" {item['ruleId']} [{item['level']}] {item['file']}:{item['line']}:{item['column']} {target} :: {item['message']}") - return last_code + return last_code if last_code else 1 def main(): @@ -231,7 +231,7 @@ def main(): parser.add_argument("-Output", default="") parser.add_argument("-Rebuild", action="store_true", help="force a full rebuild so diagnostics are regenerated even when sources are up to date") args = parser.parse_args() - root = Path(__file__).resolve().parents[1] + root = Path(__file__).resolve().parents[2] raw_dir = root / "artifacts/analyzers/raw" report = Path(args.Output) if args.Output else root / "artifacts/analyzers/analyzers.sarif" if shutil.which("dotnet") is None: diff --git a/scripts/diagnostics/update-avalonia-fluent-resource-references.py b/scripts/diagnostics/update-avalonia-fluent-resource-references.py new file mode 100755 index 00000000..eaa7c240 --- /dev/null +++ b/scripts/diagnostics/update-avalonia-fluent-resource-references.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Refresh Fluent resource-reference evidence from the exact NuGet source commit.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +import json +from pathlib import Path +import re +import urllib.request +import xml.etree.ElementTree as ET +from zipfile import ZipFile + + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = Path(__file__).with_name("avalonia-fluent-resource-references.json") +PACKAGE_ID = "Avalonia.Themes.Fluent" +EXPECTED_REPOSITORY = "https://github.com/AvaloniaUI/Avalonia/" +SOURCE_DIRECTORY = "src/Avalonia.Themes.Fluent/" +PACKAGE_REFERENCE = "https://api.nuget.org/v3-flatcontainer/avalonia.themes.fluent/{version}/avalonia.themes.fluent.{version}.nupkg" +SOURCE_TREE_API = "https://api.github.com/repos/AvaloniaUI/Avalonia/git/trees/{commit}?recursive=1" +RAW_SOURCE = "https://raw.githubusercontent.com/AvaloniaUI/Avalonia/{commit}/{path}" +MARKUP_RESOURCE = re.compile(r"\{(?:DynamicResource|StaticResource)([^{}]*)\}") +RESOURCE_KEY_ARGUMENT = re.compile(r"(?:^|,)\s*ResourceKey\s*=\s*([^,\s]+)") +USER_AGENT = "ChapterTool-UI-resource-audit/1.0" + + +def pinned_version() -> str: + versions = set() + for path in (ROOT / "src").rglob("*.csproj"): + project = ET.parse(path).getroot() # nosec B314 + for reference in project.iter("PackageReference"): + if reference.attrib.get("Include", "").casefold() == PACKAGE_ID.casefold(): + version = reference.attrib.get("Version") + if version: + versions.add(version) + if len(versions) != 1: + raise ValueError(f"Expected one explicit {PACKAGE_ID} version, found: {sorted(versions)}") + return versions.pop() + + +def download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(request, timeout=30) as response: # nosec B310 + return response.read() + + +def package_source_commit(version: str) -> tuple[str, str]: + package = ZipFile(BytesIO(download(PACKAGE_REFERENCE.format(version=version)))) + with package: + nuspec_name = next(name for name in package.namelist() if name.endswith(".nuspec")) + metadata = ET.fromstring(package.read(nuspec_name)) # nosec B314 + details = next((node for node in metadata.iter() if node.tag.rsplit("}", 1)[-1] == "metadata"), None) + if details is None: + raise ValueError(f"{PACKAGE_ID} {version} package has no metadata") + package_values = {node.tag.rsplit("}", 1)[-1]: node.text for node in details} + if package_values.get("id", "").casefold() != PACKAGE_ID.casefold() or package_values.get("version") != version: + raise ValueError(f"NuGet package metadata does not match {PACKAGE_ID} {version}") + repository = next((node for node in details if node.tag.rsplit("}", 1)[-1] == "repository"), None) + if repository is None: + raise ValueError(f"{PACKAGE_ID} {version} package has no source repository metadata") + repository_url = repository.attrib.get("url", "").rstrip("/") + "/" + commit = repository.attrib.get("commit", "") + if repository_url != EXPECTED_REPOSITORY or not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError(f"Unexpected source metadata for {PACKAGE_ID} {version}: {repository.attrib}") + return repository_url, commit + + +def resource_key_references(text: str) -> set[str]: + references = set() + for match in MARKUP_RESOURCE.finditer(text): + arguments = match.group(1) + if not arguments or not arguments[0].isspace(): + continue + arguments = arguments.strip() + if not arguments: + continue + named_key = RESOURCE_KEY_ARGUMENT.search(arguments) + key = named_key.group(1) if named_key else arguments.split(",", 1)[0].strip().split()[0] + if key: + references.add(key) + return references + + +def source_files(commit: str) -> list[str]: + tree = json.loads(download(SOURCE_TREE_API.format(commit=commit))) + if tree.get("truncated"): + raise ValueError(f"GitHub returned a truncated source tree for commit {commit}") + paths = [ + entry["path"] + for entry in tree.get("tree", []) + if entry.get("type") == "blob" + and entry["path"].startswith(SOURCE_DIRECTORY) + and entry["path"].endswith((".xaml", ".axaml")) + ] + if not paths: + raise ValueError(f"No Fluent XAML files found in commit {commit}") + return sorted(paths) + + +def collect_references(commit: str) -> tuple[dict[str, list[str]], list[str]]: + paths = source_files(commit) + references: dict[str, set[str]] = {} + def scan(path: str) -> tuple[str, set[str]]: + source = download(RAW_SOURCE.format(commit=commit, path=path)) + document = ET.fromstring(source) # nosec B314 + keys = set() + for element in document.iter(): + values = [*element.attrib.values()] + if element.text: + values.append(element.text) + for value in values: + keys.update(resource_key_references(value)) + if element.tag.rsplit("}", 1)[-1] in {"StaticResource", "DynamicResource"}: + key = element.attrib.get("ResourceKey") + if key: + keys.add(key) + return path, keys + + with ThreadPoolExecutor(max_workers=8) as executor: + for path, keys in executor.map(scan, paths): + for key in keys: + references.setdefault(key, set()).add(path) + return {key: sorted(paths) for key, paths in sorted(references.items())}, paths + + +def generate_manifest() -> dict[str, object]: + version = pinned_version() + repository_url, commit = package_source_commit(version) + references, scanned_files = collect_references(commit) + return { + "package": {"id": PACKAGE_ID, "version": version}, + "repository": repository_url, + "commit": commit, + "sourceDirectory": SOURCE_DIRECTORY.rstrip("/"), + "sourceTreeApi": SOURCE_TREE_API.format(commit=commit), + "scannedFiles": scanned_files, + "scannedFileExtensions": [".xaml", ".axaml"], + "scannedReferenceKinds": ["DynamicResource", "StaticResource"], + "scannedReferenceCount": sum(len(paths) for paths in references.values()), + "references": references, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="report whether the committed manifest is current without writing it") + options = parser.parse_args() + generated = json.dumps(generate_manifest(), ensure_ascii=False, indent=2) + "\n" + if options.check: + current = MANIFEST.read_text(encoding="utf-8") if MANIFEST.exists() else "" + if current != generated: + print(f"Fluent resource evidence is out of date: {MANIFEST}") + return 1 + print(f"Fluent resource evidence is current: {MANIFEST}") + return 0 + MANIFEST.write_text(generated, encoding="utf-8") + manifest = json.loads(generated) + print( + f"Updated {MANIFEST} from {manifest['package']['id']} {manifest['package']['version']} " + f"at {manifest['commit']}: {len(manifest['references'])} keys, " + f"{manifest['scannedReferenceCount']} references." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError, StopIteration, ET.ParseError) as error: + raise SystemExit(f"Fluent resource evidence update failed: {error}") from error diff --git a/scripts/axaml-to-json.py b/scripts/localization/axaml-to-json.py similarity index 98% rename from scripts/axaml-to-json.py rename to scripts/localization/axaml-to-json.py index 0a1c92c1..e0fce2df 100755 --- a/scripts/axaml-to-json.py +++ b/scripts/localization/axaml-to-json.py @@ -45,7 +45,7 @@ def main() -> int: parser.add_argument("--check", action="store_true", help="fail when generated files differ") args = parser.parse_args() - root = Path(__file__).resolve().parents[1] + root = Path(__file__).resolve().parents[2] source_dir = (root / args.source_dir).resolve() if not args.source_dir.is_absolute() else args.source_dir output_dir = (root / args.output_dir).resolve() if not args.output_dir.is_absolute() else args.output_dir diff --git a/scripts/generate-app-icons-macos.sh b/scripts/maintenance/generate-app-icons-macos.sh similarity index 97% rename from scripts/generate-app-icons-macos.sh rename to scripts/maintenance/generate-app-icons-macos.sh index c930b855..80324886 100755 --- a/scripts/generate-app-icons-macos.sh +++ b/scripts/maintenance/generate-app-icons-macos.sh @@ -4,7 +4,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" svg="$repo_root/src/ChapterTool.Avalonia/Assets/Icons/app-icon.svg" icns="$repo_root/src/ChapterTool.Avalonia/Assets/Icons/app-icon.icns" ico="$repo_root/src/ChapterTool.Avalonia/Assets/Icons/app-icon.ico" diff --git a/scripts/normalize-changed-text-files.py b/scripts/maintenance/normalize-changed-text-files.py similarity index 86% rename from scripts/normalize-changed-text-files.py rename to scripts/maintenance/normalize-changed-text-files.py index c179b813..66301f80 100755 --- a/scripts/normalize-changed-text-files.py +++ b/scripts/maintenance/normalize-changed-text-files.py @@ -11,24 +11,17 @@ def git_paths(root, *args): result = subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) return {line.strip() for line in result.stdout.splitlines() if line.strip()} -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("paths", nargs="*") - parser.add_argument("-IncludeUntracked", "--include-untracked", dest="include_untracked", action="store_true") - parser.add_argument("-WhatIf", "--what-if", dest="what_if", action="store_true") - args = parser.parse_args() - root = Path(__file__).resolve().parents[1] +def explicit_paths(root, values): candidates = set() - for value in args.paths: + for value in values: path = (root / value).resolve() if not path.exists(): print(f"Warning: Path not found: {value}") continue candidates.update(str(item.relative_to(root)).replace("\\", "/") for item in (path.rglob("*") if path.is_dir() else [path]) if item.is_file()) - candidates.update(git_paths(root, "diff", "--name-only")) - candidates.update(git_paths(root, "diff", "--cached", "--name-only")) - if args.include_untracked: - candidates.update(git_paths(root, "ls-files", "--others", "--exclude-standard")) + return candidates + +def normalize_candidates(root, candidates, what_if): normalized, skipped = [], [] for relative in sorted(candidates): path = root / relative @@ -42,8 +35,23 @@ def main(): if not raw.startswith(b"\xef\xbb\xbf") and "\r" not in text: continue normalized.append(relative) - if not args.what_if: + if not what_if: path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), encoding="utf-8", newline="\n") + return normalized, skipped + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*") + parser.add_argument("-IncludeUntracked", "--include-untracked", dest="include_untracked", action="store_true") + parser.add_argument("-WhatIf", "--what-if", dest="what_if", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + candidates = explicit_paths(root, args.paths) + candidates.update(git_paths(root, "diff", "--name-only")) + candidates.update(git_paths(root, "diff", "--cached", "--name-only")) + if args.include_untracked: + candidates.update(git_paths(root, "ls-files", "--others", "--exclude-standard")) + normalized, skipped = normalize_candidates(root, candidates, args.what_if) print("Normalized files:\n" + "\n".join(f" {p}" for p in normalized) if normalized else "No changed text files required normalization.") if skipped: print("Skipped non-text paths:\n" + "\n".join(f" {p}" for p in skipped)) diff --git a/scripts/maintenance/subset-shortcut-font.py b/scripts/maintenance/subset-shortcut-font.py new file mode 100644 index 00000000..5f0eff33 --- /dev/null +++ b/scripts/maintenance/subset-shortcut-font.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Subset the Avalonia shortcut-symbol font with FontTools.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +DEFAULT_SOURCE = Path("src/ChapterTool.Avalonia.UI/Assets/Fonts/source/Iosevka-Regular.ttf") +DEFAULT_OUTPUT = Path("src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf") +KEEP_LAYOUT_FEATURES = "vert,vrtr,vrt2,vkna" + + +def required_text(extra_text: str) -> str: + # Shortcut text boxes accept canonical ASCII gestures. The remaining + # characters are the platform-neutral symbols produced for those gestures. + ascii_gestures = "".join(chr(value) for value in range(0x20, 0x7F)) + symbols = "⌘⌃⌥⇧⇞⇟↑↓←→↵⎋⌫⌦⎀␠" + return "".join(sorted(set(ascii_gestures + symbols + extra_text))) + + +def find_pyftsubset(path: str | None) -> str: + if path: + return path + + executable = shutil.which("pyftsubset") + if executable: + return executable + + raise RuntimeError( + "pyftsubset was not found. Install FontTools with " + "'uv sync --project scripts' or pass --pyftsubset." + ) + + +def supported_options(executable: str) -> str: + result = subprocess.run([executable, "--help"], check=False, capture_output=True, text=True) + return result.stdout + result.stderr + + +def run_subset(executable: str, source: Path, output: Path, text_file: Path) -> None: + command = [ + executable, + str(source), + f"--text-file={text_file}", + f"--output-file={output}", + "--name-languages=*", + f"--layout-features={KEEP_LAYOUT_FEATURES}", + ] + help_text = supported_options(executable) + if "--no-prune-codepage-ranges" in help_text: + command.append("--no-prune-codepage-ranges") + if "--drop-tables" in help_text: + command.append("--drop-tables+=BASE") + try: + subprocess.run(command, check=True, text=True) + except FileNotFoundError as error: + raise RuntimeError(f"FontTools command was not found: {executable}") from error + except subprocess.CalledProcessError as error: + raise RuntimeError(f"FontTools failed with exit code {error.returncode}") from error + + +def subset_font( + source: Path, + output: Path, + executable: str, + extra_text: str, + check: bool, +) -> int: + if not source.is_file(): + raise RuntimeError(f"Source font was not found: {source}") + if source.resolve() == output.resolve(): + raise RuntimeError("Source and output must be different files") + + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="chaptertool-font-") as temporary: + temporary_path = Path(temporary) + text_file = temporary_path / "characters.txt" + generated_font = temporary_path / output.name + text_file.write_text(required_text(extra_text), encoding="utf-8") + run_subset(executable, source, generated_font, text_file) + + if check: + if not output.is_file() or output.read_bytes() != generated_font.read_bytes(): + print(f"{output} is not up to date", file=sys.stderr) + return 1 + print(f"{output} is up to date") + return 0 + + os.replace(generated_font, output) + print(f"Wrote {output} ({output.stat().st_size:,} bytes)") + return 0 + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Original font path") + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Subset font path") + parser.add_argument("--pyftsubset", help="Path to the pyftsubset executable") + parser.add_argument("--extra-text", default="", help="Additional characters to include") + parser.add_argument("--check", action="store_true", help="Fail when the output is not current") + args = parser.parse_args(arguments) + + try: + return subset_font(args.source, args.output, find_pyftsubset(args.pyftsubset), args.extra_text, args.check) + except RuntimeError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish.ps1 b/scripts/packaging/publish-desktop.ps1 similarity index 93% rename from scripts/publish.ps1 rename to scripts/packaging/publish-desktop.ps1 index 4b63c510..962727d6 100644 --- a/scripts/publish.ps1 +++ b/scripts/packaging/publish-desktop.ps1 @@ -13,10 +13,10 @@ if ($Runtime -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { } if (-not $Runtime.StartsWith("win-", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "publish.ps1 only supports Windows runtime identifiers. Use scripts/publish.sh for '$Runtime'." + throw "publish-desktop.ps1 only supports Windows runtime identifiers. Use scripts/packaging/publish-desktop.sh for '$Runtime'." } -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..") $project = Join-Path $repoRoot "src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj" $publishKind = if ($SelfContained) { "self-contained" } else { "framework-dependent" } $output = Join-Path $repoRoot "artifacts/publish/$publishKind/$Runtime" diff --git a/scripts/publish.sh b/scripts/packaging/publish-desktop.sh similarity index 93% rename from scripts/publish.sh rename to scripts/packaging/publish-desktop.sh index 8119ed75..89a9a965 100755 --- a/scripts/publish.sh +++ b/scripts/packaging/publish-desktop.sh @@ -2,10 +2,10 @@ # Publish ChapterTool.Avalonia from bash. # Tested on macOS (bash 3.2), Linux (bash 4+/5), and Git Bash on Windows. # Usage: -# ./scripts/publish.sh # win-x64, framework-dependent -# ./scripts/publish.sh -Runtime osx-arm64 -# ./scripts/publish.sh -Runtime linux-x64 -SelfContained -# ./scripts/publish.sh -Runtime osx-arm64 -NoRestore -PublishSingleFile # creates .app and .dmg on macOS +# ./scripts/packaging/publish-desktop.sh # win-x64, framework-dependent +# ./scripts/packaging/publish-desktop.sh -Runtime osx-arm64 +# ./scripts/packaging/publish-desktop.sh -Runtime linux-x64 -SelfContained +# ./scripts/packaging/publish-desktop.sh -Runtime osx-arm64 -NoRestore -PublishSingleFile # creates .app and .dmg on macOS set -euo pipefail # ---- defaults ---- @@ -74,7 +74,7 @@ fi # ---- paths ---- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -repo_root="$(cd "$script_dir/.." && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" project="$repo_root/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj" if [[ "$SelfContained" == "true" ]]; then @@ -93,6 +93,9 @@ case "$Runtime" in dotnet_output="$output/.publish-raw" fi ;; + *) + : + ;; esac # ---- publish ---- diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index c9a491d7..54ea908f 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -2,13 +2,26 @@ name = "chaptertool-scripts" version = "0.1.0" description = "Repository utility scripts for ChapterTool" -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "defusedxml>=0.7", + "fonttools>=4.44", ] +[project.scripts] +subset-shortcut-font = "command:subset_shortcut_font" +check-shortcut-font = "command:check_shortcut_font" +lint-scripts = "command:lint_scripts" + [tool.uv] -package = false +package = true + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["command"] [dependency-groups] dev = [ diff --git a/scripts/testing/check-headless-xunit.py b/scripts/testing/check-headless-xunit.py new file mode 100755 index 00000000..62f603f5 --- /dev/null +++ b/scripts/testing/check-headless-xunit.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Check the xUnit dependency declared by Avalonia.Headless.XUnit.""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import http.client +import io +import re +import sys +from urllib.parse import urlsplit +import zipfile +from pathlib import Path +from xml.etree import ElementTree + + +PACKAGE = "Avalonia.Headless.XUnit" +NUGET_BASE = "https://api.nuget.org/v3-flatcontainer" +XUNIT_ID = "xunit.v3.extensibility.core" +VERSION_PATTERN = re.compile(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$") + + +def load_json(url: str) -> dict: + with open_nuget_url(url) as response: + import json + + return json.load(response) + + +@contextmanager +def open_nuget_url(url: str): + parsed = urlsplit(url) + if (parsed.scheme != "https" or parsed.hostname != "api.nuget.org" or parsed.port not in (None, 443) + or parsed.username or parsed.password): + raise ValueError("NuGet metadata URLs must use HTTPS on api.nuget.org.") + connection = http.client.HTTPSConnection("api.nuget.org", timeout=15) + try: + connection.request("GET", parsed.path, headers={"Accept": "application/json, application/octet-stream"}) + response = connection.getresponse() + if response.status != 200: + raise OSError(f"NuGet returned HTTP {response.status} for {parsed.path}.") + yield response + finally: + connection.close() + + +def version_key(version: str) -> tuple[int, int, int, int, str]: + match = VERSION_PATTERN.match(version) + if not match: + return (0, 0, 0, 0, version) + major, minor, patch = (int(value or 0) for value in match.groups()[:3]) + prerelease = 0 if "-" in version else 1 + return (major, minor, patch, prerelease, version) + + +def latest_stable_version() -> str: + versions = load_json(f"{NUGET_BASE}/{PACKAGE.lower()}/index.json")["versions"] + stable = [version for version in versions if "-" not in version] + if not stable: + raise RuntimeError(f"No stable versions found for {PACKAGE}") + return max(stable, key=version_key) + + +def dependency_version(version: str) -> str | None: + url = f"{NUGET_BASE}/{PACKAGE.lower()}/{version}/{PACKAGE.lower()}.{version}.nupkg" + with open_nuget_url(url) as response: + archive = zipfile.ZipFile(io.BytesIO(response.read())) + nuspec_name = next(name for name in archive.namelist() if name.lower().endswith(".nuspec")) + root = ElementTree.fromstring(archive.read(nuspec_name)) # nosec B314 + for dependency in root.iter(): + if dependency.tag.rsplit("}", 1)[-1] != "dependency": + continue + if dependency.attrib.get("id", "").lower() == XUNIT_ID: + return dependency.attrib.get("version") + return None + + +def project_version(path: Path) -> str | None: + root = ElementTree.parse(path).getroot() # nosec B314 + for reference in root.iter(): + if reference.tag.rsplit("}", 1)[-1] == "PackageReference" and reference.attrib.get("Include", "").lower() == "xunit.v3": + return reference.attrib.get("Version") + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="NuGet package version to inspect (defaults to latest stable)") + parser.add_argument("--project", type=Path, help="Project file whose xunit.v3 reference should be shown") + args = parser.parse_args() + + try: + version = args.version or latest_stable_version() + dependency = dependency_version(version) + except (OSError, ValueError, ElementTree.ParseError, StopIteration, RuntimeError) as error: + print(f"Unable to inspect {PACKAGE}: {error}", file=sys.stderr) + return 2 + + print(f"{PACKAGE} {version}") + print(f"Declared {XUNIT_ID}: {dependency or '(not declared)'}") + if dependency is None: + print("Result: no xUnit extensibility dependency was found.") + elif version_key(dependency) >= version_key("4.0.0"): + print("Result: the package declares xUnit 4-compatible extensibility.") + else: + print("Result: the package still declares an xUnit 3 extensibility dependency.") + if args.project: + print(f"Project xunit.v3: {project_version(args.project) or '(not found)'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-coverage.py b/scripts/testing/collect-coverage.py similarity index 98% rename from scripts/test-coverage.py rename to scripts/testing/collect-coverage.py index 092427c1..173e7e37 100755 --- a/scripts/test-coverage.py +++ b/scripts/testing/collect-coverage.py @@ -107,11 +107,11 @@ def main(): parser.add_argument("-NoBuild", action="store_true") parser.add_argument("-SkipHtml", action="store_true") args = parser.parse_args() - root = Path(__file__).resolve().parents[1] + root = Path(__file__).resolve().parents[2] output = root / "artifacts/coverage" results = output / "test-results" report_dir = output / "html" - runsettings = root / "scripts/coverage.runsettings" + runsettings = root / "scripts/testing/coverage.runsettings" tests = [root / p for p in ("tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj", "tests/ChapterTool.Infrastructure.Tests/ChapterTool.Infrastructure.Tests.csproj", "tests/ChapterTool.CommandLine.Tests/ChapterTool.CommandLine.Tests.csproj", "tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj", "tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj")] builds = [root / p for p in ("src/ChapterTool.Core/ChapterTool.Core.csproj", "src/ChapterTool.Infrastructure/ChapterTool.Infrastructure.csproj", "src/ChapterTool.CommandLine/ChapterTool.CommandLine.csproj", "src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj")] + tests if shutil.which("dotnet") is None: diff --git a/scripts/coverage.runsettings b/scripts/testing/coverage.runsettings similarity index 100% rename from scripts/coverage.runsettings rename to scripts/testing/coverage.runsettings diff --git a/scripts/uv.lock b/scripts/uv.lock index da2c30de..5e57bff3 100644 --- a/scripts/uv.lock +++ b/scripts/uv.lock @@ -1,13 +1,21 @@ version = 1 revision = 3 -requires-python = ">=3.8" +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] [[package]] name = "chaptertool-scripts" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "defusedxml" }, + { name = "fonttools", version = "4.60.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.dev-dependencies] @@ -16,7 +24,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "defusedxml", specifier = ">=0.7" }] +requires-dist = [ + { name = "defusedxml", specifier = ">=0.7" }, + { name = "fonttools", specifier = ">=4.44" }, +] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.16.5" }] @@ -30,27 +41,239 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "fonttools" +version = "4.60.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/c4/db6a7b5eb0656534c3aa2596c2c5e18830d74f1b9aa5aa8a7dff63a0b11d/fonttools-4.60.2.tar.gz", hash = "sha256:d29552e6b155ebfc685b0aecf8d429cb76c14ab734c22ef5d3dea6fdf800c92c", size = 3562254, upload-time = "2025-12-09T13:38:11.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/de/9e10a99fb3070accb8884886a41a4ce54e49bf2fa4fc63f48a6cf2061713/fonttools-4.60.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e36fadcf7e8ca6e34d490eef86ed638d6fd9c55d2f514b05687622cfc4a7050", size = 2850403, upload-time = "2025-12-09T13:35:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/e4/40/d5b369d1073b134f600a94a287e13b5bdea2191ba6347d813fa3da00e94a/fonttools-4.60.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e500fc9c04bee749ceabfc20cb4903f6981c2139050d85720ea7ada61b75d5c", size = 2398629, upload-time = "2025-12-09T13:35:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b5/123819369aaf99d1e4dc49f1de1925d4edc7379114d15a56a7dd2e9d56e6/fonttools-4.60.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22efea5e784e1d1cd8d7b856c198e360a979383ebc6dea4604743b56da1cbc34", size = 4893471, upload-time = "2025-12-09T13:35:58.927Z" }, + { url = "https://files.pythonhosted.org/packages/24/29/f8f8acccb9716b899be4be45e9ce770d6aa76327573863e68448183091b0/fonttools-4.60.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:677aa92d84d335e4d301d8ba04afca6f575316bc647b6782cb0921943fcb6343", size = 4854686, upload-time = "2025-12-09T13:36:01.767Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/f3f51d7519f44f2dd5c9a60d7cd41185ebcee4348f073e515a3a93af15ff/fonttools-4.60.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:edd49d3defbf35476e78b61ff737ff5efea811acff68d44233a95a5a48252334", size = 4871233, upload-time = "2025-12-09T13:36:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/4d4fd47d3bc40ab4d76718555185f8adffb5602ea572eac4bbf200c47d22/fonttools-4.60.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:126839492b69cecc5baf2bddcde60caab2ffafd867bbae2a88463fce6078ca3a", size = 4988936, upload-time = "2025-12-09T13:36:08.42Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/83bbdefa43f2c3ae206fd8c4b9a481f3c913eef871b1ce9a453069239e39/fonttools-4.60.2-cp310-cp310-win32.whl", hash = "sha256:ffcab6f5537136046ca902ed2491ab081ba271b07591b916289b7c27ff845f96", size = 2278044, upload-time = "2025-12-09T13:36:10.641Z" }, + { url = "https://files.pythonhosted.org/packages/d4/04/7d9a137e919d6c9ef26704b7f7b2580d9cfc5139597588227aacebc0e3b7/fonttools-4.60.2-cp310-cp310-win_amd64.whl", hash = "sha256:9c68b287c7ffcd29dd83b5f961004b2a54a862a88825d52ea219c6220309ba45", size = 2326522, upload-time = "2025-12-09T13:36:12.981Z" }, + { url = "https://files.pythonhosted.org/packages/e0/80/b7693d37c02417e162cc83cdd0b19a4f58be82c638b5d4ce4de2dae050c4/fonttools-4.60.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2aed0a7931401b3875265717a24c726f87ecfedbb7b3426c2ca4d2812e281ae", size = 2847809, upload-time = "2025-12-09T13:36:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9a/9c2c13bf8a6496ac21607d704e74e9cc68ebf23892cf924c9a8b5c7566b9/fonttools-4.60.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea6868e9d2b816c9076cfea77754686f3c19149873bdbc5acde437631c15df1", size = 2397302, upload-time = "2025-12-09T13:36:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/f6/ce38ff6b2d2d58f6fd981d32f3942365bfa30eadf2b47d93b2d48bf6097f/fonttools-4.60.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fa27f34950aa1fe0f0b1abe25eed04770a3b3b34ad94e5ace82cc341589678a", size = 5054418, upload-time = "2025-12-09T13:36:19.062Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/5353bea128ff39e857c31de3dd605725b4add956badae0b31bc9a50d4c8e/fonttools-4.60.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13a53d479d187b09bfaa4a35ffcbc334fc494ff355f0a587386099cb66674f1e", size = 5031652, upload-time = "2025-12-09T13:36:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/71/05/ebca836437f6ebd57edd6428e7eff584e683ff0556ddb17d62e3b731f46c/fonttools-4.60.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fac5e921d3bd0ca3bb8517dced2784f0742bc8ca28579a68b139f04ea323a779", size = 5030321, upload-time = "2025-12-09T13:36:23.515Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/eb9d2a2ce30c99f840c1cc3940729a970923cf39d770caf88909d98d516b/fonttools-4.60.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:648f4f9186fd7f1f3cd57dbf00d67a583720d5011feca67a5e88b3a491952cfb", size = 5154255, upload-time = "2025-12-09T13:36:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/08/a2/088b6ceba8272a9abb629d3c08f9c1e35e5ce42db0ccfe0c1f9f03e60d1d/fonttools-4.60.2-cp311-cp311-win32.whl", hash = "sha256:3274e15fad871bead5453d5ce02658f6d0c7bc7e7021e2a5b8b04e2f9e40da1a", size = 2276300, upload-time = "2025-12-09T13:36:27.772Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/8e4c3d908cc5dade7bb1316ce48589f6a24460c1056fd4b8db51f1fa309a/fonttools-4.60.2-cp311-cp311-win_amd64.whl", hash = "sha256:91d058d5a483a1525b367803abb69de0923fbd45e1f82ebd000f5c8aa65bc78e", size = 2327574, upload-time = "2025-12-09T13:36:30.89Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/530c9eddcd1c39219dc0aaede2b5a4c8ab80e0bb88d1b3ffc12944c4aac3/fonttools-4.60.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e0164b7609d2b5c5dd4e044b8085b7bd7ca7363ef8c269a4ab5b5d4885a426b2", size = 2847196, upload-time = "2025-12-09T13:36:33.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2f/4077a482836d5bbe3bc9dac1c004d02ee227cf04ed62b0a2dfc41d4f0dfd/fonttools-4.60.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1dd3d9574fc595c1e97faccae0f264dc88784ddf7fbf54c939528378bacc0033", size = 2395842, upload-time = "2025-12-09T13:36:35.47Z" }, + { url = "https://files.pythonhosted.org/packages/dd/05/aae5bb99c5398f8ed4a8b784f023fd9dd3568f0bd5d5b21e35b282550f11/fonttools-4.60.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98d0719f1b11c2817307d2da2e94296a3b2a3503f8d6252a101dca3ee663b917", size = 4949713, upload-time = "2025-12-09T13:36:37.874Z" }, + { url = "https://files.pythonhosted.org/packages/b4/37/49067349fc78ff0efbf09fadefe80ddf41473ca8f8a25400e3770da38328/fonttools-4.60.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d3ea26957dd07209f207b4fff64c702efe5496de153a54d3b91007ec28904dd", size = 4999907, upload-time = "2025-12-09T13:36:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/16/31/d0f11c758bd0db36b664c92a0f9dfdcc2d7313749aa7d6629805c6946f21/fonttools-4.60.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ee301273b0850f3a515299f212898f37421f42ff9adfc341702582ca5073c13", size = 4939717, upload-time = "2025-12-09T13:36:43.075Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bc/1cff0d69522e561bf1b99bee7c3911c08c25e919584827c3454a64651ce9/fonttools-4.60.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6eb4694cc3b9c03b7c01d65a9cf35b577f21aa6abdbeeb08d3114b842a58153", size = 5089205, upload-time = "2025-12-09T13:36:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/fb174f0069b7122e19828c551298bfd34fdf9480535d2a6ac2ed37afacd3/fonttools-4.60.2-cp312-cp312-win32.whl", hash = "sha256:57f07b616c69c244cc1a5a51072eeef07dddda5ebef9ca5c6e9cf6d59ae65b70", size = 2264674, upload-time = "2025-12-09T13:36:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/75/57/6552ffd6b582d3e6a9f01780c5275e6dfff1e70ca146101733aa1c12a129/fonttools-4.60.2-cp312-cp312-win_amd64.whl", hash = "sha256:310035802392f1fe5a7cf43d76f6ff4a24c919e4c72c0352e7b8176e2584b8a0", size = 2314701, upload-time = "2025-12-09T13:36:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/8381d0ca6b6c6c484660b03517ec5b5b81feeefca3808726dece36c652a9/fonttools-4.60.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bb5fd231e56ccd7403212636dcccffc96c5ae0d6f9e4721fa0a32cb2e3ca432", size = 2842063, upload-time = "2025-12-09T13:36:53.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/4367117ee8ff4f4374787a1222da0bd413d80cf3522111f727a7b8f80d1d/fonttools-4.60.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:536b5fab7b6fec78ccf59b5c59489189d9d0a8b0d3a77ed1858be59afb096696", size = 2393792, upload-time = "2025-12-09T13:36:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/a76b6dffa193869e54e32ca2f9abb0d0e66784bc8a24e6f86eb093015481/fonttools-4.60.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b9288fc38252ac86a9570f19313ecbc9ff678982e0f27c757a85f1f284d3400", size = 4924020, upload-time = "2025-12-09T13:36:58.229Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/0078200e2259f0061c86a74075f507d64c43dd2ab38971956a5c0012d344/fonttools-4.60.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93fcb420791d839ef592eada2b69997c445d0ce9c969b5190f2e16828ec10607", size = 4980070, upload-time = "2025-12-09T13:37:00.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/1f/d87c85a11cb84852c975251581862681e4a0c1c3bd456c648792203f311b/fonttools-4.60.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7916a381b094db4052ac284255186aebf74c5440248b78860cb41e300036f598", size = 4921411, upload-time = "2025-12-09T13:37:02.345Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/7efad650f5ed8e317c2633133ef3c64917e7adf2e4e2940c798f5d57ec6e/fonttools-4.60.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58c8c393d5e16b15662cfc2d988491940458aa87894c662154f50c7b49440bef", size = 5063465, upload-time = "2025-12-09T13:37:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/750518c4f8cdd79393b386bc81226047ade80239e58c6c9f5dbe1fdd8ea1/fonttools-4.60.2-cp313-cp313-win32.whl", hash = "sha256:19c6e0afd8b02008caa0aa08ab896dfce5d0bcb510c49b2c499541d5cb95a963", size = 2263443, upload-time = "2025-12-09T13:37:06.762Z" }, + { url = "https://files.pythonhosted.org/packages/b8/22/026c60376f165981f80a0e90bd98a79ae3334e9d89a3d046c4d2e265c724/fonttools-4.60.2-cp313-cp313-win_amd64.whl", hash = "sha256:6a500dc59e11b2338c2dba1f8cf11a4ae8be35ec24af8b2628b8759a61457b76", size = 2313800, upload-time = "2025-12-09T13:37:08.713Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/7cf1f5204e1366ddf9dc5cdc2789b571feb9eebcee0e3463c3f457df5f52/fonttools-4.60.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9387c532acbe323bbf2a920f132bce3c408a609d5f9dcfc6532fbc7e37f8ccbb", size = 2841690, upload-time = "2025-12-09T13:37:10.696Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/0bf83c6f863cc8b934952567fa2bf737cfcec8fc4ffb59b3f93820095f89/fonttools-4.60.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6f1c824185b5b8fb681297f315f26ae55abb0d560c2579242feea8236b1cfef", size = 2392191, upload-time = "2025-12-09T13:37:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/40090d148b8907fbea12e9bdf1ff149f30cdf1769e3b2c3e0dbf5106b88d/fonttools-4.60.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55a3129d1e4030b1a30260f1b32fe76781b585fb2111d04a988e141c09eb6403", size = 4873503, upload-time = "2025-12-09T13:37:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e0/d8b13f99e58b8c293781288ba62fe634f1f0697c9c4c0ae104d3215f3a10/fonttools-4.60.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b196e63753abc33b3b97a6fd6de4b7c4fef5552c0a5ba5e562be214d1e9668e0", size = 4968493, upload-time = "2025-12-09T13:37:18.272Z" }, + { url = "https://files.pythonhosted.org/packages/46/c5/960764d12c92bc225f02401d3067048cb7b282293d9e48e39fe2b0ec38a9/fonttools-4.60.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de76c8d740fb55745f3b154f0470c56db92ae3be27af8ad6c2e88f1458260c9a", size = 4920015, upload-time = "2025-12-09T13:37:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ab/839d8caf253d1eef3653ef4d34427d0326d17a53efaec9eb04056b670fff/fonttools-4.60.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ba6303225c95998c9fda2d410aa792c3d2c1390a09df58d194b03e17583fa25", size = 5031165, upload-time = "2025-12-09T13:37:23.57Z" }, + { url = "https://files.pythonhosted.org/packages/de/bf/3bc862796a6841cbe0725bb5512d272239b809dba631a4b0301df885e62d/fonttools-4.60.2-cp314-cp314-win32.whl", hash = "sha256:0a89728ce10d7c816fedaa5380c06d2793e7a8a634d7ce16810e536c22047384", size = 2267526, upload-time = "2025-12-09T13:37:25.821Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/c1909cacf00c76dc37b4743451561fbaaf7db4172c22a6d9394081d114c3/fonttools-4.60.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa8446e6ab8bd778b82cb1077058a2addba86f30de27ab9cc18ed32b34bc8667", size = 2319096, upload-time = "2025-12-09T13:37:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/29/b3/f66e71433f08e3a931b2b31a665aeed17fcc5e6911fc73529c70a232e421/fonttools-4.60.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4063bc81ac5a4137642865cb63dd270e37b3cd1f55a07c0d6e41d072699ccca2", size = 2925167, upload-time = "2025-12-09T13:37:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/2e/13/eeb491ff743594bbd0bee6e49422c03a59fe9c49002d3cc60eeb77414285/fonttools-4.60.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebfdb66fa69732ed604ab8e2a0431e6deff35e933a11d73418cbc7823d03b8e1", size = 2430923, upload-time = "2025-12-09T13:37:32.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/db609f785e460796e53c4dbc3874a5f4948477f27beceb5e2d24b2537666/fonttools-4.60.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50b10b3b1a72d1d54c61b0e59239e1a94c0958f4a06a1febf97ce75388dd91a4", size = 4877729, upload-time = "2025-12-09T13:37:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/85e4484dd4bfb03fee7bd370d65888cccbd3dee2681ee48c869dd5ccb23f/fonttools-4.60.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:beae16891a13b4a2ddec9b39b4de76092a3025e4d1c82362e3042b62295d5e4d", size = 5096003, upload-time = "2025-12-09T13:37:37.862Z" }, + { url = "https://files.pythonhosted.org/packages/30/49/1a98e44b71030b83d2046f981373b80571868259d98e6dae7bc20099dac6/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522f017fdb3766fd5d2d321774ef351cc6ce88ad4e6ac9efe643e4a2b9d528db", size = 4974410, upload-time = "2025-12-09T13:37:40.166Z" }, + { url = "https://files.pythonhosted.org/packages/42/07/d6f775d950ee8a841012472c7303f8819423d8cc3b4530915de7265ebfa2/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cceceaf9c09a965a75b84a4b240dd3768e596ffb65ef53852681606fe7c9ba", size = 5002036, upload-time = "2025-12-09T13:37:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/73/f6/ba6458f83ce1a9f8c3b17bd8f7b8a2205a126aac1055796b7e7cfebbd38f/fonttools-4.60.2-cp314-cp314t-win32.whl", hash = "sha256:bbfbc918a75437fe7e6d64d1b1e1f713237df1cf00f3a36dedae910b2ba01cee", size = 2330985, upload-time = "2025-12-09T13:37:45.157Z" }, + { url = "https://files.pythonhosted.org/packages/91/24/fea0ba4d3a32d4ed1103a1098bfd99dc78b5fe3bb97202920744a37b73dc/fonttools-4.60.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0e5cd9b0830f6550d58c84f3ab151a9892b50c4f9d538c5603c0ce6fff2eb3f1", size = 2396226, upload-time = "2025-12-09T13:37:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/a6d9446cb258d3fe87e311c2d7bacf8e8da3e5809fbdc3a8306db4f6b14e/fonttools-4.60.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a3c75b8b42f7f93906bdba9eb1197bb76aecbe9a0a7cf6feec75f7605b5e8008", size = 2857184, upload-time = "2025-12-09T13:37:49.96Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f3/1b41d0b6a8b908aa07f652111155dd653ebbf0b3385e66562556c5206685/fonttools-4.60.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f86c8c37bc0ec0b9c141d5e90c717ff614e93c187f06d80f18c7057097f71bc", size = 2401877, upload-time = "2025-12-09T13:37:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/048fd781680c38b05c5463657d0d95d5f2391a51972176e175c01de29d42/fonttools-4.60.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe905403fe59683b0e9a45f234af2866834376b8821f34633b1c76fb731b6311", size = 4878073, upload-time = "2025-12-09T13:37:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/363364f052a893cebd3d449588b21244a9d873620fda03ad92702d2e1bc7/fonttools-4.60.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38ce703b60a906e421e12d9e3a7f064883f5e61bb23e8961f4be33cfe578500b", size = 4835385, upload-time = "2025-12-09T13:37:58.882Z" }, + { url = "https://files.pythonhosted.org/packages/1c/38/e392bb930b2436287e6021672345db26441bf1f85f1e98f8b9784334e41d/fonttools-4.60.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9e810c06f3e79185cecf120e58b343ea5a89b54dd695fd644446bcf8c026da5e", size = 4853084, upload-time = "2025-12-09T13:38:01.578Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/0d77faeaecf7a3276a8a6dc49e2274357e6b3ed6a1774e2fdb2a7f142db0/fonttools-4.60.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:38faec8cc1d12122599814d15a402183f5123fb7608dac956121e7c6742aebc5", size = 4971144, upload-time = "2025-12-09T13:38:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/6d3ac3afbcd598631bce24c3ecb919e7d0644a82fea8ddc4454312fc0be6/fonttools-4.60.2-cp39-cp39-win32.whl", hash = "sha256:80a45cf7bf659acb7b36578f300231873daba67bd3ca8cce181c73f861f14a37", size = 1499411, upload-time = "2025-12-09T13:38:05.586Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/9dedf6420e23f9fa630bb97941839dddd2e1e57d1b2b85a902378dbe0bd2/fonttools-4.60.2-cp39-cp39-win_amd64.whl", hash = "sha256:c355d5972071938e1b1e0f5a1df001f68ecf1a62f34a3407dc8e0beccf052501", size = 1547943, upload-time = "2025-12-09T13:38:07.604Z" }, + { url = "https://files.pythonhosted.org/packages/79/6c/10280af05b44fafd1dff69422805061fa1af29270bc52dce031ac69540bf/fonttools-4.60.2-py3-none-any.whl", hash = "sha256:73cf92eeda67cf6ff10c8af56fc8f4f07c1647d989a979be9e388a49be26552a", size = 1144610, upload-time = "2025-12-09T13:38:09.5Z" }, +] + +[[package]] +name = "fonttools" +version = "4.65.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d9/1caaa015dd207da7ccd3feba87289997bd88334ea3e74a367cdc7b0e50a3/fonttools-4.65.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93a73af2075036d36d7fbf856779c56a1b3b86ffcdae6abede7596604c42c156", size = 3095449, upload-time = "2026-09-10T15:33:04.814Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/988cd9b33ae2d92b15a51d73eb77c991f7a0fe90a7bc74526e9669247789/fonttools-4.65.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c130be2232e3caf8d2b476854ea78421ec1642917ff5ab695284bac31bbb072b", size = 2587697, upload-time = "2026-09-10T15:33:07.653Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6a/36f465a1c277131f9569f6a56fe99cf135391b4860b9c4f4b23e5b1cd5df/fonttools-4.65.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3944e0bdba42effb71959e43d91b599326b02b59c78310d5675e8a75525e7d8", size = 5313308, upload-time = "2026-09-10T15:33:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/ee/56/151b5e81d20c63834f48ad37a0cbbbe2f9b248e38f8d10387f0cf5219244/fonttools-4.65.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb53892b570f7f1f0055e75fc4de32673e32f749c4c8a606b63d5c436650e634", size = 5253649, upload-time = "2026-09-10T15:33:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/6389215da9d4f98623aacdca9479c1030bbd516cb658e893bc54b61098ce/fonttools-4.65.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a6c8d184e523580a7c55d21cde37176a3c91cb539cf06c2aa36ffc634fd75296", size = 5277745, upload-time = "2026-09-10T15:33:14.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/e3/c1037a1dfb7c8efe6f2a7d1951ebdde40cbbf82e9c5d796fcb03077e4790/fonttools-4.65.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5ceccaf2e57d83b753a2b5db5d94aa0a8071886d4afebd2d520c9683e6bef0e", size = 5413259, upload-time = "2026-09-10T15:33:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/44/b9/7dd72330168d39635c329f23a98279393b1e825e42a6db362e09897aad7b/fonttools-4.65.0-cp310-cp310-win32.whl", hash = "sha256:aff640a4fcb021fa83f9879d5bfa115b6931522dae991a24faa75888bd6aeff6", size = 1577124, upload-time = "2026-09-10T15:33:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c2/e959385b4626989b25b82b9f4f99e7c3ac68377d6846b376239b6f126966/fonttools-4.65.0-cp310-cp310-win_amd64.whl", hash = "sha256:5c1700a60e4ff23a0425d5a64abf43d092e6b55071354825781faf255904dcb4", size = 1633354, upload-time = "2026-09-10T15:33:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/58250cdc54d96fcfacb544e12997a6390fa4e6b71ae2241cfcfe5b341803/fonttools-4.65.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06273c71e692caf5989c0437ca50875a5e49e216ddf653228fe9bb35bdc82c0f", size = 3089141, upload-time = "2026-09-10T15:33:24.509Z" }, + { url = "https://files.pythonhosted.org/packages/3e/67/0f0416069e38da0a1327a847a2e8dd1edb425d0043d8a3e63eb940070209/fonttools-4.65.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ca2b02d74e9ad7e21a1d11e4701425800a4b0c63cf90486e60258262feccbcbf", size = 2584298, upload-time = "2026-09-10T15:33:26.598Z" }, + { url = "https://files.pythonhosted.org/packages/99/0d/7e40e9957359afc0bab081131c215370a6d2d203361bb6f945ab595e924c/fonttools-4.65.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7830e9fa3bebc44dbc27ff44d8201def30ea5c48a773696d58e69e6bcd9cd5d4", size = 5498163, upload-time = "2026-09-10T15:33:29.071Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e6/e48cf0a272a5d4d17a09d44f92727e67f975ddfa94acc8464763d19a654d/fonttools-4.65.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3991732c87b3f054a2a8cf86dd0d602833fa8cb37c911503173771646e1013d", size = 5457456, upload-time = "2026-09-10T15:33:31.918Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/a5c67ddeda82ee5e4ec3bc52ac1cbb685f0bb7a0516badc55643b454ab0d/fonttools-4.65.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6031e77b3fb8c765055ba2b8bd8dcb17030f3bf2484c448b472fdedf4460ba80", size = 5464334, upload-time = "2026-09-10T15:33:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/d7/16/294e77383b2d39c9f8f25144a7ba23fe1cbbc05227cc72545097785ff07c/fonttools-4.65.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6813cc1e2e883bd6c15b3e04f72c78dc65fdc4ca861063adf5f341fbaec2ca62", size = 5595597, upload-time = "2026-09-10T15:33:37.591Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/8e74ce8626c734959c782f2d89af8e9f14d078fd3d4ddf8b5a51401ae475/fonttools-4.65.0-cp311-cp311-win32.whl", hash = "sha256:4a5db8442453da4b6f43ad325879381b726bf2238a2253efd9584be21a2cefc2", size = 2441114, upload-time = "2026-09-10T15:33:40.592Z" }, + { url = "https://files.pythonhosted.org/packages/37/3e/835dc6c658426e2670b7f38c38295492fcbaeb06080e9dce89ce8105993c/fonttools-4.65.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f201796c8e24e657be77c16fa664e798a46122144217f90838982937a964f0a", size = 2498823, upload-time = "2026-09-10T15:33:43.402Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5c/343a4225e83eb06f82c1d8bf41fc5e5f71eab2f62bc7ca215c764722c0a9/fonttools-4.65.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b7bb52817a24731d2e4f4df0e71fdde05e6c806c8f8f1517b015d142fdacfa5", size = 3094663, upload-time = "2026-09-10T15:34:07.235Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/49b2401be932741d9218181c08948c96db32eab21ecaaf79283b752e13e7/fonttools-4.65.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e2c21772fcf70325189707b19f346812690bb1b0bd7e207e6ac205244806b303", size = 2584521, upload-time = "2026-09-10T15:34:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/c9/48b07e6c5cf44fa56f758a04c3772a66c0e9ba82bbe22077e4076746a62b/fonttools-4.65.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c9b26816415b5e3d899e9077109d327b22140fe3c4066644d8cdbad5bb1569", size = 5395399, upload-time = "2026-09-10T15:34:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2d/5cc5a10c8ed56079d6c2e9e3e5920622529a2ae045c9f47a6055ccb1a319/fonttools-4.65.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dd6243f60e2d6160c2966e1e14020dc261ffd741b69a2e4ca8bfd051592e4b7", size = 5376659, upload-time = "2026-09-10T15:34:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6f/ccf33739d936bb3afa1655a225be7ee5d63d6d3c8b7d0e570bc5a2a7b899/fonttools-4.65.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:834962fd7cf21c58e81ac50a59e6ed2306f9df5e3dd481dad1cd7d2c4c60b773", size = 5336795, upload-time = "2026-09-10T15:34:17.817Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ffd17483f2094f0f4118974295b514b5b82afd4f6e3c80c23f01684e97a6/fonttools-4.65.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:580eb68ff7bd6954a7a76afddd864bfc66eaaf5f5c20dd6ead9186d0055a4ffe", size = 5496530, upload-time = "2026-09-10T15:34:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/b1/19/9aca7712d0676ba5f8d1530ce20478a9bb09cccd0153d56693337379cdcf/fonttools-4.65.0-cp313-cp313-win32.whl", hash = "sha256:7a18b2ffd44249fe84289253197aa65ad4f2de554c0d381f18b1f5939bc6bc60", size = 2430392, upload-time = "2026-09-10T15:34:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6f/f015dea0f4354e0798751b657cea2dee482914737f88bf1a078349ce92cf/fonttools-4.65.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ae1846b0f192fd485d26a455af19b8f5cf05aff08f9836f533913d8fcea133c", size = 2481620, upload-time = "2026-09-10T15:34:25.816Z" }, + { url = "https://files.pythonhosted.org/packages/64/29/606365ef601668bfebed14cfe3dc72bb7fcd1e23011bbb2833f17fea3065/fonttools-4.65.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:dc87a9f846bec83c3795804f62b4632716d46e3522869a3dd9cd44a5d245b006", size = 3098393, upload-time = "2026-09-10T15:34:28.472Z" }, + { url = "https://files.pythonhosted.org/packages/c7/61/11412939d6b7abf5ac7ce0d61d7f94a0a4fbabc9f1ab0a04fa622e0fc11c/fonttools-4.65.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa50dd7b9baf75e2bbd43401fc0d237f7a94a8ad2e0c57ea97160fc631af5eb0", size = 2585766, upload-time = "2026-09-10T15:34:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/db/17/734921d8aee8309801da42590375d32d4d46f771b73373ec9520d5d4220b/fonttools-4.65.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d2a9892fdb3b7e2d0f4174e3b907d226ff83698249762eeefce08ec5b2de1dd", size = 5380679, upload-time = "2026-09-10T15:34:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/cf/eb/2a4d78d60d978e694cfa04c98e4d8ddbf7f028fd768ddef470bb9da5d69e/fonttools-4.65.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d815734e7fede0ad1f233f23f0f191cbe8fc64762ff041e589bc0f78e0b2397", size = 5321833, upload-time = "2026-09-10T15:34:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ca/1cd48b5c11ef9658732787bf2362e1bf3871dad5945d2f6cc8f675ca769c/fonttools-4.65.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71e4c67b6196a2f447f46476fd2302604721617f5e0a21b0988bdd87b6bb9687", size = 5320938, upload-time = "2026-09-10T15:34:38.992Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/90e6051bded926cccabe9dd0bce3b6ca012f4d5779d61167afbf4989ceb6/fonttools-4.65.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b11d8a4a0c3ca74bbd4c105b7ef82501945c939e6096d9934ec7d288cdf5aaa9", size = 5451747, upload-time = "2026-09-10T15:34:41.387Z" }, + { url = "https://files.pythonhosted.org/packages/18/74/23e0268e48029ff0752083f69312c49b738163d5af919add9dc4ac81e907/fonttools-4.65.0-cp314-cp314-win32.whl", hash = "sha256:8e44a34d91b3c793879767eb115867ced74d2eb94974e64e72fe9e2eea71cf1a", size = 2434246, upload-time = "2026-09-10T15:34:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2d/ee69affecd4bc81cb932a213438d4199fb48bf8ca6d664438ccc7f623c2a/fonttools-4.65.0-cp314-cp314-win_amd64.whl", hash = "sha256:0aa8901db22875c831d6a91796549590d7e747da37438f38b69d771b668be445", size = 2486706, upload-time = "2026-09-10T15:34:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9c/edee5f785198ce3327e1ddeace91c773122d47f086a67e0a84b800f4a940/fonttools-4.65.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2e4a380ca40d3a5372e31b340f0da0d53b4583aadbb8e41f6a516afa69c509a4", size = 3172027, upload-time = "2026-09-10T15:34:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c6/252ec9884381089bc30da75978b072593920249de60219817f16cbc9145f/fonttools-4.65.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:661bd91c4be13721408b2d4b67a9b3fa7736713adc9a6c9780c9c60fc7959f90", size = 2619020, upload-time = "2026-09-10T15:34:51.453Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/f71b0d202b8473bb45b07876c08a474de9fe560ed2c0cde642812a81e22a/fonttools-4.65.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62c5e42c79449def957adf8a9a65a43018efa7e2a6bc6baa3afe955e0d5fb2ab", size = 5545942, upload-time = "2026-09-10T15:34:54.804Z" }, + { url = "https://files.pythonhosted.org/packages/4a/bd/52e1bf33e0aebfe22ecc9a85c634db707c1dd9f6b1b438efeed98c55b959/fonttools-4.65.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:36fca8efc46b5adfca327c666e739fc05b7a7a6ef17840230f81b22f53230f61", size = 5351477, upload-time = "2026-09-10T15:34:57.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/76/8c6b2ad20beec95cd446f3a8bdc753c7e4a4fd69ef3e66704c0f7c8cb0b5/fonttools-4.65.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa1291e4c767abf1b0b79ca2d6895f7c0b661d9d95d03b5791c883a9d1e1f08", size = 5412271, upload-time = "2026-09-10T15:35:00.895Z" }, + { url = "https://files.pythonhosted.org/packages/79/49/fadbf11bbbd2d699d88a5498a0634280206e01e3bd5da9a4e0c504953ce9/fonttools-4.65.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fcf39949f56911348514b466714efa9118bec3d2be249e1c487263f7cda6edab", size = 5446450, upload-time = "2026-09-10T15:35:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/df/77/5fda646d3a6d5ee26465865c00319cc0925cf484a3b42dd8232d5b39f973/fonttools-4.65.0-cp314-cp314t-win32.whl", hash = "sha256:ffc918702661f1d74d2fbb2f5551036b64f6d2d743139e105289b694bcd16f54", size = 2467858, upload-time = "2026-09-10T15:35:05.744Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/afa0f3de70ebe02bba46b32cccb30b1de52624472b14ac2e7cd403d08db9/fonttools-4.65.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a977e3645dbffaee924209828aa702a215f7ff68bc08010740c10c723787e62", size = 2518248, upload-time = "2026-09-10T15:35:07.846Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/b273cf5712b36a381c13284fbf244e811e1e1d7081aed20f200f0a191ffa/fonttools-4.65.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:7aa0518b45ff5286ad56f063938db3add3816e899aab58d782b3f9a252523caa", size = 3092770, upload-time = "2026-09-10T15:35:10.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/d3e4511475954d4ec4f3c254e81f0fcc1ade504cb7381eeca48b8c44b8a6/fonttools-4.65.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:673e2b3ac4ac8e4f3607d390ecc5a606e5db5c4e88fb4cb2999593efb65afea2", size = 2584364, upload-time = "2026-09-10T15:35:13.54Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3f/7cfaba467bdd1d3d04be21ba7b5bd75c6ca58ba280e0519707ca96a43c4d/fonttools-4.65.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a03cff943b204a90bf3d1c04c97b9509a8aa0ee99e2e544084ca43ad995975b", size = 5377819, upload-time = "2026-09-10T15:35:15.839Z" }, + { url = "https://files.pythonhosted.org/packages/25/17/a68d9b19a97bb2ee37e8098fea50657073df97c7e5392afbe1b9d7c0c581/fonttools-4.65.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41f684ee6212e411196ab054f8308faf6605f154950e6f4686fb8f2103d624b0", size = 5341296, upload-time = "2026-09-10T15:35:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8d/d744653ed607a241339015d4af6743ca3d85a74a2045a02cc06ea0383c71/fonttools-4.65.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:52ea9d2a8385075770db74d5e5718fa80b2222bb4fc62856a377dd2865ca8848", size = 5315310, upload-time = "2026-09-10T15:35:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c8/1ca6dc69cbaa0e394ff70d9e266777124d3ce3c6433026a6b4de50b890ae/fonttools-4.65.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d0d25027ade65ec46b13c0436e51bcb7c5171a4ea255a5e7a8d0d1d3ab4cffd7", size = 5463840, upload-time = "2026-09-10T15:35:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/07/97/d374df38a14f2ac04ba9ed96bca89d4988e7d63aa5ed85c54fd8f2badd7d/fonttools-4.65.0-cp315-cp315-win32.whl", hash = "sha256:22cb846d35d278235ef3b7e947c6040b2057d72e8305a314f21d5342eca49040", size = 2433149, upload-time = "2026-09-10T15:35:26.59Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/eb8f7506faf6a70c5a8f2eccbeea3cd826c748a6fd78c7b4b3effb5a3a11/fonttools-4.65.0-cp315-cp315-win_amd64.whl", hash = "sha256:aecc899fdbf9ecbf728f8977977e2e1043ee4d70c257124c8fa4cbcf796fcd83", size = 2485725, upload-time = "2026-09-10T15:35:28.945Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/2df0d97514feb8d857d5de854cd8d660d9a7ee1fd081bc98497124f515a8/fonttools-4.65.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:6275863dad195ee34b6e0ca3fc61c74096bc37e5d6fb8e049f4d68d65865a2b7", size = 3164065, upload-time = "2026-09-10T15:35:31.197Z" }, + { url = "https://files.pythonhosted.org/packages/15/33/e09661c09e6c8a3b0bd50da0e72918df7576dede31f39c948cc245011434/fonttools-4.65.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d8ffd2f62b402180b0edae8f86a071f583970e2177143117db5cf4c52da60079", size = 2615195, upload-time = "2026-09-10T15:35:33.569Z" }, + { url = "https://files.pythonhosted.org/packages/64/d7/114b05f4193679d0935272220083ec43de7f918b5a777018c5ac5c1ae39e/fonttools-4.65.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:830f91327ca83bfc1278e7060068a498938f84d05dc4869675486f84f55d4fe1", size = 5521239, upload-time = "2026-09-10T15:35:36.291Z" }, + { url = "https://files.pythonhosted.org/packages/ba/10/67d615939f859ffe75663a67bca3593966febbcd0109ec72f20f73cbb4cc/fonttools-4.65.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9db2cb95847c18eef74a4ef0fe257a893ae3f4b0395f4866e2f426ab07f3d804", size = 5343802, upload-time = "2026-09-10T15:35:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/d1/06/faa793a806da03acce862fbed353f76026b28103d43860d69be9a6a1f0fd/fonttools-4.65.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:be9b9a95ed0af03375e99020e921c4bc6b41fad10e053dea7acad370521a3c46", size = 5388482, upload-time = "2026-09-10T15:35:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/53/d2/eb7258df60e634db60c9a8cd72bc9eaf8dea409d1b1ceec3b9fb49531ad2/fonttools-4.65.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bbd9faf777a9deb6790df4f2b0be611857c45fe86605e840d7154a028d828af7", size = 5435088, upload-time = "2026-09-10T15:35:44.639Z" }, + { url = "https://files.pythonhosted.org/packages/00/6a/58597f16e1265fe9205de3069e338a339e3eef0b1daf049ee08269458091/fonttools-4.65.0-cp315-cp315t-win32.whl", hash = "sha256:c779d838815b91889c95ed64c9be5950ad5a683279f91aeb23384cb757ddc6a3", size = 2464925, upload-time = "2026-09-10T15:35:47.224Z" }, + { url = "https://files.pythonhosted.org/packages/4b/95/122fc172006db747f4968e08c710f52a94f55eb007173b859b3f80b5b810/fonttools-4.65.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d9484b7ee1b49b6b8a0231c849f3983723dec29e3a7366d9b1b02f4036f71944", size = 2513945, upload-time = "2026-09-10T15:35:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, +] + +[[package]] +name = "fonttools" +version = "4.66.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/87/b6/126c659ab7e0e03e01a5f5d223abf7b2c0691ae92718085a212a3924a2a3/fonttools-4.66.1.tar.gz", hash = "sha256:64967c6ddb0d4c610dfd8cb1485981b2d27972ddfb7d4bbbd9e199d2a089c450", size = 3694174, upload-time = "2026-09-29T16:11:53.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/2e/2c6d3daaa5152bbb2fc2b44037399366af7eb5fb2fbf7d9a236813753f77/fonttools-4.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d4f76868aea9cc4ce47fdbeaa904c02ee7d85dd0ad095071ae77f0bda6e62cf5", size = 3089406, upload-time = "2026-09-29T16:09:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1c/500fbc0fd5b6d9cb701c1107a6f38ec4d3319057681e520014c1b9beb009/fonttools-4.66.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34378db9a398b59de18cc79d942f0a907c6fc6301945e065ec888202f607aa3f", size = 2586728, upload-time = "2026-09-29T16:09:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/f3ac6374058bc93bd8a685c4849815b598bbd5e3ec5f706f3f4944a060e1/fonttools-4.66.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05c0fff6b4a5d872ed89cab2c4f81060b86ace263903eb4e8d0edcac47a60dfa", size = 5484428, upload-time = "2026-09-29T16:09:55.987Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/ed45f50fe7a7320656ac7dde60f26afa3a92a21e14749135b4c03f3385b7/fonttools-4.66.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72299346b96b9244dabcc051b24e4653da4edfda6105544cfb10ce856a1afaac", size = 5447695, upload-time = "2026-09-29T16:09:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c0/919293f7b38ff81a6014a7fce45fbcc113aaf473f22180a7f7897c702a67/fonttools-4.66.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c724e56213494c6695335577822b2d1628d102e71614de8b7eb8e30886d6a314", size = 5450236, upload-time = "2026-09-29T16:10:00.105Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/00864b96e013a05df55b347b3eb9b1726803267d52345676cd86e928ddf1/fonttools-4.66.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b913b8e9f7ca9bec44d1eb919f591c596c61041aa357c96be55ff93169859e91", size = 5588056, upload-time = "2026-09-29T16:10:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/7b0259de6d874686532a781059fd85796dcd3e07a12146361092141169f3/fonttools-4.66.1-cp311-cp311-win32.whl", hash = "sha256:e7ea7a08547a453fa000db96ed5714a3dc7e2b4255b9243f897921f8c10c169a", size = 1577702, upload-time = "2026-09-29T16:10:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/39/ff/ccaddfb8ac343e90f40f86fa460f832cd45460c65ee9288b6da923c72728/fonttools-4.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:36bb24d4b98faacaff04af1d5e0a4285feba6ed1da6728cd34b6b6deb6bbb934", size = 1635298, upload-time = "2026-09-29T16:10:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/06/1b/fcb22638f2f5918c855abfbab203701e4b03739953d26a59f3e6e59b8f30/fonttools-4.66.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8526b2b7ec4db6b81efb83438be52b1264eda9a4994d867163cfe8c65581ce8d", size = 3097433, upload-time = "2026-09-29T16:10:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0d/f51141407f9a64efc9fb39b94b0194c4a99c1ffff50834e7ca7a3f1cdd53/fonttools-4.66.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6946fe7bfb28590a1fd4061a17609c9a843952deb65dcf30d1fe725070c3e7a4", size = 2587566, upload-time = "2026-09-29T16:10:10.099Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/5810d73f9102eb1a08f26b8f7a6c22498622b43e05e684cd5ec602be8ffd/fonttools-4.66.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ae73bd219e1245debd8376077a0fa6e03175e255c4f51bae5f6a271bfe384a", size = 5416460, upload-time = "2026-09-29T16:10:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b8ff9e0edbcee2fbf7dff0c41b9041c1901c26acf64e23adb67495012df11de", size = 5397326, upload-time = "2026-09-29T16:10:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/e6/98/8522cc7a5e5ad64a2b2b6e9598489809ed4956c532b887c65b131b8500b7/fonttools-4.66.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38ce8f5fbd5c17dd2153d47d7c8d4108f3deda3f2b4a79b60ddc470a58faded3", size = 5353857, upload-time = "2026-09-29T16:10:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/f9/ab87d67c23178886e57f24397b3113b4ac35297f086467c2c4d4231671a8/fonttools-4.66.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8aed2bbcd6216253ef1b015763593365ee8084f621dfa53bb957c19d5e05f7cd", size = 5517604, upload-time = "2026-09-29T16:10:18.791Z" }, + { url = "https://files.pythonhosted.org/packages/d4/20/e126062610aea31919310b0b3de4d0bbe73ccbdcb3d0409cc52027db93ee/fonttools-4.66.1-cp312-cp312-win32.whl", hash = "sha256:9ea6c93091cbf83161a544388746a0911550bd98cb911faca3591cf5ead166ac", size = 2435084, upload-time = "2026-09-29T16:10:21.145Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/5c245a0587e5b7b3dd209f640b9f70d8e63404ad8dc93819ba578d685985/fonttools-4.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:261d8dc95845e751f975fe8d6075600593ee253470d46d1b84801688051b09f6", size = 2486987, upload-time = "2026-09-29T16:10:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f4/e410b8c913da5b3fdbb4d16db0f2d2a0952f59c4db8d52dcf2d421d82044/fonttools-4.66.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53e5854ea8003efec34adc0863c18ce91da923018354d27366f7fee7db928d7a", size = 3095249, upload-time = "2026-09-29T16:10:25.261Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/275108baf41d9f2f4d1d77cf5f1e22200fe47efd5099dafabc3eba0b6197/fonttools-4.66.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60f5ea17aed4262630afa43f26997ceabd6417fa05dcedf54c665f5a29193e18", size = 2587288, upload-time = "2026-09-29T16:10:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/11e5e6beb7e336d80f0ca870ae080033a91ebfe34fd5390dbcf78f8df56f/fonttools-4.66.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1801fdad5600118327171e0e8aa79f7cc48831dd55ab36998c9de03bd5ffe6cd", size = 5389836, upload-time = "2026-09-29T16:10:28.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/6ec22372362b03350fe3da7bf33491a07cc9a553a36dd2383b76ec1741eb/fonttools-4.66.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83572afe48733bad7a4a9c11721d3a726c2e976d82b063fc9bdd049d76955abd", size = 5372059, upload-time = "2026-09-29T16:10:31.011Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/cb7971f1c0f40f891028ee8c46dadc6897ef61e44aa925a23fba2ef06e2a/fonttools-4.66.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8956e3ec990c75230d92f1630b215e8f3738c83a003421c22b31ebfd0ce15", size = 5332824, upload-time = "2026-09-29T16:10:33.563Z" }, + { url = "https://files.pythonhosted.org/packages/e0/86/563e671f1d43fa8ffb2518d7fe16630fb16c7faf0420cc39f8e80181f486/fonttools-4.66.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fdf4afd75c643e60ef4a96fe64fc8a9def27d2a542112332371a9e5066885f9a", size = 5490659, upload-time = "2026-09-29T16:10:35.788Z" }, + { url = "https://files.pythonhosted.org/packages/79/f7/2573ddfd256be6503458f8523e2893443e66257fc17f6055d7e0f0e721b7/fonttools-4.66.1-cp313-cp313-win32.whl", hash = "sha256:dbb7b950f8c02deaffb6968994691e8589d671b7ef8396bc9d5b5c0dfbb7292f", size = 2433450, upload-time = "2026-09-29T16:10:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/68bc2be04b83535607fbb70ebb2ba02380bf4286d79597c4515b7d247187/fonttools-4.66.1-cp313-cp313-win_amd64.whl", hash = "sha256:43d1284c1964666ee833f2badd3017dc138f53d4889043ffca66c5ce4188f188", size = 2485146, upload-time = "2026-09-29T16:10:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967", size = 3099264, upload-time = "2026-09-29T16:10:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/dd698f10bf0f743873077259e8a6fce075861dde3bb01eb22b2c4f7aefe8/fonttools-4.66.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f08ab7f8461c37ecfdd29ad97fb0c0780b50501bd664bb0f46b6e83ed2b9d2a7", size = 2588769, upload-time = "2026-09-29T16:10:43.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/10b5caa2aa779e62411b67949bda9741d4d7532ba0b6dea647b715131260/fonttools-4.66.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf4f996f9b1cb549bff9ea4c50813988a26ec922c95cfa85c7e4f1270447e06", size = 5374278, upload-time = "2026-09-29T16:10:45.727Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9ac5c6773feec1b40e57eac106d869886f66a1e44082d343ac1e1e1fb773/fonttools-4.66.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9261ef507f2dd74203443a472b65b5a26429eb378f975016dec7dc7305b24898", size = 5317826, upload-time = "2026-09-29T16:10:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/69beb11f6b714ac90ee73ad4600ac91d7dd4e1ce361d087c8425bb8472de/fonttools-4.66.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e1cde50b3ec84ca6fe63ca815de183dbecb88e8adf8ada82d8ea130ef12b2b43", size = 5316239, upload-time = "2026-09-29T16:10:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/7a77359e469d3a638df91d3e225cef4a3c1184c20e98381238042f7835fa/fonttools-4.66.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f0a8f16c4f3a5a87ca971de2631792d8cb4d570951f2000acf712f157d40db", size = 5449423, upload-time = "2026-09-29T16:10:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/ea0b2f1ef90431b1879d6e6c680a7fde497129cf511ab995ade0ff8e19a7/fonttools-4.66.1-cp314-cp314-win32.whl", hash = "sha256:b878c78b2af11b879bd4f26bb0d8bda2a4c64543fdd3f28efe2c80f97f043885", size = 2437673, upload-time = "2026-09-29T16:10:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/629dbb4a40c4a7b3de61442c6b4430d36ab6e0e8cf941c547f4fd66f3337/fonttools-4.66.1-cp314-cp314-win_amd64.whl", hash = "sha256:05aeb146451f37289f782c3c861f3d0f4b86c2dd2e4620b46683544c7406640e", size = 2489763, upload-time = "2026-09-29T16:10:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/342e5fce9438f88882524128d1feb0311d4014cb6f8bdeb4607fcc00713f/fonttools-4.66.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:66fad3b7874062c2a2692f0ae6dea56d24f01b778c7f191950ca3ff997e25a88", size = 3172931, upload-time = "2026-09-29T16:10:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/96196ebfd02676f28fa9b3776d85e18281bca0c8450d7e214c40e346bf92/fonttools-4.66.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eef76d5796e604f9d6753fa6d323c4eb9f4e0e43f1dcca553f3e6914f1667b64", size = 2621937, upload-time = "2026-09-29T16:11:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3f4b761037ebc2e5597c52c218a9e95dbc4a2cab572828654f6004f422f5/fonttools-4.66.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c47299bca4b5acaaeb32100f77b944feea151de9ef1773365a410dc3d49b945b", size = 5546932, upload-time = "2026-09-29T16:11:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/3f506cc79608becbc287785db8c44eb3f93079b49752266eb9f57700ecc4/fonttools-4.66.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfba62cc93199ba62c376f90f2a9147d92730d301e44f88e013e50ff5edf6193", size = 5353546, upload-time = "2026-09-29T16:11:05.394Z" }, + { url = "https://files.pythonhosted.org/packages/6d/27/6534d84430ba1641185f8a0c9e2c7ecd395b15ff98f96967e3fb3c728b09/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c7340497cf53490293e0c2b61011e0191633022ede0a0a964a68157a98b0fb4", size = 5414313, upload-time = "2026-09-29T16:11:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/831ceca06d78855b11dc203b0e3ba5e6fd8a63a71ee0343ea8bd367fda55/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c666fefdd5613a0e99aa4516e6ff4ef87aa86cf1c7ba12a73550f4770e46b750", size = 5449661, upload-time = "2026-09-29T16:11:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/21dc18bcbc8d0354814f6ea58af3d76d3bcd9b0d7246df454cb9e00c1740/fonttools-4.66.1-cp314-cp314t-win32.whl", hash = "sha256:2ce4c93160535761f22c80b2afbc96cabc09855363a5d1a5554265b8a4c85901", size = 2471431, upload-time = "2026-09-29T16:11:12.237Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/eb0489e7d58ac0d3387584afc7f3e505f60f60fe4b4f5a0274f013d444a2/fonttools-4.66.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b13c8c541ce0b794add3211b3641cc0e113d707f73e06235e6fe9731bd7c45a9", size = 2521288, upload-time = "2026-09-29T16:11:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:2d637468dac23aac0e223bd52e66f8faa3b0dfcef57435460fa2107e830226cd", size = 3093646, upload-time = "2026-09-29T16:11:16.809Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/7bcd4046b3b5644c563059cce6421b488fe57f65c59171ef01ed11b66d3a/fonttools-4.66.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:90de3477394c73481d27d2b86091c1c736053ee13ff52c42f0e151948e8578c6", size = 2587335, upload-time = "2026-09-29T16:11:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b6/05a093ec04fa2ad449ecc67638aad0f8d60df380df2471b68b549fe2a4b2/fonttools-4.66.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d84ac0bf776b68396185bd919dd29e633d94300660335efc40b55b294b886903", size = 5371509, upload-time = "2026-09-29T16:11:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0dc6fd99cb8c30941036308b148da9432640442a6f26f36d71dad9be24cbd0e9", size = 5334853, upload-time = "2026-09-29T16:11:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/af/a8/44bb4021c585b76f8e480116e1f3fca62eb7d88fe5794e2ec84c10d2da76/fonttools-4.66.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d3b5403e82d0c7659ff1d9f956e29a3a68d094f043e9f5bc0442796fc3a4fb58", size = 5311704, upload-time = "2026-09-29T16:11:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/dd482902fb7fd8b71d3b6508431a57938b5e41b29bf6fb252ed3cfce065f/fonttools-4.66.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b8b71db96d605784e2c5ebf0788a406018ea8fdd80338491f4c83613d5cd1fec", size = 5459477, upload-time = "2026-09-29T16:11:27.536Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a5/07611ba4d4b298b90908cb15005a6d730c334e25548f5175a09907b2eea6/fonttools-4.66.1-cp315-cp315-win32.whl", hash = "sha256:668f092bc0de8902167df6a0d5c5aedc3b4f9e43cf88eea92e9b46a2bd3968f5", size = 2436532, upload-time = "2026-09-29T16:11:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/5c39a05bf7c518743c6072cd75b63cd27285c58a70b1086e923fc071fb84/fonttools-4.66.1-cp315-cp315-win_amd64.whl", hash = "sha256:7f49f2834f5d006fe0f3bb10fec73b261806c50941f0cfbc08294074ffc32210", size = 2488769, upload-time = "2026-09-29T16:11:31.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a6/1205f7a7dd746581498457e55bfbcdfbea87105a454a7b3465259816bb79/fonttools-4.66.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:71c7ca1b5f46f5dd549f56b47d47c0b709217675c23d3a7bc6aa1a69b6d9bbae", size = 3164459, upload-time = "2026-09-29T16:11:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/42/915ff8f3c5d3bc9877007e708774e52f7ec431f9e59f607a86e50fe1864c/fonttools-4.66.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2d320483928c7831f0139ecb361954a26b2e2a8995681200155835dd8cd4a7d5", size = 2618183, upload-time = "2026-09-29T16:11:36.067Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c6/cae2f6ebe38f8927a8d0978a349b202047268016344991a14ae978c2aee3/fonttools-4.66.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2aeb745f2664eb811026997c95628071137a777ea2ad296deec9cb393f0b23cf", size = 5524861, upload-time = "2026-09-29T16:11:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/f2/14/1941629956b526d6fb46ee764cf0942221f0238581adb94de0ac229fe67f/fonttools-4.66.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3087a430722aba8de429c2539fd2a58a9cf05238cdfefd8626460001052ca878", size = 5346561, upload-time = "2026-09-29T16:11:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/62/1f/b7e7f4757dcae74285f4ecd8453d870d63c7ba38a3d46bd9175a124c350b/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:058cd823b80bac59e64dfad9e3b6fcd677852f9a3804971bbf6b48cc611e785c", size = 5392826, upload-time = "2026-09-29T16:11:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/76db3cbdcfac0e9b3ba26e1e6e8740040cfe5f7b5199dfb9b854bc8da2c3/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:56d41d650cb8fc6cfe1d85ed7c62a0a56cbeed07bc65ca795475b914d401312a", size = 5439233, upload-time = "2026-09-29T16:11:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/10/37/cdc6b213c9fbabdf36e9169f845e8596b419c7e0cceba48e5594b952d2cf/fonttools-4.66.1-cp315-cp315t-win32.whl", hash = "sha256:c258eba62260beb33c110b03a6912cefa3635239c4ab5615b7225fb6f7b85238", size = 2468614, upload-time = "2026-09-29T16:11:47.363Z" }, + { url = "https://files.pythonhosted.org/packages/fb/35/e2247e7e29e8da213e02691a6ada7a30592c7bc0d1db8d2786ebb9bea138/fonttools-4.66.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5de5d80fbc0e50ff794c244e8fb7afd3eadfe0fa232ba8b162b8c551df22fcb4", size = 2516977, upload-time = "2026-09-29T16:11:49.425Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/d45b74135d5d642cb3a4fb0a957c1613ef93de4c8548671dfc3a5bf38299/fonttools-4.66.1-py3-none-any.whl", hash = "sha256:7234ae9e28db64273fbbfa72caebd0a97e3bdba6b05064114741b9539ef339d0", size = 1202222, upload-time = "2026-09-29T16:11:51.678Z" }, +] + [[package]] name = "ruff" -version = "0.16.5" +version = "0.16.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] diff --git a/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-LICENSE.md b/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-LICENSE.md new file mode 100644 index 00000000..a6234602 --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-LICENSE.md @@ -0,0 +1,110 @@ +Copyright (c) 2015-2026, Renzhi Li (aka. Belleve Invis, belleve@typeof.net) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +-------------------------- + + +SIL Open Font License v1.1 +==================================================== + + +Preamble +---------- + +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + + +Definitions +------------- + +`"Font Software"` refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +`"Reserved Font Name"` refers to any names specified as such after the +copyright statement(s). + +`"Original Version"` refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +`"Modified Version"` refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +`"Author"` refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + + +Permission & Conditions +------------------------ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1. Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + +2. Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3. No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + +4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5. The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + + + +Termination +----------- + +This license becomes null and void if any of the above conditions are +not met. + + + DISCLAIMER + + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf b/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf new file mode 100644 index 00000000..ae250c0d Binary files /dev/null and b/src/ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf differ diff --git a/src/ChapterTool.Avalonia.UI/Assets/Fonts/VERSIONS.md b/src/ChapterTool.Avalonia.UI/Assets/Fonts/VERSIONS.md new file mode 100644 index 00000000..5eb3a763 --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/Assets/Fonts/VERSIONS.md @@ -0,0 +1 @@ +Iosevka-Regular.ttf: 34.8.1 diff --git a/src/ChapterTool.Avalonia.UI/Assets/Fonts/source/.gitkeep b/src/ChapterTool.Avalonia.UI/Assets/Fonts/source/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/ChapterTool.Avalonia.UI/ChapterTool.Avalonia.UI.csproj b/src/ChapterTool.Avalonia.UI/ChapterTool.Avalonia.UI.csproj index 89319a05..39496785 100644 --- a/src/ChapterTool.Avalonia.UI/ChapterTool.Avalonia.UI.csproj +++ b/src/ChapterTool.Avalonia.UI/ChapterTool.Avalonia.UI.csproj @@ -14,19 +14,20 @@ - + - + - + - + + diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml index cae50695..deb073a0 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml @@ -1,4 +1,4 @@ - + Cut Copy @@ -6,12 +6,7 @@ Append MPLS… Browse template… Close - Combine segments: entries={entries}, sourceType={sourceType} - Delete rows: indexes={indexes} Download - Edit {kind}: row={row}, value='{value}' - Edit chapters - Insert row: index={index} Load Load OGM sample Log @@ -20,8 +15,6 @@ Reload Restore defaults Settings - Shift frames forward: frames={frames} - Split combined segments: entries={entries}, sourceType={sourceType} [VCB-Studio] ChapterTool Apply Cancel @@ -32,6 +25,19 @@ Warnings Errors Filter by level + Level + Reset filters + More actions + Export format + Open details + Match found in details. + {level}: {summary}, {time}. {context} + No log entries yet. + No entries match the current search or filter. + Message + Context + No additional details for this entry. + Back to log list Copy summary Copy details Technical details @@ -45,6 +51,13 @@ Properties Raw data Search log entries… + JSON + CSV + Exported to {value} + Export failed: {value} + Close details + Open log folder + Export logs This entry has no nested structured data. This entry has no exception data. Save @@ -110,7 +123,10 @@ No Vorbis cuesheet comment was found. Frame rate must be greater than zero. Frame text did not contain a frame number or the frame rate was invalid. + DVD loaded: titles={titleCount}, chapters={chapterCount}, title sets={titleSetCount}. The IFO file is invalid: {message} + DVD chapter times for {source} were read from VOB navigation timestamps. + VOB navigation timestamps for {source} could not be verified. DVD chapters retain the original IFO time estimates without frame alignment. The primary importer was unavailable; a fallback importer was used. Lua expression execution was cancelled. Lua expression syntax error: {message} @@ -157,8 +173,6 @@ Expected Chapters root, got {name}. No HD-DVD chapters were parsed. The XPL playlist could not be parsed: {message} - frame - name Load a file, drop one here, or load the sample to compute frames and expressions. No chapters loaded Add the missing operand before applying this token. @@ -178,10 +192,12 @@ Frames Name # + Chapters Time Chapter name Clip / playlist entry {0} ({1} chapters) + Title {number} Custom expression Preset Frame rate used for Frames column @@ -193,31 +209,14 @@ Simplified Chinese English Japanese - Appending MPLS: path='{path}' - Auto frame-rate detection: option={option}, confidence={confidence}, accurate={accurate}/{evaluated}, deviation={deviation} - Convert to current FPS: source={sourceFps}, target={targetFps}, chapters {before} -> {after} - Create zones: selectedRows={selectedRows}, chapters={chapters} - {operation} diagnostic: severity={severity}, code={code}{location} message='{message}'{details} - {action}: chapters {before} -> {after} - Frame info updated: option={option}, fps={fps}, round={round}, chapters={chapters} - {operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics} - Language set to {language} - Loading source: path='{path}' - {status}: path='{path}' - {status}: reference='{reference}', resolved='{resolved}' - Saving chapters: format={format}, directory='{directory}', source='{source}', chapters={chapters}, applyExpression={applyExpression}, expression='{expression}' - Selected source option: index={index}, label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, fps={fps} - Settings loaded: savingPath='{savingPath}', language='{language}' - {status} - Unexpected UI operation failure - {status} from {path} - Failed to load chapter name template from '{path}' - Loaded chapter name template '{name}' from '{path}' Append MPLS Auto {count} chapters + {count} chapter Convert to current FPS Combine + Split by boundaries + Split by boundaries Create Zones Forward Translation Insert @@ -271,14 +270,14 @@ Monospace font Monospace font preview for {name} Theme preset - Avalonia Default - Ayu Dark - Ayu Light - Ayu Mirage - Gruvbox Dark - Gruvbox Light - Solarized Dark - Solarized Light + Avalonia Default + Ayu Dark + Ayu Light + Ayu Mirage + Gruvbox Dark + Gruvbox Light + Solarized Dark + Solarized Light Palette preview Palette preview for {name} System monospace default @@ -298,7 +297,10 @@ Frame display Round to integer Keep decimal places + Do not truncate Decimal places + Show repeating frame decimals + In Do not truncate mode, use an overline for repeating digits. Turn this off to show ordinary decimal text. Delete row timing Preserve chapter times Normalize chapter times @@ -332,6 +334,10 @@ Discard Settings changes are already applied to this session but have not been saved. Save them before closing? Unsaved Settings + End document session? + Continuing will end this document session and its undo history. Exported chapter files do not include history. Continue? + Continue + The current document session is unchanged. Check Tools Append failed Appended {0} MPLS segment(s) from {1}. @@ -353,6 +359,7 @@ File is too large (max 64 MB). Duplicated row {0}. Expression applied · {0} fps · {1} row(s) + Could not apply the expression. Applied expression preset: {0} Unknown expression preset: {0} Frames updated · {0} fps · {1} row(s) @@ -360,6 +367,7 @@ Load failed Loaded {0} chapter(s) from {1}. Loaded {count} chapters + Loaded {count} chapter Loading… Loading source... Discovering titles... @@ -382,7 +390,7 @@ Ready Related media file was not found Browser cannot open local media paths; references are shown for download/open when available. - Restored separate clips. + Split by source boundaries. Rows refreshed · {0} fps · {1} row(s) Save failed Saved @@ -395,6 +403,50 @@ Loaded template {name} Not selected Updated + Edit history + History is available until this document session closes. + Current session only + Undo + Redo + Close + Current history position + Restore to this node + Retry + Loading details… + Initial document overview + History navigation will discard the pending preview. + Added + Removed + Modified + Details could not be loaded because resources are low. + This history node is no longer available. + This document session has ended. + History details could not be loaded. + Document restored to the selected node. + Locate current + Current node is hidden below this branch + Preferred redo branch + Redo branch choices + Redo selected branch + Summary + Changes + Parameters + Edit + Initial document + Edit chapter + Delete chapters + Insert chapter + Shift chapters + Change frame rate + Apply expression + Apply template names + Apply chapter options + Merge tracks + Split tracks + Append clips + Edit undone + Edit redone + History navigation failed. The operation failed unexpectedly. Check the log for details. No zones generated. Zones generated @@ -404,6 +456,78 @@ Refresh grid from current options Export with current options and download Apply expression + Time changes: {times}/{count} + Affected: {affected} · Frames: {frames} · Properties: {properties} + Other changes + Expression preview + No chapter values will change. + Expression preview canceled. + Updating expression preview… + Preview options + Update preview + Discard + Preview is out of date. Update it before applying. + The muted value is committed. The arrow points to the preview value. + {field}: committed {before}; preview {candidate}; change {delta}. + Current track: {track} · {count} chapters + {count} separator rows excluded + Affected chapters: {affected} · time changes: {times} · frame updates: {frames} · properties: {properties} + {value} frames + Applied expression: {affected} affected chapters, {times} time changes, {frames} frame updates. One Undo restores the previous values. + Chapter times are unchanged. + Document and track properties + Show + Changed chapters + All chapters + Units + Time + Frames + Chapter + Before + After + Change + Frame details + Before frames + After frames + Frame accuracy + No chapters are available for preview. + Waiting to update the preview… + Computing the preview… + Preview ready. + Enter an expression or choose a preset. + Finish text composition to update the preview. + The chapters changed. Update the preview before applying. + This expression cannot be applied. + The complete result has no changes to apply. + Applying reviewed changes… + Update preview + Apply changes + Not available + Not calculated + Not set + Frame rates differ + frames + fps + Frame accurate + Between frames + Not calculated + Frame accurate means the distance from the nearest frame is under 0.01 frame. + Document + Track + Segment + Chapter + Title + Source + Import format + Duration + Frame rate + Name + Chapter number + Chapter type + Chapter entry + Track order + Additional field + Updated Load Lua... Preset Expression editor @@ -416,4 +540,168 @@ Use standard template names Zones Undetermined + Shortcuts + Customize keyboard gestures for primary actions. + Reset shortcut + Press a key combination to assign this shortcut. + This shortcut is fixed by the application. + Save + Reload + Refresh + Log + Preview + Select clip + Insert row + Delete row + Load + Previous clip + Next clip + Undo edit + Redo edit + Select save format + Expression + Advanced export options + Naming and numbering + Enter an integer offset from 0 to 1000. + Enter an integer frame shift from -1000000 to 1000000. + Completions + Position + Could not load this Lua script. The previous draft was kept. + Discard unsaved settings? + Discard + Keep editing + Settings could not be saved. Keep editing or retry. + Invalid, reserved, or conflicting shortcut. + Search logs + Severity + All levels + {count} entries + Details + Back to logs + Raw entry + Export logs + Match in details + The log action failed. You can retry. + Log download started. + Info + Info + Warning + Error + Debug + Trace + Source FPS + Target FPS + This operation cannot be applied. Review the values and details. + Chinese + German + French + Italian + Spanish + History + Details + All changes + No changes match this filter. + Added + Removed + Modified + Field + Before + After + Delta + {0} ticks + Not present + Null + Empty + Field + Chapter + Chapters + Value + Display Frame Rate + Frames + Before Chapter + Source + Expression + Expression + Generate Names + Use Template Names + Template + Order Shift + Source Frame Rate + Target Frame Rate + Title + Source name + Import format + Duration + Frame rate + Track order + Presence + Name + Chapter order + Segment order + Track + Position + Chapter number + Start time + End time + Frames + Frame accuracy + Type + Source track + Source entry + Media files + Media tracks + Chapter count + Import name + Disc title + Source frame rate + Source chapter count + Scope + Track + Tracks + Track Count + Delete Timing + Frame Display + Frame Decimals + Name + Start time + Frame + Unknown + fps + frame rate {Before} → {After} + Unavailable track + {Track} · chapter position {Position} + Unknown + OGM + Matroska XML + WebVTT + CUE + Premiere markers + Blu-ray playlist (MPLS) + DVD IFO + HD-DVD XPL + Media container + Blu-ray disc + Text + XML + JSON + Marker + Separator + Neutral + Early + Late + Track Name + Changed tracks + Changed segments + Changes below compare this node with its parent. + Inspecting history does not move the current saved state. + Yes + No + Preserve timestamps + Normalize remaining times + Whole frames + Decimal frames + Toggle history branch + No history entry is available. + Choose valid export options. + Could not apply these settings. The draft is still available. diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml index 69896a37..76168eaa 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml @@ -1,4 +1,4 @@ - + 切り取り コピー @@ -6,12 +6,7 @@ MPLS を追加… テンプレートを参照… 閉じる - セグメントを結合: オプション数={entries}, ソース種別={sourceType} - 行を削除: インデックス={indexes} ダウンロード - {kind}を編集: 行={row}, 値='{value}' - チャプターを編集 - 行を挿入: インデックス={index} 読み込み OGM サンプルを読み込む ログ @@ -20,8 +15,6 @@ 再読み込み 既定に戻す 設定 - チャプターを前へ移動: フレーム数={frames} - 結合済みセグメントを分割: オプション数={entries}, ソース種別={sourceType} [VCB-Studio] ChapterTool 適用 キャンセル @@ -32,6 +25,19 @@ 警告 エラー レベルでフィルター + レベル + フィルターをリセット + その他の操作 + エクスポート形式 + 詳細を開く + 詳細に一致する内容があります。 + {level}:{summary}、{time}。{context} + ログはまだありません。 + 検索またはフィルターに一致するログはありません。 + メッセージ + コンテキスト + このログに追加の詳細はありません。 + ログ一覧に戻る 概要をコピー 詳細をコピー 技術詳細 @@ -45,6 +51,13 @@ プロパティ 生データ ログを検索… + JSON + CSV + {value} にエクスポートしました + エクスポートに失敗しました: {value} + 詳細を閉じる + ログフォルダーを開く + ログをエクスポート このログにネストされた構造化データはありません。 このログに例外データはありません。 保存 @@ -110,7 +123,10 @@ Vorbis cuesheet コメントが見つかりませんでした。 フレームレートは 0 より大きくなければなりません。 フレームテキストにフレーム番号が含まれていないか、フレームレートが無効です。 + DVD を読み込みました:{titleCount} 個のタイトル、{chapterCount} 個のチャプター、{titleSetCount} 個のタイトルセット。 IFO ファイルが無効です: {message} + {source} に対応する VOB のナビゲーションデータから DVD チャプター時刻を読み込みました。 + {source} に対応する VOB のナビゲーション時刻を検証できませんでした。DVD チャプターは元の IFO 推定時刻を保持し、フレーム位置への調整は行っていません。 プライマリ インポーターが利用できないため、フォールバック インポーターを使用しました。 Lua 式の実行がキャンセルされました。 Lua 式の構文エラー: {message} @@ -157,8 +173,6 @@ ルート要素は Chapters である必要がありますが、実際は {name} です。 HD-DVD のチャプターは 1 件も解析されませんでした。 XPL プレイリストの解析に失敗しました: {message} - フレーム - 名前 ファイルを読み込むか、ここにドロップするか、サンプルを読み込むと、フレーム数と式の結果が計算されます。 チャプターはまだ読み込まれていません このトークンを使用する前に、不足しているオペランドを追加してください。 @@ -178,10 +192,12 @@ フレーム 名前 # + チャプター 時刻 チャプター名 クリップ / プレイリスト項目 {0}({1} 個のチャプター) + タイトル {number} カスタム式 プリセット フレーム列で使用するフレームレート @@ -193,31 +209,14 @@ 簡体字中国語 英語 日本語 - MPLS を追加中: path='{path}' - 自動フレームレート検出: オプション={option}, 信頼度={confidence}, 正確={accurate}/{evaluated}, 偏差={deviation} - 現在の FPS に変換: 変換元={sourceFps}, 変換先={targetFps}, チャプター数 {before} -> {after} - Zones を生成: 選択行={selectedRows}, チャプター数={chapters} - {operation} diagnostic: severity={severity}, code={code}{location} message='{message}'{details} - {action}: チャプター数 {before} -> {after} - フレーム情報を更新: オプション={option}, fps={fps}, 丸め={round}, チャプター数={chapters} - {operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics} - 言語を {language} に設定しました - ソースを読み込み中: path='{path}' - {status}: path='{path}' - {status}: reference='{reference}', resolved='{resolved}' - チャプターを保存中: 形式={format}, ディレクトリ='{directory}', ソース='{source}', チャプター数={chapters}, 式を適用={applyExpression}, 式='{expression}' - ソースオプションを選択: インデックス={index}, ラベル='{label}', ソース='{source}', ソース種別={sourceType}, チャプター数={chapters}, fps={fps} - 設定を読み込みました: savingPath='{savingPath}', language='{language}' - {status} - ユーザーインターフェイスの操作中に予期しないエラーが発生しました - {status}、ソース: {path} - '{path}' からチャプター名テンプレートを読み込めませんでした - チャプター名テンプレート '{name}' を '{path}' から読み込みました MPLS を追加して結合 自動 {count} 個のチャプター + {count} 個のチャプター 現在の FPS に変換 結合 + 境界で分割 + 境界で分割 Zones を生成 フレームを前へ移動 挿入 @@ -271,14 +270,14 @@ 等幅フォント {name} の等幅フォントプレビュー テーマプリセット - Avalonia デフォルト - Ayu ダーク - Ayu ライト - Ayu ミラージュ - Gruvbox ダーク - Gruvbox ライト - Solarized ダーク - Solarized ライト + Avalonia デフォルト + Ayu ダーク + Ayu ライト + Ayu ミラージュ + Gruvbox ダーク + Gruvbox ライト + Solarized ダーク + Solarized ライト パレットプレビュー {name} のパレットプレビュー システム既定の等幅フォント @@ -298,7 +297,10 @@ フレーム表示 整数に丸める 小数点以下を保持 + 桁を切り捨てない 小数点以下の桁数 + 循環小数を表示 + 桁を切り捨てないモードでは、循環する桁を上線で表示できます。無効にすると通常の小数形式を使います。 行削除時の時間処理 チャプター時間を保持 チャプター時間を正規化 @@ -332,6 +334,10 @@ 破棄 設定の変更は現在のセッションにすでに反映されていますが、まだ保存されていません。閉じる前に保存しますか? 未保存の設定 + ドキュメントセッションを終了しますか? + 続行すると、このドキュメントセッションと取り消し履歴を終了します。エクスポートした章ファイルに履歴は含まれません。続行しますか? + 続行 + 現在のドキュメントセッションは変更されていません。 ツールを確認 MPLS の追加に失敗しました {1} から {0} 個の MPLS セグメントを追加しました。 @@ -353,6 +359,7 @@ ファイルが大きすぎます(最大 64 MB)。 {0} 行目を複製しました。 式を適用しました · {0} fps · {1} 行 + 式を適用できませんでした。 式プリセットを適用しました: {0} 不明な式プリセット: {0} フレームを更新しました · {0} fps · {1} 行 @@ -360,6 +367,7 @@ 読み込みに失敗しました {1} から {0} 個のチャプターを読み込みました。 {count} 個のチャプターを読み込みました + {count} 個のチャプターを読み込みました 読み込み中… 読み込み中… タイトルを検出中… @@ -382,7 +390,7 @@ 準備完了 関連メディアファイルが見つかりません ブラウザーではローカルメディアのパスを開けません。参照先を利用できる場合は、ダウンロードまたはリンクとしてのみ表示します。 - 結合前の個別クリップに戻しました。 + ソース境界で分割しました。 行を更新しました · {0} fps · {1} 行 保存に失敗しました 保存しました @@ -395,6 +403,50 @@ テンプレート {name} を読み込みました 未選択 更新しました + 編集履歴 + 履歴は現在のドキュメントセッション中のみ保持されます。 + 現在のセッションのみ + 元に戻す + やり直す + 閉じる + 現在の履歴位置 + このノードに復元 + 再試行 + 詳細を読み込み中… + 初期ドキュメントの概要 + 履歴を移動すると保留中のプレビューは破棄されます。 + 追加 + 削除 + 変更 + リソース不足のため詳細を読み込めません。 + この履歴ノードは利用できません。 + ドキュメントセッションは終了しました。 + 履歴の詳細を読み込めませんでした。 + 選択したノードに復元しました。 + 現在の位置を表示 + 現在のノードはこの分岐の下にあります + 優先するやり直し分岐 + やり直し分岐の選択肢 + 選択した分岐をやり直す + 概要 + 変更 + パラメーター + 編集 + 初期ドキュメント + チャプターを編集 + チャプターを削除 + チャプターを挿入 + チャプターを移動 + フレームレートを変更 + 式を適用 + テンプレート名を適用 + チャプター設定を適用 + トラックを結合 + トラックを分割 + クリップを追加 + 編集を元に戻しました + 編集をやり直しました + 履歴を移動できませんでした。 操作中に予期しないエラーが発生しました。詳細はログを確認してください。 zones は生成されませんでした。 Zones を生成しました @@ -404,6 +456,78 @@ 現在のオプションでグリッドを更新 現在のオプションで書き出してダウンロード 式を適用 + 時刻変更 {times}/{count} 件 + 対象 {affected} 件 · フレーム {frames} 件 · プロパティ {properties} 件 + その他の変更 + 式のプレビュー + チャプターの値は変更されません。 + 式のプレビューをキャンセルしました。 + 式のプレビューを更新しています… + オプションをプレビュー + プレビューを更新 + 破棄 + プレビューが古くなっています。更新してから適用してください。 + 薄い値は確定済みです。矢印はプレビュー値を示します。 + {field}: 確定値 {before}、プレビュー {candidate}、変更 {delta}。 + 現在のトラック:{track} · {count} 件のチャプター + 区切り行 {count} 件を除外 + 影響するチャプター:{affected} · 時刻の変更:{times} · フレーム情報の更新:{frames} · プロパティの変更:{properties} + {value} フレーム + 式を適用しました:影響するチャプター {affected} 件、時刻の変更 {times} 件、フレーム情報の更新 {frames} 件。1 回の取り消しで戻せます。 + チャプター時刻は変わりません。 + ドキュメントとトラックのプロパティ + 表示 + 変更したチャプター + すべてのチャプター + 単位 + 時刻 + フレーム + チャプター + 変更前 + 変更後 + 差分 + フレーム情報の詳細 + 変更前のフレーム + 変更後のフレーム + フレーム精度 + プレビューできるチャプターがありません。 + プレビューの更新を待っています… + プレビューを計算しています… + プレビューを確認できます。 + 式を入力するか、プリセットを選択してください。 + 文字入力が完了するとプレビューを更新します。 + チャプターが変更されました。適用前にプレビューを更新してください。 + この式は適用できません。 + 適用できる変更はありません。 + 確認済みの変更を適用しています… + プレビューを更新 + 変更を適用 + 利用できません + 未計算 + 未設定 + フレームレートが異なります + フレーム + fps + フレーム境界に一致 + フレーム間 + 未計算 + 最も近いフレームとの差が 0.01 フレーム未満の場合、フレーム境界に一致と表示します。 + ドキュメント + トラック + セグメント + チャプター + タイトル + ソース + インポート形式 + 長さ + フレームレート + 名前 + チャプター番号 + チャプター種別 + チャプター項目 + トラック順序 + 追加フィールド + 更新済み Lua スクリプトを読み込む… プリセット 式エディター @@ -416,4 +540,168 @@ 標準テンプレート名を使用 Zones 未確定 + ショートカット + 主な操作のキーボードジェスチャーを設定します。 + ショートカットをリセット + キーの組み合わせを押してショートカットを設定します。 + このショートカットはアプリケーションで固定されています。 + 保存 + 再読み込み + 更新 + ログ + プレビュー + クリップを選択 + 行を挿入 + 行を削除 + 読み込み + 前のクリップ + 次のクリップ + 編集を元に戻す + 編集をやり直す + 保存形式を選択 + 式 + 詳細なエクスポート設定 + 名前と番号 + 0 から 1000 の整数を入力してください。 + -1000000 から 1000000 の整数フレームを入力してください。 + 入力候補 + 位置 + Lua スクリプトを読み込めません。元の下書きを保持しました。 + 未保存の設定を破棄しますか? + 破棄 + 編集を続ける + 設定を保存できません。編集を続けるか再試行してください。 + 無効、予約済み、または競合するショートカットです。 + ログを検索 + レベル + すべてのレベル + {count} 件 + 詳細 + ログに戻る + 元のログ + ログをエクスポート + 詳細に一致 + ログ操作に失敗しました。再試行できます。 + ログのダウンロードを開始しました。 + 情報 + 情報 + 警告 + エラー + デバッグ + トレース + 変換元のフレームレート + 変換先のフレームレート + この操作を適用できません。入力値と詳細を確認してください。 + 中国語 + ドイツ語 + フランス語 + イタリア語 + スペイン語 + 履歴 + 詳細 + すべての変更 + この条件に一致する変更はありません。 + 追加 + 削除 + 変更 + フィールド + 変更前 + 変更後 + 差分 + {0} ティック + 存在しない + Null + 空のテキスト + フィールド + チャプター + チャプター + 値 + 表示フレームレート + フレーム数 + 前のチャプター + ソース + 式 + 式 + 名前を生成 + テンプレート名を使用 + テンプレート + 順序の移動 + ソースフレームレート + 目標フレームレート + タイトル + ソース名 + インポート形式 + 時間 + フレームレート + トラック順 + 有無 + 名前 + チャプター順 + セグメント順 + トラック + 位置 + チャプター番号 + 開始時間 + 終了時間 + フレーム + フレーム精度 + 種類 + ソーストラック + ソース項目 + メディアファイル + メディアトラック + チャプター数 + インポート名 + ディスクタイトル + ソースフレームレート + ソースチャプター数 + 範囲 + トラック + トラック + トラック数 + 削除タイミング + フレーム表示 + フレーム小数位 + 名前 + 開始時間 + フレーム + 不明 + fps + フレームレート {Before} → {After} + 利用できないトラック + {Track} · チャプター位置 {Position} + 不明 + OGM + Matroska XML + WebVTT + CUE + Premiere マーカー + Blu-ray プレイリスト (MPLS) + DVD IFO + HD-DVD XPL + メディアコンテナー + Blu-ray ディスク + テキスト + XML + JSON + マーカー + 区切り + 標準 + 早め + 遅め + トラック名 + 変更されたトラック + 変更されたセグメント + 以下の変更はこのノードと親ノードを比較します。 + 履歴を確認しても現在の保存状態は移動しません。 + はい + いいえ + タイムスタンプを保持 + 残りの時間を正規化 + 整数フレーム + 小数フレーム + 履歴分岐を展開または折りたたむ + 利用できる履歴項目はありません。 + 有効なエクスポート設定を選択してください。 + 設定を適用できませんでした。下書きは引き続き編集できます。 diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml index 7224e201..100a2fac 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml @@ -1,4 +1,4 @@ - + 剪切 复制 @@ -6,12 +6,7 @@ 追加 MPLS… 浏览模板… 关闭 - 合并段落:选项数={entries},源类型={sourceType} - 删除行:索引={indexes} 下载 - 编辑{kind}:行={row},值='{value}' - 编辑章节 - 插入行:索引={index} 加载 加载 OGM 示例 日志 @@ -20,8 +15,6 @@ 重新加载 恢复默认 设置 - 帧数前移:帧数={frames} - 拆分已合并段落:选项数={entries},源类型={sourceType} [VCB-Studio] ChapterTool 应用 取消 @@ -32,6 +25,19 @@ 警告 错误 按级别筛选 + 级别 + 重置筛选 + 更多操作 + 导出格式 + 打开详情 + 详情中有匹配内容。 + {level}:{summary},{time}。{context} + 暂时没有日志记录。 + 没有符合搜索或筛选条件的记录。 + 消息 + 上下文 + 该记录没有更多详情。 + 返回日志列表 复制摘要 复制详情 技术详情 @@ -45,6 +51,13 @@ 属性 原始数据 搜索日志… + JSON + CSV + 已导出到 {value} + 导出失败:{value} + 关闭详情 + 打开日志文件夹 + 导出日志 该日志没有嵌套结构化数据。 该日志没有异常数据。 保存 @@ -110,7 +123,10 @@ 未找到 Vorbis cuesheet 注释。 帧率必须大于零。 帧文本未包含帧编号,或帧率无效。 + 已读取 DVD 光盘:{titleCount} 个标题,{chapterCount} 个章节,{titleSetCount} 个标题集。 IFO 文件无效:{message} + 已从 {source} 对应的 VOB 导航数据读取 DVD 章节时间。 + 无法验证 {source} 对应的 VOB 导航时间戳。DVD 章节保留原始 IFO 估算时间,未作对齐调整。 主导入器不可用,已使用备用导入器。 Lua 表达式已取消。 Lua 表达式语法错误:{message} @@ -157,8 +173,6 @@ 应为 Chapters 根节点,实际为 {name}。 未解析到 HD-DVD 章节。 无法解析 XPL 播放列表:{message} - 帧 - 名称 加载文件、拖放到此处,或加载示例以计算帧与表达式。 尚未加载章节 在应用此记号前补充缺失的操作数。 @@ -178,10 +192,12 @@ 帧 名称 # + 章节 时间 章节名 片段 / 播放列表项 {0}({1} 个章节) + 标题 {number} 表达式 预设 用于帧列的帧率 @@ -193,31 +209,14 @@ 简体中文 英语 日语 - 正在追加 MPLS:path='{path}' - 自动帧率检测:option={option},confidence={confidence},accurate={accurate}/{evaluated},deviation={deviation} - 转换至当前帧率:源配置={sourceFps},目标配置={targetFps},章节数 {before} -> {after} - 生成 Zones:选中行={selectedRows},章节数={chapters} - {operation} 诊断:severity={severity},code={code}{location} message='{message}'{details} - {action}:章节数 {before} -> {after} - 帧信息已更新:option={option},fps={fps},round={round},chapters={chapters} - {operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics} - 语言已设置为 {language} - 正在载入源:path='{path}' - {status}:path='{path}' - {status}:reference='{reference}',resolved='{resolved}' - 正在保存章节:格式={format},目录='{directory}',来源='{source}',章节数={chapters},应用表达式={applyExpression},表达式='{expression}' - 已选择源选项:index={index},label='{label}',source='{source}',sourceType={sourceType},chapters={chapters},fps={fps} - 已载入设置:保存目录='{savingPath}',语言='{language}' - {status} - 用户界面操作意外失败 - {status},来源:{path} - 无法从 '{path}' 加载章节名称模板 - 已从 '{path}' 加载章节名称模板 '{name}' 追加合并 MPLS 自动 {count} 个章节 + {count} 个章节 转换至当前帧率 合并 + 按边界拆分 + 按边界拆分 生成 Zones 前移帧 插入 @@ -271,14 +270,14 @@ 等宽字体 {name} 的等宽字体预览 主题预设 - Avalonia 默认 - Ayu 深色 - Ayu 浅色 - Ayu 幻景 - Gruvbox 深色 - Gruvbox 浅色 - Solarized 深色 - Solarized 浅色 + Avalonia 默认 + Ayu 深色 + Ayu 浅色 + Ayu 幻景 + Gruvbox 深色 + Gruvbox 浅色 + Solarized 深色 + Solarized 浅色 配色预览 {name} 的配色预览 系统等宽默认 @@ -298,7 +297,10 @@ 帧显示 取整 保留小数位 + 不截取 小数位数 + 显示小数循环节 + 选择“不截取”时,可用上划线显示循环节;关闭后显示普通小数格式。 删除行时的时间处理 保留章节时间 归一化章节时间 @@ -332,6 +334,10 @@ 放弃 设置修改已实时应用到当前会话,但尚未保存。关闭前要保存吗? 设置尚未保存 + 结束当前文档会话? + 继续后将结束当前文档会话及其撤销历史。导出的章节文件不包含历史记录。要继续吗? + 继续 + 当前文档会话未更改。 检查工具 追加失败 已从 {1} 追加 {0} 个 MPLS 片段。 @@ -353,6 +359,7 @@ 文件过大(最大 64 MB)。 已复制第 {0} 行。 已应用表达式 · {0} fps · {1} 行 + 无法应用表达式。 已应用表达式预设:{0} 未知表达式预设:{0} 已更新帧 · {0} fps · {1} 行 @@ -360,6 +367,7 @@ 载入失败 已从 {1} 加载 {0} 个章节。 已载入 {count} 个章节 + 已载入 {count} 个章节 加载中… 正在加载... 正在发现标题... @@ -382,7 +390,7 @@ 就绪 未找到相关媒体文件 浏览器无法打开本地媒体路径;可用时仅以下载/链接方式展示引用。 - 已恢复分开的剪辑。 + 已按源边界拆分。 已刷新行 · {0} fps · {1} 行 保存失败 已保存 @@ -395,6 +403,50 @@ 已加载模板 {name} 未选择 已更新 + 编辑历史 + 历史记录仅在当前文档会话期间保留。 + 仅当前会话 + 撤销 + 重做 + 关闭 + 当前历史位置 + 恢复到此节点 + 重试 + 正在加载详细信息… + 初始文档概览 + 历史导航会丢弃待处理的预览。 + 新增 + 删除 + 修改 + 资源不足,无法加载详细信息。 + 此历史节点已不可用。 + 此文档会话已结束。 + 无法加载历史详细信息。 + 已恢复到所选节点。 + 定位当前节点 + 当前节点隐藏在此分支下 + 首选重做分支 + 重做分支选项 + 重做所选分支 + 摘要 + 变更 + 参数 + 编辑 + 初始文档 + 编辑章节 + 删除章节 + 插入章节 + 偏移章节 + 更改帧率 + 应用表达式 + 应用模板名称 + 应用章节选项 + 合并轨道 + 拆分轨道 + 追加片段 + 已撤销编辑 + 已重做编辑 + 无法切换历史记录。 操作意外失败。请查看日志了解详细信息。 未生成 zones。 Zones 已生成 @@ -404,6 +456,78 @@ 按当前选项刷新表格 按当前选项导出并下载 应用表达式 + 时间修改 {times}/{count} 个章节 + 影响 {affected} 章 · 帧更新 {frames} 项 · 属性 {properties} 项 + 其他修改 + 表达式预览 + 章节值不会发生变化。 + 已取消表达式预览。 + 正在更新表达式预览… + 预览其他选项 + 更新预览 + 放弃 + 预览已过期。请更新后再应用。 + 浅色值为已提交内容;箭头指向预览值。 + {field}:已提交 {before};预览 {candidate};变化 {delta}。 + 当前轨道:{track} · {count} 个章节 + 已排除 {count} 个分隔行 + 受影响章节:{affected} · 时间变化:{times} · 帧信息更新:{frames} · 属性变化:{properties} + {value} 帧 + 已应用表达式:{affected} 个受影响章节,{times} 处时间变化,{frames} 处帧信息更新。撤销一次即可恢复。 + 章节时间未变化。 + 文档和轨道属性 + 显示 + 仅显示变化 + 所有章节 + 单位 + 时间 + 帧数 + 章节 + 修改前 + 修改后 + 变化量 + 帧信息详情 + 修改前帧数 + 修改后帧数 + 帧精度 + 当前没有可预览的章节。 + 等待更新预览… + 正在计算预览… + 预览已就绪。 + 请输入表达式或选择预设。 + 完成文字输入后再更新预览。 + 章节已变化。请先更新预览再应用。 + 此表达式无法应用。 + 结果没有可应用的变化。 + 正在应用已检查的更改… + 更新预览 + 应用更改 + 不可用 + 未计算 + 未设置 + 帧率不同 + 帧 + 帧/秒 + 帧精确 + 位于帧之间 + 未计算 + 与最近帧的距离小于 0.01 帧时,标记为帧精确。 + 文档 + 轨道 + 片段 + 章节 + 标题 + 来源 + 导入格式 + 时长 + 帧率 + 名称 + 章节编号 + 章节类型 + 章节条目 + 轨道顺序 + 其他字段 + 已更新 加载 Lua... 预设 表达式 @@ -416,4 +540,168 @@ 使用标准模板名称 Zones 未确定 + 快捷键 + 自定义主要操作的键盘手势。 + 恢复快捷键 + 按下组合键即可设置快捷键。 + 此快捷键由应用程序固定。 + 保存 + 重新加载 + 刷新 + 日志 + 预览 + 选择片段 + 插入行 + 删除行 + 加载 + 上一个片段 + 下一个片段 + 撤销编辑 + 重做编辑 + 选择保存格式 + 表达式 + 高级导出选项 + 命名与编号 + 请输入 0 至 1000 的整数偏移量。 + 请输入 -1000000 至 1000000 的整数帧平移量。 + 补全建议 + 位置 + 无法加载此 Lua 脚本,已保留原草稿。 + 放弃未保存的设置? + 放弃 + 继续编辑 + 设置保存失败,请继续编辑或重试。 + 快捷键无效、被保留或存在冲突。 + 搜索日志 + 级别 + 全部级别 + {count} 条日志 + 详情 + 返回日志 + 原始条目 + 导出日志 + 详情匹配 + 日志操作失败,可以重试。 + 已发起日志下载。 + 信息 + 信息 + 警告 + 错误 + 调试 + 跟踪 + 源帧率 + 目标帧率 + 无法应用此操作。请检查输入值与详细信息。 + 中文 + 德语 + 法语 + 意大利语 + 西班牙语 + 历史 + 详情 + 所有更改 + 没有符合此筛选条件的更改。 + 新增 + 移除 + 修改 + 字段 + 更改前 + 更改后 + 差值 + {0} 个刻度 + 不存在 + 空值 + 空文本 + 字段 + 章节 + 章节 + 值 + 显示帧率 + 帧数 + 前置章节 + 来源 + 表达式 + 表达式 + 生成名称 + 使用模板名称 + 模板 + 顺序偏移 + 源帧率 + 目标帧率 + 标题 + 来源名称 + 导入格式 + 时长 + 帧率 + 轨道顺序 + 存在状态 + 名称 + 章节顺序 + 片段顺序 + 轨道 + 位置 + 章节编号 + 开始时间 + 结束时间 + 帧数 + 帧精度 + 类型 + 来源轨道 + 来源条目 + 媒体文件 + 媒体轨道 + 章节数 + 导入名称 + 光盘标题 + 源帧率 + 源章节数 + 范围 + 轨道 + 轨道 + 轨道数 + 删除时间处理 + 帧显示 + 帧小数位 + 名称 + 开始时间 + 帧 + 未知 + 帧/秒 + 帧率 {Before} → {After} + 不可用的轨道 + {Track} · 章节位置 {Position} + 未知 + OGM + Matroska XML + WebVTT + CUE + Premiere 标记 + 蓝光播放列表 (MPLS) + DVD IFO + HD-DVD XPL + 媒体容器 + 蓝光光盘 + 文本 + XML + JSON + 标记 + 分隔符 + 标准 + 偏早 + 偏晚 + 轨道名称 + 变更轨道 + 变更片段 + 以下更改将此节点与其父节点进行比较。 + 查看历史记录不会移动当前保存状态。 + 是 + 否 + 保留时间戳 + 规范剩余时间 + 整数帧 + 小数帧 + 展开或折叠历史分支 + 没有可用的历史记录。 + 请选择有效的导出选项。 + 无法应用这些设置。草稿仍可继续编辑。 diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/AuxiliaryTools.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/AuxiliaryTools.cs index 95a004f9..7bfc6ef7 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/AuxiliaryTools.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/AuxiliaryTools.cs @@ -8,6 +8,7 @@ using ChapterTool.Core.Exporting; using ChapterTool.Core.Transform; using ChapterTool.Core.Transform.Expressions; +using Microsoft.Extensions.Logging; namespace ChapterTool.Avalonia.UI.PlatformPorts; @@ -49,6 +50,7 @@ public static class ToolIds public static readonly ToolId TemplateNames = new("template-names"); public static readonly ToolId Zones = new("zones"); public static readonly ToolId ForwardShift = new("forward-shift"); + public static readonly ToolId History = new("history"); } public enum ToolRefreshPolicy @@ -76,9 +78,10 @@ public sealed record AuxiliaryToolRequest( IWorkspaceToolSession Session, IAppLocalizer Localizer, IRuntimeCapabilities Capabilities, - Window? HostWindow = null, IFilePickerService? FilePicker = null, - IClipboardService? Clipboard = null); + IClipboardService? Clipboard = null, + Action? CloseTool = null, + Window? HostWindow = null); public interface IAuxiliaryToolHost : IDisposable { @@ -144,7 +147,8 @@ public sealed record ToolCreationContext( IClipboardService Clipboard, Window? HostWindow = null, IFilePickerService? FilePicker = null, - IRuntimeCapabilities? Capabilities = null); + IRuntimeCapabilities? Capabilities = null, + Action? CloseTool = null); public sealed record ToolDescriptor( ToolId Id, @@ -153,7 +157,8 @@ public sealed record ToolDescriptor( ToolRefreshPolicy RefreshPolicy, Func CreateContent, bool RequiresCloseConfirmation = false, - IReadOnlySet? RequiredPorts = null); + IReadOnlySet? RequiredPorts = null, + bool IsModal = false); public interface IToolCatalog { @@ -220,7 +225,7 @@ public sealed record WorkspaceHostServices( public sealed record HostEffectServices( IApplicationLogService LogService, - Microsoft.Extensions.Logging.ILogger Logger, + ILogger Logger, IShellService ShellService); public sealed record SettingsAppearanceServices( diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/EmbeddedAuxiliaryToolHost.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/EmbeddedAuxiliaryToolHost.cs index 5c7a5945..aa77a1eb 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/EmbeddedAuxiliaryToolHost.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/EmbeddedAuxiliaryToolHost.cs @@ -59,7 +59,7 @@ public ValueTask OpenAsync( if (descriptor.RefreshPolicy == ToolRefreshPolicy.RefreshRequest) { DisposeContent(existing); - existing = descriptor.CreateContent(contextFactory(request)); + existing = descriptor.CreateContent(contextFactory(CreateToolRequest(toolId, request))); contentById[toolId] = existing; } @@ -67,7 +67,7 @@ public ValueTask OpenAsync( return ValueTask.FromResult(new AuxiliaryToolResult(AuxiliaryToolResultKind.Activated, toolId)); } - var content = descriptor.CreateContent(contextFactory(request)); + var content = descriptor.CreateContent(contextFactory(CreateToolRequest(toolId, request))); contentById.Add(toolId, content); presenter.SetContent(toolId, content); return ValueTask.FromResult(new AuxiliaryToolResult(AuxiliaryToolResultKind.Opened, toolId)); @@ -139,4 +139,9 @@ private static void DisposeContent(Control content) content.DataContext = null; } } + + private AuxiliaryToolRequest CreateToolRequest(ToolId toolId, AuxiliaryToolRequest request) => request with + { + CloseTool = () => _ = CloseAsync(toolId, CancellationToken.None) + }; } diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/IChapterSaveService.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/IChapterSaveService.cs index e149c024..8644df63 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/IChapterSaveService.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/IChapterSaveService.cs @@ -7,7 +7,7 @@ public interface IChapterSaveService { ValueTask SaveAsync( ChapterSet info, - ChapterExportOptions options, + ChapterSerializationOptions options, string? directory, CancellationToken cancellationToken, string? sourcePath = null); diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/MainWindowPortAdapters.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/MainWindowPortAdapters.cs index 56e91eb2..4a86d362 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/MainWindowPortAdapters.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/MainWindowPortAdapters.cs @@ -4,7 +4,10 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Editing; using ChapterTool.Core.Exporting; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; using ChapterTool.Core.Transform.Expressions; +using Microsoft.Extensions.Logging; using DeleteRowsTimingMode = ChapterTool.Contracts.Configuration.DeleteRowsTimingMode; namespace ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; @@ -20,6 +23,7 @@ public MainWindowPortAdapters(MainWindowViewModel owner) ExportPreferences = new ExportPreferencePortAdapter(owner); NamingPreferences = new NamingPreferencePortAdapter(owner); ChapterEdit = new ChapterEditPortAdapter(owner); + ContentOperations = new ChapterContentOperationPortAdapter(owner); } public ExpressionSessionPortAdapter Expression { get; } @@ -33,6 +37,8 @@ public MainWindowPortAdapters(MainWindowViewModel owner) public NamingPreferencePortAdapter NamingPreferences { get; } public ChapterEditPortAdapter ChapterEdit { get; } + + public ChapterContentOperationPortAdapter ContentOperations { get; } } public sealed class ExpressionSessionPortAdapter(MainWindowViewModel owner, IMainShellNotificationPort? notifications = null) : IExpressionSessionPort @@ -43,13 +49,13 @@ public sealed class ExpressionSessionPortAdapter(MainWindowViewModel owner, IMai public IReadOnlyList ExpressionPresets => owner.ExpressionEngine.Presets; - public string Expression => owner.Workspace.Projection.Expression; + public string Expression => owner.Workspace.OperationDrafts.Expression; - public bool ApplyExpression => owner.Workspace.Projection.ApplyExpression; + public string ExpressionPresetId => owner.Workspace.OperationDrafts.ExpressionPresetId; - public string ExpressionPresetId => owner.Workspace.Projection.ExpressionPresetId; + public string ExpressionSourceName => owner.Workspace.OperationDrafts.ExpressionSourceName; - public string ExpressionSourceName => owner.Workspace.Projection.ExpressionSourceName; + public FrameRateValue ComputationFrameRate => owner.ComputationFrameRate; public async ValueTask LoadScriptAsync(string path, CancellationToken cancellationToken) { @@ -59,57 +65,45 @@ public sealed class ExpressionSessionPortAdapter(MainWindowViewModel owner, IMai } var text = await File.ReadAllTextAsync(path, cancellationToken); - var diagnostic = ApplyLuaExpressionSettings( - string.IsNullOrWhiteSpace(text) ? "t" : text, - applyExpression: true, - expressionPresetId: string.Empty, - expressionSourceName: Path.GetFileName(path)); + var script = string.IsNullOrWhiteSpace(text) ? "t" : text; + owner.Expression = script; + owner.ExpressionPresetId = string.Empty; + owner.ExpressionSourceName = Path.GetFileName(path); + notifications.RefreshExpressionFields(); + var diagnostic = ValidateLuaExpressionScript(script, logDiagnostics: true); if (diagnostic is null) { owner.SetStatus("Status.LuaExpressionScriptLoaded", ("path", ExpressionSourceName)); - owner.LogStatus(); + owner.Log(LogLevel.Information, $"Lua expression script loaded: path='{ExpressionSourceName}'", "Edit", + ("path", ExpressionSourceName)); } return diagnostic; } - public ChapterDiagnostic? ApplyLuaExpressionSettings( + public void SaveAppliedExpressionPreview( string expression, - bool applyExpression, string expressionPresetId, - string expressionSourceName) + string expressionSourceName, + ExpressionPreviewProjection projection) { - owner.Workspace.ApplyExpressionFields(expression, applyExpression, expressionPresetId, expressionSourceName); + ArgumentNullException.ThrowIfNull(projection); + owner.Workspace.SetExpressionOperationDrafts(expression, applyExpression: false, expressionPresetId, expressionSourceName); notifications.RefreshExpressionFields(); - notifications.RefreshRows(); - - if (!ApplyExpression) - { - owner.SetStatus("Status.Updated"); - notifications.RefreshStatus(); - return null; - } - - var diagnostic = ValidateLuaExpressionScript(Expression, logDiagnostics: true); - if (diagnostic is null) - { - owner.SetStatus("Status.Updated"); - } - else - { - owner.SetStatus(null, diagnostic); - } - + owner.SetStatus("Expression.Preview.Applied", + ("affected", projection.AffectedChapterCount), + ("times", projection.TimeChangedChapterCount), + ("frames", projection.FrameInformationChangedChapterCount)); notifications.RefreshStatus(); - return diagnostic; } public ChapterDiagnostic? ValidateLuaExpressionScript(string scriptText, bool logDiagnostics) { - var result = owner.ExpressionEngine.Evaluate( + var result = new Core.Transform.ChapterExpressionService(owner.ExpressionEngine).Apply( + ChapterExpressionValidation.CreateInput(owner.CurrentChapterSet), true, string.IsNullOrWhiteSpace(scriptText) ? "t" : scriptText, - ChapterExpressionValidation.CreateContext(owner.CurrentChapterSet)); + owner.ComputationFrameRate.IsKnown ? owner.ComputationFrameRate : null); if (logDiagnostics) { owner.LogDiagnostics("Lua expression script", result.Diagnostics); @@ -135,6 +129,8 @@ public sealed class PreferenceSinkAdapter(MainWindowViewModel owner) : IPreferen public decimal FrameAccuracyTolerance => owner.FrameAccuracyTolerance; + public bool ShowRepeatingFrameDecimals => owner.ShowRepeatingFrameDecimals; + public ChapterEditingOptions EditingOptions => owner.EditingOptions; public void ApplyLoadedSettings(AppSettings settings) => ApplyPreferences(settings, applyDefaultSaveFormat: true); @@ -153,7 +149,8 @@ public async ValueTask SaveUiLanguageAsync(string language, CancellationToken ca await owner.SettingsStore.UpdateAsync( current => current with { Application = current.Application with { Language = owner.UiLanguage } }, cancellationToken); - owner.Log("Log.LanguageSet", ("language", owner.UiLanguage)); + owner.Log(LogLevel.Information, $"Language set to {owner.UiLanguage}", "Settings", + ("language", owner.UiLanguage)); owner.NotifyStateChanged(); } @@ -169,15 +166,15 @@ private void ApplyPreferences(AppSettings settings, bool applyDefaultSaveFormat) } owner.FrameAccuracyTolerance = settings.FrameAccuracyTolerance; + owner.ShowRepeatingFrameDecimals = settings.ShowRepeatingFrameDecimals; + var frameDisplayMode = (Core.Editing.FrameDisplayMode)FrameDisplayModes.ParseOrDefault(settings.FrameDisplayMode); owner.ApplyEditingOptions(new ChapterEditingOptions( DeleteRowsTimingModes.ParseOrDefault(settings.DeleteRowsTimingMode) == DeleteRowsTimingMode.Preserve ? ChapterTool.Core.Editing.DeleteRowsTimingMode.Preserve : ChapterTool.Core.Editing.DeleteRowsTimingMode.Normalize, - FrameDisplayModes.ParseOrDefault(settings.FrameDisplayMode) == Contracts.Configuration.FrameDisplayMode.Round - ? Core.Editing.FrameDisplayMode.Round - : Core.Editing.FrameDisplayMode.DecimalPlaces, + frameDisplayMode, FrameDisplayModes.NormalizeDecimalPlaces(settings.FrameDecimalPlaces))); - owner.RoundFrames = FrameDisplayModes.ParseOrDefault(settings.FrameDisplayMode) == Contracts.Configuration.FrameDisplayMode.Round; + owner.RoundFrames = frameDisplayMode == Core.Editing.FrameDisplayMode.Round; owner.XmlLanguage = string.IsNullOrWhiteSpace(settings.DefaultXmlLanguage) ? "und" : settings.DefaultXmlLanguage; owner.EmitBom = settings.EmitBom; owner.OutputTextEncoding = OutputTextEncodings.ParseOrDefault(settings.OutputTextEncoding); @@ -205,7 +202,7 @@ public ChapterExportFormat SaveFormat } /// -/// Naming mode port backed by workspace projection state through ViewModel setters. +/// Naming mode port backed by explicit workspace operation drafts through ViewModel setters. /// public sealed class NamingPreferencePortAdapter(MainWindowViewModel owner) : INamingPreferencePort { @@ -227,25 +224,49 @@ public bool UseTemplateNames /// public sealed class ChapterEditPortAdapter(MainWindowViewModel owner) : IChapterEditPort { - public void ShiftFramesForward(int frames) + public async ValueTask ShiftFramesForwardAsync(int frames, CancellationToken cancellationToken = default) { if (owner.CurrentChapterSet is null) { return; } - owner.ApplyEditFromPort( - owner.ClipEditingCoordinator.ShiftFramesForward(owner.CurrentChapterSet, frames), - owner.Localizer.Format(LocalizedMessage.Create("Action.ShiftFramesForward", ("frames", frames)))); + var preview = owner.ClipEditingCoordinator.ShiftFramesForward(frames, owner.ComputationFrameRate); + var outcome = await owner.ClipEditingCoordinator.ApplyCandidateAsync(preview, cancellationToken); + owner.ApplyContentOutcome(outcome, $"Shift frames forward: frames={frames}"); } } +public sealed class ChapterContentOperationPortAdapter(MainWindowViewModel owner) : IChapterContentOperationPort +{ + public bool CanPrepareExpression => owner.CurrentChapterSet is not null; + + public ChapterContentPreview PrepareExpression(string expression) => owner.PrepareExpressionOperation(expression); + + public ChapterContentPreview PrepareTemplateNames(bool autoGenerateNames, bool useTemplateNames) => + owner.PrepareTemplateNamesOperation(autoGenerateNames, useTemplateNames); + + public ChapterContentPreview PrepareContentOptions() => owner.PrepareContentOptionsOperation(); + + public ChapterContentPreview PrepareFrameShift(int frames) => owner.ClipEditingCoordinator.ShiftFramesForward(frames, + owner.ComputationFrameRate); + + public ChapterContentPreview PrepareFrameRateConversion(FrameRateValue sourceFps, FrameRateValue targetFps) => owner.PrepareFrameRateOperation(sourceFps, targetFps); + + public async ValueTask ApplyAsync(ChapterContentPreview preview, CancellationToken cancellationToken = default) + { + var outcome = await owner.ApplyContentPreviewAsync(preview, cancellationToken); + return outcome; + } + + public void Cancel(ChapterContentPreview preview) => owner.CancelContentPreview(preview); +} + public sealed class MainShellNotificationPort(MainWindowViewModel owner) : IMainShellNotificationPort { public void RefreshExpressionFields() { owner.NotifyPropertyChanged(nameof(MainWindowViewModel.Expression)); - owner.NotifyPropertyChanged(nameof(MainWindowViewModel.ApplyExpression)); owner.NotifyPropertyChanged(nameof(MainWindowViewModel.ExpressionPresetId)); owner.NotifyPropertyChanged(nameof(MainWindowViewModel.ExpressionSourceName)); } diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/ShellPorts.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/ShellPorts.cs index b9d51fb3..35da7c66 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/ShellPorts.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/ShellPorts.cs @@ -3,6 +3,8 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Editing; using ChapterTool.Core.Exporting; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; using ChapterTool.Core.Transform.Expressions; namespace ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; @@ -16,19 +18,19 @@ public interface IExpressionSessionPort string Expression { get; } - bool ApplyExpression { get; } - string ExpressionPresetId { get; } string ExpressionSourceName { get; } + FrameRateValue ComputationFrameRate => FrameRateValue.Unknown; + ValueTask LoadScriptAsync(string path, CancellationToken cancellationToken); - ChapterDiagnostic? ApplyLuaExpressionSettings( + void SaveAppliedExpressionPreview( string expression, - bool applyExpression, string expressionPresetId, - string expressionSourceName); + string expressionSourceName, + ExpressionPreviewProjection projection); ChapterDiagnostic? ValidateLuaExpressionScript(string scriptText, bool logDiagnostics); @@ -50,6 +52,8 @@ public interface IPreferenceSink decimal FrameAccuracyTolerance { get; } + bool ShowRepeatingFrameDecimals => true; + ChapterEditingOptions EditingOptions => ChapterEditingOptions.Default; void ApplyLoadedSettings(AppSettings settings); @@ -78,7 +82,27 @@ public interface INamingPreferencePort /// Chapter edit surface for tools such as forward-shift. public interface IChapterEditPort { - void ShiftFramesForward(int frames); + ValueTask ShiftFramesForwardAsync(int frames, CancellationToken cancellationToken = default); +} + +/// Builds one-shot content previews and applies them through the active history session. +public interface IChapterContentOperationPort +{ + bool CanPrepareExpression => true; + + ChapterContentPreview PrepareExpression(string expression); + + ChapterContentPreview PrepareTemplateNames(bool autoGenerateNames, bool useTemplateNames); + + ChapterContentPreview PrepareContentOptions(); + + ChapterContentPreview PrepareFrameShift(int frames); + + ChapterContentPreview PrepareFrameRateConversion(FrameRateValue sourceFps, FrameRateValue targetFps); + + ValueTask ApplyAsync(ChapterContentPreview preview, CancellationToken cancellationToken = default); + + void Cancel(ChapterContentPreview preview); } public interface IMainShellNotificationPort diff --git a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/WorkspaceToolSession.cs b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/WorkspaceToolSession.cs index 9ac91f7d..1caff86e 100644 --- a/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/WorkspaceToolSession.cs +++ b/src/ChapterTool.Avalonia.UI/PlatformPorts/SessionPorts/WorkspaceToolSession.cs @@ -1,10 +1,13 @@ using ChapterTool.Avalonia.UI.ViewModels; using ChapterTool.Contracts.PlatformPorts; +using ChapterTool.Core.Session; namespace ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; public interface IWorkspaceToolSession { + IHistorySessionPort History { get; } + IExpressionSessionPort Expression { get; } IPreferenceSink Preferences { get; } @@ -15,6 +18,8 @@ public interface IWorkspaceToolSession IChapterEditPort ChapterEdit { get; } + IChapterContentOperationPort ContentOperations { get; } + IApplicationLogService LogService { get; } IMainShellNotificationPort Notifications { get; } @@ -26,6 +31,37 @@ public interface IWorkspaceToolSession ValueTask ReportUnexpectedUiException(Exception exception); } +public interface IHistorySessionPort +{ + IReadOnlyList Entries { get; } + + bool CanUndo { get; } + + bool CanRedo { get; } + + string UndoDescription { get; } + + string RedoDescription { get; } + + UiCommand UndoCommand { get; } + + UiCommand RedoCommand { get; } + + UiCommand NavigateCommand { get; } + + SessionHistorySnapshot? Snapshot { get; } + + bool HasPendingPreview { get; } + + bool IsEnded { get; } + + string NavigationStatus { get; } + + ValueTask InspectAsync(Guid nodeId, CancellationToken cancellationToken); + + event EventHandler? StateChanged; +} + /// Owns the narrow ports used by secondary tools beside the main shell. public sealed class MainWindowToolSession : IWorkspaceToolSession { @@ -38,11 +74,13 @@ public MainWindowToolSession(MainWindowViewModel owner) { ArgumentNullException.ThrowIfNull(owner); portAdapters = new MainWindowPortAdapters(owner); + History = new MainWindowHistorySessionPort(owner); Expression = portAdapters.Expression; Preferences = portAdapters.Preferences; ExportPreferences = portAdapters.ExportPreferences; NamingPreferences = portAdapters.NamingPreferences; ChapterEdit = portAdapters.ChapterEdit; + ContentOperations = portAdapters.ContentOperations; LogService = owner.LogService; buildPreview = owner.BuildPreview; createZonesText = owner.CreateZonesText; @@ -51,6 +89,8 @@ public MainWindowToolSession(MainWindowViewModel owner) public IExpressionSessionPort Expression { get; } + public IHistorySessionPort History { get; } + public IPreferenceSink Preferences { get; } public IExportPreferencePort ExportPreferences { get; } @@ -59,6 +99,8 @@ public MainWindowToolSession(MainWindowViewModel owner) public IChapterEditPort ChapterEdit { get; } + public IChapterContentOperationPort ContentOperations { get; } + public IApplicationLogService LogService { get; } public IMainShellNotificationPort Notifications => portAdapters.Notifications; @@ -69,3 +111,59 @@ public MainWindowToolSession(MainWindowViewModel owner) public ValueTask ReportUnexpectedUiException(Exception exception) => reportUnexpectedUiException(exception); } + +internal sealed class MainWindowHistorySessionPort : IHistorySessionPort, IDisposable +{ + private readonly MainWindowViewModel owner; + + public MainWindowHistorySessionPort(MainWindowViewModel owner) + { + this.owner = owner; + owner.PropertyChanged += OnOwnerPropertyChanged; + } + + public IReadOnlyList Entries => owner.HistoryEntries; + + public bool CanUndo => owner.CanUndo; + + public bool CanRedo => owner.CanRedo; + + public string UndoDescription => owner.UndoDescription; + + public string RedoDescription => owner.RedoDescription; + + public UiCommand UndoCommand => owner.UndoCommand; + + public UiCommand RedoCommand => owner.RedoCommand; + + public UiCommand NavigateCommand => owner.NavigateHistoryCommand; + + public SessionHistorySnapshot? Snapshot => owner.Workspace.ContentSession?.GetHistorySnapshot(); + + public bool HasPendingPreview => owner.IsContentPreviewPending; + + public bool IsEnded => owner.Workspace.ContentSession?.IsEnded ?? true; + + public string NavigationStatus => owner.StatusText; + + public ValueTask InspectAsync(Guid nodeId, CancellationToken cancellationToken) => + owner.Workspace.ContentSession?.InspectHistoryNodeAsync(nodeId, cancellationToken) + ?? ValueTask.FromResult(new HistoryInspectionOutcome(HistoryInspectionOutcomeKind.Ended, null, [])); + + public event EventHandler? StateChanged; + + public void Dispose() => owner.PropertyChanged -= OnOwnerPropertyChanged; + + private void OnOwnerPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args) + { + if (args.PropertyName is nameof(MainWindowViewModel.HistoryEntries) + or nameof(MainWindowViewModel.CanUndo) + or nameof(MainWindowViewModel.CanRedo) + or nameof(MainWindowViewModel.UndoDescription) + or nameof(MainWindowViewModel.RedoDescription) + or null) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/ChapterTool.Avalonia.UI/Resources/SharedResources.axaml b/src/ChapterTool.Avalonia.UI/Resources/SharedResources.axaml index e45c1246..e63ff21c 100644 --- a/src/ChapterTool.Avalonia.UI/Resources/SharedResources.axaml +++ b/src/ChapterTool.Avalonia.UI/Resources/SharedResources.axaml @@ -5,6 +5,7 @@ sans-serif monospace + avares://ChapterTool.Avalonia.UI/Assets/Fonts/Iosevka-Regular.ttf#Iosevka 12 13 14 diff --git a/src/ChapterTool.Avalonia.UI/Resources/Styles.axaml b/src/ChapterTool.Avalonia.UI/Resources/Styles.axaml index 47ea6486..83525f10 100644 --- a/src/ChapterTool.Avalonia.UI/Resources/Styles.axaml +++ b/src/ChapterTool.Avalonia.UI/Resources/Styles.axaml @@ -9,14 +9,17 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:i="https://github.com/projektanker/icons.avalonia" xmlns:ae="using:AvaloniaEdit" - xmlns:aee="using:AvaloniaEdit.Editing" - xmlns:aes="using:AvaloniaEdit.Search"> +xmlns:aee="using:AvaloniaEdit.Editing" +xmlns:aes="using:AvaloniaEdit.Search" +xmlns:vm="using:ChapterTool.Avalonia.UI.ViewModels" +xmlns:controls="using:ChapterTool.Avalonia.UI.Views.Controls"> + M0,9 L10,0 20,9 19,10 10,2 1,10 z M0,1 L10,10 20,1 19,0 10,8 1,0 z @@ -204,8 +207,8 @@ @@ -790,8 +793,8 @@ @@ -864,10 +867,11 @@ - - - - diff --git a/src/ChapterTool.Avalonia.UI/Resources/Themes.axaml b/src/ChapterTool.Avalonia.UI/Resources/Themes.axaml index 5c673341..8877a415 100644 --- a/src/ChapterTool.Avalonia.UI/Resources/Themes.axaml +++ b/src/ChapterTool.Avalonia.UI/Resources/Themes.axaml @@ -18,7 +18,6 @@ #FF898989 #FFCFCFCF #FFF8F8F8 - White #FF1F1F1F #FF6F6F6F #FFD6E9F8 @@ -36,7 +35,6 @@ #FF7C7C7C #FF404040 #FF303030 - #FF333333 #FFDFDFDF #FF9F9F9F #FF333333 @@ -48,13 +46,13 @@ + - diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/ChapterExpressionValidation.cs b/src/ChapterTool.Avalonia.UI/ViewModels/ChapterExpressionValidation.cs index ee310c8f..78d11523 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/ChapterExpressionValidation.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/ChapterExpressionValidation.cs @@ -1,6 +1,5 @@ using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; -using ChapterTool.Core.Transform.Expressions; namespace ChapterTool.Avalonia.UI.ViewModels; @@ -9,20 +8,12 @@ namespace ChapterTool.Avalonia.UI.ViewModels; /// internal static class ChapterExpressionValidation { - public static ChapterExpressionContext CreateContext(ChapterSet? info) + public static ChapterSet CreateInput(ChapterSet? info) { var chapters = info?.Chapters.Where(static chapter => !chapter.IsSeparator).ToList() ?? []; var chapter = chapters.FirstOrDefault() ?? new Chapter(1, TimeSpan.Zero, "Chapter 01"); - var fps = info is { FramesPerSecond: > 0 } - ? (decimal)info.FramesPerSecond - : 24m; - return new ChapterExpressionContext( - chapter, - 1, - Math.Max(1, chapters.Count), - (decimal)chapter.StartTime.TotalSeconds, - fps, - chapters.Count > 0 ? chapters : [chapter]); + return new ChapterSet(info?.Title ?? string.Empty, info?.SourceName, info?.ImportFormat ?? ChapterImportFormat.Unknown, + info?.FrameRate ?? FrameRateValue.Unknown, info?.Duration ?? TimeSpan.Zero, chapters.Count > 0 ? chapters : [chapter]); } public static bool IsLuaExpressionDiagnostic(ChapterDiagnostic diagnostic) => diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/ChapterRowViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/ChapterRowViewModel.cs index 9362caf4..8e6b0433 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/ChapterRowViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/ChapterRowViewModel.cs @@ -1,4 +1,7 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; using ChapterTool.Core.Models; +using ChapterTool.Core.Session; using ChapterTool.Core.Transform; namespace ChapterTool.Avalonia.UI.ViewModels; @@ -7,8 +10,27 @@ public sealed class ChapterRowViewModel( Chapter chapter, IChapterTimeFormatter formatter, int? number = null, - string? name = null) + string? name = null) : INotifyPropertyChanged { + private ExpressionChapterComparison? previewComparison; + private string previewTimeText = string.Empty; + private string previewBeforeTimeText = string.Empty; + private string previewTimeDelta = string.Empty; + private string previewName = string.Empty; + private string previewNumber = string.Empty; + private string previewFrames = string.Empty; + private string previewBeforeFrames = string.Empty; + private string previewTimeAccessibleName = string.Empty; + private string previewNameAccessibleName = string.Empty; + private string previewNumberAccessibleName = string.Empty; + private string previewFramesAccessibleName = string.Empty; + private decimal previewBeforeFramesPerSecond; + private bool isNarrowPreviewLayout; + private FramePresentationInput? framePresentationInput; + private FramePresentationInput? previewBeforeFramePresentationInput; + private FramePresentationInput? previewCandidateFramePresentationInput; + private FramePresentationPolicy framePresentationPolicy = new(false, true, 3); + public Chapter Chapter { get; } = chapter; public int Number { get; } = number ?? chapter.DisplayNumber; @@ -19,9 +41,276 @@ public sealed class ChapterRowViewModel( public string FramesInfo { get; set; } = chapter.FramesInfo; + public FramePresentationParts? FramePresentation { get; private set; } + + public FramePresentationParts? PreviewBeforeFramePresentation { get; private set; } + + public FramePresentationParts? PreviewCandidateFramePresentation { get; private set; } + public bool IsFrameAccurate { get; } = chapter.FrameAccuracy == FrameAccuracy.Accurate; public bool IsFrameInexact { get; } = chapter.FrameAccuracy == FrameAccuracy.Inexact; public bool IsFrameNeutral { get; } = chapter.FrameAccuracy == FrameAccuracy.Neutral; + + public ChapterTrackId? TrackId { get; internal set; } + + public ChapterId? ChapterId { get; internal set; } + + public ExpressionChapterComparison? PreviewComparison + { + get => previewComparison; + internal set + { + if (previewComparison == value) + { + return; + } + + previewComparison = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasPreviewTimeChange)); + OnPropertyChanged(nameof(HasPreviewNameChange)); + OnPropertyChanged(nameof(HasPreviewNumberChange)); + OnPropertyChanged(nameof(HasPreviewFramesChange)); + OnPropertyChanged(nameof(IsPreviewingWide)); + OnPropertyChanged(nameof(IsPreviewingNarrow)); + NotifyCellTemplateStates(); + } + } + + public bool HasPreviewTimeChange + { + get + { + if (((PreviewComparison?.Changes ?? ExpressionChapterChangeKind.None) & ExpressionChapterChangeKind.Time) == ExpressionChapterChangeKind.None + || PreviewComparison?.Before is not { } before + || PreviewComparison.Candidate is not { } candidate) + { + return false; + } + + return formatter.Format(TimeSpan.FromTicks(before.StartTicks)) + != formatter.Format(TimeSpan.FromTicks(candidate.StartTicks)); + } + } + + public bool HasPreviewNameChange => PreviewComparison is { Changes: var changes, Before: var before, Candidate: var candidate } + && (changes & ExpressionChapterChangeKind.OtherProperties) != ExpressionChapterChangeKind.None + && before?.Name != candidate?.Name; + + public bool HasPreviewNumberChange => PreviewComparison is { Changes: var changes, Before: var before, Candidate: var candidate } + && (changes & ExpressionChapterChangeKind.OtherProperties) != ExpressionChapterChangeKind.None + && before?.DisplayNumber != candidate?.DisplayNumber; + + public bool HasPreviewFramesChange => + ((PreviewComparison?.Changes ?? ExpressionChapterChangeKind.None) & ExpressionChapterChangeKind.FrameInformation) != ExpressionChapterChangeKind.None + && PreviewComparison?.CandidateFrames is { } candidateFrames + && !string.Equals(PreviewFrameBaseline, candidateFrames.Text, StringComparison.Ordinal); + + public string PreviewTimeText { get => previewTimeText; internal set => SetProperty(ref previewTimeText, value); } + + public string PreviewBeforeTimeText { get => previewBeforeTimeText; internal set => SetProperty(ref previewBeforeTimeText, value); } + + public string PreviewTimeDelta { get => previewTimeDelta; internal set => SetProperty(ref previewTimeDelta, value); } + + public string PreviewName { get => previewName; internal set => SetProperty(ref previewName, value); } + + public string PreviewNumber { get => previewNumber; internal set => SetProperty(ref previewNumber, value); } + + public string PreviewFrames { get => previewFrames; internal set => SetProperty(ref previewFrames, value); } + + public string PreviewBeforeFrames { get => previewBeforeFrames; internal set => SetProperty(ref previewBeforeFrames, value); } + + public bool IsPreviewBeforeFrameAccurate => PreviewBeforeFrameAccuracy == FrameAccuracy.Accurate; + + public bool IsPreviewBeforeFrameInexact => PreviewBeforeFrameAccuracy == FrameAccuracy.Inexact; + + public bool IsPreviewBeforeFrameNeutral => PreviewBeforeFrameAccuracy == FrameAccuracy.Neutral; + + public bool IsPreviewCandidateFrameAccurate => PreviewCandidateFrameAccuracy == FrameAccuracy.Accurate; + + public bool IsPreviewCandidateFrameInexact => PreviewCandidateFrameAccuracy == FrameAccuracy.Inexact; + + public bool IsPreviewCandidateFrameNeutral => PreviewCandidateFrameAccuracy == FrameAccuracy.Neutral; + + internal FrameAccuracy PreviewBeforeFrameAccuracy { get; private set; } = FrameAccuracy.Neutral; + + internal FrameAccuracy PreviewCandidateFrameAccuracy { get; private set; } = FrameAccuracy.Neutral; + + public string PreviewTimeAccessibleName { get => previewTimeAccessibleName; internal set => SetProperty(ref previewTimeAccessibleName, value); } + + public string PreviewNameAccessibleName { get => previewNameAccessibleName; internal set => SetProperty(ref previewNameAccessibleName, value); } + + public string PreviewNumberAccessibleName { get => previewNumberAccessibleName; internal set => SetProperty(ref previewNumberAccessibleName, value); } + + public string PreviewFramesAccessibleName { get => previewFramesAccessibleName; internal set => SetProperty(ref previewFramesAccessibleName, value); } + + internal decimal PreviewBeforeFramesPerSecond + { + get => previewBeforeFramesPerSecond; + private set => previewBeforeFramesPerSecond = value; + } + + internal string PreviewFrameBaseline { get; private set; } = string.Empty; + + internal void UpdatePreviewComparison( + ExpressionChapterComparison? comparison, + decimal displayFrameRate, + string missingFramesText) + { + if (previewComparison is null && comparison is not null) + { + PreviewFrameBaseline = string.IsNullOrEmpty(FramesInfo) ? missingFramesText : FramesInfo; + PreviewBeforeFrameAccuracy = IsFrameAccurate + ? FrameAccuracy.Accurate + : IsFrameInexact + ? FrameAccuracy.Inexact + : FrameAccuracy.Neutral; + PreviewBeforeFramesPerSecond = displayFrameRate; + NotifyBeforeFrameAccuracy(); + } + + var candidateAccuracy = comparison?.CandidateFrames?.Accuracy ?? FrameAccuracy.Neutral; + if (PreviewCandidateFrameAccuracy != candidateAccuracy) + { + PreviewCandidateFrameAccuracy = candidateAccuracy; + NotifyCandidateFrameAccuracy(); + } + + PreviewComparison = comparison; + } + + internal void SetFramePresentationInput(FramePresentationInput input, FramePresentationPolicy policy) + { + framePresentationInput = input; + framePresentationPolicy = policy; + RefreshFramePresentation(); + } + + internal void SetPreviewFramePresentationInputs( + FramePresentationInput? before, + FramePresentationInput? candidate, + FramePresentationPolicy policy) + { + previewBeforeFramePresentationInput = before; + previewCandidateFramePresentationInput = candidate; + framePresentationPolicy = policy; + RefreshFramePresentation(); + } + + internal void RefreshFramePresentation(FramePresentationPolicy? policy = null) + { + if (policy is not null) + { + framePresentationPolicy = policy; + } + + FramePresentation = framePresentationInput is { } input + ? FrameValueFormatter.Format(input, framePresentationPolicy) + : null; + OnPropertyChanged(nameof(FramePresentation)); + PreviewBeforeFramePresentation = previewBeforeFramePresentationInput is { } before + ? FrameValueFormatter.Format(before, framePresentationPolicy) + : null; + OnPropertyChanged(nameof(PreviewBeforeFramePresentation)); + PreviewCandidateFramePresentation = previewCandidateFramePresentationInput is { } candidate + ? FrameValueFormatter.Format(candidate, framePresentationPolicy) + : null; + OnPropertyChanged(nameof(PreviewCandidateFramePresentation)); + } + + private void NotifyBeforeFrameAccuracy() + { + OnPropertyChanged(nameof(IsPreviewBeforeFrameAccurate)); + OnPropertyChanged(nameof(IsPreviewBeforeFrameInexact)); + OnPropertyChanged(nameof(IsPreviewBeforeFrameNeutral)); + } + + private void NotifyCandidateFrameAccuracy() + { + OnPropertyChanged(nameof(IsPreviewCandidateFrameAccurate)); + OnPropertyChanged(nameof(IsPreviewCandidateFrameInexact)); + OnPropertyChanged(nameof(IsPreviewCandidateFrameNeutral)); + } + + public bool IsNarrowPreviewLayout + { + get => isNarrowPreviewLayout; + internal set + { + if (isNarrowPreviewLayout == value) + { + return; + } + + isNarrowPreviewLayout = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsPreviewingWide)); + OnPropertyChanged(nameof(IsPreviewingNarrow)); + NotifyCellTemplateStates(); + } + } + + public bool IsPreviewingWide => PreviewComparison is not null && !IsNarrowPreviewLayout; + + public bool IsPreviewingNarrow => PreviewComparison is not null && IsNarrowPreviewLayout; + + public bool ShowNormalTime => !HasPreviewTimeChange; + + public bool ShowWideTimePreview => HasPreviewTimeChange && !IsNarrowPreviewLayout; + + public bool ShowNarrowTimePreview => HasPreviewTimeChange && IsNarrowPreviewLayout; + + public bool ShowNormalName => !HasPreviewNameChange; + + public bool ShowWideNamePreview => HasPreviewNameChange && !IsNarrowPreviewLayout; + + public bool ShowNarrowNamePreview => HasPreviewNameChange && IsNarrowPreviewLayout; + + public bool ShowNormalNumber => !HasPreviewNumberChange; + + public bool ShowWideNumberPreview => HasPreviewNumberChange && !IsNarrowPreviewLayout; + + public bool ShowNarrowNumberPreview => HasPreviewNumberChange && IsNarrowPreviewLayout; + + public bool ShowNormalFrames => !HasPreviewFramesChange; + + public bool ShowWideFramesPreview => HasPreviewFramesChange && !IsNarrowPreviewLayout; + + public bool ShowNarrowFramesPreview => HasPreviewFramesChange && IsNarrowPreviewLayout; + + public string FormatPreviewTime(TimeSpan value) => formatter.Format(value); + + private void SetProperty(ref string field, string value, [CallerMemberName] string? propertyName = null) + { + if (field == value) + { + return; + } + + field = value; + OnPropertyChanged(propertyName); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void NotifyCellTemplateStates() + { + OnPropertyChanged(nameof(ShowNormalTime)); + OnPropertyChanged(nameof(ShowWideTimePreview)); + OnPropertyChanged(nameof(ShowNarrowTimePreview)); + OnPropertyChanged(nameof(ShowNormalName)); + OnPropertyChanged(nameof(ShowWideNamePreview)); + OnPropertyChanged(nameof(ShowNarrowNamePreview)); + OnPropertyChanged(nameof(ShowNormalNumber)); + OnPropertyChanged(nameof(ShowWideNumberPreview)); + OnPropertyChanged(nameof(ShowNarrowNumberPreview)); + OnPropertyChanged(nameof(ShowNormalFrames)); + OnPropertyChanged(nameof(ShowWideFramesPreview)); + OnPropertyChanged(nameof(ShowNarrowFramesPreview)); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/HistoryEntryViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/HistoryEntryViewModel.cs new file mode 100644 index 00000000..5529f49f --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/ViewModels/HistoryEntryViewModel.cs @@ -0,0 +1,184 @@ +using System.Collections.ObjectModel; +using ChapterTool.Core.Session; + +namespace ChapterTool.Avalonia.UI.ViewModels; + +/// A stable selectable node in the native hierarchical session history tree. +public sealed class HistoryEntryViewModel : ObservableViewModel +{ + private string description; + private string title; + private bool isCurrent; + private bool isCurrentPath; + private bool isExpanded; + private bool isPreferredRedo; + + internal HistoryEntryViewModel(SessionHistoryNodeSnapshot node, string title) + { + Id = node.Id; + ParentId = node.ParentId; + PublishedAt = node.PublishedAt; + Operation = node.Operation; + description = node.Description; + this.title = title; + } + + public Guid Id { get; } + + public Guid? ParentId { get; private set; } + + public DateTimeOffset? PublishedAt { get; private set; } + + public HistoryOperationDescriptor? Operation { get; private set; } + + public ObservableCollection Children { get; } = []; + + public string Description + { + get => description; + private set => SetProperty(ref description, value); + } + + public string Title + { + get => title; + private set => SetProperty(ref title, value); + } + + public bool HasChildren => Children.Count > 0; + + public bool IsCurrent + { + get => isCurrent; + private set => SetProperty(ref isCurrent, value); + } + + public bool IsCurrentPath + { + get => isCurrentPath; + private set => SetProperty(ref isCurrentPath, value); + } + + public bool IsExpanded + { + get => isExpanded; + set => SetProperty(ref isExpanded, value); + } + + public bool IsPreferredRedo + { + get => isPreferredRedo; + private set => SetProperty(ref isPreferredRedo, value); + } + + internal void Update(SessionHistoryNodeSnapshot node, string localizedTitle, bool current, bool currentPath, bool expanded, bool preferred) + { + Description = node.Description; + Title = localizedTitle; + ParentId = node.ParentId; + PublishedAt = node.PublishedAt; + Operation = node.Operation; + IsCurrent = current; + IsCurrentPath = currentPath; + IsExpanded = expanded; + IsPreferredRedo = preferred; + } + + internal static (IReadOnlyList Roots, IReadOnlyList All) CreateTree( + SessionHistorySnapshot? history, + IReadOnlyDictionary previous, + ISet expandedNodes, + Func titleProvider) + { + if (history is null) + { + return ([], []); + } + + var snapshots = history.Nodes.ToDictionary(static node => node.Id); + var currentPath = GetCurrentPath(history, snapshots); + var projected = BuildProjectedEntries(history, snapshots, previous, expandedNodes, currentPath, titleProvider); + var roots = projected.TryGetValue(history.RootId, out var root) ? new[] { root } : []; + return (roots, Flatten(roots, projected.Count)); + } + + private static HashSet GetCurrentPath( + SessionHistorySnapshot history, + IReadOnlyDictionary snapshots) + { + var currentPath = new HashSet(); + var pathId = history.CursorId; + while (snapshots.TryGetValue(pathId, out var pathNode)) + { + currentPath.Add(pathId); + if (pathNode.ParentId is not Guid parentId) + { + break; + } + + pathId = parentId; + } + + return currentPath; + } + + private static Dictionary BuildProjectedEntries( + SessionHistorySnapshot history, + IReadOnlyDictionary snapshots, + IReadOnlyDictionary previous, + ISet expandedNodes, + ISet currentPath, + Func titleProvider) + { + var projected = new Dictionary(history.Nodes.Length); + foreach (var node in history.Nodes.Reverse()) + { + var hasPrevious = previous.TryGetValue(node.Id, out var existing); + var entry = hasPrevious ? existing! : new HistoryEntryViewModel(node, titleProvider(node)); + var preferredChild = node.ParentId is Guid parent && snapshots.TryGetValue(parent, out var parentNode) + ? parentNode.PreferredChildId + : null; + var isExpanded = hasPrevious + ? existing!.IsExpanded + : expandedNodes.Contains(node.Id) || currentPath.Contains(node.Id); + entry.Update(node, titleProvider(node), node.Id == history.CursorId, currentPath.Contains(node.Id), isExpanded, + preferredChild == node.Id); + entry.Children.Clear(); + projected[node.Id] = entry; + foreach (var childId in node.ChildIds) + { + if (projected.TryGetValue(childId, out var child)) + { + entry.Children.Add(child); + } + } + } + + foreach (var entry in projected.Values) + { + entry.OnPropertyChanged(nameof(HasChildren)); + } + + return projected; + } + + private static List Flatten(IReadOnlyList roots, int capacity) + { + var all = new List(capacity); + var pending = new Stack(); + for (var index = roots.Count - 1; index >= 0; index--) + { + pending.Push(roots[index]); + } + while (pending.TryPop(out var entry)) + { + all.Add(entry); + for (var index = entry.Children.Count - 1; index >= 0; index--) + { + pending.Push(entry.Children[index]); + } + } + + return all; + } +} diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Commands.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Commands.cs index c6342d4d..961a1bf2 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Commands.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Commands.cs @@ -1,9 +1,8 @@ -using ChapterTool.Contracts.Configuration; -using ChapterTool.Core.Models; using ChapterTool.Core.Session; namespace ChapterTool.Avalonia.UI.ViewModels; +/// Contains command wiring for the main window. public sealed partial class MainWindowViewModel { private void InitializeCommands() @@ -38,6 +37,14 @@ private IEnumerable AllCommands() yield return TemplateNamesCommand; yield return ZonesCommand; yield return ForwardShiftCommand; + yield return HistoryCommand; + yield return PreviewContentOptionsCommand; + yield return ApplyContentPreviewCommand; + yield return RefreshContentPreviewCommand; + yield return CancelContentPreviewCommand; + yield return UndoCommand; + yield return RedoCommand; + yield return NavigateHistoryCommand; } private void InitializeFileCommands() @@ -89,16 +96,21 @@ private void InitializeFileCommands() private void InitializeEditCommands() { + UndoCommand = new UiCommand(async (_, token) => await NavigateHistoryAsync("undo", null, token), _ => CanUndo); + RedoCommand = new UiCommand(async (_, token) => await NavigateHistoryAsync("redo", null, token), _ => CanRedo); + NavigateHistoryCommand = new UiCommand(async (parameter, token) => + { + if (parameter is Guid nodeId) + { + await NavigateHistoryAsync("navigate", nodeId, token); + } + }, parameter => parameter is Guid nodeId && HistoryEntries.Any(entry => entry.Id == nodeId)); RefreshCommand = new UiCommand((_, _) => { ApplyFrameInfo(); return ValueTask.CompletedTask; - }, _ => CurrentInfo is not null); - ChangeFpsCommand = new UiCommand((_, _) => - { - ChangeFpsToSelectedOption(); - return ValueTask.CompletedTask; - }, _ => CurrentInfo is not null && selectedFrameRateOption.IsValid); + }, _ => CanEditRows); + ChangeFpsCommand = new UiCommand((_, token) => ChangeFpsToSelectedOption(token), _ => CanEditRows && SelectedFrameRateOption.IsValid); SelectClipCommand = new UiCommand((parameter, _) => { SelectClip(Convert.ToInt32(parameter)); @@ -112,25 +124,169 @@ private void InitializeEditCommands() EditTimeCommand = new UiCommand(parameter => EditCell(parameter, EditKind.Time)); EditNameCommand = new UiCommand(parameter => EditCell(parameter, EditKind.Name)); EditFrameCommand = new UiCommand(parameter => EditCell(parameter, EditKind.Frame)); - DeleteCommand = new UiCommand(parameter => + DeleteCommand = new UiCommand(async (parameter, token) => { if (CurrentInfo is not null && parameter is IReadOnlySet indexes) { - ApplyEdit(ClipEditingCoordinator.Delete(CurrentInfo, indexes, EditingOptions), EnglishLogText("Action.DeleteRows", ("indexes", string.Join(",", indexes.Order())))); + var preview = ClipEditingCoordinator.Delete(indexes, EditingOptions); + var outcome = await ClipEditingCoordinator.ApplyCandidateAsync(preview, token); + ApplyContentOutcome(outcome, $"Delete rows: indexes={string.Join(",", indexes.Order())}"); } - - return ValueTask.CompletedTask; - }, _ => CurrentInfo is not null); - InsertCommand = new UiCommand(parameter => + }, _ => CanEditRows); + InsertCommand = new UiCommand(async (parameter, token) => { if (CurrentInfo is not null) { var index = parameter is int value ? value : Rows.Count; - ApplyEdit(ClipEditingCoordinator.InsertBefore(CurrentInfo, index), EnglishLogText("Action.InsertRow", ("index", index))); + var preview = ClipEditingCoordinator.InsertBefore(index); + var outcome = await ClipEditingCoordinator.ApplyCandidateAsync(preview, token); + ApplyContentOutcome(outcome, $"Insert row: index={index}"); } - + }, _ => CanEditRows); + PreviewContentOptionsCommand = new UiCommand((_, _) => + { + RefreshContentOptionsPreview(); return ValueTask.CompletedTask; }, _ => CurrentInfo is not null); + ApplyContentPreviewCommand = new UiCommand(async (_, token) => + { + if (pendingContentPreview is null || !pendingContentPreview.IsValid) + { + return; + } + + var preview = pendingContentPreview; + var previewText = ExpressionPreviewText; + isApplyingContentPreview = true; + pendingContentPreview = null; + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + OnPropertyChanged(nameof(IsChapterGridReadOnly)); + OnPropertyChanged(nameof(CanEditRows)); + TransactionOutcome outcome; + try + { + outcome = await ApplyContentPreviewAsync(preview, token); + } + finally + { + isApplyingContentPreview = false; + OnPropertyChanged(nameof(IsChapterGridReadOnly)); + OnPropertyChanged(nameof(CanEditRows)); + } + if (pendingContentPreview is null + && outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) + { + pendingContentPreview = preview; + ExpressionPreviewText = previewText; + } + else if (outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange) + { + ExpressionPreviewText = string.Empty; + } + + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + }, _ => CanApplyContentPreview); + RefreshContentPreviewCommand = new UiCommand((_, _) => + { + if (pendingContentPreview is not { } stalePreview || !CanRefreshContentPreview) + { + return ValueTask.CompletedTask; + } + + pendingContentPreview = stalePreview.Operation switch + { + "Apply expression" => PrepareExpressionOperation(Expression), + "Change chapter frame rate" => PrepareFrameRateOperation( + ResolveSourceFrameRate(), + SelectedFrameRateOption.Rate), + _ => PrepareContentOptionsOperation() + }; + ExpressionPreviewText = BuildExpressionPreviewText(pendingContentPreview, Localizer); + StatusText = Localizer.GetString(pendingContentPreview.IsValid + ? "Expression.State.Ready" + : "Expression.State.Invalid"); + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + return ValueTask.CompletedTask; + }, _ => CanRefreshContentPreview); + CancelContentPreviewCommand = new UiCommand((_, _) => + { + if (expressionPreviewTimer.IsEnabled) + { + expressionPreviewTimer.Stop(); + ExpressionPreviewText = string.Empty; + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + return ValueTask.CompletedTask; + } + + if (pendingContentPreview is { } preview) + { + CancelContentPreview(preview); + pendingContentPreview = null; + expressionPreviewTimer.Stop(); + ExpressionPreviewText = string.Empty; + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + SetStatus("Status.Updated"); + } + + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + return ValueTask.CompletedTask; + }, _ => IsContentPreviewPending); + } + + private async ValueTask NavigateHistoryAsync(string action, Guid? nodeId, CancellationToken cancellationToken) + { + InvalidatePendingContentPreview(); + + var session = Workspace.ContentSession; + if (session is null) + { + return; + } + + var outcome = action switch + { + "undo" => await session.UndoAsync(cancellationToken), + "redo" => await session.RedoAsync(cancellationToken), + _ => await session.NavigateToAsync(nodeId!.Value, cancellationToken) + }; + if (outcome.Kind == HistoryNavigationOutcomeKind.Committed) + { + Workspace.PublishContentDocument(outcome.Snapshot.Document); + RefreshRows(); + StatusText = Localizer.GetString(action switch + { + "undo" => "History.UndoApplied", + "redo" => "History.RedoApplied", + _ => "History.RestoreApplied" + }); + } + else if (outcome.Kind is not HistoryNavigationOutcomeKind.NoChange) + { + StatusText = outcome.Errors.FirstOrDefault() ?? Localizer.GetString("History.NavigationFailed"); + } + + NotifyStateChanged(); } private void InitializeWindowCommands() @@ -140,9 +296,10 @@ private void InitializeWindowCommands() SettingsCommand = WindowCommand("settings"); LanguageCommand = WindowCommand("language"); ExpressionCommand = WindowCommand("expression"); - TemplateNamesCommand = WindowCommand("template-names"); - ZonesCommand = WindowCommand("zones"); - ForwardShiftCommand = WindowCommand("forward-shift"); + TemplateNamesCommand = WindowCommand("template-names", () => CanOpenContentTools); + ZonesCommand = WindowCommand("zones", () => CanOpenContentTools); + ForwardShiftCommand = WindowCommand("forward-shift", () => CanOpenContentTools); + HistoryCommand = WindowCommand("history", () => CanOpenHistory); OpenRelatedMediaCommand = new UiCommand(async (parameter, token) => await OpenRelatedMediaAsync(parameter, token), _ => RelatedMediaReferences.Count > 0); } } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Editing.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Editing.cs index 373790f9..a6c7605a 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Editing.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Editing.cs @@ -1,9 +1,10 @@ using System.Collections.Specialized; +using ChapterTool.Avalonia.UI.Localization; using ChapterTool.Avalonia.UI.Workflows; -using ChapterTool.Core.Editing; using ChapterTool.Core.Models; using ChapterTool.Core.Session; using ChapterTool.Core.Transform; +using Microsoft.Extensions.Logging; namespace ChapterTool.Avalonia.UI.ViewModels; @@ -21,45 +22,66 @@ private void SelectClip(int index, bool logSelection = true) { return; } + InvalidatePendingContentPreview(); SelectedClipIndex = Workspace.ClipSession.SelectedIndex; if (CurrentInfo is null) { return; } - configuredFrameRate = (decimal)CurrentInfo.FramesPerSecond; if (logSelection) { - Log("Log.SelectedSourceOption", - ("index", index), - ("label", ClipOptions[index].DisplayName), - ("source", CurrentInfo.SourceName ?? string.Empty), - ("sourceType", ChapterImportFormats.DisplayName(CurrentInfo.ImportFormat)), - ("chapters", CurrentInfo.Chapters.Count), - ("fps", $"{CurrentInfo.FramesPerSecond:0.###}")); + var label = ClipOptions[index].DisplayName; + var source = CurrentInfo.SourceName ?? string.Empty; + var sourceType = ChapterImportFormats.DisplayName(CurrentInfo.ImportFormat); + var fps = $"{CurrentInfo.FrameRate.ToDecimal():0.###}"; + Log($"Selected source option: index={index}, label='{label}', source='{source}', sourceType={sourceType}, chapters={CurrentInfo.Chapters.Count}, fps={fps}", + "Edit", + ("index", index), ("label", label), ("source", source), ("sourceType", sourceType), + ("chapters", CurrentInfo.Chapters.Count), ("fps", fps)); } - selectedFrameRateOption = frameRateService.FindByValue((decimal)CurrentInfo.FramesPerSecond); - SetSelectedFrameRateIndexSilent(ComboIndexFor(selectedFrameRateOption)); + SelectedFrameRateOption = frameRateService.FindByRate(CurrentInfo.FrameRate); + displayOptionCoordinator.SetContentRate(CurrentInfo.FrameRate, FrameRateDisplayOptions); + SetSelectedFrameRateIndexSilent(ComboIndexFor(SelectedFrameRateOption)); ApplyFrameInfo(logSelection); } - private ValueTask EditCell(object? parameter, EditKind kind) + private async ValueTask EditCell(object? parameter, EditKind kind) { - if (CurrentInfo is null || parameter is not ChapterCellEdit edit) + if (!CanEditRows || CurrentInfo is null || parameter is not ChapterCellEdit edit) { - return ValueTask.CompletedTask; + return; } - var result = ClipEditingCoordinator.Edit(CurrentInfo, edit, kind switch + var previous = OldCellValue(edit, kind); + var preview = ClipEditingCoordinator.Edit(edit, kind switch { EditKind.Time => ChapterEditKind.Time, EditKind.Name => ChapterEditKind.Name, EditKind.Frame => ChapterEditKind.Frame, _ => throw new ArgumentOutOfRangeException(nameof(kind)) - }); - ApplyEdit(result, EnglishLogText("Action.EditCell", ("kind", logContentLocalizer.GetString($"EditKind.{kind}")), ("row", edit.Index), ("value", edit.Value))); - return ValueTask.CompletedTask; + }, ComputationFrameRate); + var outcome = await ClipEditingCoordinator.ApplyCandidateAsync(preview); + ApplyContentOutcome(outcome, $"Edit {kind.ToString().ToLowerInvariant()}: row={edit.Index}, value='{edit.Value}', previous='{previous}'"); + } + + private string OldCellValue(ChapterCellEdit edit, EditKind kind) + { + var chapters = CurrentInfo?.Chapters ?? []; + if (edit.Index < 0 || edit.Index >= chapters.Count) + { + return string.Empty; + } + + var chapter = chapters[edit.Index]; + return kind switch + { + EditKind.Time => timeFormatter.Format(chapter.StartTime), + EditKind.Name => chapter.Name, + EditKind.Frame => chapter.FramesInfo, + _ => string.Empty + }; } private void CombineSegments() @@ -78,57 +100,302 @@ private void CombineSegments() var transition = ClipEditingCoordinator.ToggleCombine(); if (!transition.Succeeded || transition.Session is null) { - ApplyEdit( - transition.EditResult, - EnglishLogText( - "Action.CombineSegments", - ("entries", originalGroup.Entries.Count), - ("sourceType", ChapterImportFormats.DisplayName(originalGroup.Entries[0].ChapterSet.ImportFormat)))); + SetStatus(null, transition.EditResult.Diagnostics.FirstOrDefault()); return; } ApplyClipSessionUi(transition.Session, selectIndex: transition.Session.SelectedIndex); SetStatus("Status.Updated"); + var afterCount = CurrentInfo?.Chapters.Count ?? 0; if (transition.Restored) { - Log("Log.EditChapters", - ("action", EnglishLogText("Action.SplitCombinedSegments", - ("entries", Workspace.ClipSession.OriginalGroup.Entries.Count), - ("sourceType", ChapterImportFormats.DisplayName(Workspace.ClipSession.OriginalGroup.Entries[0].ChapterSet.ImportFormat)))), - ("before", beforeCount), - ("after", CurrentInfo?.Chapters.Count ?? 0)); + var action = CombineActionText("Split by boundaries", Workspace.ClipSession.OriginalGroup); + Log($"{action}: chapters {beforeCount} -> {afterCount}", "Edit", + ("action", action), ("before", beforeCount), ("after", afterCount)); } else { - Log("Log.EditChapters", - ("action", EnglishLogText( - "Action.CombineSegments", - ("entries", originalGroup.Entries.Count), - ("sourceType", ChapterImportFormats.DisplayName(originalGroup.Entries[0].ChapterSet.ImportFormat)))), - ("before", beforeCount), - ("after", CurrentInfo?.Chapters.Count ?? 0)); + var action = CombineActionText("Combine segments", originalGroup); + Log($"{action}: chapters {beforeCount} -> {afterCount}", "Edit", + ("action", action), ("before", beforeCount), ("after", afterCount)); } NotifyStateChanged(); } - private void ApplyEdit(ChapterEditResult result, string? action = null) + private static string CombineActionText(string verb, ChapterImportSource group) + { + var sourceType = ChapterImportFormats.DisplayName(group.Entries[0].ChapterSet.ImportFormat); + return $"{verb}: entries={group.Entries.Count}, sourceType={sourceType}"; + } + + internal void ApplyContentOutcome(TransactionOutcome outcome, string action) { - var effectiveAction = action ?? EnglishLogText("Action.EditChapters"); - var before = CurrentInfo?.Chapters.Count ?? 0; - CurrentInfo = result.ChapterSet; - ApplyFrameInfo(logResult: false); - SetStatus(result.Diagnostics.Count == 0 ? "Status.Updated" : null, diagnostic: result.Diagnostics.FirstOrDefault()); - Log("Log.EditChapters", ("action", effectiveAction), ("before", before), ("after", CurrentInfo.Chapters.Count)); - LogDiagnostics("Edit", result.Diagnostics); + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) + { + // Cell editors bind directly to the row. Rebuild it after a rejected edit so + // invalid draft text cannot remain visible as if it had been committed. + RefreshRows(); + StatusText = outcome.Errors.FirstOrDefault() ?? outcome.Kind.ToString(); + Log(LogLevel.Warning, $"{action} rejected: {StatusText}", "Edit", ("action", action), ("outcome", outcome.Kind.ToString())); + NotifyStateChanged(); + return; + } + + if (CurrentInfo is null) + { + return; + } + + SetStatus("Status.Updated"); + Log($"{action}: chapters={CurrentInfo.Chapters.Count}, outcome={outcome.Kind}", "Edit", + ("action", action), ("chapters", CurrentInfo.Chapters.Count), ("outcome", outcome.Kind)); + RefreshRows(); NotifyStateChanged(); } - internal void ApplyEditFromPort(ChapterEditResult result, string? action = null) => ApplyEdit(result, action); + internal ChapterContentPreview PrepareExpressionOperation(string expression) => + ClipEditingCoordinator.PrepareCandidate("Apply expression", document => + ClipEditingCoordinator.CandidateBuilder.ApplyExpression( + document, + string.IsNullOrWhiteSpace(expression) ? "t" : expression, + SelectedFrameRateOption.IsValid ? SelectedFrameRateOption.Rate : null), + new HistoryOperationDescriptor("expression.apply", + [ + new HistoryOperationParameter("script", string.IsNullOrWhiteSpace(expression) ? "t" : expression), + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackId", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Id.ToString()), + new HistoryOperationParameter("trackName", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Name), + new HistoryOperationParameter("targetChapterIds", CaptureCurrentTrackChapterIds()), + new HistoryOperationParameter("displayFps", DisplayFrameRate.ToString(System.Globalization.CultureInfo.InvariantCulture), "decimal") + ])); + + internal ChapterContentPreview PrepareTemplateNamesOperation(bool autoGenerateNames, bool useTemplateNames) => + ClipEditingCoordinator.PrepareCandidate("Apply template names", document => + ClipEditingCoordinator.CandidateBuilder.ApplyOutputOptions( + document, + autoGenerateNames, + useTemplateNames, + Workspace.OperationDrafts.ChapterNameTemplateText, + orderShift: 0, + applyExpression: false, + expression: "t"), + new HistoryOperationDescriptor("template.apply", + [ + new HistoryOperationParameter("autoGenerateNames", autoGenerateNames.ToString(), "boolean"), + new HistoryOperationParameter("useTemplateNames", useTemplateNames.ToString(), "boolean"), + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackId", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Id.ToString()), + new HistoryOperationParameter("trackName", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Name), + new HistoryOperationParameter("targetChapterIds", CaptureCurrentTrackChapterIds()), + new HistoryOperationParameter("template", Workspace.OperationDrafts.ChapterNameTemplateText) + ])); + + internal ChapterContentPreview PrepareContentOptionsOperation() => + ClipEditingCoordinator.PrepareCandidate("Apply chapter options", document => + ClipEditingCoordinator.CandidateBuilder.ApplyOutputOptions( + document, + Workspace.OperationDrafts.AutoGenerateNames, + Workspace.OperationDrafts.UseTemplateNames, + Workspace.OperationDrafts.ChapterNameTemplateText, + Workspace.OperationDrafts.OrderShift, + applyExpression: false, + Workspace.OperationDrafts.Expression), + new HistoryOperationDescriptor("chapter.options-apply", + [ + new HistoryOperationParameter("autoGenerateNames", Workspace.OperationDrafts.AutoGenerateNames.ToString(), "boolean"), + new HistoryOperationParameter("useTemplateNames", Workspace.OperationDrafts.UseTemplateNames.ToString(), "boolean"), + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackId", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Id.ToString()), + new HistoryOperationParameter("trackName", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Name), + new HistoryOperationParameter("targetChapterIds", CaptureCurrentTrackChapterIds()), + new HistoryOperationParameter("template", Workspace.OperationDrafts.ChapterNameTemplateText), + new HistoryOperationParameter("orderShift", Workspace.OperationDrafts.OrderShift.ToString(System.Globalization.CultureInfo.InvariantCulture), "integer"), + new HistoryOperationParameter("expression", Workspace.OperationDrafts.Expression) + ])); + + private void RefreshContentOptionsPreview() + { + if (CurrentInfo is null) + { + return; + } + + pendingContentPreview = PrepareContentOptionsOperation(); + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ExpressionPreviewText = BuildExpressionPreviewText(pendingContentPreview, Localizer); + StatusText = Localizer.GetString(pendingContentPreview.IsValid + ? "Expression.State.Ready" + : "Expression.State.Invalid"); + ApplyContentPreviewCommand?.RaiseCanExecuteChanged(); + CancelContentPreviewCommand?.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand?.RaiseCanExecuteChanged(); + } + + internal ChapterContentPreview PrepareFrameRateOperation( + FrameRateValue sourceFps, + FrameRateValue targetFps) => + ClipEditingCoordinator.PrepareCandidate("Change chapter frame rate", document => + ClipEditingCoordinator.CandidateBuilder.ChangeFrameRate( + document, + sourceFps, + targetFps), + new HistoryOperationDescriptor("chapter.frame-rate-change", + [ + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackName", Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex)?.Name), + new HistoryOperationParameter("targetChapterIds", CaptureCurrentTrackChapterIds()), + new HistoryOperationParameter("sourceFps", sourceFps.ToString(), sourceFps.Kind.ToString()), + new HistoryOperationParameter("targetFps", targetFps.ToString(), targetFps.Kind.ToString()) + ])); + + private string CaptureCurrentTrackChapterIds() + { + var track = Workspace.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(Workspace.CurrentTrackIndex); + return track is null ? string.Empty : string.Join(",", track.Chapters.Select(static chapter => chapter.Id.ToString())); + } + + internal async ValueTask ApplyContentPreviewAsync( + ChapterContentPreview preview, + CancellationToken cancellationToken = default) + { + var outcome = await ClipEditingCoordinator.ApplyCandidateAsync(preview, cancellationToken); + ApplyContentOutcome(outcome, preview.Operation); + if ((outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange) + && preview.Operation is "Apply chapter options" or "Apply template names") + { + Workspace.SetExpressionOperationDrafts( + Workspace.OperationDrafts.Expression, + applyExpression: false, + Workspace.OperationDrafts.ExpressionPresetId, + Workspace.OperationDrafts.ExpressionSourceName); + Workspace.OperationDrafts.SetAutoGenerateNames(false); + Workspace.OperationDrafts.SetUseTemplateNames(false); + Workspace.OperationDrafts.SetOrderShift(0); + OnPropertyChanged(nameof(AutoGenerateNames)); + OnPropertyChanged(nameof(UseTemplateNames)); + OnPropertyChanged(nameof(OrderShift)); + } + + if (preview.Operation == "Apply expression" && outcome.Kind == TransactionOutcomeKind.Conflict) + { + RefreshExpressionPreviewNow(); + } + + if ((outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange) + && preview.Operation == "Change chapter frame rate" + && CurrentInfo is { } currentInfo) + { + SelectedFrameRateOption = frameRateService.FindByRate(currentInfo.FrameRate); + displayOptionCoordinator.SetContentRate(currentInfo.FrameRate, FrameRateDisplayOptions); + SetSelectedFrameRateIndexSilent(ComboIndexFor(SelectedFrameRateOption)); + ApplyFrameInfo(logResult: false); + var sourceFps = preview.Before.FrameRate.ToDouble(); + var targetFps = preview.Candidate.FrameRate.ToDouble(); + var optionName = SelectedFrameRateOption.DisplayName; + Log($"Convert to current FPS: option='{optionName}', source={sourceFps:0.###}, target={targetFps:0.###}, chapters {preview.Before.Tracks.Sum(static track => track.Chapters.Length)} -> {currentInfo.Chapters.Count}", + "Edit", + ("option", optionName), + ("sourceFps", $"{sourceFps:0.###}"), + ("targetFps", $"{targetFps:0.###}"), + ("before", preview.Before.Tracks.Sum(static track => track.Chapters.Length)), + ("after", currentInfo.Chapters.Count)); + } + + return outcome; + } + + internal void CancelContentPreview(ChapterContentPreview preview) + { + ArgumentNullException.ThrowIfNull(preview); + } + + private void ScheduleExpressionPreview() + { + InvalidatePendingContentPreview(); + + ExpressionPreviewText = Localizer.GetString("Expression.Preview.Pending"); + if (CurrentInfo is not null && !string.IsNullOrWhiteSpace(Expression)) + { + expressionPreviewTimer.Start(); + } + else + { + ExpressionPreviewText = string.Empty; + } + + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ApplyContentPreviewCommand?.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand?.RaiseCanExecuteChanged(); + CancelContentPreviewCommand?.RaiseCanExecuteChanged(); + } + + private void InvalidatePendingContentPreview() + { + expressionPreviewTimer.Stop(); + if (pendingContentPreview is { } preview) + { + CancelContentPreview(preview); + pendingContentPreview = null; + } + + ExpressionPreviewText = string.Empty; + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ApplyContentPreviewCommand?.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand?.RaiseCanExecuteChanged(); + CancelContentPreviewCommand?.RaiseCanExecuteChanged(); + } + + private void OnExpressionPreviewTimerTick(object? sender, EventArgs args) + { + expressionPreviewTimer.Stop(); + RefreshExpressionPreviewNow(); + } + + internal void RefreshExpressionPreviewNow() + { + expressionPreviewTimer.Stop(); + if (CurrentInfo is null || string.IsNullOrWhiteSpace(Expression)) + { + ExpressionPreviewText = string.Empty; + return; + } + + pendingContentPreview = PrepareExpressionOperation(Expression); + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ExpressionPreviewText = BuildExpressionPreviewText(pendingContentPreview, Localizer); + ApplyContentPreviewCommand?.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand?.RaiseCanExecuteChanged(); + CancelContentPreviewCommand?.RaiseCanExecuteChanged(); + } + + private static string BuildExpressionPreviewText(ChapterContentPreview preview, IAppLocalizer localizer) + { + if (!preview.IsValid) + { + return string.Join(Environment.NewLine, preview.Errors); + } + + var projection = ExpressionPreviewProjector.Build(preview); + return projection.HasChanges ? string.Empty : localizer.GetString("Expression.Preview.NoChanges"); + } private void ApplyFrameInfo(bool logResult = true) { + Workspace.ExportPreferences.SetComputationRate(SelectedFrameRateOption.IsValid ? SelectedFrameRateOption.Rate : null); if (CurrentInfo is null) { RefreshRows(); @@ -137,35 +404,35 @@ private void ApplyFrameInfo(bool logResult = true) var outcome = ClipEditingCoordinator.UpdateFrames( CurrentInfo, - selectedFrameRateOption, - RoundFrames - ? 0 - : EditingOptions.FrameDisplay == FrameDisplayMode.DecimalPlaces - ? EditingOptions.EffectiveFrameDecimalPlaces - : -1, - FrameAccuracyTolerance, - configuredFrameRate); + SelectedFrameRateOption, + EditingOptions.EffectiveFrameDecimalPlaces, + FrameAccuracyTolerance); var result = outcome.FrameResult; var detection = outcome.Detection; var appliedOption = outcome.AppliedOption; - CurrentInfo = outcome.CurrentChapterSet; - + appliedFrameRateOption = appliedOption; if (detection is not null) { - selectedFrameRateOption = frameRateService.Options[0]; + SelectedFrameRateOption = frameRateService.Options[0]; SetStatus("Status.DetectedFrameRate", ("displayName", detection.Option.DisplayName), ("confidence", detection.Confidence)); } - else + else if (SelectedFrameRateOption.Code != "Auto") { - selectedFrameRateOption = result.SelectedOption; + SelectedFrameRateOption = result.SelectedOption; } - SetSelectedFrameRateIndexSilent(ComboIndexFor(selectedFrameRateOption)); + SetSelectedFrameRateIndexSilent(ComboIndexFor(SelectedFrameRateOption)); if (logResult) { - Log("Log.FrameInfoUpdated", + var message = $"Frame info updated: option={appliedOption.DisplayName}, fps={result.Rate.ToDecimal():0.###}, round={RoundFrames}, chapters={CurrentInfo.Chapters.Count}"; + if (detection is not null) + { + message += $", autoDetected=true, confidence={detection.Confidence}"; + } + + Log(message, "Edit", ("option", appliedOption.DisplayName), - ("fps", $"{result.FramesPerSecond:0.###}"), + ("fps", $"{result.Rate.ToDecimal():0.###}"), ("round", RoundFrames), ("chapters", CurrentInfo.Chapters.Count), ("autoDetected", detection is not null), @@ -177,38 +444,34 @@ private void ApplyFrameInfo(bool logResult = true) NotifyStateChanged(); } - private void ChangeFpsToSelectedOption() + private async ValueTask ChangeFpsToSelectedOption(CancellationToken cancellationToken = default) { - if (CurrentInfo is null || !selectedFrameRateOption.IsValid) - { - return; - } - - var sourceFps = configuredFrameRate ?? (decimal)CurrentInfo.FramesPerSecond; - var targetOption = selectedFrameRateOption; - var targetFps = targetOption.Value; - var result = ChapterFpsTransformService.ChangeFps(CurrentInfo, sourceFps, targetFps); - if (!result.Success) + if (CurrentInfo is null || !SelectedFrameRateOption.IsValid) { - SetStatus(null, diagnostic: result.Diagnostics.FirstOrDefault()); - LogDiagnostics("Change FPS", result.Diagnostics); - NotifyStateChanged(); return; } - var beforeCount = CurrentInfo.Chapters.Count; - CurrentInfo = result.Info; - configuredFrameRate = targetFps; - ApplyFrameInfo(logResult: false); - SetStatus("Status.Updated"); - Log("Log.ChangeFps", - ("sourceFps", $"{sourceFps:0.###}"), - ("targetFps", $"{targetFps:0.###}"), - ("before", beforeCount), - ("after", result.Info.Chapters.Count)); - NotifyStateChanged(); + var sourceFps = ResolveSourceFrameRate(); + var targetOption = SelectedFrameRateOption; + var targetFps = targetOption.Rate; + pendingContentPreview = PrepareFrameRateOperation(sourceFps, targetFps); + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + ExpressionPreviewText = BuildExpressionPreviewText(pendingContentPreview, Localizer); + StatusText = Localizer.GetString(pendingContentPreview.IsValid + ? "Expression.State.Ready" + : "Expression.State.Invalid"); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); } + private FrameRateValue ResolveSourceFrameRate() => CurrentInfo?.FrameRate is { IsKnown: true } rate + ? rate : SelectedFrameRateOption.IsValid ? SelectedFrameRateOption.Rate : FrameRateValue.Unknown; + + internal FrameRateValue ComputationFrameRate => SelectedFrameRateOption.IsValid + ? SelectedFrameRateOption.Rate : CurrentInfo?.FrameRate ?? FrameRateValue.Unknown; + /// /// Rebuilds bindable clip options / selection after the workspace session was already updated. /// @@ -223,8 +486,7 @@ private void ApplyClipSessionUi(ClipSession session, int selectIndex) if (ClipOptions.Count == 0) { - Workspace.SetCurrentChapterSet(null); - Workspace.ClearProjectionCache(); + Workspace.ClearSession(); return; } @@ -274,7 +536,7 @@ private void SyncClipDisplayOptions(NotifyCollectionChangedEventArgs args) => displayOptionCoordinator.SyncClipDisplayOptions(args, ClipOptions, ClipDisplayOptions); private int ComboIndexFor(FrameRateOption entry) - => DisplayOptionCoordinator.ComboIndexFor(entry); + => displayOptionCoordinator.ComboIndexFor(entry); private FrameRateOption? FrameRateOptionForComboIndex(int frameRateIndex) => displayOptionCoordinator.FrameRateOptionForComboIndex(frameRateIndex); diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Expression.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Expression.cs index 5837d443..932a0712 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Expression.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Expression.cs @@ -1,51 +1,183 @@ -using ChapterTool.Core.Diagnostics; -using ChapterTool.Core.Exporting; +using System.Globalization; +using ChapterTool.Avalonia.UI.Localization; +using ChapterTool.Avalonia.UI.ViewModels.Tools; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; +using ChapterTool.Core.Transform; namespace ChapterTool.Avalonia.UI.ViewModels; -/// Contains expression projection behavior for the main window. +/// Contains expression diagnostics and chapter row behavior for the main window. public sealed partial class MainWindowViewModel { private void RefreshRows() { - var refresh = projectionFacade.RefreshRows(Rows, ApplyExpression); - if (refresh.Projection is not null) + var frameUpdate = CurrentInfo is null ? null : ClipEditingCoordinator.UpdateFrames(CurrentInfo, + SelectedFrameRateOption, EditingOptions.EffectiveFrameDecimalPlaces, + FrameAccuracyTolerance); + appliedFrameRateOption = frameUpdate?.AppliedOption; + var display = frameUpdate?.FrameResult; + DisplayFrameRate = display?.Rate.ToDecimal() ?? 0m; + workspaceContentRows.RefreshRows(Rows, display?.Info, EffectiveFrameRateOption, FramePresentationPolicy); + SetNarrowPreviewLayout(isNarrowPreviewLayout); + UpdateInlinePreviewProjection(); + } + + private void UpdateInlinePreviewProjection() + { + var comparisons = BuildInlinePreviewComparisons(pendingContentPreview); + foreach (var row in Rows) { - ReportProjectionExpressionDiagnostics(refresh.Projection.Diagnostics); + RefreshInlinePreviewRow(row, comparisons); } } - internal void RefreshRowsFromPort() => RefreshRows(); + private IReadOnlyDictionary<(ChapterTrackId TrackId, ChapterId ChapterId), ExpressionChapterComparison> + BuildInlinePreviewComparisons(ChapterContentPreview? preview) => preview is { IsValid: true } + && Workspace.IsContentTokenCurrent(preview.BaseToken) + ? ExpressionPreviewProjector.Build(preview, displayFrameRate: EffectiveFrameRateOption.Rate, + frameDecimalPlaces: PreviewFrameDecimalPlaces) + .Chapters.ToDictionary(item => (item.TrackId, item.Id)) + : new Dictionary<(ChapterTrackId TrackId, ChapterId ChapterId), ExpressionChapterComparison>(); + + private void RefreshInlinePreviewRow( + ChapterRowViewModel row, + IReadOnlyDictionary<(ChapterTrackId TrackId, ChapterId ChapterId), ExpressionChapterComparison> comparisons) + { + var comparison = row.TrackId is { } trackId && row.ChapterId is { } chapterId + && comparisons.TryGetValue((trackId, chapterId), out var mappedComparison) + ? mappedComparison + : null; + row.UpdatePreviewComparison(comparison, DisplayFrameRate, Localizer.GetString("Expression.Value.NotCalculated")); + UpdatePreviewTimes(row); + UpdatePreviewValues(row); + UpdatePreviewAccessibleNames(row); + UpdatePreviewFramePresentations(row); + } + + private void UpdatePreviewTimes(ChapterRowViewModel row) + { + row.PreviewTimeText = row.HasPreviewTimeChange && row.PreviewComparison?.Candidate is { } candidate + ? ExpressionChapterRowViewModel.FormatTicks(candidate.StartTicks) + : string.Empty; + row.PreviewBeforeTimeText = row.HasPreviewTimeChange && row.PreviewComparison?.Before is { } before + ? ExpressionChapterRowViewModel.FormatTicks(before.StartTicks) + : string.Empty; + row.PreviewTimeDelta = row.PreviewComparison is { } comparison && row.HasPreviewTimeChange + ? new ExpressionChapterRowViewModel(comparison, showFrames: false, Localizer).DeltaValue + : string.Empty; + } - private void ReportProjectionExpressionDiagnostics(IReadOnlyList diagnostics) + private void UpdatePreviewValues(ChapterRowViewModel row) { - if (!ApplyExpression) + row.PreviewName = row.HasPreviewNameChange + ? row.PreviewComparison?.Candidate?.Name ?? Localizer.GetString("Expression.Value.Missing") + : string.Empty; + row.PreviewNumber = row.HasPreviewNumberChange + ? row.PreviewComparison?.Candidate?.DisplayNumber.ToString() ?? Localizer.GetString("Expression.Value.Missing") + : string.Empty; + row.PreviewFrames = row.HasPreviewFramesChange + ? row.PreviewComparison?.CandidateFrames?.Text ?? Localizer.GetString("Expression.Value.NotCalculated") + : string.Empty; + row.PreviewBeforeFrames = row.HasPreviewFramesChange ? row.PreviewFrameBaseline : string.Empty; + } + + private void UpdatePreviewAccessibleNames(ChapterRowViewModel row) + { + row.PreviewTimeAccessibleName = row.HasPreviewTimeChange + ? FormatPreviewAccessibleName(Localizer.GetString("Grid.Time"), row.PreviewBeforeTimeText, row.PreviewTimeText, row.PreviewTimeDelta) + : string.Empty; + row.PreviewNameAccessibleName = row.HasPreviewNameChange + ? FormatPreviewAccessibleName(Localizer.GetString("Grid.Name"), row.Name, row.PreviewName) + : string.Empty; + row.PreviewNumberAccessibleName = row.HasPreviewNumberChange + ? FormatPreviewAccessibleName(Localizer.GetString("Grid.Number"), row.Number.ToString(CultureInfo.CurrentCulture), row.PreviewNumber) + : string.Empty; + row.PreviewFramesAccessibleName = row.HasPreviewFramesChange + ? FormatPreviewAccessibleName(Localizer.GetString("Grid.Frames"), row.PreviewBeforeFrames, row.PreviewFrames) + : string.Empty; + } + + private void UpdatePreviewFramePresentations(ChapterRowViewModel row) + { + var beforeInput = row.HasPreviewFramesChange && row.PreviewComparison is { Before: { } before, BeforeFrames: { } beforeFrames } + ? CreateFramePresentationInput(before.StartTicks, beforeFrames.FrameRate, row.PreviewBeforeFrames) + : null; + var candidateInput = row.HasPreviewFramesChange && row.PreviewComparison is { Candidate: { } candidate, CandidateFrames: { } candidateFrames } + ? CreateFramePresentationInput(candidate.StartTicks, candidateFrames.FrameRate, row.PreviewFrames) + : null; + row.SetPreviewFramePresentationInputs(beforeInput, candidateInput, FramePresentationPolicy); + if (!row.HasPreviewFramesChange) { - lastExpressionDiagnosticSignature = null; return; } - var diagnostic = diagnostics.FirstOrDefault(ChapterExpressionValidation.IsLuaExpressionDiagnostic); - if (diagnostic is null) + var beforeDescription = row.PreviewBeforeFramePresentation?.AccessibleDescription ?? row.PreviewBeforeFrames; + var candidateDescription = row.PreviewCandidateFramePresentation?.AccessibleDescription ?? row.PreviewFrames; + row.PreviewFramesAccessibleName = FormatPreviewAccessibleName( + Localizer.GetString("Grid.Frames"), + $"{Localizer.GetString("Expression.Preview.BeforeFrames")}: {beforeDescription}", + $"{Localizer.GetString("Expression.Preview.CandidateFrames")}: {candidateDescription}"); + } + + private FramePresentationInput CreateFramePresentationInput(long startTicks, FrameRateValue rate, string fallback) + { + if (SelectedFrameRateOption.IsValid && SelectedFrameRateOption.Rate.IsKnown) { - lastExpressionDiagnosticSignature = null; - return; + return new FramePresentationInput(startTicks, SelectedFrameRateOption.Rate, fallback); } - SetStatus(null, diagnostic); - var signature = $"{Expression}\n{diagnostic.Code}\n{diagnostic.Message}"; - if (string.Equals(signature, lastExpressionDiagnosticSignature, StringComparison.Ordinal)) + if (rate.IsKnown) { - return; + return new FramePresentationInput(startTicks, rate, fallback); } - lastExpressionDiagnosticSignature = signature; - LogDiagnostics("Lua expression script", [diagnostic]); + return EffectiveFrameRateOption.Rate.IsKnown + ? new FramePresentationInput(startTicks, EffectiveFrameRateOption.Rate, fallback) + : FramePresentationInput.FromNumericText(fallback); + } + + internal FramePresentationPolicy FramePresentationPolicy => new( + RoundFrames, + ShowRepeatingFrameDecimals && EditingOptions.FrameDisplay == Core.Editing.FrameDisplayMode.FullPrecision, + PreviewFrameDecimalPlaces); + + internal void RefreshFramePresentations() + { + var policy = FramePresentationPolicy; + foreach (var row in Rows) + { + row.RefreshFramePresentation(policy); + } } - private ChapterOutputProjectionResult CurrentOutputProjection() => - projectionFacade.ProjectCurrent(); + private string FormatPreviewAccessibleName(string field, string before, string candidate, string delta = "") => + Localizer.Format(LocalizedMessage.Create( + "Expression.Preview.ComparisonAccessibleName", + ("field", field), + ("before", before), + ("candidate", candidate), + ("delta", delta))); + + internal void SetNarrowPreviewLayout(bool isNarrow) + { + isNarrowPreviewLayout = isNarrow; + foreach (var row in Rows) + { + row.IsNarrowPreviewLayout = isNarrow; + } + } + + internal decimal DisplayFrameRate { get; private set; } + + public FrameRateValue ExpressionFrameRate => ComputationFrameRate; + + internal FrameRateOption SelectedFrameRateOption { get; private set; } + + internal FrameRateOption EffectiveFrameRateOption => appliedFrameRateOption ?? SelectedFrameRateOption; + + private int PreviewFrameDecimalPlaces => EditingOptions.EffectiveFrameDecimalPlaces; + + internal void RefreshRowsFromPort() => RefreshRows(); - private ChapterExportOptions CurrentExportOptionsForProjectedInfo() => - projectionFacade.CreateExportOptionsForProjectedInfo(); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.ImportExport.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.ImportExport.cs index b0ce5930..5134f097 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.ImportExport.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.ImportExport.cs @@ -33,7 +33,8 @@ public async ValueTask LoadChapterNameTemplateFromPathAsync(string path, Cancell if (string.IsNullOrWhiteSpace(text)) { SetStatus("Status.TemplateLoadFailed", ("path", Path.GetFileName(path))); - Log(LogLevel.Warning, "Log.TemplateLoadFailed", ("path", path), ("reason", "empty")); + Log(LogLevel.Warning, $"Failed to load chapter name template from '{path}': file is empty", "Template", + ("path", path), ("reason", "empty")); return; } @@ -41,7 +42,14 @@ public async ValueTask LoadChapterNameTemplateFromPathAsync(string path, Cancell ChapterNameTemplateStatus = Path.GetFileName(path); ChapterNameModeIndex = 2; SetStatus("Status.TemplateLoaded", ("name", ChapterNameTemplateStatus)); - Log("Log.TemplateLoaded", ("path", path), ("name", ChapterNameTemplateStatus)); + Log(LogLevel.Information, $"Loaded chapter name template '{ChapterNameTemplateStatus}' from '{path}'", "Template", + ("path", path), ("name", ChapterNameTemplateStatus)); + + if (CurrentInfo is not null) + { + PreviewLoadedTemplateNames(); + SetStatus("Status.TemplateLoaded", ("name", ChapterNameTemplateStatus)); + } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) { @@ -52,10 +60,24 @@ public async ValueTask LoadChapterNameTemplateFromPathAsync(string path, Cancell ChapterNameTemplateStatus = previousStatus; SetStatus("Status.TemplateLoadFailed", ("path", Path.GetFileName(path))); - Log(LogLevel.Warning, "Log.TemplateLoadFailed", exception.Message, ("path", path)); + Log(LogLevel.Warning, $"Failed to load chapter name template from '{path}': {exception.Message}", "Template", + technicalDetail: exception.Message, ("path", path)); } } + private void PreviewLoadedTemplateNames() + { + pendingContentPreview = PrepareTemplateNamesOperation(autoGenerateNames: false, useTemplateNames: true); + ExpressionPreviewText = BuildExpressionPreviewText(pendingContentPreview, Localizer); + OnPropertyChanged(nameof(IsContentPreviewPending)); + OnPropertyChanged(nameof(CanPreviewContentOptions)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + } + private ValueTask LoadPathAsync(string path, CancellationToken cancellationToken) => LoadSourceAsync(new LocalPathChapterSource(path), cancellationToken); @@ -64,26 +86,36 @@ private async ValueTask LoadSourceAsync(ChapterSourceDocument source, Cancellati if (string.IsNullOrWhiteSpace(source.DisplayName)) { SetStatus("Status.NoSourceSelected"); - LogStatus(); + Log(LogLevel.Information, "Load source skipped: no source selected", "Load", + ("displayName", source.DisplayName)); NotifyStateChanged(); return; } - Log("Log.LoadingSource", ("path", source.DisplayName)); + var sourcePath = source is LocalPathChapterSource localSource ? localSource.NormalizedPath : source.DisplayName; + Log(LogLevel.Information, $"Loading source: path='{sourcePath}'", "Load", + ("path", sourcePath), ("displayName", source.DisplayName)); Progress = 0.05; SetProgressStatus(ChapterImportProgressPhase.LoadingSource); var outcome = await loadSaveWorkflow.LoadAsync(source, update => { Progress = Math.Clamp(update.Fraction ?? Progress, 0, 0.98); SetProgressStatus(update.Phase); - }, cancellationToken); + }, cancellationToken, () => RequiresSessionLossConfirmation, ConfirmSessionLossAsync); switch (outcome.State) { case LoadWorkflowState.Stale: return; case LoadWorkflowState.EmptyPath: SetStatus("Status.NoSourceSelected"); - LogStatus(); + Log(LogLevel.Information, "Load source skipped: no source selected", "Load", + ("displayName", source.DisplayName)); + NotifyStateChanged(); + return; + case LoadWorkflowState.Cancelled: + SetStatus("Status.SessionReplacementCanceled"); + ClearProgressStatus(); + Progress = 0; NotifyStateChanged(); return; case LoadWorkflowState.Failed: @@ -110,7 +142,7 @@ private async ValueTask LoadSourceAsync(ChapterSourceDocument source, Cancellati OnPropertyChanged(nameof(CurrentPath)); OnPropertyChanged(nameof(DisplayPath)); ApplyClipSessionUi(session, selectIndex: session.SelectedIndex); - SetStatus("Status.LoadedChapters", ("count", Rows.Count)); + SetStatus(Rows.Count == 1 ? "Status.LoadedChapter" : "Status.LoadedChapters", ("count", Rows.Count)); ClearProgressStatus(); Progress = 1; LogImportDiagnostics("Load", result.Diagnostics); @@ -125,17 +157,28 @@ private async ValueTask SaveAsync(string? directoryOverride, CancellationToken c } var directory = ResolveSaveDirectory(directoryOverride); - var projection = CurrentOutputProjection(); - var entries = CurrentExportOptionsForProjectedInfo(); - Log("Log.SavingChapters", + var chapterSet = workspaceContentRows.GetCurrentChapterSet(); + var entries = Workspace.CreateSerializationOptions(); + Log(LogLevel.Information, + $"Saving chapters: format={entries.Format}, directory='{directory ?? string.Empty}', source='{CurrentInfo.SourceName ?? string.Empty}', " + + $"chapters={chapterSet.Chapters.Count}, expression='{Expression}', " + + $"xmlLanguage='{entries.XmlLanguage ?? string.Empty}', encoding={entries.TextEncoding}, bom={entries.EmitBom}", + "Save", ("format", entries.Format), ("directory", directory ?? string.Empty), ("source", CurrentInfo.SourceName ?? string.Empty), - ("chapters", projection.Info.Chapters.Count), - ("applyExpression", ApplyExpression), - ("expression", Expression)); - LogDiagnostics("Output projection", projection.Diagnostics); - var result = await loadSaveWorkflow.SaveAsync(projection.Info, entries, directory, cancellationToken); + ("chapters", chapterSet.Chapters.Count), + ("expression", Expression), + ("xmlLanguage", entries.XmlLanguage ?? string.Empty), + ("encoding", entries.TextEncoding), + ("bom", entries.EmitBom)); + var exportSnapshot = Workspace.CaptureExportSnapshot(chapterSet, entries, Workspace.CurrentTrackIndex); + var result = await loadSaveWorkflow.SaveAsync(chapterSet, entries, directory, cancellationToken); + if (result.Success && exportSnapshot is not null) + { + Workspace.RecordSuccessfulExport(exportSnapshot); + } + ApplySaveStatus(result); LogDiagnostics("Save", result.Diagnostics); NotifyStateChanged(); @@ -172,12 +215,17 @@ private async ValueTask AppendSourceAsync(ChapterSourceDocument source, Cancella if (Workspace.ClipSession is null) { SetStatus("Status.NoCurrentMplsGroup"); - LogStatus(); + Log(LogLevel.Information, "Append MPLS skipped: no active MPLS group", "Append"); NotifyStateChanged(); return; } - Log("Log.AppendingMpls", ("path", source.DisplayName)); + var sourcePath = source is LocalPathChapterSource localSource ? localSource.NormalizedPath : source.DisplayName; + Log(LogLevel.Information, + $"Appending MPLS: path='{sourcePath}', currentEntries={Workspace.ClipSession.ClipOptions.Count}, currentChapters={CurrentInfo?.Chapters.Count ?? 0}", + "Append", + ("path", sourcePath), ("displayName", source.DisplayName), + ("currentEntries", Workspace.ClipSession.ClipOptions.Count), ("currentChapters", CurrentInfo?.Chapters.Count ?? 0)); var outcome = await loadSaveWorkflow.AppendAsync(source, cancellationToken); switch (outcome.State) { @@ -185,7 +233,7 @@ private async ValueTask AppendSourceAsync(ChapterSourceDocument source, Cancella return; case AppendWorkflowState.NoSession: SetStatus("Status.NoCurrentMplsGroup"); - LogStatus(); + Log(LogLevel.Information, "Append MPLS skipped: no active MPLS group", "Append"); NotifyStateChanged(); return; case AppendWorkflowState.FailedLoad: diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Settings.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Settings.cs index 451a3017..3d11730f 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Settings.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.Settings.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace ChapterTool.Avalonia.UI.ViewModels; /// Contains settings loading behavior for the main window. @@ -12,9 +14,17 @@ public async ValueTask LoadSettingsAsync(CancellationToken cancellationToken) var settings = await SettingsStore.LoadAsync(cancellationToken); ToolSession.Preferences.ApplyLoadedSettings(settings.Application); - Log("Log.SettingsLoaded", + Log(LogLevel.Information, + $"Settings loaded: savingPath='{SaveDirectory ?? string.Empty}', language='{UiLanguage}', " + + $"defaultSaveFormat={SaveFormat}, frameDisplay={EditingOptions.FrameDisplay}, " + + $"frameAccuracy={FrameAccuracyTolerance}, xmlLanguage='{XmlLanguage}'", + "Settings", ("savingPath", SaveDirectory ?? string.Empty), - ("language", UiLanguage)); + ("language", UiLanguage), + ("defaultSaveFormat", SaveFormat), + ("frameDisplay", EditingOptions.FrameDisplay), + ("frameAccuracy", FrameAccuracyTolerance), + ("xmlLanguage", XmlLanguage)); NotifyStateChanged(); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.StatusLog.cs b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.StatusLog.cs index e3189173..0fd45ed3 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.StatusLog.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/MainWindowViewModel.StatusLog.cs @@ -1,4 +1,3 @@ -using ChapterTool.Avalonia.UI.Localization; using ChapterTool.Avalonia.UI.Workflows; using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Importing; @@ -9,8 +8,6 @@ namespace ChapterTool.Avalonia.UI.ViewModels; /// Contains status and application-log behavior for the main window. public sealed partial class MainWindowViewModel { - private readonly IAppLocalizer logContentLocalizer = new AppLocalizationManager("en-US"); - internal void SetStatus(string? key, params (string Name, object? Value)[] arguments) => statusDiagnosticsPresenter.SetStatus(key, arguments); @@ -24,19 +21,19 @@ private void SetProgressStatus(ChapterImportProgressPhase? phase, params (string internal string LocalizeDiagnostic(ChapterDiagnostic diagnostic) => statusDiagnosticsPresenter.LocalizeDiagnostic(diagnostic); - internal void LogStatus(LogLevel level = LogLevel.Information) => Log(level, "Log.Status", ("status", StatusText)); - - internal void Log(string key, params (string Name, object? Value)[] arguments) => - statusDiagnosticsPresenter.Log(LogLevel.Information, key, technicalDetail: null, arguments); + internal void Log(string message, string? operation, params (string Name, object? Value)[] arguments) + => Log(LogLevel.Information, message, operation, arguments); - private void Log(LogLevel level, string key, params (string Name, object? Value)[] arguments) - => statusDiagnosticsPresenter.Log(level, key, technicalDetail: null, arguments); + internal void Log(LogLevel level, string message, string? operation, params (string Name, object? Value)[] arguments) + => statusDiagnosticsPresenter.Log(level, message, operation, technicalDetail: null, arguments: arguments); - private void Log(LogLevel level, string key, string? technicalDetail, params (string Name, object? Value)[] arguments) - => statusDiagnosticsPresenter.Log(level, key, technicalDetail, arguments); + internal void Log(LogLevel level, string message, string? operation, string technicalDetail, params (string Name, object? Value)[] arguments) + => statusDiagnosticsPresenter.Log(level, message, operation, technicalDetail, arguments: arguments); private void RefreshLocalizedState() { + OnPropertyChanged(nameof(ChapterCountDisplay)); + OnPropertyChanged(nameof(SelectedRowCountDisplay)); RefreshChapterNameModeOptions(); RefreshFrameRateDisplayOptions(); RefreshXmlLanguageDisplayOptions(notify: true); @@ -90,9 +87,6 @@ private void RefreshFrameRateDisplayOptions() internal void LogDiagnostics(string operation, IReadOnlyList diagnostics) => statusDiagnosticsPresenter.LogDiagnostics(operation, diagnostics); - internal string EnglishLogText(string key, params (string Name, object? Value)[] arguments) - => logContentLocalizer.Format(LocalizedMessage.Create(key, arguments)); - private void LogImportDiagnostics(string operation, IReadOnlyList diagnostics) => statusDiagnosticsPresenter.LogDiagnostics( operation, @@ -101,7 +95,11 @@ private void LogImportDiagnostics(string operation, IReadOnlyList xmlLanguageDisplayOptions = []; - private FrameRateOption selectedFrameRateOption; - private decimal? configuredFrameRate; + private FrameRateOption? appliedFrameRateOption; private bool isRefreshingChapterNameModeOptions; + private bool isApplyingContentPreview; + private bool isNarrowPreviewLayout; private string chapterNameTemplateStatus; private string statusText; - private string? lastExpressionDiagnosticSignature; + private ChapterContentPreview? pendingContentPreview; + private string expressionPreviewText = string.Empty; + private IReadOnlyList historyEntries = []; + private SessionHistorySnapshot? historySnapshot; - private ChapterSet? CurrentInfo - { - get => Workspace.CurrentChapterSet; - set => Workspace.SetCurrentChapterSet(value); - } + private ChapterSet? CurrentInfo => Workspace.CurrentChapterSet; public MainWindowViewModel(AvaloniaHostComposition composition) : this( @@ -105,6 +111,7 @@ public MainWindowViewModel( this.editingService = editingService; this.AuxiliaryToolHost = auxiliaryToolHost; this.frameRateService = frameRateService; + this.timeFormatter = formatter; this.ExpressionEngine = expressionEngine; this.exportService = exportService; this.Capabilities = capabilities ?? new RuntimeCapabilities( @@ -127,7 +134,7 @@ public MainWindowViewModel( ExpressionAuthoringService = expressionAuthoringService ?? new ExpressionAuthoringService(this.ExpressionEngine); loadSaveWorkflow = new LoadSaveWorkflow(Workspace, loadService, saveService); ClipEditingCoordinator = new ClipEditingCoordinator(Workspace, this.editingService, this.frameRateService); - projectionFacade = new ProjectionFacade(Workspace, this.ExpressionEngine, formatter); + workspaceContentRows = new WorkspaceContentRows(Workspace, formatter); statusDiagnosticsPresenter = new StatusDiagnosticsPresenter(Localizer, logger, formatter, value => StatusText = value); displayOptionCoordinator = new DisplayOptionCoordinator(Localizer, this.frameRateService); var toolSession = new MainWindowToolSession(this); @@ -140,27 +147,32 @@ public MainWindowViewModel( RefreshFrameRateDisplayOptions(); cultureChangedHandler = (_, _) => RefreshLocalizedState(); Localizer.CultureChanged += cultureChangedHandler; - selectedFrameRateOption = this.frameRateService.Options[0]; + SelectedFrameRateOption = this.frameRateService.Options[0]; ClipOptions.CollectionChanged += OnClipOptionsChanged; Rows.CollectionChanged += OnRowsChanged; InitializeCommands(); + expressionPreviewTimer.Tick += OnExpressionPreviewTimerTick; + RefreshHistoryEntries(); } public void Dispose() { + expressionPreviewTimer.Stop(); + expressionPreviewTimer.Tick -= OnExpressionPreviewTimerTick; + EndDocumentSession(); Localizer.CultureChanged -= cultureChangedHandler; ClipOptions.CollectionChanged -= OnClipOptionsChanged; Rows.CollectionChanged -= OnRowsChanged; } - /// Explicit workspace owning clip session, edit buffer, path, and revision. + /// Gets explicit workspace owning clip session, edit buffer, path, and revision. internal ChapterWorkspace Workspace { get; } = new(); - /// Session facade that owns the narrow ports used by auxiliary tools. + /// Gets session facade that owns the narrow ports used by auxiliary tools. public IWorkspaceToolSession ToolSession { get; } - /// Host effects projected into shared command and visibility state. + /// Gets host effects projected into shared command and visibility state. public IRuntimeCapabilities Capabilities { get; } public IAuxiliaryToolHost AuxiliaryToolHost { get; } @@ -173,6 +185,35 @@ public void Dispose() internal ISettingsStore? SettingsStore { get; } + public ShortcutMapping ActiveShortcutMapping { get; private set; } = ShortcutMapping.Default; + + public string SaveShortcutGesture => ActiveShortcutMapping.RowGesture(ShortcutCatalog.SaveId) ?? string.Empty; + + public string ReloadShortcutGesture => ActiveShortcutMapping.RowGesture(ShortcutCatalog.ReloadId) ?? string.Empty; + + public string LogShortcutGesture => ActiveShortcutMapping.RowGesture(ShortcutCatalog.LogId) ?? string.Empty; + + public string PreviewShortcutGesture => ActiveShortcutMapping.RowGesture(ShortcutCatalog.PreviewId) ?? string.Empty; + + public KeyGesture? ReloadShortcutKeyGesture => ParseGesture(ReloadShortcutGesture); + + public KeyGesture? PreviewShortcutKeyGesture => ParseGesture(PreviewShortcutGesture); + + public void SetShortcutSettings(ShortcutSettings settings) + { + ActiveShortcutMapping = ShortcutMapping.Resolve(settings); + OnPropertyChanged(nameof(ActiveShortcutMapping)); + OnPropertyChanged(nameof(SaveShortcutGesture)); + OnPropertyChanged(nameof(ReloadShortcutGesture)); + OnPropertyChanged(nameof(LogShortcutGesture)); + OnPropertyChanged(nameof(PreviewShortcutGesture)); + OnPropertyChanged(nameof(ReloadShortcutKeyGesture)); + OnPropertyChanged(nameof(PreviewShortcutKeyGesture)); + } + + private static KeyGesture? ParseGesture(string gesture) => + string.IsNullOrWhiteSpace(gesture) ? null : KeyGesture.Parse(gesture); + internal void NotifyPropertyChanged(string propertyName) => OnPropertyChanged(propertyName); internal void SetUiErrorHandler(Func errorHandler) @@ -188,7 +229,7 @@ internal void SetUiErrorHandler(Func errorHandler) public string DisplayPath => Workspace.DisplayPath; /// - /// Authoritative path text for the source path box and reload/load adapters. + /// Gets or sets authoritative path text for the source path box and reload/load adapters. /// Updated by browse/drop and synchronized from successful loads. /// public string SourcePath @@ -201,6 +242,20 @@ public string SourcePath public bool IsChapterGridEmpty => Rows.Count == 0; + public int ChapterCount => Rows.Count; + + public int SelectedRowCount => SelectedRowIndexes.Count; + + public string ChapterCountDisplay => Localizer.Format(ChapterCount == 1 ? "Main.ChapterCountSingular" : "Main.ChapterCount", new Dictionary { ["count"] = ChapterCount }); + + public string ClipStructureActionText => Localizer.GetString(IsClipCombineChecked ? "Main.SplitByBoundaries" : "Main.Combine"); + + public string SelectedRowCountDisplay => Localizer.Format("Status.SelectedRows", new Dictionary { ["0"] = SelectedRowCount }); + + public string SourceDisplayText => string.IsNullOrWhiteSpace(SourcePath) ? string.Empty : Path.GetFileName(SourcePath); + + public bool IsTemplateFileApplicable => UseTemplateNames; + public ObservableCollection ClipOptions { get; } = []; public ObservableCollection ClipDisplayOptions { get; } = []; @@ -253,7 +308,28 @@ public bool RoundFrames { if (SetProperty(ref field, value)) { - OnFrameOptionsChangedFromBinding(); + if (value) + { + EditingOptions = EditingOptions with { FrameDisplay = Core.Editing.FrameDisplayMode.Round }; + } + else if (EditingOptions.FrameDisplay == Core.Editing.FrameDisplayMode.Round) + { + EditingOptions = EditingOptions with { FrameDisplay = Core.Editing.FrameDisplayMode.FullPrecision }; + } + OnFrameOptionsChangedFromBinding(invalidatePendingPreview: false); + } + } + } = true; + + public bool ShowRepeatingFrameDecimals + { + get; + set + { + if (SetProperty(ref field, value)) + { + RefreshFramePresentations(); + NotifyStateChanged(); } } } = true; @@ -266,6 +342,7 @@ public decimal FrameAccuracyTolerance var normalized = NormalizeFrameAccuracyTolerance(value); if (SetProperty(ref field, normalized)) { + InvalidatePendingContentPreview(); RefreshRows(); } } @@ -273,7 +350,17 @@ public decimal FrameAccuracyTolerance public ChapterEditingOptions EditingOptions { get; private set; } = ChapterEditingOptions.Default; - internal void ApplyEditingOptions(ChapterEditingOptions options) => EditingOptions = options ?? ChapterEditingOptions.Default; + internal void ApplyEditingOptions(ChapterEditingOptions? options) + { + var updatedOptions = options ?? ChapterEditingOptions.Default; + if (EditingOptions == updatedOptions) + { + return; + } + + EditingOptions = updatedOptions; + RefreshFramePresentations(); + } public int SelectedFrameRateIndex { @@ -290,7 +377,7 @@ public int SelectedFrameRateIndex var entry = FrameRateOptionForComboIndex(value); if (entry is not null) { - selectedFrameRateOption = entry; + SelectedFrameRateOption = entry; } } @@ -300,7 +387,7 @@ public int SelectedFrameRateIndex public bool IsClipSelectionVisible => ClipOptions.Count > 0 || IsClipCombineChecked; - /// Derived from typed clip session mode (combined vs split). + /// Gets a value indicating whether the clip session mode is combined. public bool IsClipCombineChecked => Workspace.ClipSession?.IsCombined == true; public bool IsAdvancedPanelExpanded @@ -413,19 +500,21 @@ public string UiLanguage public bool AutoGenerateNames { - get => Workspace.Projection.AutoGenerateNames; + get => Workspace.OperationDrafts.AutoGenerateNames; set { - var previousTemplate = Workspace.Projection.UseTemplateNames; - if (Workspace.Projection.SetAutoGenerateNames(value)) + var previousTemplate = Workspace.OperationDrafts.UseTemplateNames; + if (Workspace.OperationDrafts.SetAutoGenerateNames(value)) { + InvalidatePendingContentPreview(); OnPropertyChanged(); - if (previousTemplate != Workspace.Projection.UseTemplateNames) + if (previousTemplate != Workspace.OperationDrafts.UseTemplateNames) { OnPropertyChanged(nameof(UseTemplateNames)); } OnPropertyChanged(nameof(ChapterNameModeIndex)); + OnPropertyChanged(nameof(IsTemplateFileApplicable)); RefreshRows(); } } @@ -433,19 +522,21 @@ public bool AutoGenerateNames public bool UseTemplateNames { - get => Workspace.Projection.UseTemplateNames; + get => Workspace.OperationDrafts.UseTemplateNames; set { - var previousAuto = Workspace.Projection.AutoGenerateNames; - if (Workspace.Projection.SetUseTemplateNames(value)) + var previousAuto = Workspace.OperationDrafts.AutoGenerateNames; + if (Workspace.OperationDrafts.SetUseTemplateNames(value)) { + InvalidatePendingContentPreview(); OnPropertyChanged(); - if (previousAuto != Workspace.Projection.AutoGenerateNames) + if (previousAuto != Workspace.OperationDrafts.AutoGenerateNames) { OnPropertyChanged(nameof(AutoGenerateNames)); } OnPropertyChanged(nameof(ChapterNameModeIndex)); + OnPropertyChanged(nameof(IsTemplateFileApplicable)); RefreshRows(); } } @@ -453,11 +544,12 @@ public bool UseTemplateNames public string ChapterNameTemplateText { - get => Workspace.Projection.ChapterNameTemplateText; + get => Workspace.OperationDrafts.ChapterNameTemplateText; set { - if (Workspace.Projection.SetChapterNameTemplateText(value)) + if (Workspace.OperationDrafts.SetChapterNameTemplateText(value)) { + InvalidatePendingContentPreview(); OnPropertyChanged(); OnPropertyChanged(nameof(ChapterNameModeIndex)); RefreshRows(); @@ -509,49 +601,38 @@ public int ChapterNameModeIndex public int OrderShift { - get => Workspace.Projection.OrderShift; - set - { - if (Workspace.Projection.SetOrderShift(value)) - { - OnPropertyChanged(); - RefreshRows(); - } - } - } - - public bool ApplyExpression - { - get => Workspace.Projection.ApplyExpression; + get => Workspace.OperationDrafts.OrderShift; set { - if (Workspace.Projection.SetApplyExpression(value)) + if (Workspace.OperationDrafts.SetOrderShift(value)) { + InvalidatePendingContentPreview(); OnPropertyChanged(); RefreshRows(); + RefreshContentOptionsPreview(); } } } public string Expression { - get => Workspace.Projection.Expression; + get => Workspace.OperationDrafts.Expression; set { - if (Workspace.Projection.SetExpression(value)) + if (Workspace.OperationDrafts.SetExpression(value)) { OnPropertyChanged(); - RefreshRows(); + ScheduleExpressionPreview(); } } } public string ExpressionPresetId { - get => Workspace.Projection.ExpressionPresetId; + get => Workspace.OperationDrafts.ExpressionPresetId; set { - if (Workspace.Projection.SetExpressionPresetId(value)) + if (Workspace.OperationDrafts.SetExpressionPresetId(value)) { OnPropertyChanged(); } @@ -560,10 +641,10 @@ public string ExpressionPresetId public string ExpressionSourceName { - get => Workspace.Projection.ExpressionSourceName; + get => Workspace.OperationDrafts.ExpressionSourceName; set { - if (Workspace.Projection.SetExpressionSourceName(value)) + if (Workspace.OperationDrafts.SetExpressionSourceName(value)) { OnPropertyChanged(); } @@ -599,6 +680,109 @@ public string StatusText private set => SetProperty(ref statusText, value); } + public IReadOnlyList HistoryEntries + { + get => historyEntries; + private set => SetProperty(ref historyEntries, value); + } + + public bool HasHistory => HistoryEntries.Count > 0; + + public bool CanOpenHistory => Workspace.ContentSession is not null; + + public string HistoryLifetimeText => Localizer.GetString("History.SessionLifetimeShort"); + + /// Gets whether ending this document session would discard history, a draft, or unexported content. + public bool RequiresSessionLossConfirmation + { + get + { + var history = Workspace.ContentSession?.GetHistorySnapshot(); + var hasHistory = history is not null && history.Nodes.Length > 1; + return hasHistory || pendingContentPreview is not null || HasUnexportedContent; + } + } + + public bool HasUnexportedContent + { + get + { + if (CurrentInfo is null) + { + return false; + } + + var capture = Workspace.CaptureExportSnapshot( + workspaceContentRows.GetCurrentChapterSet(), + Workspace.CreateSerializationOptions(), + Workspace.CurrentTrackIndex); + return !Workspace.IsExported(capture); + } + } + + internal Func>? SessionLossConfirmation { get; set; } + + /// Asks the host before ending the current session. + public ValueTask ConfirmSessionLossAsync(CancellationToken cancellationToken) => + SessionLossConfirmation is null + ? ValueTask.FromResult(true) + : SessionLossConfirmation(cancellationToken); + + /// Ends the current session and drops its host projections and drafts. + public void EndDocumentSession() + { + expressionPreviewTimer.Stop(); + if (pendingContentPreview is { } preview) + { + CancelContentPreview(preview); + pendingContentPreview = null; + } + ExpressionPreviewText = string.Empty; + + Workspace.ClearSession(); + SourcePath = string.Empty; + Rows.Clear(); + ClipOptions.Clear(); + ClipDisplayOptions.Clear(); + SelectedClipIndex = -1; + SelectedRowIndexes = []; + HistoryEntries = []; + historySnapshot = null; + NotifyStateChanged(); + } + + public string UndoDescription => CurrentHistoryNode?.Description ?? string.Empty; + + public string RedoDescription => PreferredRedoNode?.Description ?? string.Empty; + + public bool CanUndo => historySnapshot is { } snapshot && snapshot.CursorId != snapshot.RootId; + + public bool CanRedo => PreferredRedoNode is not null; + + private SessionHistoryNodeSnapshot? CurrentHistoryNode + { + get + { + var snapshot = historySnapshot; + return snapshot?.Nodes.FirstOrDefault(node => node.Id == snapshot.CursorId); + } + } + + private SessionHistoryNodeSnapshot? PreferredRedoNode + { + get + { + var snapshot = historySnapshot; + var cursor = snapshot?.Nodes.FirstOrDefault(node => node.Id == snapshot.CursorId); + var childId = cursor is null + ? (Guid?)null + : cursor.PreferredChildId ?? (cursor.ChildIds.IsEmpty ? null : cursor.ChildIds[0]); + return childId is null + ? null + : snapshot?.Nodes.FirstOrDefault(node => node.Id == childId.Value); + } + } + public double Progress { get; @@ -628,13 +812,15 @@ public string SaveButtonTooltip public bool CanAppendMpls => Workspace.ClipSession?.CanAppendMpls == true; - public bool CanCombine => Workspace.ClipSession?.CanCombine == true; + public bool CanCombine => Workspace.ClipSession?.CanCombine == true && !IsChapterGridReadOnly; + + public bool CanOpenContentTools => CanEditRows; public bool CanSave => CurrentInfo is not null; public bool CanRefreshRows => CurrentInfo is not null; - public bool CanEditRows => CurrentInfo is not null; + public bool CanEditRows => CurrentInfo is not null && !IsChapterGridReadOnly; public bool CanOpenRelatedMedia => Capabilities.CanOpenLocalPaths && RelatedMediaReferences.Count > 0; @@ -712,10 +898,92 @@ internal set public UiCommand ForwardShiftCommand { get; private set; } = null!; + public UiCommand HistoryCommand { get; private set; } = null!; + + public UiCommand PreviewContentOptionsCommand { get; private set; } = null!; + + public UiCommand ApplyContentPreviewCommand { get; private set; } = null!; + + public UiCommand RefreshContentPreviewCommand { get; private set; } = null!; + + public UiCommand CancelContentPreviewCommand { get; private set; } = null!; + + public UiCommand UndoCommand { get; private set; } = null!; + + public UiCommand RedoCommand { get; private set; } = null!; + + public UiCommand NavigateHistoryCommand { get; private set; } = null!; + + public bool IsContentPreviewPending => pendingContentPreview is not null || expressionPreviewTimer.IsEnabled; + + public string ExpressionPreviewText + { + get => pendingContentPreview is { } preview && !Workspace.IsContentTokenCurrent(preview.BaseToken) + ? Localizer.GetString("Expression.Preview.Stale") + : expressionPreviewText; + private set + { + SetProperty(ref expressionPreviewText, value); + OnPropertyChanged(nameof(ContentPreviewMetadataTooltip)); + OnPropertyChanged(nameof(IsChapterGridReadOnly)); + OnPropertyChanged(nameof(CanEditRows)); + OnPropertyChanged(nameof(CanOpenContentTools)); + OnPropertyChanged(nameof(CanCombine)); + UpdateInlinePreviewProjection(); + if (InsertCommand is not null) + { + InsertCommand.RaiseCanExecuteChanged(); + DeleteCommand.RaiseCanExecuteChanged(); + ChangeFpsCommand.RaiseCanExecuteChanged(); + CombineCommand.RaiseCanExecuteChanged(); + TemplateNamesCommand.RaiseCanExecuteChanged(); + ZonesCommand.RaiseCanExecuteChanged(); + ForwardShiftCommand.RaiseCanExecuteChanged(); + } + } + } + + public bool IsChapterGridReadOnly => isApplyingContentPreview || CanApplyContentPreview; + + public bool IsInlineCandidateVisible => pendingContentPreview is { IsValid: true } preview + && Workspace.IsContentTokenCurrent(preview.BaseToken) + && ExpressionPreviewProjector.Build(preview).HasChanges; + + public string ContentPreviewMetadataTooltip + { + get + { + if (pendingContentPreview is not { IsValid: true } preview + || !Workspace.IsContentTokenCurrent(preview.BaseToken)) + { + return string.Empty; + } + + var projection = ExpressionPreviewProjector.Build(preview); + return string.Join(Environment.NewLine, projection.Properties + .Where(static property => property.OwnerKind != ExpressionPropertyOwnerKind.Chapter) + .Select(property => + { + var row = new ExpressionPropertyRowViewModel(property, Localizer); + return $"{row.OwnerLabel} · {row.OwnerName} · {row.PropertyLabel}: {row.BeforeValue} → {row.CandidateValue}"; + })); + } + } + + public bool CanPreviewContentOptions => pendingContentPreview is null + && !expressionPreviewTimer.IsEnabled; + + public bool CanRefreshContentPreview => pendingContentPreview is { } preview + && !Workspace.IsContentTokenCurrent(preview.BaseToken); + + public bool CanApplyContentPreview => pendingContentPreview is { IsValid: true, Differences.IsEmpty: false } preview + && Workspace.IsContentTokenCurrent(preview.BaseToken); + public UiCommand OpenRelatedMediaCommand { get; private set; } = null!; public void SetFrameOptions(int frameRateIndex, bool roundFrames) { + var priorComputationRate = ComputationFrameRate; suppressFrameOptionsRefresh = true; try { @@ -723,29 +991,46 @@ public void SetFrameOptions(int frameRateIndex, bool roundFrames) var entry = FrameRateOptionForComboIndex(frameRateIndex); if (entry is not null) { - selectedFrameRateOption = entry; + SelectedFrameRateOption = entry; SelectedFrameRateIndex = frameRateIndex; - return; } - - selectedFrameRateOption = CurrentInfo is null - ? frameRateService.Options[0] - : frameRateService.FindByValue((decimal)CurrentInfo.FramesPerSecond); - SelectedFrameRateIndex = ComboIndexFor(selectedFrameRateOption); + else + { + SelectedFrameRateOption = CurrentInfo is null + ? frameRateService.Options[0] + : frameRateService.FindByRate(CurrentInfo.FrameRate); + SelectedFrameRateIndex = ComboIndexFor(SelectedFrameRateOption); + } } finally { suppressFrameOptionsRefresh = false; } + + if (CurrentInfo is not null) + { + if (priorComputationRate != ComputationFrameRate) + { + InvalidatePendingContentPreview(); + } + + OnPropertyChanged(nameof(ExpressionFrameRate)); + ApplyFrameInfo(); + } } - private void OnFrameOptionsChangedFromBinding() + private void OnFrameOptionsChangedFromBinding(bool invalidatePendingPreview = true) { if (suppressFrameOptionsRefresh || CurrentInfo is null) { return; } + if (invalidatePendingPreview) + { + InvalidatePendingContentPreview(); + OnPropertyChanged(nameof(ExpressionFrameRate)); + } ApplyFrameInfo(); } @@ -756,6 +1041,7 @@ private void SetSelectedFrameRateIndexSilent(int index) try { SelectedFrameRateIndex = index; + OnPropertyChanged(nameof(SelectedFrameRateIndex)); } finally { @@ -773,11 +1059,11 @@ public string BuildPreview() return string.Empty; } - var projection = CurrentOutputProjection(); - var entries = CurrentExportOptionsForProjectedInfo(); + var chapterSet = workspaceContentRows.GetCurrentChapterSet(); + var entries = Workspace.CreateSerializationOptions(); // Use composition-injected export service (same path family as save), not ad-hoc construction. - var result = exportService.Export(projection.Info, entries); + var result = exportService.Serialize(chapterSet, entries); if (!result.Success) { return string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => diagnostic.Message)); @@ -791,6 +1077,8 @@ public string BuildPreview() public void UpdateSelectedRows(IReadOnlySet indexes) { SelectedRowIndexes = [.. indexes.Where(index => index >= 0)]; + OnPropertyChanged(nameof(SelectedRowCount)); + OnPropertyChanged(nameof(SelectedRowCountDisplay)); NotifyCommandStates(); } @@ -804,9 +1092,13 @@ public string CreateZonesText() var indexes = SelectedRowIndexes.Count == 0 ? [.. Enumerable.Range(0, CurrentInfo.Chapters.Count)] : SelectedRowIndexes; - var result = editingService.CreateZones(CurrentInfo, indexes, (decimal)CurrentInfo.FramesPerSecond); + var result = editingService.CreateZones(CurrentInfo, indexes, ComputationFrameRate); SetStatus(result.Diagnostics.Count == 0 ? "Status.ZonesGenerated" : null, diagnostic: result.Diagnostics.FirstOrDefault()); - Log("Log.CreateZones", ("selectedRows", indexes.Count), ("chapters", CurrentInfo.Chapters.Count)); + var zoneCount = string.IsNullOrEmpty(result.Zones) ? 0 : result.Zones.Split('/').Length - 1; + Log(LogLevel.Information, + $"Create zones: selectedRows={indexes.Count}, chapters={CurrentInfo.Chapters.Count}, zones={zoneCount}", + "Zones", + ("selectedRows", indexes.Count), ("chapters", CurrentInfo.Chapters.Count), ("zones", zoneCount)); LogDiagnostics("Create zones", result.Diagnostics); NotifyStateChanged(); return result.Zones; @@ -815,17 +1107,24 @@ public string CreateZonesText() private void OnRowsChanged(object? sender, NotifyCollectionChangedEventArgs args) { OnPropertyChanged(nameof(IsChapterGridEmpty)); + OnPropertyChanged(nameof(ChapterCount)); + OnPropertyChanged(nameof(ChapterCountDisplay)); NotifyCommandStates(); } internal void NotifyStateChanged() { + RefreshHistoryEntries(); + OnPropertyChanged(nameof(ExpressionFrameRate)); OnPropertyChanged(nameof(IsClipSelectionVisible)); OnPropertyChanged(nameof(IsClipCombineChecked)); + OnPropertyChanged(nameof(ClipStructureActionText)); OnPropertyChanged(nameof(RelatedMediaReferences)); OnPropertyChanged(nameof(CanAppendMpls)); OnPropertyChanged(nameof(CanCombine)); OnPropertyChanged(nameof(CanSave)); + OnPropertyChanged(nameof(Workspace.HasUnexportedChanges)); + OnPropertyChanged(nameof(HasUnexportedContent)); OnPropertyChanged(nameof(CanRefreshRows)); OnPropertyChanged(nameof(CanEditRows)); OnPropertyChanged(nameof(CanOpenRelatedMedia)); @@ -833,10 +1132,41 @@ internal void NotifyStateChanged() OnPropertyChanged(nameof(CanUseExternalTools)); OnPropertyChanged(nameof(CanRunExternalActions)); OnPropertyChanged(nameof(EffectiveSaveDirectoryDisplay)); + OnPropertyChanged(nameof(SourceDisplayText)); + OnPropertyChanged(nameof(ChapterCountDisplay)); + OnPropertyChanged(nameof(SelectedRowCountDisplay)); + OnPropertyChanged(nameof(ChapterCount)); + OnPropertyChanged(nameof(SelectedRowCount)); OnPropertyChanged(nameof(SaveButtonTooltip)); + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(UndoDescription)); + OnPropertyChanged(nameof(RedoDescription)); + OnPropertyChanged(nameof(CanApplyContentPreview)); + OnPropertyChanged(nameof(CanRefreshContentPreview)); + OnPropertyChanged(nameof(HistoryLifetimeText)); NotifyCommandStates(); } + private void RefreshHistoryEntries() + { + historySnapshot = Workspace.ContentSession?.GetHistorySnapshot(); + HistoryEntries = HistoryEntryViewModel.CreateTree(historySnapshot, new Dictionary(), + new HashSet(), static node => node.Description).All; + OnPropertyChanged(nameof(HasHistory)); + OnPropertyChanged(nameof(CanOpenHistory)); + HistoryCommand?.RaiseCanExecuteChanged(); + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(UndoDescription)); + OnPropertyChanged(nameof(RedoDescription)); + UndoCommand?.RaiseCanExecuteChanged(); + RedoCommand?.RaiseCanExecuteChanged(); + NavigateHistoryCommand?.RaiseCanExecuteChanged(); + ApplyContentPreviewCommand?.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand?.RaiseCanExecuteChanged(); + } + private void NotifyCommandStates() { ReloadCommand.RaiseCanExecuteChanged(); @@ -857,6 +1187,13 @@ private void NotifyCommandStates() TemplateNamesCommand.RaiseCanExecuteChanged(); ZonesCommand.RaiseCanExecuteChanged(); ForwardShiftCommand.RaiseCanExecuteChanged(); + PreviewContentOptionsCommand.RaiseCanExecuteChanged(); + ApplyContentPreviewCommand.RaiseCanExecuteChanged(); + RefreshContentPreviewCommand.RaiseCanExecuteChanged(); + CancelContentPreviewCommand.RaiseCanExecuteChanged(); + UndoCommand.RaiseCanExecuteChanged(); + RedoCommand.RaiseCanExecuteChanged(); + NavigateHistoryCommand.RaiseCanExecuteChanged(); } @@ -878,7 +1215,10 @@ private async ValueTask OpenRelatedMediaAsync(object? parameter, CancellationTok if (shellService is null || !Capabilities.CanOpenLocalPaths) { SetStatus("Status.ShellUnavailable"); - LogStatus(LogLevel.Warning); + Log(LogLevel.Warning, + "Open related media skipped: shell unavailable", + "Open", + ("reference", (parameter as ReferencedMediaFile)?.RelativePath ?? string.Empty)); NotifyStateChanged(); return; } @@ -889,7 +1229,9 @@ private async ValueTask OpenRelatedMediaAsync(object? parameter, CancellationTok if (string.IsNullOrWhiteSpace(target) || !File.Exists(target)) { SetStatus("Status.RelatedMediaNotFound"); - Log(LogLevel.Warning, "Log.RelatedMediaNotFound", + Log(LogLevel.Warning, + $"Related media not found: reference='{reference?.RelativePath ?? string.Empty}', resolved='{target ?? string.Empty}'", + "Open", ("status", StatusText), ("reference", reference?.RelativePath ?? string.Empty), ("resolved", target ?? string.Empty)); @@ -899,7 +1241,8 @@ private async ValueTask OpenRelatedMediaAsync(object? parameter, CancellationTok await shellService.OpenAsync(target, cancellationToken); SetStatus("Status.OpenedFile", ("fileName", Path.GetFileName(target))); - Log("Log.OpenedPath", ("status", StatusText), ("path", target)); + Log(LogLevel.Information, $"Opened related media: path='{target}'", "Open", + ("status", StatusText), ("path", target)); NotifyStateChanged(); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/SettingsToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/SettingsToolViewModel.cs index 82d78a32..c7fd6aaa 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/SettingsToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/SettingsToolViewModel.cs @@ -8,6 +8,7 @@ using ChapterTool.Avalonia.UI.ViewModels.Tools; using ChapterTool.Contracts.Configuration; using ChapterTool.Contracts.PlatformPorts; +using ChapterTool.Contracts.Shortcuts; using ChapterTool.Core.Exporting; namespace ChapterTool.Avalonia.UI.ViewModels; @@ -15,6 +16,8 @@ namespace ChapterTool.Avalonia.UI.ViewModels; /// Provides settings state and commands for the settings tool. public sealed class SettingsToolViewModel : ObservableViewModel, IDisposable { + public static event Action? ShortcutsSaved; + private static IReadOnlyList SaveFormats => ChapterExportFormats.All; private static IReadOnlyList OutputEncodings => OutputTextEncodings.All; @@ -22,6 +25,7 @@ public sealed class SettingsToolViewModel : ObservableViewModel, IDisposable private readonly IPreferenceSink preferenceSink; private readonly IAppLocalizer localizer; private readonly ObservableCollection languages = []; + private readonly ObservableCollection shortcutRows = []; private readonly ISettingsPickerService? picker; private readonly IShellService? shellService; private readonly Func? unexpectedErrorHandler; @@ -38,6 +42,7 @@ public sealed class SettingsToolViewModel : ObservableViewModel, IDisposable private int deleteRowsTimingModeIndex; private int frameDisplayModeIndex; private int frameDecimalPlaces; + private bool showRepeatingFrameDecimals; private double frameAccuracyToleranceSliderValue; private bool isRefreshingLanguages; @@ -87,17 +92,27 @@ public SettingsToolViewModel( outputTextEncodingIndex = Math.Max(0, IndexOf(OutputEncodings, preferenceSink.OutputTextEncoding)); frameAccuracyTolerance = MainWindowViewModel.NormalizeFrameAccuracyTolerance(preferenceSink.FrameAccuracyTolerance); deleteRowsTimingModeIndex = preferenceSink.EditingOptions.DeleteRowsTiming == Core.Editing.DeleteRowsTimingMode.Preserve ? 0 : 1; - frameDisplayModeIndex = preferenceSink.EditingOptions.FrameDisplay == Core.Editing.FrameDisplayMode.Round ? 0 : 1; - frameDecimalPlaces = preferenceSink.EditingOptions.EffectiveFrameDecimalPlaces; + frameDisplayModeIndex = (int)preferenceSink.EditingOptions.FrameDisplay; + frameDecimalPlaces = FrameDisplayModes.NormalizeDecimalPlaces(preferenceSink.EditingOptions.FrameDecimalPlaces); + showRepeatingFrameDecimals = preferenceSink.ShowRepeatingFrameDecimals; frameAccuracyToleranceSliderValue = (double)frameAccuracyTolerance; ReplaceLanguages(BuildLanguageOptions()); RefreshXmlLanguageDisplayOptions(notify: false); + LoadShortcutRows(ChapterToolSettings.Default.Shortcuts); UpdateDraftSnapshot(); snapshotCoordinator.Commit(snapshotCoordinator.Draft); + InitializeCommands(settingsStore, shellService); + cultureChangedHandler = CreateCultureChangedHandler(); + this.localizer.CultureChanged += cultureChangedHandler; + InitializationTask = autoLoad ? InitializeAsync() : Task.CompletedTask; + } + + private void InitializeCommands(ISettingsStore? settingsStore, IShellService? shellService) + { SaveCommand = new UiCommand( async (_, token) => await SaveAsync(token), - _ => settingsStore is not null); + _ => settingsStore is not null && IsShortcutConfigurationValid); ResetCommand = new UiCommand((_, _) => { ApplyDefaults(); @@ -114,21 +129,24 @@ public SettingsToolViewModel( OpenSettingsFolderCommand = new UiCommand( async (_, token) => await OpenSettingsFolderAsync(token), _ => IsSettingsFolderVisible && shellService is not null && !string.IsNullOrWhiteSpace(settingsDirectory)); - cultureChangedHandler = (_, _) => + } + + private EventHandler CreateCultureChangedHandler() => (_, _) => { RefreshLanguages(); Appearance.RefreshLocalizedOptions(); RefreshXmlLanguageDisplayOptions(notify: true); OnPropertyChanged(nameof(DeleteRowsTimingOptions)); + foreach (var row in shortcutRows) + { + row.RefreshLocalizedName(); + } RefreshToolStatuses(); if (!string.IsNullOrWhiteSpace(StatusText)) { StatusText = StatusTextForCurrentLoadState(); } }; - this.localizer.CultureChanged += cultureChangedHandler; - InitializationTask = autoLoad ? InitializeAsync() : Task.CompletedTask; - } internal Task InitializationTask { get; } @@ -323,7 +341,8 @@ public decimal FrameAccuracyTolerance public IReadOnlyList FrameDisplayOptions => [ localizer.GetString("Settings.FrameDisplayRound"), - localizer.GetString("Settings.FrameDisplayDecimalPlaces") + localizer.GetString("Settings.FrameDisplayDecimalPlaces"), + localizer.GetString("Settings.FrameDisplayFullPrecision") ]; public int FrameDisplayModeIndex @@ -331,9 +350,11 @@ public int FrameDisplayModeIndex get => frameDisplayModeIndex; set { - if (SetProperty(ref frameDisplayModeIndex, Math.Clamp(value, 0, 1))) + if (SetProperty(ref frameDisplayModeIndex, Math.Clamp(value, 0, 2))) { OnPropertyChanged(nameof(IsDecimalFrameDisplayMode)); + OnPropertyChanged(nameof(IsIntegerFrameDisplayMode)); + OnPropertyChanged(nameof(IsRepeatingFrameDecimalsEnabled)); ApplyLiveSettings(); } } @@ -341,6 +362,22 @@ public int FrameDisplayModeIndex public bool IsDecimalFrameDisplayMode => FrameDisplayModeIndex == 1; + public bool IsIntegerFrameDisplayMode => FrameDisplayModeIndex == 0; + + public bool IsRepeatingFrameDecimalsEnabled => FrameDisplayModeIndex == (int)FrameDisplayMode.FullPrecision; + + public bool ShowRepeatingFrameDecimals + { + get => showRepeatingFrameDecimals; + set + { + if (SetProperty(ref showRepeatingFrameDecimals, value)) + { + ApplyLiveSettings(); + } + } + } + public int FrameDecimalPlaces { get => frameDecimalPlaces; @@ -415,27 +452,27 @@ public string FfprobeStatus private set => SetProperty(ref field, value); } = string.Empty; - public UiCommand SaveCommand { get; } + public UiCommand SaveCommand { get; private set; } = null!; - public UiCommand ResetCommand { get; } + public UiCommand ResetCommand { get; private set; } = null!; - public UiCommand ValidateToolsCommand { get; } + public UiCommand ValidateToolsCommand { get; private set; } = null!; - public UiCommand BrowseSaveDirectoryCommand { get; } + public UiCommand BrowseSaveDirectoryCommand { get; private set; } = null!; - public UiCommand BrowseMkvToolnixCommand { get; } + public UiCommand BrowseMkvToolnixCommand { get; private set; } = null!; - public UiCommand BrowseFfprobeCommand { get; } + public UiCommand BrowseFfprobeCommand { get; private set; } = null!; - public UiCommand ClearSaveDirectoryCommand { get; } + public UiCommand ClearSaveDirectoryCommand { get; private set; } = null!; - public UiCommand ClearMkvToolnixCommand { get; } + public UiCommand ClearMkvToolnixCommand { get; private set; } = null!; - public UiCommand ClearFfprobeCommand { get; } + public UiCommand ClearFfprobeCommand { get; private set; } = null!; - public UiCommand OpenRepositoryCommand { get; } + public UiCommand OpenRepositoryCommand { get; private set; } = null!; - public UiCommand OpenSettingsFolderCommand { get; } + public UiCommand OpenSettingsFolderCommand { get; private set; } = null!; public async ValueTask LoadAsync(CancellationToken cancellationToken) { @@ -454,6 +491,7 @@ public async ValueTask LoadAsync(CancellationToken cancellationToken) Appearance.ApplyThemeSettings(snapshotCoordinator.Saved.Theme); Appearance.ApplyFontSettings(snapshotCoordinator.Saved.Font); Appearance.ApplyToServices(snapshotCoordinator.Saved.Theme, snapshotCoordinator.Saved.Font); + LoadShortcutRows(snapshotCoordinator.Saved.Shortcuts); // Capture the post-apply UI snapshot so resolved fonts/paths are not marked dirty. UpdateDraftSnapshot(); @@ -528,6 +566,7 @@ private async ValueTask SaveAsync(CancellationToken cancellationToken) var settings = CurrentSettings(); await SettingsStoreForTesting.SaveAsync(settings, cancellationToken); snapshotCoordinator.Commit(settings); + ShortcutsSaved?.Invoke(settings.Shortcuts); Appearance.ApplyThemeSettings(settings.Theme); Appearance.ApplyFontSettings(settings.Font); Appearance.ApplyToServices(settings.Theme, settings.Font); @@ -566,6 +605,7 @@ public void DiscardUnsavedChanges() ApplyCurrentAppSettingsToOwner(); Appearance.ApplyToServices(snapshotCoordinator.Saved.Theme, snapshotCoordinator.Saved.Font); snapshotCoordinator.DiscardDraft(); + LoadShortcutRows(snapshotCoordinator.Saved.Shortcuts); RefreshToolStatuses(); NotifyUnsavedChanges(); } @@ -577,6 +617,7 @@ private void ApplyDefaults() ApplyAppSettingsToFields(defaults.Application); Appearance.ApplyThemeSettings(defaults.Theme); Appearance.ApplyFontSettings(defaults.Font); + LoadShortcutRows(defaults.Shortcuts); Appearance.ApplyToServices(defaults.Theme, defaults.Font); ApplyLiveSettings(); RefreshToolStatuses(); @@ -773,6 +814,7 @@ private ChapterToolSettings CurrentSettings() Application = CurrentAppSettings(), Theme = Appearance.CurrentThemeSettings(), Font = Appearance.CurrentFontSettings(), + Shortcuts = BuildShortcutSettings(), }); snapshotCoordinator.UpdateDraft(settings); @@ -791,8 +833,9 @@ private AppSettings CurrentAppSettings() => EmitBom: EmitBom, FrameAccuracyTolerance: FrameAccuracyTolerance, DeleteRowsTimingMode: DeleteRowsTimingModes.Id(DeleteRowsTimingModeIndex == 0 ? DeleteRowsTimingMode.Preserve : DeleteRowsTimingMode.Normalize), - FrameDisplayMode: FrameDisplayModes.Id(FrameDisplayModeIndex == 0 ? FrameDisplayMode.Round : FrameDisplayMode.DecimalPlaces), - FrameDecimalPlaces: FrameDecimalPlaces); + FrameDisplayMode: FrameDisplayModes.Id((FrameDisplayMode)FrameDisplayModeIndex), + FrameDecimalPlaces: FrameDecimalPlaces, + ShowRepeatingFrameDecimals: ShowRepeatingFrameDecimals); private void ApplyAppSettingsToFields(AppSettings settings) { @@ -806,8 +849,53 @@ private void ApplyAppSettingsToFields(AppSettings settings) EmitBom = settings.EmitBom; FrameAccuracyTolerance = settings.FrameAccuracyTolerance; DeleteRowsTimingModeIndex = DeleteRowsTimingModes.ParseOrDefault(settings.DeleteRowsTimingMode) == DeleteRowsTimingMode.Preserve ? 0 : 1; - FrameDisplayModeIndex = FrameDisplayModes.ParseOrDefault(settings.FrameDisplayMode) == FrameDisplayMode.Round ? 0 : 1; + FrameDisplayModeIndex = (int)FrameDisplayModes.ParseOrDefault(settings.FrameDisplayMode); FrameDecimalPlaces = FrameDisplayModes.NormalizeDecimalPlaces(settings.FrameDecimalPlaces); + ShowRepeatingFrameDecimals = settings.ShowRepeatingFrameDecimals; + } + + public IReadOnlyList ShortcutRows => shortcutRows; + + public bool IsShortcutConfigurationValid + { + get + { + if (shortcutRows.Any(static row => !row.IsValid)) + { + return false; + } + + var rows = BuildShortcutSettings(); + return ShortcutConflictValidator.FindConflicts(rows).Count == 0; + } + } + + private ShortcutSettings BuildShortcutSettings() => + new(shortcutRows.ToDictionary(row => row.ActionId, row => row.Gesture, StringComparer.OrdinalIgnoreCase)); + + private void RefreshShortcutValidation() + { + var conflicts = ShortcutConflictValidator.FindConflicts(BuildShortcutSettings()); + foreach (var row in shortcutRows) + { + row.SetConflict(conflicts.Contains(row.ActionId)); + } + } + + private void LoadShortcutRows(ShortcutSettings settings) + { + shortcutRows.Clear(); + foreach (var action in ShortcutCatalog.All) + { + var gesture = action.IsEditable && settings.TryGetValue(action.Id, out var value) + ? value + : action.DefaultGesture; + shortcutRows.Add(new ShortcutRowViewModel(action, gesture, NotifyUnsavedChanges, localizer.GetString)); + } + OnPropertyChanged(nameof(ShortcutRows)); + RefreshShortcutValidation(); + OnPropertyChanged(nameof(IsShortcutConfigurationValid)); + SaveCommand?.RaiseCanExecuteChanged(); } private void ApplyLiveSettings() @@ -826,7 +914,10 @@ private void ApplyLiveSettings() private void NotifyUnsavedChanges() { UpdateDraftSnapshot(); + RefreshShortcutValidation(); OnPropertyChanged(nameof(HasUnsavedChanges)); + OnPropertyChanged(nameof(IsShortcutConfigurationValid)); + SaveCommand.RaiseCanExecuteChanged(); } private void UpdateDraftSnapshot() => _ = CurrentSettings(); diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutGestureDisplayConverter.cs b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutGestureDisplayConverter.cs new file mode 100644 index 00000000..990ed0fe --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutGestureDisplayConverter.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using ChapterTool.Contracts.Shortcuts; + +namespace ChapterTool.Avalonia.UI.ViewModels; + +public sealed class ShortcutGestureDisplayConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + ShortcutGestureText.FormatForDisplay(value?.ToString()); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRouter.cs b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRouter.cs index 95ef6dec..ba07043e 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRouter.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRouter.cs @@ -1,19 +1,33 @@ namespace ChapterTool.Avalonia.UI.ViewModels; -public sealed class ShortcutRouter(MainWindowViewModel viewModel) +using ChapterTool.Contracts.Shortcuts; + +public sealed class ShortcutRouter( + MainWindowViewModel viewModel, + ShortcutMapping? mapping = null, + Func? load = null, + Func? navigateClip = null) { + private ShortcutMapping mapping = mapping ?? ShortcutMapping.Default; + + public void UpdateMapping(ShortcutMapping next) => mapping = next ?? throw new ArgumentNullException(nameof(next)); + public ValueTask RouteAsync(string gesture, CancellationToken cancellationToken = default) { - return gesture switch + var action = mapping.ActionForGesture(gesture); + return action switch { - "Ctrl+S" => viewModel.SaveCommand.ExecuteAsync(cancellationToken: cancellationToken), - "Ctrl+R" or "F5" => viewModel.ReloadCommand.ExecuteAsync(cancellationToken: cancellationToken), - "Ctrl+L" => viewModel.LogCommand.ExecuteAsync(cancellationToken: cancellationToken), - "F11" => viewModel.PreviewCommand.ExecuteAsync(cancellationToken: cancellationToken), - "Ctrl+0" => viewModel.SelectClipCommand.ExecuteAsync(9, cancellationToken), - _ when gesture.StartsWith("Ctrl+", StringComparison.Ordinal) && int.TryParse(gesture["Ctrl+".Length..], out var index) => - viewModel.SelectClipCommand.ExecuteAsync(index - 1, cancellationToken), + ShortcutCatalog.SaveId => viewModel.SaveCommand.ExecuteAsync(cancellationToken: cancellationToken), + ShortcutCatalog.ReloadId => viewModel.ReloadCommand.ExecuteAsync(cancellationToken: cancellationToken), + ShortcutCatalog.LogId => viewModel.LogCommand.ExecuteAsync(cancellationToken: cancellationToken), + ShortcutCatalog.PreviewId => viewModel.PreviewCommand.ExecuteAsync(cancellationToken: cancellationToken), + ShortcutCatalog.LoadId when load is not null => load(), + ShortcutCatalog.PreviousClipId when navigateClip is not null => navigateClip("PageUp"), + ShortcutCatalog.NextClipId when navigateClip is not null => navigateClip("PageDown"), + ShortcutCatalog.UndoId => viewModel.UndoCommand.ExecuteAsync(cancellationToken: cancellationToken), + ShortcutCatalog.RedoId => viewModel.RedoCommand.ExecuteAsync(cancellationToken: cancellationToken), _ => ValueTask.CompletedTask }; } + } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRowViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRowViewModel.cs new file mode 100644 index 00000000..aab7de5d --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/ViewModels/ShortcutRowViewModel.cs @@ -0,0 +1,89 @@ +using ChapterTool.Contracts.Shortcuts; + +namespace ChapterTool.Avalonia.UI.ViewModels; + +public sealed class ShortcutRowViewModel : ObservableViewModel +{ + private readonly Action changed; + private readonly Func? resolveName; + private string gesture; + + public ShortcutRowViewModel(ShortcutAction action, string gesture, Action changed, Func? resolveName = null) + { + Action = action; + this.gesture = gesture; + this.changed = changed; + this.resolveName = resolveName; + ResetCommand = new UiCommand((_, _) => + { + Reset(); + return ValueTask.CompletedTask; + }); + } + + public ShortcutAction Action { get; } + + public string ActionId => Action.Id; + + public string NameKey => Action.NameKey; + + public string DisplayName => resolveName?.Invoke(NameKey) ?? NameKey; + + public void RefreshLocalizedName() => OnPropertyChanged(nameof(DisplayName)); + + public string DefaultGesture => Action.DefaultGesture; + + public bool IsEditable => Action.IsEditable; + + public bool IsFixedSymbol => !Action.IsEditable && Gesture is "Insert" or "Delete"; + + public bool HasConflict + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(Validation)); + OnPropertyChanged(nameof(IsValid)); + } + } + } + + public UiCommand ResetCommand { get; } + + public string Gesture + { + get => gesture; + set + { + var normalized = string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + if (SetProperty(ref gesture, normalized)) + { + OnPropertyChanged(nameof(DisplayGesture)); + OnPropertyChanged(nameof(Validation)); + OnPropertyChanged(nameof(IsValid)); + changed(); + } + } + } + + public string DisplayGesture => ShortcutGestureText.FormatForDisplay(Gesture); + + public string? Validation => !Action.IsEditable + ? null + : HasConflict + ? "This shortcut is already assigned" + : ShortcutGestureText.Validate(Gesture) switch + { + ShortcutGestureStatus.Malformed => "Invalid gesture", + ShortcutGestureStatus.Reserved => "A modifier is required", + _ => null + }; + + public bool IsValid => Validation is null; + + public void SetConflict(bool value) => HasConflict = value; + + public void Reset() => Gesture = DefaultGesture; +} diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ExpressionToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ExpressionToolViewModel.cs index 0d2b8aef..ba493767 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ExpressionToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ExpressionToolViewModel.cs @@ -1,63 +1,132 @@ +using System.Globalization; +using Avalonia.Threading; using ChapterTool.Avalonia.UI.Localization; using ChapterTool.Avalonia.UI.PlatformPorts; using ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; using ChapterTool.Core.Transform; namespace ChapterTool.Avalonia.UI.ViewModels.Tools; -public sealed class ExpressionToolViewModel : ObservableViewModel +public sealed class ExpressionToolViewModel : ObservableViewModel, IDisposable { private readonly IExpressionSessionPort expressionSession; + private readonly IChapterContentOperationPort? contentOperations; private readonly IFilePickerService? filePicker; + private readonly Action? closeTool; + private readonly DispatcherTimer previewTimer = new() { Interval = TimeSpan.FromMilliseconds(250) }; + private ChapterContentPreview? pendingPreview; + private bool settingExpressionFromPreset; + private int selectedPresetIndex = -1; + private long draftRevision; + private long preparedDraftRevision = -1; + private string expression = string.Empty; + private string expressionSourceName = string.Empty; + private string statusText = string.Empty; + private string previewSummary = string.Empty; + private IReadOnlyList previewRows = []; + private bool showAllChapters; + private bool showFrames; public ExpressionToolViewModel( IExpressionSessionPort expressionSession, IFilePickerService? filePicker = null, IExpressionAuthoringService? expressionAuthoringService = null, - Func? errorHandler = null) + Func? errorHandler = null, + IChapterContentOperationPort? contentOperations = null, + Action? closeTool = null) { this.expressionSession = expressionSession; + this.contentOperations = contentOperations; this.filePicker = filePicker; + this.closeTool = closeTool; ExpressionAuthoringService = expressionAuthoringService; - Expression = expressionSession.Expression; - ApplyExpression = expressionSession.ApplyExpression; - ExpressionSourceName = expressionSession.ExpressionSourceName; + expression = expressionSession.Expression; + expressionSourceName = expressionSession.ExpressionSourceName; Presets = [ - .. expressionSession.ExpressionPresets - .Select(static preset => - new ExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText)) + .. expressionSession.ExpressionPresets.Select(static preset => + new ExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText)) ]; - SelectedPresetIndex = Presets.ToList().FindIndex(preset => string.Equals(preset.Id, expressionSession.ExpressionPresetId, StringComparison.Ordinal)); - BrowseScriptCommand = new UiCommand(async (_, token) => await BrowseScriptAsync(token), _ => this.filePicker is not null) + selectedPresetIndex = Presets.ToList().FindIndex(preset => + string.Equals(preset.Id, expressionSession.ExpressionPresetId, StringComparison.Ordinal)); + previewTimer.Tick += OnPreviewTimerTick; + var commands = CreateCommands(errorHandler); + BrowseScriptCommand = commands.BrowseScript; + ApplyCommand = commands.Apply; + ConfirmApplyCommand = commands.ConfirmApply; + CancelPreviewCommand = commands.CancelPreview; + UpdatePreviewCommand = commands.UpdatePreview; + InitializePreviewState(); + } + + private (UiCommand BrowseScript, UiCommand Apply, UiCommand ConfirmApply, UiCommand CancelPreview, UiCommand UpdatePreview) + CreateCommands(Func? errorHandler) => + (CreateBrowseScriptCommand(errorHandler), CreateApplyCommand(errorHandler), + new UiCommand(ApplyPreviewAsync, _ => CanApplyPreview) { ErrorHandler = errorHandler }, + CreateCancelPreviewCommand(), CreateUpdatePreviewCommand()); + + private UiCommand CreateBrowseScriptCommand(Func? errorHandler) => + new(async (_, token) => await BrowseScriptAsync(token), _ => filePicker is not null) { ErrorHandler = errorHandler }; + + private UiCommand CreateApplyCommand(Func? errorHandler) => + new((_, _) => { - ErrorHandler = errorHandler - }; - ApplyCommand = new UiCommand((parameter, _) => + RefreshPreviewNow(); + return ValueTask.CompletedTask; + }, _ => contentOperations is not null && !IsApplying) { ErrorHandler = errorHandler }; + + private UiCommand CreateCancelPreviewCommand() => new((_, _) => + { + previewTimer.Stop(); + if (pendingPreview is { } preview) { - if (parameter is ExpressionToolViewModel viewModel) - { - var diagnostic = expressionSession.ApplyLuaExpressionSettings( - viewModel.Expression, - viewModel.ApplyExpression, - viewModel.SelectedPreset?.Id ?? string.Empty, - viewModel.ExpressionSourceName); - viewModel.StatusText = diagnostic is null - ? expressionSession.Localizer.GetString("Status.Updated") - : expressionSession.FormatDiagnosticForDisplay(diagnostic); - } + contentOperations?.Cancel(preview); + pendingPreview = null; + } - return ValueTask.CompletedTask; - }) + PreviewReadiness = ExpressionPreviewReadiness.Empty; + Projection = null; + previewRows = []; + PropertyRows = []; + PreviewSummary = Localizer.GetString("Expression.State.Empty"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + closeTool?.Invoke(); + return ValueTask.CompletedTask; + }, _ => !IsApplying); + + private UiCommand CreateUpdatePreviewCommand() => new((_, _) => + { + if (PreviewReadiness == ExpressionPreviewReadiness.Stale) { - ErrorHandler = errorHandler - }; + RefreshPreviewNow(); + } + + return ValueTask.CompletedTask; + }, _ => PreviewReadiness == ExpressionPreviewReadiness.Stale && !IsApplying); + + private void InitializePreviewState() + { + if (contentOperations?.CanPrepareExpression == true && !string.IsNullOrWhiteSpace(Expression)) + { + RefreshPreviewNow(); + } + else if (contentOperations?.CanPrepareExpression == false) + { + PreviewReadiness = ExpressionPreviewReadiness.Unavailable; + PreviewSummary = Localizer.GetString("Expression.State.Unavailable"); + StatusText = PreviewSummary; + } } public IAppLocalizer Localizer => expressionSession.Localizer; public IExpressionAuthoringService? ExpressionAuthoringService { get; } + public FrameRateValue ExpressionFrameRate => expressionSession.ComputationFrameRate; + public IReadOnlyList Presets { get; } public ExpressionPresetViewModel? SelectedPreset => @@ -65,10 +134,10 @@ .. expressionSession.ExpressionPresets public int SelectedPresetIndex { - get; + get => selectedPresetIndex; set { - if (!SetProperty(ref field, value)) + if (!SetProperty(ref selectedPresetIndex, value)) { return; } @@ -76,43 +145,437 @@ public int SelectedPresetIndex OnPropertyChanged(nameof(SelectedPreset)); if (SelectedPreset is { } preset) { - Expression = preset.ScriptText; + settingExpressionFromPreset = true; + try + { + Expression = preset.ScriptText; + } + finally + { + settingExpressionFromPreset = false; + } ExpressionSourceName = preset.DisplayName; - StatusText = expressionSession.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionPresetSelected", ("preset", preset.DisplayName))); + StatusText = expressionSession.Localizer.Format( + LocalizedMessage.Create("Status.LuaExpressionPresetSelected", ("preset", preset.DisplayName))); + SchedulePreview(); } } } public string Expression { - get; - set => SetProperty(ref field, value); - } + get => expression; + set + { + if (!SetProperty(ref expression, value ?? string.Empty)) + { + return; + } - public bool ApplyExpression - { - get; - set => SetProperty(ref field, value); + if (!settingExpressionFromPreset && SelectedPresetIndex >= 0) + { + selectedPresetIndex = -1; + OnPropertyChanged(nameof(SelectedPresetIndex)); + OnPropertyChanged(nameof(SelectedPreset)); + } + SchedulePreview(); + } } public string ExpressionSourceName { - get; - set => SetProperty(ref field, value); + get => expressionSourceName; + set => SetProperty(ref expressionSourceName, value ?? string.Empty); } public string StatusText { - get; - private set => SetProperty(ref field, value); - } = string.Empty; + get => statusText; + private set => SetProperty(ref statusText, value); + } public bool CanBrowseScript => filePicker is not null; + public bool IsPreviewPending => PreviewReadiness is ExpressionPreviewReadiness.Waiting or ExpressionPreviewReadiness.Computing; + + public bool CanPreparePreview => false; + + public bool CanApplyPreview => PreviewReadiness == ExpressionPreviewReadiness.Ready + && pendingPreview is { IsValid: true } + && preparedDraftRevision == draftRevision + && Projection?.HasChanges == true + && !IsApplying; + + public ExpressionPreviewReadiness PreviewReadiness { get; private set; } = ExpressionPreviewReadiness.Unavailable; + + public bool IsApplying { get; private set; } + + public bool CanEditExpression => !IsApplying; + + public bool HasPreviewResult => Projection is not null; + + public bool HasPropertyChanges => PropertyRows.Count > 0; + + public bool TimesUnchanged => Projection?.TimesUnchanged == true; + + public bool ShowPreviewState => PreviewReadiness != ExpressionPreviewReadiness.Ready; + + public bool CanRefreshPreview => PreviewReadiness == ExpressionPreviewReadiness.Stale && !IsApplying; + + public bool HasExcludedSeparators => Projection?.ExcludedSeparatorCount > 0; + + public string ExcludedSeparatorSummary => Projection is null || Projection.ExcludedSeparatorCount == 0 + ? string.Empty + : Localizer.Format(LocalizedMessage.Create("Expression.Preview.Separators", ("count", Projection.ExcludedSeparatorCount))); + + public string PreviewStateText => PreviewReadiness switch + { + ExpressionPreviewReadiness.Empty => Localizer.GetString("Expression.State.Empty"), + ExpressionPreviewReadiness.Waiting => Localizer.GetString("Expression.State.Waiting"), + ExpressionPreviewReadiness.Computing => Localizer.GetString("Expression.State.Computing"), + ExpressionPreviewReadiness.Ready => Localizer.GetString("Expression.State.Ready"), + ExpressionPreviewReadiness.Unchanged => Localizer.GetString("Expression.State.Unchanged"), + ExpressionPreviewReadiness.Invalid => StatusText, + ExpressionPreviewReadiness.Stale => Localizer.GetString("Expression.State.Stale"), + ExpressionPreviewReadiness.Applying => Localizer.GetString("Expression.State.Applying"), + ExpressionPreviewReadiness.Failed => StatusText, + _ => Localizer.GetString("Expression.State.Unavailable") + }; + + public string FooterStatusText => PreviewReadiness switch + { + ExpressionPreviewReadiness.Applying => Localizer.GetString("Expression.State.Applying"), + ExpressionPreviewReadiness.Failed => StatusText, + _ => string.Empty + }; + + public string ScopeSummary => Projection is null + ? string.Empty + : Localizer.Format(LocalizedMessage.Create( + "Expression.Preview.Scope", + ("track", Projection.ScopeTrackName), + ("count", Projection.ParticipatingChapterCount))); + + public string TimeChangeSummary => Projection is null ? string.Empty : Localizer.Format( + LocalizedMessage.Create("Expression.Review.TimeSummary", + ("times", Projection.TimeChangedChapterCount), ("count", Projection.ParticipatingChapterCount))); + + public string OtherChangeSummary => Projection is null ? string.Empty : Localizer.Format( + LocalizedMessage.Create("Expression.Review.OtherSummary", + ("affected", Projection.AffectedChapterCount), ("frames", Projection.FrameInformationChangedChapterCount), ("properties", Projection.Properties.Length))); + + public ExpressionPreviewProjection? Projection { get; private set; } + + public IReadOnlyList PreviewRows => showAllChapters + ? previewRows + : [.. previewRows.Where(static row => row.Changes != ExpressionChapterChangeKind.None)]; + + public IReadOnlyList PropertyRows { get; private set; } = []; + + public bool ShowAllChapters + { + get => showAllChapters; + set + { + if (SetProperty(ref showAllChapters, value)) + { + OnPropertyChanged(nameof(PreviewRows)); + } + } + } + + public bool ShowFrames + { + get => showFrames; + set + { + if (SetProperty(ref showFrames, value)) + { + previewRows = BuildRows(Projection, value, Localizer); + OnPropertyChanged(nameof(PreviewRows)); + } + } + } + + public string PreviewSummary + { + get => previewSummary; + private set => SetProperty(ref previewSummary, value); + } + public UiCommand BrowseScriptCommand { get; } public UiCommand ApplyCommand { get; } + public UiCommand ConfirmApplyCommand { get; } + + public UiCommand CancelPreviewCommand { get; } + + public UiCommand UpdatePreviewCommand { get; } + + public void RefreshPreviewNow() + { + previewTimer.Stop(); + if (contentOperations is null || !contentOperations.CanPrepareExpression) + { + SetUnavailablePreview(); + return; + } + + CancelPendingPreview(); + + if (string.IsNullOrWhiteSpace(Expression)) + { + SetEmptyPreview(); + return; + } + + BuildPreview(); + } + + private void SetUnavailablePreview() + { + PreviewReadiness = ExpressionPreviewReadiness.Unavailable; + PreviewSummary = Localizer.GetString("Expression.State.Unavailable"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + } + + private void CancelPendingPreview() + { + if (pendingPreview is { } previous) + { + contentOperations?.Cancel(previous); + pendingPreview = null; + } + } + + private void SetEmptyPreview() + { + Projection = null; + previewRows = []; + PropertyRows = []; + PreviewReadiness = ExpressionPreviewReadiness.Empty; + PreviewSummary = Localizer.GetString("Expression.State.Empty"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + } + + private void BuildPreview() + { + PreviewReadiness = ExpressionPreviewReadiness.Computing; + PreviewSummary = Localizer.GetString("Expression.Preview.Pending"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + var preview = contentOperations!.PrepareExpression(Expression); + pendingPreview = preview; + var projection = ExpressionPreviewProjector.Build(preview); + Projection = projection; + preparedDraftRevision = draftRevision; + previewRows = BuildRows(projection, showFrames, Localizer); + PropertyRows = [.. projection.Properties.Select(row => new ExpressionPropertyRowViewModel(row, Localizer))]; + PreviewSummary = BuildPreviewSummary(projection, expressionSession.Localizer); + UpdatePreviewResult(preview, projection); + NotifyPreviewStateChanged(); + } + + private void UpdatePreviewResult(ChapterContentPreview preview, ExpressionPreviewProjection projection) + { + var diagnostic = preview.IsValid + ? null + : expressionSession.ValidateLuaExpressionScript(Expression, logDiagnostics: true); + PreviewReadiness = GetPreviewReadiness(preview, projection); + StatusText = diagnostic is not null + ? expressionSession.FormatDiagnosticForDisplay(diagnostic) + : preview.IsValid ? PreviewSummary : string.Join(Environment.NewLine, preview.Errors); + } + + private static ExpressionPreviewReadiness GetPreviewReadiness( + ChapterContentPreview preview, + ExpressionPreviewProjection projection) + { + if (!preview.IsValid) + { + return ExpressionPreviewReadiness.Invalid; + } + + if (projection.ParticipatingChapterCount == 0) + { + return ExpressionPreviewReadiness.Unavailable; + } + + return projection.HasChanges ? ExpressionPreviewReadiness.Ready : ExpressionPreviewReadiness.Unchanged; + } + + public void Dispose() + { + previewTimer.Stop(); + previewTimer.Tick -= OnPreviewTimerTick; + if (pendingPreview is { } preview) + { + contentOperations?.Cancel(preview); + pendingPreview = null; + } + } + + private async ValueTask ApplyPreviewAsync(object? sender, CancellationToken cancellationToken) + { + if (!CanApplyPreview || contentOperations is null || pendingPreview is not { IsValid: true } preview) + { + return; + } + + IsApplying = true; + PreviewReadiness = ExpressionPreviewReadiness.Applying; + StatusText = Localizer.GetString("Expression.State.Applying"); + NotifyPreviewStateChanged(); + try + { + var result = await contentOperations.ApplyAsync(preview, cancellationToken); + HandleApplyResult(result); + } + catch (Exception exception) when (exception is not StackOverflowException) + { + SetApplyFailure(exception.Message); + } + finally + { + IsApplying = false; + if (PreviewReadiness == ExpressionPreviewReadiness.Applying) + { + PreviewReadiness = ExpressionPreviewReadiness.Ready; + } + NotifyPreviewStateChanged(); + } + } + + private void HandleApplyResult(TransactionOutcome result) + { + if (result.Kind == TransactionOutcomeKind.Conflict) + { + PreviewReadiness = ExpressionPreviewReadiness.Stale; + StatusText = Localizer.GetString("Expression.State.Stale"); + return; + } + + if (result.Kind == TransactionOutcomeKind.Committed && Projection is { } appliedProjection) + { + SaveAppliedPreview(appliedProjection); + return; + } + + SetApplyFailure(string.Join(Environment.NewLine, result.Errors)); + } + + private void SaveAppliedPreview(ExpressionPreviewProjection appliedProjection) + { + expressionSession.SaveAppliedExpressionPreview( + Expression, + SelectedPreset?.Id ?? string.Empty, + ExpressionSourceName, + appliedProjection); + pendingPreview = null; + StatusText = Localizer.Format(LocalizedMessage.Create( + "Expression.Preview.Applied", + ("affected", appliedProjection.AffectedChapterCount), + ("times", appliedProjection.TimeChangedChapterCount), + ("frames", appliedProjection.FrameInformationChangedChapterCount))); + closeTool?.Invoke(); + } + + private void SetApplyFailure(string message) + { + PreviewReadiness = ExpressionPreviewReadiness.Failed; + StatusText = message; + } + + private void SchedulePreview() + { + previewTimer.Stop(); + draftRevision++; + preparedDraftRevision = -1; + if (contentOperations is null || !contentOperations.CanPrepareExpression) + { + PreviewReadiness = ExpressionPreviewReadiness.Unavailable; + PreviewSummary = Localizer.GetString("Expression.State.Unavailable"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + return; + } + + if (pendingPreview is { } previous) + { + contentOperations.Cancel(previous); + pendingPreview = null; + } + Projection = null; + previewRows = []; + PropertyRows = []; + if (string.IsNullOrWhiteSpace(Expression)) + { + PreviewReadiness = ExpressionPreviewReadiness.Empty; + PreviewSummary = Localizer.GetString("Expression.State.Empty"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + return; + } + + PreviewReadiness = ExpressionPreviewReadiness.Waiting; + PreviewSummary = Localizer.GetString("Expression.Preview.Pending"); + StatusText = PreviewSummary; + NotifyPreviewStateChanged(); + previewTimer.Start(); + } + + private void OnPreviewTimerTick(object? sender, EventArgs args) + { + previewTimer.Stop(); + RefreshPreviewNow(); + } + + private void NotifyPreviewStateChanged() + { + OnPropertyChanged(nameof(IsPreviewPending)); + OnPropertyChanged(nameof(IsApplying)); + OnPropertyChanged(nameof(CanEditExpression)); + OnPropertyChanged(nameof(HasPreviewResult)); + OnPropertyChanged(nameof(HasPropertyChanges)); + OnPropertyChanged(nameof(TimesUnchanged)); + OnPropertyChanged(nameof(CanRefreshPreview)); + OnPropertyChanged(nameof(HasExcludedSeparators)); + OnPropertyChanged(nameof(ExcludedSeparatorSummary)); + OnPropertyChanged(nameof(PreviewStateText)); + OnPropertyChanged(nameof(ShowPreviewState)); + OnPropertyChanged(nameof(FooterStatusText)); + OnPropertyChanged(nameof(ScopeSummary)); + OnPropertyChanged(nameof(TimeChangeSummary)); + OnPropertyChanged(nameof(OtherChangeSummary)); + OnPropertyChanged(nameof(CanPreparePreview)); + OnPropertyChanged(nameof(CanApplyPreview)); + OnPropertyChanged(nameof(PreviewReadiness)); + OnPropertyChanged(nameof(Projection)); + OnPropertyChanged(nameof(PreviewRows)); + OnPropertyChanged(nameof(PropertyRows)); + ConfirmApplyCommand.RaiseCanExecuteChanged(); + CancelPreviewCommand.RaiseCanExecuteChanged(); + UpdatePreviewCommand.RaiseCanExecuteChanged(); + } + + private static IReadOnlyList BuildRows( + ExpressionPreviewProjection? preview, + bool showFrames, + IAppLocalizer localizer) => preview?.Chapters + .Select(row => new ExpressionChapterRowViewModel(row, showFrames, localizer)) + .ToArray() ?? []; + + private static string BuildPreviewSummary(ExpressionPreviewProjection preview, IAppLocalizer localizer) => + localizer.Format(LocalizedMessage.Create( + "Expression.Preview.Summary", + ("affected", preview.AffectedChapterCount), + ("times", preview.TimeChangedChapterCount), + ("frames", preview.FrameInformationChangedChapterCount), + ("properties", preview.Properties.Length))); + private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken) { if (filePicker is null) @@ -128,13 +591,13 @@ private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken) try { - var text = await File.ReadAllTextAsync(path, cancellationToken); - Expression = text; + Expression = await File.ReadAllTextAsync(path, cancellationToken); ExpressionSourceName = Path.GetFileName(path); SelectedPresetIndex = -1; var diagnostic = expressionSession.ValidateLuaExpressionScript(Expression, logDiagnostics: true); StatusText = diagnostic is null - ? expressionSession.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionScriptLoaded", ("path", ExpressionSourceName))) + ? expressionSession.Localizer.Format(LocalizedMessage.Create( + "Status.LuaExpressionScriptLoaded", ("path", ExpressionSourceName))) : expressionSession.FormatDiagnosticForDisplay(diagnostic); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) @@ -146,3 +609,187 @@ private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken) } public sealed record ExpressionPresetViewModel(string Id, string DisplayName, string Description, string ScriptText); + +public enum ExpressionPreviewReadiness +{ + Unavailable, + Empty, + Waiting, + Computing, + Ready, + Unchanged, + Invalid, + Stale, + Applying, + Failed +} + +public sealed record ExpressionChapterRowViewModel +{ + public ExpressionChapterRowViewModel(ExpressionChapterComparison comparison, bool showFrames, IAppLocalizer localizer) + { + Comparison = comparison; + var before = comparison.Before; + var candidate = comparison.Candidate; + NumberAndName = candidate is null && before is null + ? localizer.GetString("Expression.Preview.Chapter") + : $"{(candidate ?? before)!.DisplayNumber} · {(candidate ?? before)!.Name}"; + BeforeValue = FormatValue(before, comparison.BeforeFrames, showFrames, localizer); + CandidateValue = FormatValue(candidate, comparison.CandidateFrames, showFrames, localizer); + DeltaValue = FormatDelta(comparison, showFrames, localizer); + BeforeFrameDetails = FormatFrameDetails(comparison.BeforeFrames, localizer); + CandidateFrameDetails = FormatFrameDetails(comparison.CandidateFrames, localizer); + BeforeAccuracy = FormatAccuracy(comparison.BeforeFrames?.Accuracy, localizer); + CandidateAccuracy = FormatAccuracy(comparison.CandidateFrames?.Accuracy, localizer); + } + + public ExpressionChapterComparison Comparison { get; } + + public ChapterId Id => Comparison.Id; + + public ExpressionChapterChangeKind Changes => Comparison.Changes; + + public string NumberAndName { get; } + + public string BeforeValue { get; } + + public string CandidateValue { get; } + + public string DeltaValue { get; } + + public string BeforeFrameDetails { get; } + + public string CandidateFrameDetails { get; } + + public string BeforeAccuracy { get; } + + public string CandidateAccuracy { get; } + + private static string FormatValue(EditableChapter? chapter, ExpressionFrameValue? frames, bool showFrames, IAppLocalizer localizer) => + chapter is null + ? localizer.GetString("Expression.Value.Missing") + : showFrames ? FormatFrameDetails(frames, localizer) : FormatTicks(chapter.StartTicks); + + private static string FormatDelta(ExpressionChapterComparison comparison, bool showFrames, IAppLocalizer localizer) + { + if (showFrames) + { + return comparison.DeltaFrames is { } frames + ? localizer.Format(LocalizedMessage.Create("Expression.Preview.DeltaFrames", ("value", frames))) + : localizer.GetString("Expression.Value.IncompatibleFrames"); + } + + if (comparison.DeltaTicks is not { } ticks) + { + return localizer.GetString("Expression.Value.Missing"); + } + + var sign = ticks > 0 ? "+" : ticks < 0 ? "−" : string.Empty; + return sign + FormatTicks(Math.Abs(ticks)); + } + + private static string FormatFrameDetails(ExpressionFrameValue? value, IAppLocalizer localizer) + { + if (value is null || value.IsMissing) + { + return localizer.GetString("Expression.Value.NotCalculated"); + } + + var rate = value.FrameRate.IsKnown + ? $"{value.FrameRate} {localizer.GetString("Expression.Unit.FramesPerSecond")}" + : localizer.GetString("Expression.Value.NotSet"); + return $"{value.Text} {localizer.GetString("Expression.Unit.FramesShort")} · {rate}"; + } + + private static string FormatAccuracy(FrameAccuracy? accuracy, IAppLocalizer localizer) => accuracy switch + { + FrameAccuracy.Accurate => localizer.GetString("Expression.FrameAccuracy.Accurate"), + FrameAccuracy.Inexact => localizer.GetString("Expression.FrameAccuracy.Inexact"), + _ => localizer.GetString("Expression.FrameAccuracy.NotCalculated") + }; + + internal static string FormatTicks(long ticks) + { + var totalMilliseconds = (long)decimal.Round( + ticks / (decimal)TimeSpan.TicksPerMillisecond, + 0, + MidpointRounding.ToEven); + var hours = totalMilliseconds / 3_600_000; + var minutes = totalMilliseconds / 60_000 % 60; + var seconds = totalMilliseconds / 1_000 % 60; + var milliseconds = totalMilliseconds % 1_000; + return string.Create(CultureInfo.InvariantCulture, $"{hours:00}:{minutes:00}:{seconds:00}.{milliseconds:000}"); + } +} + +public sealed record ExpressionPropertyRowViewModel +{ + public ExpressionPropertyRowViewModel(ExpressionPropertyComparison comparison, IAppLocalizer localizer) + { + OwnerLabel = comparison.OwnerKind switch + { + ExpressionPropertyOwnerKind.Document => localizer.GetString("Expression.PropertyOwner.Document"), + ExpressionPropertyOwnerKind.Track => localizer.GetString("Expression.PropertyOwner.Track"), + ExpressionPropertyOwnerKind.Segment => localizer.GetString("Expression.PropertyOwner.Segment"), + _ => localizer.GetString("Expression.PropertyOwner.Chapter") + }; + OwnerName = comparison.OwnerName; + PropertyLabel = comparison.Property switch + { + "Title" => localizer.GetString("Expression.Property.Title"), + "SourceName" => localizer.GetString("Expression.Property.Source"), + "ImportFormat" => localizer.GetString("Expression.Property.ImportFormat"), + "Duration" => localizer.GetString("Expression.Property.Duration"), + "FrameRate" => localizer.GetString("Expression.Property.FrameRate"), + "Name" => localizer.GetString("Expression.Property.Name"), + "DisplayNumber" => localizer.GetString("Expression.Property.Number"), + "Kind" => localizer.GetString("Expression.Property.Type"), + "Presence" => localizer.GetString("Expression.Property.Presence"), + "TrackOrder" => localizer.GetString("Expression.Property.TrackOrder"), + _ => localizer.GetString("Expression.Property.Additional") + }; + BeforeValue = FormatValue(comparison.Property, comparison.Before, localizer); + CandidateValue = FormatValue(comparison.Property, comparison.Candidate, localizer); + } + + public string OwnerLabel { get; } + + public string OwnerName { get; } + + public string PropertyLabel { get; } + + public string BeforeValue { get; } + + public string CandidateValue { get; } + + private static string FormatValue(string property, object? value, IAppLocalizer localizer) + { + if (value is null) + { + return localizer.GetString("Expression.Value.NotSet"); + } + + return FormatFrameRateValue(value, localizer) + ?? FormatDurationValue(value, localizer) + ?? FormatTextValue(property, value) + ?? FormatPrimitiveValue(value) + ?? localizer.GetString("Expression.Property.ValueChanged"); + } + + private static string? FormatFrameRateValue(object value, IAppLocalizer localizer) => value is ChapterFrameRate rate + ? $"{rate.Numerator}/{rate.Denominator} {localizer.GetString("Expression.Unit.FramesPerSecond")}" + : null; + + private static string? FormatDurationValue(object value, IAppLocalizer localizer) => value is ChapterDuration duration + ? duration.IsKnown + ? ExpressionChapterRowViewModel.FormatTicks(duration.Ticks) + : localizer.GetString("Expression.Value.NotSet") + : null; + + private static string? FormatTextValue(string property, object value) => + value is string text && property is "Title" or "SourceName" or "Name" ? text : null; + + private static string? FormatPrimitiveValue(object value) => value is int or long or bool + ? Convert.ToString(value, CultureInfo.CurrentUICulture) ?? string.Empty + : null; +} diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ForwardShiftToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ForwardShiftToolViewModel.cs index 0ac92ba2..1019a763 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ForwardShiftToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/ForwardShiftToolViewModel.cs @@ -1,23 +1,80 @@ using ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; +using ChapterTool.Core.Session; namespace ChapterTool.Avalonia.UI.ViewModels.Tools; public sealed class ForwardShiftToolViewModel : ObservableViewModel { - public ForwardShiftToolViewModel(IChapterEditPort chapterEdit, Func? errorHandler = null) + private readonly IChapterEditPort chapterEdit; + private readonly IChapterContentOperationPort? contentOperations; + private ChapterContentPreview? pendingPreview; + + public ForwardShiftToolViewModel( + IChapterEditPort chapterEdit, + Func? errorHandler = null, + IChapterContentOperationPort? contentOperations = null) { - ApplyCommand = new UiCommand((parameter, _) => + this.chapterEdit = chapterEdit; + this.contentOperations = contentOperations; + ApplyCommand = new UiCommand(async (parameter, token) => { if (parameter is ForwardShiftToolViewModel viewModel) { - chapterEdit.ShiftFramesForward((int)viewModel.Frames); + if (this.contentOperations is null) + { + await this.chapterEdit.ShiftFramesForwardAsync((int)viewModel.Frames, token); + return; + } + + viewModel.pendingPreview = this.contentOperations.PrepareFrameShift((int)viewModel.Frames); + viewModel.OnPropertyChanged(nameof(IsPreviewPending)); + viewModel.OnPropertyChanged(nameof(CanPreparePreview)); + viewModel.OnPropertyChanged(nameof(CanApplyPreview)); + viewModel.StatusText = PreviewSummary(viewModel.pendingPreview); + viewModel.ConfirmApplyCommand!.RaiseCanExecuteChanged(); + viewModel.CancelPreviewCommand!.RaiseCanExecuteChanged(); + } + }) + { + ErrorHandler = errorHandler + }; + ConfirmApplyCommand = new UiCommand(async (_, token) => + { + if (this.contentOperations is null || this.pendingPreview is not { IsValid: true } preview) + { + return; } - return ValueTask.CompletedTask; + this.pendingPreview = null; + OnPropertyChanged(nameof(IsPreviewPending)); + OnPropertyChanged(nameof(CanPreparePreview)); + OnPropertyChanged(nameof(CanApplyPreview)); + var outcome = await this.contentOperations.ApplyAsync(preview, token); + StatusText = outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange + ? "Chapter frame shift applied." + : string.Join("; ", outcome.Errors); + ConfirmApplyCommand!.RaiseCanExecuteChanged(); + CancelPreviewCommand!.RaiseCanExecuteChanged(); }) { ErrorHandler = errorHandler }; + CancelPreviewCommand = new UiCommand((_, _) => + { + if (this.pendingPreview is { } preview) + { + this.contentOperations?.Cancel(preview); + this.pendingPreview = null; + StatusText = string.Empty; + OnPropertyChanged(nameof(IsPreviewPending)); + OnPropertyChanged(nameof(CanPreparePreview)); + OnPropertyChanged(nameof(CanApplyPreview)); + } + + ConfirmApplyCommand!.RaiseCanExecuteChanged(); + CancelPreviewCommand!.RaiseCanExecuteChanged(); + return ValueTask.CompletedTask; + }, _ => IsPreviewPending); } public decimal Frames @@ -26,5 +83,28 @@ public decimal Frames set => SetProperty(ref field, value); } + public bool IsPreviewPending => pendingPreview is not null; + + public bool CanPreparePreview => pendingPreview is null; + + public bool CanApplyPreview => pendingPreview?.IsValid == true; + + public string StatusText + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + public UiCommand ApplyCommand { get; } + + public UiCommand ConfirmApplyCommand { get; } + + public UiCommand CancelPreviewCommand { get; } + + private static string PreviewSummary(ChapterContentPreview preview) => !preview.IsValid + ? string.Join("; ", preview.Errors) + : preview.Differences.IsEmpty + ? "No chapter values will change." + : string.Join("; ", preview.Differences.Take(4).Select(static difference => + $"{difference.Field}: '{difference.Before}' → '{difference.After}'")); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/HistoryToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/HistoryToolViewModel.cs new file mode 100644 index 00000000..f8a1ca36 --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/HistoryToolViewModel.cs @@ -0,0 +1,1054 @@ +using ChapterTool.Avalonia.UI.Localization; +using ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; +using ChapterTool.Core.Transform; + +namespace ChapterTool.Avalonia.UI.ViewModels.Tools; + +public sealed class HistoryToolViewModel : ObservableViewModel, IDisposable +{ + private readonly IHistorySessionPort history; + private readonly IAppLocalizer localizer; + private readonly EventHandler stateChangedHandler; + private readonly EventHandler cultureChangedHandler; + private readonly HashSet expandedNodeIds = []; + private SessionHistorySnapshot? currentSnapshot; + private HistoryRedoChoice? selectedRedoChoice; + private bool isNarrowLayout; + private int narrowPageIndex; + private CancellationTokenSource? detailCancellation; + private HistoryEntryViewModel? selectedEntry; + private HistoryNodeDetails? details; + private bool isLoadingDetails; + private string? detailError; + private long detailGeneration; + private Guid? selectedRootId; + private string? navigationStatus; + private bool disposed; + private IReadOnlyList fieldRows = []; + private string selectedChangeFilter = "All"; + + public HistoryToolViewModel(IHistorySessionPort history, IAppLocalizer localizer, Action? close = null) + { + this.history = history ?? throw new ArgumentNullException(nameof(history)); + this.localizer = localizer ?? throw new ArgumentNullException(nameof(localizer)); + selectedChangeFilter = localizer.GetString("History.Filter.All"); + stateChangedHandler = (_, _) => + { + if (!disposed) + { + RefreshState(); + } + }; + cultureChangedHandler = (_, _) => + { + if (disposed) + { + return; + } + OnPropertyChanged(nameof(SessionLifetimeText)); + OnPropertyChanged(nameof(DetailErrorText)); + OnPropertyChanged(nameof(NavigationStatus)); + RebuildEntries(); + RebuildInspectorRows(); + RebuildRootChapterRows(); + RebuildRedoChoices(); + OnPropertyChanged(nameof(RedoChoices)); + OnPropertyChanged(nameof(HasAlternateRedoChoices)); + OnPropertyChanged(nameof(EffectSummary)); + OnPropertyChanged(nameof(HasEffectSummary)); + OnPropertyChanged(nameof(ChangeFilters)); + OnPropertyChanged(nameof(SelectedEntryDescription)); + OnPropertyChanged(nameof(HasSelectedEntryDescription)); + OnPropertyChanged(nameof(SelectedPublishedAtText)); + OnPropertyChanged(nameof(HasPublishedAtText)); + selectedChangeFilter = localizer.GetString("History.Filter.All"); + OnPropertyChanged(nameof(SelectedChangeFilter)); + OnPropertyChanged(nameof(Entries)); + }; + history.StateChanged += stateChangedHandler; + localizer.CultureChanged += cultureChangedHandler; + currentSnapshot = history.Snapshot; + RebuildRedoChoices(); + CloseCommand = new UiCommand((_, _) => + { + close?.Invoke(); + return ValueTask.CompletedTask; + }); + SelectCommand = new UiCommand((parameter, _) => + { + if (parameter is HistoryEntryViewModel entry) + { + SelectedEntry = entry; + } + return ValueTask.CompletedTask; + }); + RestoreCommand = new UiCommand(RestoreAsync, CanRestore); + ChooseRedoBranchCommand = new UiCommand(ChooseRedoBranchAsync, CanChooseRedoBranch); + RetryCommand = new UiCommand(async (_, _) => await RetryDetailsAsync(), _ => HasDetailError); + ToggleExpansionCommand = new UiCommand((parameter, _) => + { + if (parameter is Guid nodeId) + { + ToggleExpansion(nodeId); + } + return ValueTask.CompletedTask; + }, parameter => parameter is Guid nodeId && currentSnapshot?.Nodes.Any(node => node.Id == nodeId && !node.ChildIds.IsEmpty) == true); + LocateCurrentCommand = new UiCommand((_, _) => + { + SelectCurrentNode(); + return ValueTask.CompletedTask; + }); + SelectCurrentNode(); + SelectedEntry = Entries.FirstOrDefault(entry => entry.IsCurrent) ?? Entries.FirstOrDefault(); + } + + public IReadOnlyList Entries { get; private set; } = []; + + public IReadOnlyList TreeRoots { get; private set; } = []; + + public IReadOnlyList RedoChoices { get; private set; } = []; + + public HistoryRedoChoice? SelectedRedoChoice + { + get => selectedRedoChoice; + set + { + if (SetProperty(ref selectedRedoChoice, value)) + { + ChooseRedoBranchCommand?.RaiseCanExecuteChanged(); + } + } + } + + public bool HasAlternateRedoChoices => RedoChoices.Count > 1; + + public bool IsNarrowLayout + { + get => isNarrowLayout; + private set + { + if (SetProperty(ref isNarrowLayout, value)) + { + OnPropertyChanged(nameof(IsWideLayout)); + } + } + } + + public bool IsWideLayout => !IsNarrowLayout; + + public int NarrowPageIndex + { + get => narrowPageIndex; + set => SetProperty(ref narrowPageIndex, value); + } + + public HistoryEntryViewModel? SelectedEntry + { + get => selectedEntry; + set + { + if (!SetProperty(ref selectedEntry, value)) + { + return; + } + _ = LoadDetailsAsync(value?.Id); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(SelectedEntryDescription)); + OnPropertyChanged(nameof(HasSelectedEntryDescription)); + OnPropertyChanged(nameof(SelectedPublishedAtText)); + OnPropertyChanged(nameof(HasPublishedAtText)); + RestoreCommand.RaiseCanExecuteChanged(); + } + } + + public HistoryNodeDetails? Details + { + get => details; + private set + { + if (SetProperty(ref details, value)) + { + OnPropertyChanged(nameof(IsRootDetails)); + OnPropertyChanged(nameof(IsHistoryDetails)); + RebuildInspectorRows(); + RebuildRootChapterRows(); + OnPropertyChanged(nameof(EffectSummary)); + OnPropertyChanged(nameof(HasEffectSummary)); + OnPropertyChanged(nameof(HasParameters)); + OnPropertyChanged(nameof(HasChanges)); + } + } + } + + public bool IsLoadingDetails + { + get => isLoadingDetails; + private set => SetProperty(ref isLoadingDetails, value); + } + + public string? DetailError + { + get => detailError; + private set + { + if (SetProperty(ref detailError, value)) + { + OnPropertyChanged(nameof(HasDetailError)); + RetryCommand.RaiseCanExecuteChanged(); + } + } + } + + public string DetailErrorText => DetailError switch + { + "ResourceFailure" => localizer.GetString("History.DetailResourceFailure"), + "NotFound" => localizer.GetString("History.DetailNotFound"), + "Ended" => localizer.GetString("History.DetailEnded"), + _ => localizer.GetString("History.DetailFailed") + }; + + public string? NavigationStatus + { + get => navigationStatus; + private set + { + if (SetProperty(ref navigationStatus, value)) + { + OnPropertyChanged(nameof(HasNavigationStatus)); + } + } + } + + public bool HasDetailError => !string.IsNullOrWhiteSpace(DetailError); + + public bool HasSelection => SelectedEntry is not null; + + public bool HasSelectedEntryDescription => !string.IsNullOrWhiteSpace(SelectedEntryDescription); + + public bool HasNavigationStatus => !string.IsNullOrWhiteSpace(NavigationStatus); + + public bool HasDeltas => VisibleChanges.Any(static row => !string.IsNullOrWhiteSpace(row.DeltaText)); + + public bool IsRootDetails => Details?.RootDocument is not null; + + public bool IsHistoryDetails => Details is not null && !IsRootDetails; + + public IReadOnlyList ChangeFilters => [localizer.GetString("History.Filter.All"), localizer.GetString("History.Filter.Added"), localizer.GetString("History.Filter.Removed"), localizer.GetString("History.Filter.Modified")]; + + public string? SelectedEntryDescription => SelectedEntry?.Operation is null || SelectedEntry.Operation.Kind == "generic" ? SelectedEntry?.Description : null; + + public string? SelectedPublishedAtText => SelectedEntry?.PublishedAt?.ToLocalTime().ToString("g", System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName)); + + public bool HasPublishedAtText => !string.IsNullOrWhiteSpace(SelectedPublishedAtText); + + public IReadOnlyList VisibleChanges => SelectedChangeFilter switch + { + var value when value == localizer.GetString("History.Filter.Added") => + [.. fieldRows.Where(static row => !row.Before.IsPresent && row.After.IsPresent)], + var value when value == localizer.GetString("History.Filter.Removed") => + [.. fieldRows.Where(static row => row.Before.IsPresent && !row.After.IsPresent)], + var value when value == localizer.GetString("History.Filter.Modified") => + [.. fieldRows.Where(static row => row.Before.IsPresent && row.After.IsPresent)], + _ => fieldRows + }; + + public IReadOnlyList VisibleChangeGroups => VisibleChanges + .GroupBy(static row => (row.Group, row.ObjectId)) + .Select(group => new HistoryChangeGroup( + group.Key.Group, + group.Key.ObjectId, + group.FirstOrDefault(row => row.ObjectLabel.Contains(localizer.GetString("History.Parameter.DisplayFps"), StringComparison.Ordinal))?.ObjectLabel + ?? group.First().ObjectLabel, + string.Join(Environment.NewLine, group.Select(static row => row.Field)), + string.Join(Environment.NewLine, group.Select(static row => row.BeforeText)), + string.Join(Environment.NewLine, group.Select(static row => row.AfterText)), + group.Any(static row => !string.IsNullOrWhiteSpace(row.DeltaText)) + ? string.Join(Environment.NewLine, group.Select(static row => row.DeltaText ?? string.Empty)) + : null)) + .ToArray(); + + public bool HasParameters => Parameters.Count > 0; + + public bool HasChanges => fieldRows.Count > 0; + + public bool HasVisibleChanges => VisibleChanges.Count > 0; + + public bool HasNoVisibleChanges => HasChanges && !HasVisibleChanges; + + public bool HasRootChapterRows => RootChapterRows.Count > 0; + + public bool HasEffectSummary => !string.IsNullOrWhiteSpace(EffectSummary); + + public string EffectSummary + { + get + { + var summary = Details?.Summary; + if (summary is null) + { + return string.Empty; + } + var items = new List(); + Add(summary.AddedChapters, "History.Added"); + Add(summary.RemovedChapters, "History.Removed"); + Add(summary.ModifiedChapters, "History.Modified"); + Add(summary.ChangedTracks, "History.ChangedTracks"); + Add(summary.ChangedSegments, "History.ChangedSegments"); + return string.Join(" · ", items); + + void Add(int count, string key) + { + if (count > 0) + { + items.Add($"{count} {localizer.GetString(key)}"); + } + } + } + } + + public IReadOnlyList Parameters { get; private set; } = []; + + public IReadOnlyList RootChapterRows { get; private set; } = []; + + public string SelectedChangeFilter + { + get => selectedChangeFilter; + set + { + if (SetProperty(ref selectedChangeFilter, value)) + { + OnPropertyChanged(nameof(VisibleChanges)); + OnPropertyChanged(nameof(VisibleChangeGroups)); + OnPropertyChanged(nameof(HasDeltas)); + OnPropertyChanged(nameof(HasVisibleChanges)); + OnPropertyChanged(nameof(HasNoVisibleChanges)); + } + } + } + + public bool HasPendingPreview => history.HasPendingPreview; + + public UiCommand SelectCommand { get; } + + public UiCommand RetryCommand { get; } + + public UiCommand ToggleExpansionCommand { get; } + + public UiCommand LocateCurrentCommand { get; } + + public UiCommand RestoreCommand { get; } + + public UiCommand ChooseRedoBranchCommand { get; } + + public bool CanUndo => history.CanUndo; + + public bool CanRedo => history.CanRedo; + + public string UndoDescription => history.UndoDescription; + + public string RedoDescription => history.RedoDescription; + + public string SessionLifetimeText => localizer.GetString("History.SessionLifetime"); + + public UiCommand UndoCommand => history.UndoCommand; + + public UiCommand RedoCommand => history.RedoCommand; + + public UiCommand NavigateCommand => history.NavigateCommand; + + public UiCommand CloseCommand { get; } + + public async Task RetryDetailsAsync() + { + await LoadDetailsAsync(SelectedEntry?.Id); + } + + public void UpdateAvailableWidth(double width) => IsNarrowLayout = width < 800; + + public void ShowDetailsPage() => NarrowPageIndex = 1; + + public void ShowHistoryPage() => NarrowPageIndex = 0; + + public void Dispose() + { + disposed = true; + history.StateChanged -= stateChangedHandler; + localizer.CultureChanged -= cultureChangedHandler; + detailCancellation?.Cancel(); + detailCancellation?.Dispose(); + detailGeneration++; + selectedEntry = null; + details = null; + Entries = []; + TreeRoots = []; + fieldRows = []; + Parameters = []; + RootChapterRows = []; + expandedNodeIds.Clear(); + OnPropertyChanged(nameof(SelectedEntry)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(Details)); + OnPropertyChanged(nameof(Entries)); + OnPropertyChanged(nameof(TreeRoots)); + OnPropertyChanged(nameof(VisibleChanges)); + OnPropertyChanged(nameof(Parameters)); + OnPropertyChanged(nameof(RootChapterRows)); + } + + private void RefreshState() + { + if (disposed) + { + return; + } + var previousSnapshot = currentSnapshot; + var previousSelection = selectedEntry?.Id; + currentSnapshot = history.Snapshot; + RebuildEntries(); + RebuildRedoChoices(); + OnPropertyChanged(nameof(Entries)); + OnPropertyChanged(nameof(RedoChoices)); + OnPropertyChanged(nameof(HasAlternateRedoChoices)); + OnPropertyChanged(nameof(HasPendingPreview)); + var snapshot = currentSnapshot; + if (snapshot is null || history.IsEnded) + { + detailGeneration++; + detailCancellation?.Cancel(); + SelectedEntry = null; + Details = null; + DetailError = null; + } + else if (previousSnapshot is null || previousSnapshot.RootId != snapshot.RootId || previousSnapshot.CursorId != snapshot.CursorId) + { + selectedRootId = snapshot.RootId; + ExpandCurrentPath(snapshot); + RebuildEntries(); + SelectedEntry = Entries.FirstOrDefault(entry => entry.Id == snapshot.CursorId); + } + else if (previousSelection is Guid selectedId) + { + selectedRootId = snapshot.RootId; + RebuildEntries(); + SelectedEntry = Entries.FirstOrDefault(entry => entry.Id == selectedId); + } + NavigationStatus = string.IsNullOrWhiteSpace(history.NavigationStatus) ? null : history.NavigationStatus; + RestoreCommand.RaiseCanExecuteChanged(); + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(UndoDescription)); + OnPropertyChanged(nameof(RedoDescription)); + } + + private async Task LoadDetailsAsync(Guid? nodeId) + { + var generation = ++detailGeneration; + var rootId = currentSnapshot?.RootId; + selectedRootId = rootId; + var request = await StartDetailsRequestAsync(); + if (generation != detailGeneration || disposed) + { + return; + } + Details = null; + DetailError = null; + if (nodeId is null) + { + IsLoadingDetails = false; + return; + } + IsLoadingDetails = true; + try + { + await InspectAndPublishDetailsAsync(nodeId.Value, rootId, generation, request); + } + catch (OperationCanceledException) + { + } + catch (OutOfMemoryException) + { + if (!request.IsCancellationRequested) + { + DetailError = "ResourceFailure"; + } + } + catch (Exception) + { + if (!request.IsCancellationRequested && generation == detailGeneration) + { + DetailError = "Failed"; + } + } + finally + { + if (ReferenceEquals(detailCancellation, request)) + { + IsLoadingDetails = false; + } + } + } + + private async Task StartDetailsRequestAsync() + { + var previousCancellation = detailCancellation; + var request = detailCancellation = new CancellationTokenSource(); + if (previousCancellation is not null) + { + await previousCancellation.CancelAsync(); + previousCancellation.Dispose(); + } + + return request; + } + + private async Task InspectAndPublishDetailsAsync( + Guid nodeId, + Guid? rootId, + long generation, + CancellationTokenSource request) + { + var result = await history.InspectAsync(nodeId, request.Token); + if (request.IsCancellationRequested || generation != detailGeneration || SelectedEntry?.Id != nodeId + || rootId is null || history.Snapshot?.RootId != rootId || history.IsEnded) + { + return; + } + + if (result.Kind == HistoryInspectionOutcomeKind.Available) + { + Details = result.Details; + return; + } + + DetailError = result.Kind switch + { + HistoryInspectionOutcomeKind.NotFound => "NotFound", + HistoryInspectionOutcomeKind.Ended => "Ended", + HistoryInspectionOutcomeKind.ResourceFailure => "ResourceFailure", + _ => "Failed" + }; + } + + private bool CanRestore(object? parameter) => + parameter is Guid nodeId + && !history.IsEnded + && nodeId != currentSnapshot?.CursorId + && currentSnapshot?.Nodes.Any(node => node.Id == nodeId) == true; + + private bool CanChooseRedoBranch(object? parameter) => + parameter is Guid nodeId + && !history.IsEnded + && currentSnapshot?.Nodes.FirstOrDefault(node => node.Id == currentSnapshot.CursorId)?.ChildIds.Contains(nodeId) == true; + + private void ToggleExpansion(Guid nodeId) + { + var node = Entries.FirstOrDefault(entry => entry.Id == nodeId); + if (node is null) + { + return; + } + node.IsExpanded = !node.IsExpanded; + if (!node.IsExpanded && selectedEntry is { } selected && IsDescendant(selected.Id, nodeId)) + { + SelectedEntry = Entries.FirstOrDefault(entry => entry.Id == nodeId); + } + OnPropertyChanged(nameof(Entries)); + } + + private void SelectCurrentNode() + { + if (currentSnapshot is not { } snapshot) + { + return; + } + ExpandCurrentPath(snapshot); + RebuildEntries(); + OnPropertyChanged(nameof(Entries)); + SelectedEntry = Entries.FirstOrDefault(entry => entry.Id == snapshot.CursorId); + } + + private void RebuildEntries() + { + var previousNodes = Entries.ToDictionary(static entry => entry.Id); + var (roots, projected) = HistoryEntryViewModel.CreateTree(currentSnapshot, previousNodes, expandedNodeIds, LocalizeHistoryTitle); + TreeRoots = roots; + Entries = projected; + OnPropertyChanged(nameof(TreeRoots)); + if (selectedEntry is { } selected && Entries.FirstOrDefault(entry => entry.Id == selected.Id) is { } replacement && !ReferenceEquals(selected, replacement)) + { + selectedEntry = replacement; + OnPropertyChanged(nameof(SelectedEntry)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(SelectedEntryDescription)); + OnPropertyChanged(nameof(SelectedPublishedAtText)); + OnPropertyChanged(nameof(HasPublishedAtText)); + RestoreCommand?.RaiseCanExecuteChanged(); + } + } + + private void RebuildRedoChoices() + { + var oldSelection = selectedRedoChoice?.Id; + var nodes = currentSnapshot?.Nodes.ToDictionary(node => node.Id); + if (currentSnapshot is null || nodes is null || !nodes.TryGetValue(currentSnapshot.CursorId, out var current)) + { + RedoChoices = []; + SelectedRedoChoice = null; + return; + } + + RedoChoices = + [ + .. current.ChildIds.Select(childId => nodes.TryGetValue(childId, out var child) + ? new HistoryRedoChoice(child.Id, LocalizeHistoryTitle(child), child.Description, + current.PreferredChildId == child.Id) + : null).Where(choice => choice is not null).Cast() + ]; + SelectedRedoChoice = RedoChoices.FirstOrDefault(choice => choice.Id == oldSelection) + ?? RedoChoices.FirstOrDefault(choice => choice.IsPreferred) + ?? RedoChoices.FirstOrDefault(); + } + + private async ValueTask ChooseRedoBranchAsync(object? parameter, CancellationToken cancellationToken) + { + var nodeId = parameter is Guid selectedId ? selectedId : SelectedRedoChoice?.Id; + if (nodeId is null || !CanChooseRedoBranch(nodeId)) + { + return; + } + await RestoreAsync(nodeId.Value, cancellationToken); + } + + private string LocalizeHistoryTitle(SessionHistoryNodeSnapshot node) + { + var resourceKey = node.Operation?.Kind switch + { + "load" => "History.Operation.Load", + "chapter.cell-edit" => "History.Operation.Edit", + "chapter.delete" => "History.Operation.Delete", + "chapter.insert" => "History.Operation.Insert", + "chapter.frame-shift" => "History.Operation.FrameShift", + "chapter.frame-rate-change" => "History.Operation.FrameRateChange", + "expression.apply" => "History.Operation.Expression", + "template.apply" => "History.Operation.Template", + "chapter.options-apply" => "History.Operation.Options", + "workspace.merge" => "History.Operation.Merge", + "workspace.split" => "History.Operation.Split", + "workspace.append" => "History.Operation.Append", + _ => "History.Operation.Generic" + }; + var title = localizer.GetString(resourceKey); + if (node.Operation?.Kind == "chapter.cell-edit") + { + var field = node.Operation.Parameters.FirstOrDefault(static parameter => parameter.Name == "field")?.Value; + if (!string.IsNullOrWhiteSpace(field)) + { + title = $"{title} · {localizer.GetString($"History.Enum.Field.{field}")}"; + } + } + return title; + } + + private void RebuildInspectorRows() + { + fieldRows = BuildFieldRows(); + Parameters = BuildParameterRows(); + OnPropertyChanged(nameof(VisibleChanges)); + OnPropertyChanged(nameof(VisibleChangeGroups)); + OnPropertyChanged(nameof(HasChanges)); + OnPropertyChanged(nameof(HasVisibleChanges)); + OnPropertyChanged(nameof(HasNoVisibleChanges)); + OnPropertyChanged(nameof(HasDeltas)); + OnPropertyChanged(nameof(HasParameters)); + OnPropertyChanged(nameof(Parameters)); + } + + private HistoryFieldRow[] BuildFieldRows() => Details?.Changes.Select(change => new HistoryFieldRow( + change.Group, + change.ObjectId, + localizer.GetString($"History.Field.{change.Field}"), + FormatFieldContext(change), + change.Before, + change.After, + FormatHistoryValue(change, change.Before, before: true), + FormatHistoryValue(change, change.After, before: false), + FormatDelta(change))).ToArray() ?? []; + + private HistoryParameterRow[] BuildParameterRows() + { + var operationKind = Details?.Operation?.Kind; + var cellEditField = Details?.Operation?.Parameters + .FirstOrDefault(static parameter => parameter.Name == "field")?.Value; + return Details?.Operation?.Parameters + .Where(parameter => IsVisibleParameter(parameter, operationKind, cellEditField)) + .Select(CreateParameterRow).ToArray() ?? []; + } + + private static bool IsVisibleParameter(HistoryOperationParameter parameter, string? operationKind, string? cellEditField) + { + if (parameter.Name is "chapterId" or "chapterIds" or "trackId" or "trackIds" or "targetChapterIds") + { + return false; + } + + if (operationKind != "chapter.cell-edit") + { + return true; + } + + return parameter.Name is not ("field" or "value" or "trackName") + && (parameter.Name != "displayFps" || cellEditField is "Frame" or "StartTime"); + } + + private HistoryParameterRow CreateParameterRow(HistoryOperationParameter parameter) => new( + localizer.GetString($"History.Parameter.{CapitalizeKeySegment(parameter.Name)}"), + FormatParameter(parameter), + parameter.ValueKind); + + private string FormatFieldContext(HistoryFieldChange change) + { + var context = change.Group == "Chapter" + ? localizer.FormatPositional("History.ChapterContext", change.Context ?? string.Empty, change.ObjectLabel) + : change.Context is null ? change.ObjectLabel : $"{change.Context} · {change.ObjectLabel}"; + if (change.Field is not ("StartTicks" or "EndTicks" or "FramesInfo")) + { + return context; + } + + if (TryGetCapturedDisplayRate(out var displayRate)) + { + return $"{context} · {localizer.GetString("History.Parameter.DisplayFps")}: {FormatFrameRate(displayRate)}"; + } + + if (!change.BeforeFrameRate.IsKnown && !change.AfterFrameRate.IsKnown) + { + return context; + } + + var oldRate = change.BeforeFrameRate.IsKnown ? FormatFrameRate(change.BeforeFrameRate) : localizer.GetString("History.Presence.Absent"); + var newRate = change.AfterFrameRate.IsKnown ? FormatFrameRate(change.AfterFrameRate) : localizer.GetString("History.Presence.Absent"); + return $"{context} · {localizer.FormatPositional("History.FrameRateContext", oldRate, newRate)}"; + } + + private string FormatHistoryValue(HistoryFieldChange change, HistoryFieldValue value, bool before) + { + if (value.IsPresent + && TryGetCapturedDisplayRate(out var displayRate) + && TryGetRelatedStartTicks(change, before, out var startTicks)) + { + var exactFrames = (decimal)startTicks / TimeSpan.TicksPerSecond * displayRate; + var roundedFrames = ChapterRounding.RoundToInt64(exactFrames); + if (change.Field == "FramesInfo" && value.Value is string frameText && string.IsNullOrWhiteSpace(frameText)) + { + return roundedFrames.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (change.Field == "FrameAccuracy" && value.Value is FrameAccuracy.Neutral) + { + var inferredAccuracy = Math.Abs(exactFrames - roundedFrames) < 0.01m + ? FrameAccuracy.Accurate + : FrameAccuracy.Inexact; + return FormatValue(new HistoryFieldValue(true, inferredAccuracy), change.Field); + } + } + + return FormatValue(value, change.Field); + } + + private bool TryGetRelatedStartTicks(HistoryFieldChange change, bool before, out long startTicks) + { + var startChange = Details?.Changes.FirstOrDefault(candidate => + candidate.Group == change.Group + && candidate.ObjectId == change.ObjectId + && candidate.Field == "StartTicks"); + var value = before ? startChange?.Before : startChange?.After; + if (value is { IsPresent: true, Value: long ticks }) + { + startTicks = ticks; + return true; + } + + startTicks = 0; + return false; + } + + private bool TryGetCapturedDisplayRate(out decimal displayRate) + { + var value = Details?.Operation?.Parameters + .FirstOrDefault(static parameter => parameter.Name == "displayFps")?.Value; + return decimal.TryParse(value, System.Globalization.NumberStyles.Number, + System.Globalization.CultureInfo.InvariantCulture, out displayRate) + && displayRate > 0; + } + + private string FormatFrameRate(decimal frameRate) + { + var culture = System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName); + return string.Concat(frameRate.ToString("0.###", culture), " ", localizer.GetString("History.Unit.Fps")); + } + + private string FormatFrameRate(FrameRateValue frameRate) => + string.Concat(frameRate.ToString(), " ", localizer.GetString("History.Unit.Fps")); + + private string FormatFrameRate(ChapterFrameRate frameRate) + { + var culture = System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName); + return frameRate.Denominator == 1 + ? string.Concat(frameRate.Numerator.ToString(culture), " ", localizer.GetString("History.Unit.Fps")) + : string.Concat(frameRate.Numerator.ToString(culture), "/", frameRate.Denominator.ToString(culture), " ", localizer.GetString("History.Unit.Fps")); + } + + private void RebuildRootChapterRows() + { + RootChapterRows = Details?.RootDocument is { } document + ? new HistoryRootChapterRows(document.Tracks, FormatTimeSpan) + : []; + OnPropertyChanged(nameof(RootChapterRows)); + OnPropertyChanged(nameof(HasRootChapterRows)); + } + + private string FormatValue(HistoryFieldValue value, string field) + { + if (!value.IsPresent) + { + return localizer.GetString("History.Presence.Absent"); + } + return value.Value switch + { + null => localizer.GetString("History.Presence.Null"), + string { Length: 0 } => localizer.GetString("History.Presence.Empty"), + long ticks when field is "StartTicks" or "EndTicks" => FormatTimeSpan(TimeSpan.FromTicks(ticks)), + ChapterDuration duration => FormatDuration(duration), + ChapterFrameRate frameRate => FormatChapterFrameRate(frameRate), + HistoryTrackReference trackReference => string.IsNullOrEmpty(trackReference.Label) + ? localizer.GetString("History.Track.Unknown") + : trackReference.Label, + ChapterImportFormat format => localizer.GetString($"History.Enum.ImportFormat.{format}"), + ChapterKind kind => localizer.GetString($"History.Enum.ChapterKind.{kind}"), + FrameAccuracy accuracy => localizer.GetString(accuracy == FrameAccuracy.Neutral + ? "Expression.FrameAccuracy.NotCalculated" + : $"Expression.FrameAccuracy.{accuracy}"), + System.Collections.IEnumerable sequence and not string => FormatSequence(sequence), + IFormattable formattable => FormatFormattable(formattable), + _ => value.Value.ToString() ?? string.Empty + }; + } + + private string FormatDuration(ChapterDuration duration) => duration.IsKnown + ? FormatTimeSpan(TimeSpan.FromTicks(duration.Ticks)) + : localizer.GetString("History.Presence.Unknown"); + + private string FormatChapterFrameRate(ChapterFrameRate frameRate) + { + var culture = System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName); + return frameRate.Denominator == 1 + ? string.Concat(frameRate.Numerator.ToString(culture), " ", localizer.GetString("History.Unit.Fps")) + : string.Concat(frameRate.Numerator.ToString(culture), "/", frameRate.Denominator.ToString(culture), " ", localizer.GetString("History.Unit.Fps")); + } + + private string FormatSequence(System.Collections.IEnumerable sequence) + { + var culture = System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName); + var items = new List(); + foreach (var item in sequence) + { + items.Add(item is HistoryOrderItem orderItem + ? orderItem.Label + : Convert.ToString(item, culture) ?? string.Empty); + } + + return string.Join(", ", items); + } + + private string FormatFormattable(IFormattable formattable) => + formattable.ToString(null, System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName)) ?? string.Empty; + + private string? FormatDelta(HistoryFieldChange change) + { + if (!change.Before.IsPresent || !change.After.IsPresent) + { + return null; + } + if (change.Before.Value is long before && change.After.Value is long after && change.Field is "StartTicks" or "EndTicks") + { + return FormatTimeSpan(TimeSpan.FromTicks(checked(after - before)), signed: true); + } + if (change.Before.Value is ChapterDuration previous && change.After.Value is ChapterDuration next && previous.IsKnown && next.IsKnown) + { + return FormatTimeSpan(TimeSpan.FromTicks(checked(next.Ticks - previous.Ticks)), signed: true); + } + return null; + } + + private static string FormatTimeSpan(TimeSpan value, bool signed = false) + { + var prefix = signed && value > TimeSpan.Zero ? "+" : string.Empty; + var absolute = value.Duration(); + var body = ExpressionChapterRowViewModel.FormatTicks(absolute.Ticks); + return string.Concat(value < TimeSpan.Zero ? "−" : prefix, body); + } + + private string FormatParameter(HistoryOperationParameter parameter) + { + if (parameter.Value is null) + { + return localizer.GetString("History.Presence.Null"); + } + if (parameter.Name == "field") + { + return localizer.GetString($"History.Enum.Field.{parameter.Value}"); + } + if (parameter.ValueKind == "boolean" && bool.TryParse(parameter.Value, out var boolean)) + { + return localizer.GetString(boolean ? "History.Boolean.True" : "History.Boolean.False"); + } + if (parameter.ValueKind == "integer" && long.TryParse(parameter.Value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var integer)) + { + return integer.ToString(System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName)); + } + if (parameter.ValueKind == "decimal" && decimal.TryParse(parameter.Value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var decimalValue)) + { + if (parameter.Name == "displayFps") + { + return FormatFrameRate(decimalValue); + } + + return decimalValue.ToString(System.Globalization.CultureInfo.GetCultureInfo(localizer.CurrentCultureName)); + } + if (parameter.Name is "deleteTiming" or "frameDisplay") + { + return localizer.GetString($"History.Enum.{CapitalizeKeySegment(parameter.Name)}.{parameter.Value}"); + } + return parameter.Value; + } + + private static string CapitalizeKeySegment(string value) => + string.IsNullOrEmpty(value) ? value : char.ToUpperInvariant(value[0]) + value[1..]; + + private void ExpandCurrentPath(SessionHistorySnapshot snapshot) + { + var nodes = snapshot.Nodes.ToDictionary(node => node.Id); + var currentId = snapshot.CursorId; + while (nodes.TryGetValue(currentId, out var node)) + { + if (node.ParentId is not Guid parentId) + { + break; + } + expandedNodeIds.Add(parentId); + if (Entries.FirstOrDefault(entry => entry.Id == parentId) is { } existing) + { + existing.IsExpanded = true; + } + currentId = parentId; + } + } + + private bool IsDescendant(Guid candidateId, Guid ancestorId) + { + var nodes = history.Snapshot?.Nodes.ToDictionary(node => node.Id); + if (nodes is null) + { + return false; + } + var currentId = candidateId; + while (nodes.TryGetValue(currentId, out var node) && node.ParentId is Guid parentId) + { + if (parentId == ancestorId) + { + return true; + } + currentId = parentId; + } + return false; + } + + private async ValueTask RestoreAsync(object? parameter, CancellationToken cancellationToken) + { + if (parameter is not Guid nodeId || !CanRestore(nodeId)) + { + return; + } + var oldCursor = history.Snapshot?.CursorId; + try + { + await history.NavigateCommand.ExecuteAsync(nodeId, cancellationToken); + var current = history.Snapshot?.CursorId; + if (current != oldCursor) + { + SelectedEntry = Entries.FirstOrDefault(entry => entry.Id == current); + NavigationStatus = localizer.GetString("History.RestoreApplied"); + } + else + { + NavigationStatus = string.IsNullOrWhiteSpace(history.NavigationStatus) + ? localizer.GetString("History.NavigationFailed") + : history.NavigationStatus; + } + } + catch (Exception) + { + NavigationStatus = localizer.GetString("History.NavigationFailed"); + } + } +} + +public sealed record HistoryRedoChoice(Guid Id, string Title, string Description, bool IsPreferred); + +public sealed record HistoryFieldRow(string Group, string ObjectId, string Field, string ObjectLabel, HistoryFieldValue Before, + HistoryFieldValue After, string BeforeText, string AfterText, string? DeltaText); + +public sealed record HistoryChangeGroup(string Group, string ObjectId, string ObjectLabel, string Field, + string BeforeText, string AfterText, string? DeltaText); + +public sealed record HistoryParameterRow(string Name, string? Value, string ValueKind); + +public sealed record HistoryRootChapterRow(string TrackName, int DisplayNumber, string Name, string StartTime, string FramesInfo); + +internal sealed class HistoryRootChapterRows : IReadOnlyList +{ + private readonly IReadOnlyList tracks; + private readonly Func formatTimeSpan; + private readonly int[] chapterOffsets; + + public HistoryRootChapterRows(IReadOnlyList tracks, Func formatTimeSpan) + { + this.tracks = tracks; + this.formatTimeSpan = formatTimeSpan; + chapterOffsets = new int[tracks.Count + 1]; + for (var index = 0; index < tracks.Count; index++) + { + chapterOffsets[index + 1] = chapterOffsets[index] + tracks[index].Chapters.Length; + } + } + + public int Count => chapterOffsets[^1]; + + public HistoryRootChapterRow this[int index] + { + get + { + if ((uint)index >= (uint)Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + var trackIndex = Array.BinarySearch(chapterOffsets, index + 1); + trackIndex = trackIndex >= 0 ? trackIndex - 1 : ~trackIndex - 1; + var chapter = tracks[trackIndex].Chapters[index - chapterOffsets[trackIndex]]; + return new HistoryRootChapterRow(tracks[trackIndex].Name, chapter.DisplayNumber, chapter.Name, + formatTimeSpan(TimeSpan.FromTicks(chapter.StartTicks), false), chapter.FramesInfo); + } + } + + public IEnumerator GetEnumerator() + { + for (var index = 0; index < Count; index++) + { + yield return this[index]; + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs index 0c22bbff..757bd983 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs @@ -9,47 +9,37 @@ namespace ChapterTool.Avalonia.UI.ViewModels.Tools; public sealed record LogPropertyViewModel(string Name, string Value); -public sealed record LogStructuredNodeViewModel( - string Name, - string Value, - IReadOnlyList Children, - bool IsInitiallyExpanded) -{ - public bool HasChildren => Children.Count > 0; -} +public sealed record LogHighlightRun(string Text, bool IsMatch); public sealed class LogEntryViewModel( ApplicationLogEntry entry, - IAppLocalizer localizer, - IAppLocalizer contentLocalizer) + IAppLocalizer localizer) : ObservableViewModel { + private const int MaxSearchDepth = 32; + private const int MaxSearchValues = 4096; + private const int MaxCompactSummaryLength = 180; + private string? rawText; private string? searchableText; + private string? additionalSearchableText; + private string? compactSummary; public ApplicationLogEntry Entry => entry; - public string Timestamp => entry.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); - - public string Time => entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); + public string Time => entry.Timestamp.ToLocalTime().ToString("HH:mm:ss.fff"); public string LevelText => localizer.GetString(LevelKey(entry.Level)); - public string Summary - { - get - { - if (entry.Arguments is { } arguments && arguments.TryGetValue("message", out var message) - && !string.IsNullOrWhiteSpace(message?.ToString())) - { - return message.ToString()!; - } + public string Summary => entry.Message; - return entry.MessageKey is null - ? entry.Message - : contentLocalizer.Format(entry.MessageKey, entry.Arguments); - } - } + public bool HasSummary => !string.IsNullOrWhiteSpace(Summary); + + /// + /// Gets the short message used by the list. Technical arguments stay in the + /// inspector so a long path or import payload cannot take over the feed. + /// + public string CompactSummary => compactSummary ??= CreateCompactSummary(); public string Category => entry.Category ?? string.Empty; @@ -63,64 +53,92 @@ public string Summary public bool HasOperation => !string.IsNullOrWhiteSpace(Operation); - public string Context => string.IsNullOrWhiteSpace(EventName) - ? Category - : string.IsNullOrWhiteSpace(Category) - ? EventDisplay - : $"{Category} / {EventDisplay}"; + public bool HasContext => !string.IsNullOrWhiteSpace(Context); - public string Details + /// Gets only identity that helps scan a row. The full category stays in the inspector. + public string RowContext { get { - var builder = new StringBuilder(); - Append(builder, entry.TechnicalDetail); - Append(builder, entry.ExceptionText); - if (TryGetImportOverview(out var importOverview)) + var parts = new List(capacity: 2); + AddContextPart(parts, Operation); + AddContextPart(parts, EventDisplay); + return string.Join(" / ", parts); + } + } + + public bool HasRowContext => !string.IsNullOrWhiteSpace(RowContext); + + public string Context + { + get + { + var parts = new List(capacity: 3); + AddContextPart(parts, Operation); + AddContextPart(parts, Category); + AddContextPart(parts, EventDisplay); + return string.Join(" / ", parts); + } + } + + public string IdentityText => Context; + + public string AccessibleName => localizer.Format( + "Tool.Log.EntryAccessibleName", + new Dictionary { - Append(builder, importOverview); - } - else if (HasStructuredTree) + ["level"] = LevelText, + ["summary"] = CompactSummary, + ["time"] = Time, + ["context"] = Context + }) + + (HasAdditionalSearchMatch ? $" {SearchMatchIndicatorText}" : string.Empty); + + public string DetailsActionText => localizer.GetString("Tool.Log.OpenDetails"); + + public string SearchMatchIndicatorText => localizer.GetString("Tool.Log.SearchMatchAdditional"); + + public bool HasAdditionalSearchMatch + { + get; + private set + { + if (SetProperty(ref field, value)) { - Append(builder, FormatNodes(StructuredTree)); + OnPropertyChanged(nameof(AccessibleName)); } - - return builder.ToString(); } } - public bool HasDetails => !string.IsNullOrWhiteSpace(Details); + public IReadOnlyList SummaryRuns + { + get; + private set => SetProperty(ref field, value); + } = []; + + public bool HasInspectorContent => HasTechnicalDetail || HasException || HasStructuredProperties; public string TechnicalDetail => entry.TechnicalDetail?.Trim() ?? string.Empty; public bool HasTechnicalDetail => !string.IsNullOrWhiteSpace(TechnicalDetail); - public bool HasOverviewContent => HasTechnicalDetail || HasStructuredProperties; - public string ExceptionText => entry.ExceptionText?.Trim() ?? string.Empty; public bool HasException => !string.IsNullOrWhiteSpace(ExceptionText); - /// Complete, deterministic JSON for diagnostics and clipboard export. + /// Gets complete, deterministic JSON for diagnostics and clipboard export. public string RawText => rawText ??= LogRawValueFormatter.Format(entry, Summary); public IReadOnlyList StructuredProperties => - entry.StructuredState is not { Count: > 0 } + field ??= entry.StructuredState is not { Count: > 0 } ? [] : CreateStructuredProperties(); public bool HasStructuredProperties => StructuredProperties.Count > 0; - public IReadOnlyList StructuredTree => - entry.StructuredState is not { Count: > 0 } - ? [] - : CreateNodes( - entry.StructuredState - .Where(static pair => !IsHiddenStructuredKey(pair.Key)) - .Where(static pair => IsContainer(pair.Value)) - .OrderBy(static pair => pair.Key, StringComparer.Ordinal)); + public bool HasNoStructuredProperties => !HasStructuredProperties; - public bool HasStructuredTree => StructuredTree.Count > 0; + public bool ShowNoDetails => !HasInspectorContent; public bool IsInformation => entry.Level == LogLevel.Information; @@ -147,170 +165,336 @@ public bool MatchesSearch(string query) return Contains(SearchableText, query); } + public void ApplySearchHighlight(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + HasAdditionalSearchMatch = false; + SummaryRuns = [new LogHighlightRun(CompactSummary, false)]; + return; + } + + HasAdditionalSearchMatch = !Contains(CompactSummary, query) + && Contains(AdditionalSearchableText, query); + + var runs = new List(); + var offset = 0; + while (offset < CompactSummary.Length) + { + var match = CompactSummary.IndexOf(query, offset, StringComparison.OrdinalIgnoreCase); + if (match < 0) + { + runs.Add(new LogHighlightRun(CompactSummary[offset..], false)); + break; + } + + if (match > offset) + { + runs.Add(new LogHighlightRun(CompactSummary[offset..match], false)); + } + + runs.Add(new LogHighlightRun(CompactSummary.Substring(match, query.Length), true)); + offset = match + query.Length; + } + + SummaryRuns = runs; + } + public void RefreshLocalizedProperties() { - OnPropertyChanged(nameof(LevelText)); rawText = null; searchableText = null; + additionalSearchableText = null; + compactSummary = null; + + // Clear derived text before raising notifications so bindings never read a stale + // accessible name or summary during a culture switch. + OnPropertyChanged(nameof(LevelText)); + OnPropertyChanged(nameof(DetailsActionText)); + OnPropertyChanged(nameof(SearchMatchIndicatorText)); OnPropertyChanged(nameof(Summary)); - OnPropertyChanged(nameof(Details)); + OnPropertyChanged(nameof(HasSummary)); + OnPropertyChanged(nameof(CompactSummary)); + OnPropertyChanged(nameof(AccessibleName)); OnPropertyChanged(nameof(RawText)); OnPropertyChanged(nameof(TechnicalDetail)); OnPropertyChanged(nameof(ExceptionText)); OnPropertyChanged(nameof(StructuredProperties)); - OnPropertyChanged(nameof(StructuredTree)); - OnPropertyChanged(nameof(HasStructuredTree)); OnPropertyChanged(nameof(Context)); + OnPropertyChanged(nameof(RowContext)); + OnPropertyChanged(nameof(IdentityText)); + OnPropertyChanged(nameof(HasContext)); + OnPropertyChanged(nameof(HasRowContext)); + ApplySearchHighlight(string.Empty); } - private string SearchableText => searchableText ??= string.Join( - Environment.NewLine, - Summary, - Category, - Operation, - EventDisplay, - entry.MessageKey, - entry.TechnicalDetail, - entry.ExceptionText, - RawText); + private string SearchableText => searchableText ??= BuildSearchableText(); - private static bool Contains(string value, string query) => - value.Contains(query, StringComparison.OrdinalIgnoreCase); + private string AdditionalSearchableText => additionalSearchableText ??= BuildAdditionalSearchableText(); - private IReadOnlyList CreateStructuredProperties() + private string BuildSearchableText() { - var properties = entry.StructuredState! - .Where(static pair => !IsOverviewHiddenKey(pair.Key) && !IsContainer(pair.Value)) - .OrderBy(static pair => pair.Key, StringComparer.Ordinal) - .Select(static pair => new LogPropertyViewModel(HumanizeKey(pair.Key), FormatValue(pair.Value))) - .ToList(); + var values = new List + { + Summary, + CompactSummary, + AdditionalSearchableText + }; - if (TryGetImportOverview(out var importOverview)) + return string.Join(Environment.NewLine, values); + } + + private string BuildAdditionalSearchableText() + { + var values = new List { - properties.Insert(0, new LogPropertyViewModel("Import Entries", importOverview)); + entry.Message, + Summary, + Category, + Operation, + EventDisplay, + entry.TechnicalDetail ?? string.Empty, + entry.ExceptionText ?? string.Empty + }; + + var budget = MaxSearchValues; + if (entry.StructuredState is not null) + { + AppendSearchablePairs(values, entry.StructuredState, ref budget); } - return properties; + // Some callers construct ApplicationLogEntry directly and provide arguments + // without mirroring them into StructuredState. Keep those fields searchable. + if (entry.Arguments is not null) + { + AppendSearchablePairs(values, entry.Arguments, ref budget); + } + + return string.Join(Environment.NewLine, values); } - private bool TryGetImportOverview(out string value) + private static void AppendSearchablePairs( + List values, + IEnumerable> pairs, + ref int budget) { - value = entry.StructuredState is not null - && entry.StructuredState.TryGetValue("importOverview", out var overview) - ? overview?.ToString() ?? string.Empty - : string.Empty; - return !string.IsNullOrWhiteSpace(value); + foreach (var pair in pairs) + { + if (budget <= 0) + { + break; + } + + values.Add(pair.Key); + AppendSearchableValue( + values, + pair.Value, + depth: 0, + new HashSet(ReferenceEqualityComparer.Instance), + ref budget); + } } - private static void Append(StringBuilder builder, string? value) + private static void AppendSearchableValue( + List values, + object? value, + int depth, + HashSet path, + ref int budget) { - if (string.IsNullOrWhiteSpace(value)) + if (value is null || budget-- <= 0) { return; } - if (builder.Length > 0) + if (value is string text) { - builder.AppendLine(); + values.Add(text); + return; } - builder.Append(value.Trim()); - } + if (depth >= MaxSearchDepth) + { + values.Add("[depth limit]"); + return; + } - private static string LevelKey(LogLevel level) => level switch - { - LogLevel.Warning => "Tool.Log.FilterWarning", - LogLevel.Error or LogLevel.Critical => "Tool.Log.FilterError", - _ => "Tool.Log.FilterInformation" - }; + var runtimeType = value.GetType(); + var trackReference = !runtimeType.IsValueType; + if (trackReference && !path.Add(value)) + { + values.Add("[cycle]"); + return; + } - private static IReadOnlyList CreateNodes( - IEnumerable> values, - int depth = 0) + try + { + AppendSearchableValueContent(values, value, depth, path, ref budget); + } + finally + { + if (trackReference) + { + path.Remove(value); + } + } + } + + private static void AppendSearchableValueContent( + List values, + object value, + int depth, + HashSet path, + ref int budget) { - return - [ - .. values - .Select(pair => CreateNode(pair.Key, pair.Value, depth)) - ]; + if (value is IReadOnlyDictionary readOnlyDictionary) + { + AppendSearchablePairs(values, readOnlyDictionary, depth + 1, path, ref budget); + } + else if (value is IDictionary dictionary) + { + AppendSearchableDictionary(values, dictionary, depth + 1, path, ref budget); + } + else if (value is IEnumerable enumerable) + { + AppendSearchableItems(values, enumerable, depth + 1, path, ref budget); + } + else + { + values.Add(value.ToString() ?? string.Empty); + } } - private static string FormatNodes(IEnumerable nodes, int depth = 0) + private static void AppendSearchablePairs( + List values, + IEnumerable> pairs, + int depth, + HashSet path, + ref int budget) { - var lines = new List(); - foreach (var node in nodes) + foreach (var pair in pairs) { - var value = string.IsNullOrEmpty(node.Value) ? string.Empty : $" = {node.Value}"; - lines.Add($"{new string(' ', depth * 2)}{node.Name}{value}"); - if (node.HasChildren) + if (budget <= 0) { - lines.Add(FormatNodes(node.Children, depth + 1)); + break; } - } - return string.Join(Environment.NewLine, lines); + values.Add(pair.Key); + AppendSearchableValue(values, pair.Value, depth, path, ref budget); + } } - private static LogStructuredNodeViewModel CreateNode(string name, object? value, int depth) + private static void AppendSearchableDictionary( + List values, + IDictionary pairs, + int depth, + HashSet path, + ref int budget) { - if (depth >= 32) + foreach (DictionaryEntry pair in pairs) { - return new LogStructuredNodeViewModel(HumanizeKey(name), "[depth limit]", [], false); - } + if (budget <= 0) + { + break; + } - var children = CreateChildren(value, depth + 1); - return new LogStructuredNodeViewModel( - HumanizeKey(name), - FormatContainer(value, children), - children, - depth == 0 && children.Count > 0); + values.Add(pair.Key?.ToString() ?? string.Empty); + AppendSearchableValue(values, pair.Value, depth, path, ref budget); + } } - private static IReadOnlyList CreateChildren(object? value, int depth) + private static void AppendSearchableItems( + List values, + IEnumerable items, + int depth, + HashSet path, + ref int budget) { - switch (value) - { - case IReadOnlyDictionary readOnlyDictionary: - return CreateNodes( - readOnlyDictionary - .Where(static pair => !IsHiddenStructuredKey(pair.Key)) - .OrderBy(static pair => pair.Key, StringComparer.Ordinal), - depth); - case IDictionary dictionary: + foreach (var item in items) + { + if (budget <= 0) { - var pairs = new List>(); - foreach (DictionaryEntry entry in dictionary) - { - pairs.Add(new KeyValuePair(entry.Key?.ToString() ?? string.Empty, entry.Value)); - } - - return CreateNodes(pairs.OrderBy(static pair => pair.Key, StringComparer.Ordinal), depth); + break; } + + AppendSearchableValue(values, item, depth, path, ref budget); } + } - if (value is IEnumerable enumerable and not string) + private static bool Contains(string value, string query) => + value.Contains(query, StringComparison.OrdinalIgnoreCase); + + private string CreateCompactSummary() + { + if (TryArgument("Summary", out var explicitSummary) || TryArgument("summary", out explicitSummary)) { - var index = 0; + return TruncateSummary(explicitSummary); + } + + // 摘要 = 完整消息去掉尾部的载荷(message='...')。诊断等长消息保留 + // operation/severity/code 等前置上下文,原文只进检查器,不会占据整行。 + return TruncateSummary(TrimMessagePayload(Summary)); + } - return [.. from object? item in enumerable select CreateNode($"#{++index}", item, depth)]; + private static string TrimMessagePayload(string value) + { + var index = value.IndexOf(", message='", StringComparison.Ordinal); + return index > 0 ? value[..index].TrimEnd() : value; + } + + private bool TryArgument(string key, out string value) + { + if (entry.Arguments is not null + && entry.Arguments.TryGetValue(key, out var argument) + && !string.IsNullOrWhiteSpace(argument?.ToString())) + { + value = argument.ToString()!; + return true; } - return []; + value = string.Empty; + return false; } - private static string FormatContainer(object? value, IReadOnlyList children) + private static string TruncateSummary(string value) { - if (children.Count == 0) + var normalized = value.Trim(); + return normalized.Length <= MaxCompactSummaryLength + ? normalized + : normalized[..(MaxCompactSummaryLength - 1)].TrimEnd() + '\u2026'; + } + + private IReadOnlyList CreateStructuredProperties() + { + var properties = entry.StructuredState! + .Where(static pair => !IsOverviewHiddenKey(pair.Key) && !IsContainer(pair.Value)) + .OrderBy(static pair => pair.Key, StringComparer.Ordinal) + .Select(static pair => new LogPropertyViewModel(HumanizeKey(pair.Key), FormatValue(pair.Value))) + .ToList(); + + if (TryGetImportOverview(out var importOverview)) { - return FormatValue(value); + properties.Insert(0, new LogPropertyViewModel("Import Entries", importOverview)); } - return value is IDictionary or IReadOnlyDictionary - ? CountLabel(children.Count, "field") - : CountLabel(children.Count, "item"); + return properties; + } + + private bool TryGetImportOverview(out string value) + { + value = entry.StructuredState is not null + && entry.StructuredState.TryGetValue("importOverview", out var overview) + ? overview?.ToString() ?? string.Empty + : string.Empty; + return !string.IsNullOrWhiteSpace(value); } - private static string CountLabel(int count, string noun) => $"{count} {noun}{(count == 1 ? string.Empty : "s")}"; + private static string LevelKey(LogLevel level) => level switch + { + LogLevel.Warning => "Tool.Log.FilterWarning", + LogLevel.Error or LogLevel.Critical => "Tool.Log.FilterError", + _ => "Tool.Log.FilterInformation" + }; private static string HumanizeKey(string key) { @@ -344,18 +528,16 @@ private static string HumanizeKey(string key) return builder.ToString(); } - private static bool IsHiddenStructuredKey(string key) => - string.Equals(key, "MessageKey", StringComparison.Ordinal) || - string.Equals(key, "TechnicalDetail", StringComparison.Ordinal) || - string.Equals(key, "Operation", StringComparison.Ordinal) || - string.Equals(key, "operation", StringComparison.Ordinal); - /// - /// Keys hidden from the flat overview list: infrastructure keys, the operation tag - /// (already shown as a badge) and the folded import details (available in the tree). + /// Keys hidden from the flat overview list: infrastructure keys, the operation tag, + /// and import details available in the raw representation. /// private static bool IsOverviewHiddenKey(string key) => - IsHiddenStructuredKey(key) || + string.Equals(key, "TechnicalDetail", StringComparison.Ordinal) || + string.Equals(key, "Operation", StringComparison.Ordinal) || + string.Equals(key, "operation", StringComparison.Ordinal) || + string.Equals(key, "Summary", StringComparison.Ordinal) || + string.Equals(key, "summary", StringComparison.Ordinal) || string.Equals(key, "severity", StringComparison.Ordinal) || string.Equals(key, "message", StringComparison.Ordinal) || string.Equals(key, "importOverview", StringComparison.Ordinal) || @@ -370,4 +552,13 @@ private static bool IsContainer(object? value) => string text => text, _ => value.ToString() ?? string.Empty }; + + private static void AddContextPart(List parts, string value) + { + if (!string.IsNullOrWhiteSpace(value) + && !parts.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + parts.Add(value.Trim()); + } + } } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogRawValueFormatter.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogRawValueFormatter.cs index d9a65840..dc76dea4 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogRawValueFormatter.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogRawValueFormatter.cs @@ -27,7 +27,6 @@ public static string Format(ApplicationLogEntry entry, string summary) ["category"] = NullIfEmpty(entry.Category), ["eventId"] = entry.EventId, ["eventName"] = NullIfEmpty(entry.EventName), - ["messageKey"] = NullIfEmpty(entry.MessageKey), ["message"] = summary, ["technicalDetail"] = NullIfEmpty(entry.TechnicalDetail), ["exception"] = NullIfEmpty(entry.ExceptionText), @@ -75,26 +74,21 @@ private static bool TryNormalizeScalar(object? value, out object? normalized) return true; } - if (value is DateTime dateTime) + switch (value) { - normalized = dateTime.ToString("O", CultureInfo.InvariantCulture); - return true; - } - - if (value is DateTimeOffset dateTimeOffset) - { - normalized = dateTimeOffset.ToString("O", CultureInfo.InvariantCulture); - return true; + case DateTime dateTime: + normalized = dateTime.ToString("O", CultureInfo.InvariantCulture); + return true; + case DateTimeOffset dateTimeOffset: + normalized = dateTimeOffset.ToString("O", CultureInfo.InvariantCulture); + return true; + case char or Enum or Guid or Uri or TimeSpan: + normalized = value.ToString(); + return true; + default: + normalized = null; + return false; } - - if (value is char or Enum or Guid or Uri or TimeSpan) - { - normalized = value.ToString(); - return true; - } - - normalized = null; - return false; } private static bool IsNumeric(object value) => value switch diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs index 956a4c96..481c63f2 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using ChapterTool.Avalonia.UI.Localization; +using ChapterTool.Avalonia.UI.PlatformPorts; using ChapterTool.Contracts.PlatformPorts; namespace ChapterTool.Avalonia.UI.ViewModels.Tools; @@ -13,49 +15,76 @@ public enum LogSeverityFilter public sealed record LogFilterOption(LogSeverityFilter Value, string DisplayName); +public sealed record LogExportFormatOption(LogExportFormat Value, string DisplayName); + +/// +/// Projects the bounded application log into a quiet feed and an on-demand +/// inspector. Selection and inspection are deliberately separate states. +/// public sealed class LogToolViewModel : ObservableViewModel, IDisposable { private readonly IApplicationLogService logService; private readonly IAppLocalizer localizer; - private readonly IAppLocalizer contentLocalizer = new AppLocalizationManager("en-US"); private readonly IClipboardService? clipboardService; + private readonly IRuntimeCapabilities? capabilities; + private readonly IApplicationLogExporter? exporter; private readonly SynchronizationContext? synchronizationContext; private readonly List entryViewModels = []; - private IReadOnlyList filteredEntries = []; private IReadOnlyList filterOptions; + private IReadOnlyList exportFormatOptions; private LogFilterOption selectedFilter; + private LogExportFormatOption selectedExportFormat; + private string? statusResourceKey; + private string? statusArgument; private bool disposed; public LogToolViewModel( IApplicationLogService logService, IAppLocalizer localizer, - IClipboardService? clipboardService = null) + IClipboardService? clipboardService = null, + IApplicationLogExporter? exporter = null, + IRuntimeCapabilities? capabilities = null, + IShellService? shellService = null, + string? logDirectory = null) { ArgumentNullException.ThrowIfNull(logService); ArgumentNullException.ThrowIfNull(localizer); this.logService = logService; this.localizer = localizer; this.clipboardService = clipboardService; + this.capabilities = capabilities; + this.exporter = exporter; + OpenLogFolderCommand = new UiCommand( + (_, token) => shellService is null || string.IsNullOrWhiteSpace(logDirectory) + ? ValueTask.CompletedTask + : shellService.OpenAsync(logDirectory, token), + _ => shellService is not null && !string.IsNullOrWhiteSpace(logDirectory)); synchronizationContext = SynchronizationContext.Current; filterOptions = CreateFilterOptions(); + exportFormatOptions = CreateExportFormatOptions(); selectedFilter = filterOptions[0]; - ClearCommand = new UiCommand( - (_, _) => - { - logService.Clear(); - return ValueTask.CompletedTask; - }, - _ => entryViewModels.Count > 0); + selectedExportFormat = exportFormatOptions[0]; + + ClearCommand = new UiCommand((_, _) => ClearAsync(), _ => entryViewModels.Count > 0); CopySummaryCommand = new UiCommand( async (_, cancellationToken) => await CopyAsync(SelectedEntry?.Summary, cancellationToken), - _ => clipboardService is not null && SelectedEntry is not null); + _ => CanCopySelected); CopyDetailsCommand = new UiCommand( async (_, cancellationToken) => await CopyAsync(SelectedEntry?.RawText, cancellationToken), - _ => clipboardService is not null && SelectedEntry is not null); + _ => CanCopySelected); + CloseDetailsCommand = new UiCommand((_, _) => CloseDetailsAsync(), _ => IsDetailsOpen); + OpenDetailsCommand = new UiCommand( + (parameter, _) => OpenDetailsAsync(parameter), + parameter => parameter is LogEntryViewModel entry && FilteredEntries.Contains(entry)); + ResetFiltersCommand = new UiCommand((_, _) => ResetFiltersAsync(), _ => HasActiveFilters || HasSearchQuery); + ExportCommand = new UiCommand( + (_, cancellationToken) => ExportAsync(cancellationToken), + _ => CanExport); + logService.EntryAdded += OnEntryAdded; logService.Cleared += OnCleared; localizer.CultureChanged += OnCultureChanged; - RebuildFromService(); + RebuildFromSnapshot(logService.Entries); } public IReadOnlyList FilterOptions @@ -64,40 +93,50 @@ public IReadOnlyList FilterOptions private set => SetProperty(ref filterOptions, value); } + public IReadOnlyList ExportFormatOptions + { + get => exportFormatOptions; + private set => SetProperty(ref exportFormatOptions, value); + } + public LogFilterOption SelectedFilter { get => selectedFilter; set { - if (value is null || !SetProperty(ref selectedFilter, value)) + if (value is not null && SetProperty(ref selectedFilter, value)) { - return; + RefreshProjection(); } - - RefreshFilteredEntries(); } } + public LogExportFormatOption SelectedExportFormat + { + get => selectedExportFormat; + set => SetProperty(ref selectedExportFormat, value); + } + public string SearchText { get; set { - if (!SetProperty(ref field, value)) + var normalized = value ?? string.Empty; + if (SetProperty(ref field, normalized)) { - return; + RefreshProjection(); } - - RefreshFilteredEntries(); } } = string.Empty; public IReadOnlyList FilteredEntries { - get => filteredEntries; - private set => SetProperty(ref filteredEntries, value); - } + get; + private set => SetProperty(ref field, value); + } = []; + /// Gets or sets currently highlighted row. It does not open the inspector. public LogEntryViewModel? SelectedEntry { get; @@ -109,13 +148,81 @@ public LogEntryViewModel? SelectedEntry } OnPropertyChanged(nameof(HasSelectedEntry)); - CopySummaryCommand.RaiseCanExecuteChanged(); - CopyDetailsCommand.RaiseCanExecuteChanged(); + OnPropertyChanged(nameof(ShowDetails)); + OnPropertyChanged(nameof(CanCopySelected)); + OnPropertyChanged(nameof(HasSecondaryActions)); + RaiseCommandStates(); + } + } + + /// Gets a value indicating whether true only while the user is inspecting the selected row. + public bool IsDetailsOpen + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(ShowDetails)); + CloseDetailsCommand.RaiseCanExecuteChanged(); + } } } + public bool IsExporting + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(CanExport)); + OnPropertyChanged(nameof(HasSecondaryActions)); + ExportCommand.RaiseCanExecuteChanged(); + } + } + } + + public string StatusText + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(HasStatus)); + } + } + } = string.Empty; + public bool HasSelectedEntry => SelectedEntry is not null; + public bool ShowDetails => IsDetailsOpen && HasSelectedEntry; + + public bool HasRetainedEntries => entryViewModels.Count > 0; + + public bool HasNoResults => HasRetainedEntries && IsEmpty; + + public bool HasSearchQuery => !string.IsNullOrWhiteSpace(SearchText); + + public bool HasStatus => !string.IsNullOrWhiteSpace(StatusText); + + public bool HasClipboard => clipboardService is not null && (capabilities?.CanWriteClipboard ?? true); + + public bool HasExporter => exporter is not null; + + public bool CanCopySelected => HasClipboard && SelectedEntry is not null; + + public bool CanExport => HasExporter && FilteredEntries.Count > 0 && !IsExporting; + + public bool CanOpenLogFolder => OpenLogFolderCommand.CanExecute(null); + + public bool HasSecondaryActions => HasRetainedEntries || CanCopySelected || CanExport; + + public int ActiveFilterCount => SelectedFilter.Value == LogSeverityFilter.All ? 0 : 1; + + public bool HasActiveFilters => SelectedFilter.Value != LogSeverityFilter.All; + public bool IsEmpty => FilteredEntries.Count == 0; public string EntryCountText => localizer.FormatPositional("Tool.Log.EntryCount", FilteredEntries.Count); @@ -126,6 +233,16 @@ public LogEntryViewModel? SelectedEntry public UiCommand CopyDetailsCommand { get; } + public UiCommand CloseDetailsCommand { get; } + + public UiCommand OpenDetailsCommand { get; } + + public UiCommand ResetFiltersCommand { get; } + + public UiCommand ExportCommand { get; } + + public UiCommand OpenLogFolderCommand { get; } + public void Dispose() { if (disposed) @@ -139,100 +256,126 @@ public void Dispose() localizer.CultureChanged -= OnCultureChanged; } - private void OnEntryAdded(object? sender, ApplicationLogEntry entry) + private void OnEntryAdded(object? sender, ApplicationLogEntry entry) => Dispatch(() => AppendEntry(entry)); + + private void OnCleared(object? sender, EventArgs args) => Dispatch(RebuildAfterClear); + + private void Dispatch(Action action) { if (synchronizationContext is null) { - AppendEntry(entry); - return; + action(); + } + else + { + synchronizationContext.Post(_ => action(), null); } - - synchronizationContext.Post(_ => AppendEntry(entry), null); } - private void OnCleared(object? sender, EventArgs args) + private void AppendEntry(ApplicationLogEntry entry) { - if (synchronizationContext is null) + if (disposed) { - RebuildAfterClear(); return; } - synchronizationContext.Post(_ => RebuildAfterClear(), null); - } + var retained = logService.Entries.ToHashSet(ReferenceEqualityComparer.Instance); + var selectedBeforeUpdate = SelectedEntry?.Entry; + entryViewModels.RemoveAll(item => !retained.Contains(item.Entry)); + if (retained.Contains(entry) + && entryViewModels.All(item => !ReferenceEquals(item.Entry, entry))) + { + entryViewModels.Add(CreateEntry(entry)); + } - private void OnCultureChanged(object? sender, EventArgs args) - { - FilterOptions = CreateFilterOptions(); - SelectedFilter = FilterOptions.First(option => option.Value == selectedFilter.Value); - foreach (var entry in entryViewModels) + SortEntries(); + if (selectedBeforeUpdate is not null + && !entryViewModels.Any(item => ReferenceEquals(item.Entry, selectedBeforeUpdate))) { - entry.RefreshLocalizedProperties(); + SelectedEntry = null; + IsDetailsOpen = false; } - RefreshFilteredEntries(); + RefreshProjection(); } - private IReadOnlyList CreateFilterOptions() => - [ - new(LogSeverityFilter.All, localizer.GetString("Tool.Log.FilterAll")), - new(LogSeverityFilter.Information, localizer.GetString("Tool.Log.FilterInformation")), - new(LogSeverityFilter.Warning, localizer.GetString("Tool.Log.FilterWarning")), - new(LogSeverityFilter.Error, localizer.GetString("Tool.Log.FilterError")) - ]; - - private void AppendEntry(ApplicationLogEntry entry) + private async ValueTask ExportAsync(CancellationToken cancellationToken) { - if (disposed) + if (exporter is null || IsExporting) { return; } - var snapshot = logService.Entries; - var appendsEntry = snapshot.Count == entryViewModels.Count + 1 - && PrefixMatches(snapshot, entryViewModels, entryViewModels.Count) - && ReferenceEquals(snapshot[^1], entry); - var evictsThenAppends = snapshot.Count == entryViewModels.Count - && snapshot.Count > 0 - && PrefixMatches(snapshot, entryViewModels, snapshot.Count - 1, existingOffset: 1) - && ReferenceEquals(snapshot[^1], entry); - - if (!appendsEntry && !evictsThenAppends) + IsExporting = true; + try { - RebuildFromSnapshot(snapshot); - return; + var request = new ApplicationLogExportRequest( + SelectedExportFormat.Value, + [.. FilteredEntries.Select(static item => item.Entry)]); + var result = await exporter.ExportAsync(request, cancellationToken); + SetExportStatus(result.Succeeded, result.Succeeded ? result.Path : result.Error); } - - var viewModel = new LogEntryViewModel(entry, localizer, contentLocalizer); - if (evictsThenAppends) + catch (IOException exception) { - entryViewModels.RemoveAt(0); + SetExportStatus(false, exception.Message); } - - entryViewModels.Add(viewModel); - if (evictsThenAppends) + catch (UnauthorizedAccessException exception) { - RefreshFilteredEntries(); - return; + SetExportStatus(false, exception.Message); + } + catch (Exception exception) when (exception is ArgumentException or JsonException or NotSupportedException or InvalidOperationException or System.Security.SecurityException) + { + SetExportStatus(false, exception.Message); + } + finally + { + IsExporting = false; } + } + + private ValueTask ClearAsync() + { + logService.Clear(); + return ValueTask.CompletedTask; + } + + private ValueTask CloseDetailsAsync() + { + // Keep the row selected so the same event can be inspected again. + IsDetailsOpen = false; + return ValueTask.CompletedTask; + } - if (viewModel.Matches(SelectedFilter.Value) && viewModel.MatchesSearch(SearchText)) + private ValueTask OpenDetailsAsync(object? parameter) + { + if (parameter is LogEntryViewModel entry && FilteredEntries.Contains(entry)) { - FilteredEntries = [.. filteredEntries, viewModel]; - SelectedEntry ??= viewModel; - RaiseListStateChanged(); + SelectedEntry = entry; + IsDetailsOpen = true; } + + return ValueTask.CompletedTask; + } + + private ValueTask ResetFiltersAsync() + { + SearchText = string.Empty; + SelectedFilter = FilterOptions[0]; + return ValueTask.CompletedTask; } private void RebuildAfterClear() { - if (!disposed) + if (disposed) { - RebuildFromService(); + return; } - } - private void RebuildFromService() => RebuildFromSnapshot(logService.Entries); + entryViewModels.Clear(); + SelectedEntry = null; + IsDetailsOpen = false; + RefreshProjection(); + } private void RebuildFromSnapshot(IReadOnlyList entries) { @@ -241,64 +384,129 @@ private void RebuildFromSnapshot(IReadOnlyList entries) { existing[item.Entry] = item; } - entryViewModels.Clear(); foreach (var entry in entries) { entryViewModels.Add(existing.TryGetValue(entry, out var viewModel) ? viewModel - : new LogEntryViewModel(entry, localizer, contentLocalizer)); + : CreateEntry(entry)); } - RefreshFilteredEntries(); + SortEntries(); + RefreshProjection(); } - private static bool PrefixMatches( - IReadOnlyList snapshot, - IReadOnlyList existing, - int count, - int existingOffset = 0) + private void OnCultureChanged(object? sender, EventArgs args) { - for (var index = 0; index < count; index++) + var severity = SelectedFilter.Value; + var format = SelectedExportFormat.Value; + FilterOptions = CreateFilterOptions(); + ExportFormatOptions = CreateExportFormatOptions(); + selectedFilter = FilterOptions.Single(option => option.Value == severity); + selectedExportFormat = ExportFormatOptions.Single(option => option.Value == format); + OnPropertyChanged(nameof(SelectedFilter)); + OnPropertyChanged(nameof(SelectedExportFormat)); + if (statusResourceKey is not null) { - if (!ReferenceEquals(snapshot[index], existing[index + existingOffset].Entry)) - { - return false; - } + StatusText = localizer.FormatPositional(statusResourceKey, statusArgument ?? string.Empty); + } + foreach (var entry in entryViewModels) + { + entry.RefreshLocalizedProperties(); } - return true; + RefreshProjection(); } - private void RefreshFilteredEntries() + private void RefreshProjection() { var previousEntry = SelectedEntry?.Entry; + foreach (var entry in entryViewModels) + { + entry.ApplySearchHighlight(SearchText); + } + FilteredEntries = [ .. entryViewModels - .Where(entry => entry.Matches(SelectedFilter.Value) && entry.MatchesSearch(SearchText)) + .Where(entry => entry.Matches(SelectedFilter.Value)) + .Where(entry => entry.MatchesSearch(SearchText)) + .OrderByDescending(static entry => entry.Entry.Timestamp) ]; - SelectedEntry = FilteredEntries.FirstOrDefault(entry => ReferenceEquals(entry.Entry, previousEntry)) - ?? FilteredEntries.LastOrDefault(); + + var stillVisible = FilteredEntries.FirstOrDefault(entry => ReferenceEquals(entry.Entry, previousEntry)); + if (previousEntry is not null && stillVisible is null) + { + SelectedEntry = null; + IsDetailsOpen = false; + } + + // Filtering and live updates must not select a replacement entry. Keep + // an explicit selection only while that same entry remains visible. + else if (previousEntry is not null && !ReferenceEquals(SelectedEntry, stillVisible)) + { + SelectedEntry = stillVisible; + } + + OnPropertyChanged(nameof(HasActiveFilters)); + OnPropertyChanged(nameof(ActiveFilterCount)); + OnPropertyChanged(nameof(HasSearchQuery)); RaiseListStateChanged(); } private void RaiseListStateChanged() { OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(HasRetainedEntries)); + OnPropertyChanged(nameof(HasNoResults)); OnPropertyChanged(nameof(EntryCountText)); + OnPropertyChanged(nameof(HasSecondaryActions)); + OnPropertyChanged(nameof(CanCopySelected)); + OnPropertyChanged(nameof(CanExport)); + RaiseCommandStates(); + } + + private void RaiseCommandStates() + { ClearCommand.RaiseCanExecuteChanged(); CopySummaryCommand.RaiseCanExecuteChanged(); CopyDetailsCommand.RaiseCanExecuteChanged(); + CloseDetailsCommand.RaiseCanExecuteChanged(); + OpenDetailsCommand.RaiseCanExecuteChanged(); + ResetFiltersCommand.RaiseCanExecuteChanged(); + ExportCommand.RaiseCanExecuteChanged(); } private async ValueTask CopyAsync(string? text, CancellationToken cancellationToken) { - if (clipboardService is null || string.IsNullOrWhiteSpace(text)) + if (clipboardService is not null && !string.IsNullOrWhiteSpace(text)) { - return; + await clipboardService.SetTextAsync(text, cancellationToken); } + } + + private LogEntryViewModel CreateEntry(ApplicationLogEntry entry) => new(entry, localizer); - await clipboardService.SetTextAsync(text, cancellationToken); + private void SetExportStatus(bool succeeded, string? value) + { + statusResourceKey = succeeded ? "Tool.Log.ExportSucceeded" : "Tool.Log.ExportFailed"; + statusArgument = value ?? string.Empty; + StatusText = localizer.FormatPositional(statusResourceKey, statusArgument); } + + private void SortEntries() => entryViewModels.Sort(static (left, right) => right.Entry.Timestamp.CompareTo(left.Entry.Timestamp)); + + private IReadOnlyList CreateFilterOptions() => + [ + new(LogSeverityFilter.All, localizer.GetString("Tool.Log.FilterAll")), + new(LogSeverityFilter.Information, localizer.GetString("Tool.Log.FilterInformation")), + new(LogSeverityFilter.Warning, localizer.GetString("Tool.Log.FilterWarning")), + new(LogSeverityFilter.Error, localizer.GetString("Tool.Log.FilterError")) + ]; + + private IReadOnlyList CreateExportFormatOptions() => + [ + new(LogExportFormat.Json, localizer.GetString("Tool.Log.ExportJson")), + new(LogExportFormat.Csv, localizer.GetString("Tool.Log.ExportCsv")) + ]; } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/TemplateNamesToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/TemplateNamesToolViewModel.cs index dbbabd42..ad086bba 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/TemplateNamesToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/TemplateNamesToolViewModel.cs @@ -1,23 +1,121 @@ using ChapterTool.Avalonia.UI.PlatformPorts.SessionPorts; +using ChapterTool.Core.Session; namespace ChapterTool.Avalonia.UI.ViewModels.Tools; -public sealed class TemplateNamesToolViewModel(INamingPreferencePort namingPreferences) : ObservableViewModel +public sealed class TemplateNamesToolViewModel : ObservableViewModel { + private readonly INamingPreferencePort namingPreferences; + private readonly IChapterContentOperationPort? contentOperations; + private ChapterContentPreview? pendingPreview; + + public TemplateNamesToolViewModel(INamingPreferencePort namingPreferences, IChapterContentOperationPort? contentOperations = null) + { + this.namingPreferences = namingPreferences; + this.contentOperations = contentOperations; + UseTemplateNames = namingPreferences.UseTemplateNames; + ApplyCommand = new UiCommand(ApplyTemplateNamesAsync); + ConfirmApplyCommand = new UiCommand(ConfirmApplyAsync, _ => CanApplyPreview); + CancelPreviewCommand = new UiCommand(CancelPreviewAsync, _ => IsPreviewPending); + } + public bool UseTemplateNames { get; set => SetProperty(ref field, value); - } = namingPreferences.UseTemplateNames; + } + + public string StatusText + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + + public bool IsPreviewPending => pendingPreview is not null; + + public bool CanPreparePreview => pendingPreview is null; - public UiCommand ApplyCommand { get; } = new((parameter, _) => + public bool CanApplyPreview => pendingPreview?.IsValid == true; + + public UiCommand ApplyCommand { get; } + + public UiCommand ConfirmApplyCommand { get; } + + public UiCommand CancelPreviewCommand { get; } + + private ValueTask ApplyTemplateNamesAsync(object? parameter, CancellationToken cancellationToken) { - if (parameter is TemplateNamesToolViewModel viewModel) + if (parameter is not TemplateNamesToolViewModel viewModel) { - namingPreferences.AutoGenerateNames = false; - namingPreferences.UseTemplateNames = viewModel.UseTemplateNames; + return ValueTask.CompletedTask; } + namingPreferences.AutoGenerateNames = false; + namingPreferences.UseTemplateNames = viewModel.UseTemplateNames; + if (contentOperations is null) + { + return ValueTask.CompletedTask; + } + + viewModel.pendingPreview = contentOperations.PrepareTemplateNames( + autoGenerateNames: false, + useTemplateNames: viewModel.UseTemplateNames); + viewModel.OnPropertyChanged(nameof(IsPreviewPending)); + viewModel.OnPropertyChanged(nameof(CanPreparePreview)); + viewModel.OnPropertyChanged(nameof(CanApplyPreview)); + viewModel.StatusText = DescribePreview(viewModel.pendingPreview); + viewModel.ConfirmApplyCommand.RaiseCanExecuteChanged(); + viewModel.CancelPreviewCommand.RaiseCanExecuteChanged(); return ValueTask.CompletedTask; - }); + } + + private async ValueTask ConfirmApplyAsync(object? parameter, CancellationToken token) + { + if (contentOperations is null || pendingPreview is not { IsValid: true } preview) + { + return; + } + + pendingPreview = null; + RefreshPreviewCommands(); + var outcome = await contentOperations.ApplyAsync(preview, token); + StatusText = outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange + ? "Chapter names updated." + : string.Join("; ", outcome.Errors); + RaisePreviewCommands(); + } + + private ValueTask CancelPreviewAsync(object? parameter, CancellationToken cancellationToken) + { + if (pendingPreview is { } preview) + { + contentOperations?.Cancel(preview); + pendingPreview = null; + StatusText = string.Empty; + RefreshPreviewCommands(); + } + + RaisePreviewCommands(); + return ValueTask.CompletedTask; + } + + private void RefreshPreviewCommands() + { + OnPropertyChanged(nameof(IsPreviewPending)); + OnPropertyChanged(nameof(CanPreparePreview)); + OnPropertyChanged(nameof(CanApplyPreview)); + } + + private void RaisePreviewCommands() + { + ConfirmApplyCommand.RaiseCanExecuteChanged(); + CancelPreviewCommand.RaiseCanExecuteChanged(); + } + + private static string DescribePreview(ChapterContentPreview preview) => !preview.IsValid + ? string.Join("; ", preview.Errors) + : preview.Differences.IsEmpty + ? "No chapter names will change." + : string.Join("; ", preview.Differences.Take(4).Select(static difference => + $"{difference.Before} → {difference.After}")); } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/UiCommand.cs b/src/ChapterTool.Avalonia.UI/ViewModels/UiCommand.cs index 7541ac27..f9bf3868 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/UiCommand.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/UiCommand.cs @@ -20,7 +20,7 @@ public UiCommand(Func execute, Func? canExecu public event PropertyChangedEventHandler? PropertyChanged; - /// Optional host boundary for unexpected command failures. + /// Gets or sets optional host boundary for unexpected command failures. public Func? ErrorHandler { get; set; } public bool IsExecuting @@ -54,7 +54,9 @@ private set } } - public bool CanExecute(object? parameter = null) => !IsExecuting && canExecute(parameter); + public bool CanExecute(object? parameter) => !IsExecuting && canExecute(parameter); + + public bool CanExecute() => CanExecute(null); public async ValueTask ExecuteAsync(object? parameter = null, CancellationToken cancellationToken = default) { diff --git a/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml b/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml index 8469635a..e7466ae7 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml +++ b/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml @@ -1,7 +1,7 @@ - + diff --git a/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml.cs b/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml.cs index f7c071e8..87ba5ff1 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml.cs +++ b/src/ChapterTool.Avalonia.UI/Views/Controls/ExpressionEditor.axaml.cs @@ -11,6 +11,7 @@ using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; using ChapterTool.Avalonia.UI.Localization; +using ChapterTool.Core.Models; using ChapterTool.Core.Transform; namespace ChapterTool.Avalonia.UI.Views.Controls; @@ -21,9 +22,6 @@ public sealed class ExpressionEditorExpansionChangedEventArgs(double heightDelta } -// Compatibility name used by ExpressionEditor.axaml bindings. -public sealed class ExpressionCompletionKindBrushConverter : Expression.ExpressionCompletionPresentation; - /// Provides an expression editor with completion and diagnostic presentation. public sealed partial class ExpressionEditor : UserControl { @@ -39,6 +37,9 @@ public sealed partial class ExpressionEditor : UserControl public static readonly StyledProperty AuthoringServiceProperty = AvaloniaProperty.Register(nameof(AuthoringService)); + public static readonly StyledProperty FrameRateProperty = + AvaloniaProperty.Register(nameof(FrameRate)); + public static readonly StyledProperty EditorHeightProperty = AvaloniaProperty.Register(nameof(EditorHeight), 32); @@ -142,6 +143,12 @@ public double EditorHeight set => SetValue(EditorHeightProperty, value); } + public FrameRateValue FrameRate + { + get => GetValue(FrameRateProperty); + set => SetValue(FrameRateProperty, value); + } + public bool IsMultilineExpandable { get => GetValue(IsMultilineExpandableProperty); @@ -238,17 +245,13 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { AnalyzeAndRender(); } - else if (change.Property == AuthoringServiceProperty) + else if (change.Property == AuthoringServiceProperty || change.Property == FrameRateProperty) { diagnosticHideTimer.Stop(); diagnosticRenderTimer.Stop(); AnalyzeAndRender(renderDiagnosticsImmediately: true); } - else if (change.Property == EditorHeightProperty) - { - UpdateMultilineState(); - } - else if (change.Property == IsMultilineExpandableProperty) + else if (change.Property == EditorHeightProperty || change.Property == IsMultilineExpandableProperty) { UpdateMultilineState(); } @@ -429,7 +432,13 @@ private void OnWrapperGotFocus(object? sender, RoutedEventArgs args) { if (!editor.IsKeyboardFocusWithin) { - FocusInnerEditor(); + Dispatcher.UIThread.Post(() => + { + if (IsKeyboardFocusWithin && !editor.IsKeyboardFocusWithin) + { + FocusInnerEditor(); + } + }); } } @@ -446,7 +455,7 @@ private void FocusInnerEditor() private void AnalyzeAndRender(bool renderDiagnosticsImmediately = true) { - var result = EffectiveAuthoringService.Analyze(editor.Text, editor.CaretOffset); + var result = EffectiveAuthoringService.Analyze(editor.Text, editor.CaretOffset, frameRate: FrameRate); CurrentCompletions = result.Completions; colorizer.Update(result.Spans); editor.TextArea.TextView.Redraw(); @@ -467,7 +476,7 @@ private void AnalyzeAndRender(bool renderDiagnosticsImmediately = true) private void OnDiagnosticRenderTimerTick(object? sender, EventArgs args) { diagnosticRenderTimer.Stop(); - var result = EffectiveAuthoringService.Analyze(editor.Text, editor.CaretOffset); + var result = EffectiveAuthoringService.Analyze(editor.Text, editor.CaretOffset, frameRate: FrameRate); RenderDiagnostics(result); } @@ -710,7 +719,7 @@ private bool TryResolveCompletion(PointerPressedEventArgs args, out ExpressionCo private bool IsOpenLeftClick(PointerPressedEventArgs args) => CompletionPopup.IsOpen && args.GetCurrentPoint(CompletionList).Properties.IsLeftButtonPressed; - private bool TryGetCompletionSource(PointerPressedEventArgs args, out ExpressionCompletion completion) + private static bool TryGetCompletionSource(PointerPressedEventArgs args, out ExpressionCompletion completion) { var item = (args.Source as Visual)?.FindAncestorOfType(); if (item?.DataContext is ExpressionCompletion found) diff --git a/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml b/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml new file mode 100644 index 00000000..67e310cb --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml.cs b/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml.cs new file mode 100644 index 00000000..6020b36e --- /dev/null +++ b/src/ChapterTool.Avalonia.UI/Views/Controls/FramePresentationView.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls; +using ChapterTool.Core.Transform; + +namespace ChapterTool.Avalonia.UI.Views.Controls; + +public partial class FramePresentationView : UserControl +{ + public static readonly StyledProperty PresentationProperty = + AvaloniaProperty.Register(nameof(Presentation)); + + public FramePresentationView() + { + InitializeComponent(); + } + + public FramePresentationParts? Presentation + { + get => GetValue(PresentationProperty); + set => SetValue(PresentationProperty, value); + } +} diff --git a/src/ChapterTool.Avalonia.UI/Views/MainView.axaml b/src/ChapterTool.Avalonia.UI/Views/MainView.axaml index de764247..bbe0cfdc 100644 --- a/src/ChapterTool.Avalonia.UI/Views/MainView.axaml +++ b/src/ChapterTool.Avalonia.UI/Views/MainView.axaml @@ -13,12 +13,22 @@ DragDrop.AllowDrop="True" DragDrop.Drop="OnDrop" x:CompileBindings="True"> + + + + - - + @@ -124,7 +135,7 @@ + + + + + + + + + + + - + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + BorderThickness="1" + ClipToBounds="True"> + + - - - @@ -44,68 +273,76 @@ - + - - - - - + - - - + + + + + + + - - + + @@ -113,306 +350,167 @@ - + - + TextAlignment="Center" /> - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - + BorderThickness="0,1,0,0" + IsVisible="{Binding HasStatus}"> + diff --git a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs index 2d51bca1..26a2929a 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs +++ b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs @@ -1,12 +1,231 @@ +using System.ComponentModel; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Highlighting; +using ChapterTool.Avalonia.UI.ViewModels.Tools; namespace ChapterTool.Avalonia.UI.Views.Tools; /// Provides the structured application log view. public sealed partial class LogToolView : UserControl { + private const double NarrowLayoutThreshold = 820; + private const double InspectorWidth = 420; + private readonly TextEditor? rawEditor; + private LogToolViewModel? subscribedViewModel; + private bool previousDetailsOpen; + public LogToolView() { InitializeComponent(); + rawEditor = new TextEditor + { + IsReadOnly = true, + ShowLineNumbers = true, + WordWrap = false, + FontSize = 12, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Background = Brushes.Transparent, + Padding = new Thickness(6, 4), + Document = new TextDocument(), + SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("Json") + }; + LogRawEditorHost.Content = rawEditor; + } + + public LogToolView(TextEditor? rawEditor) + { + this.rawEditor = rawEditor; + } + + private void OnDataContextChanged(object? sender, EventArgs args) + { + if (subscribedViewModel is not null) + { + subscribedViewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + + subscribedViewModel = DataContext as LogToolViewModel; + if (subscribedViewModel is not null) + { + subscribedViewModel.PropertyChanged += OnViewModelPropertyChanged; + previousDetailsOpen = subscribedViewModel.ShowDetails; + } + else + { + previousDetailsOpen = false; + } + + ApplyResponsiveLayout(); + + // SizeChanged is raised before descendants receive their final bounds. + // Reapply once the layout pass has propagated the new width. + Dispatcher.UIThread.Post(ApplyResponsiveLayout); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName is nameof(LogToolViewModel.ShowDetails) + or nameof(LogToolViewModel.HasSelectedEntry) + or nameof(LogToolViewModel.SelectedEntry)) + { + var detailsOpen = subscribedViewModel?.ShowDetails == true; + var changed = detailsOpen != previousDetailsOpen; + previousDetailsOpen = detailsOpen; + ApplyResponsiveLayout(); + UpdateRawEditor(); + if (changed) + { + HideFlyouts(); + Dispatcher.UIThread.Post(() => + { + ApplyResponsiveLayout(); + if (detailsOpen) + { + FocusInspector(); + } + else + { + FocusEntry(subscribedViewModel?.SelectedEntry); + } + }); + } + } + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs args) + { + if (args.PreviousSize.Width > 0 && Math.Abs(args.PreviousSize.Width - args.NewSize.Width) > 0.5) + { + HideFlyouts(); + } + + ApplyResponsiveLayout(); + Dispatcher.UIThread.Post(ApplyResponsiveLayout); + } + + private void HideFlyouts() + { + LogFilterButton.Flyout?.Hide(); + LogMoreButton.Flyout?.Hide(); + } + + private void ApplyResponsiveLayout() + { + if (!TryGetResponsiveWidth(out var width)) + { + return; + } + + var narrow = width <= NarrowLayoutThreshold; + var detailsOpen = DataContext is LogToolViewModel { ShowDetails: true }; + var layout = LogLayout!; + layout.ColumnDefinitions[0].Width = detailsOpen && narrow + ? new GridLength(0) + : new GridLength(1, GridUnitType.Star); + layout.ColumnDefinitions[1].Width = !detailsOpen + ? new GridLength(0) + : narrow + ? new GridLength(1, GridUnitType.Star) + : new GridLength(InspectorWidth, GridUnitType.Pixel); + LogListHost!.IsVisible = !detailsOpen || !narrow; + ConfigureDetailsPanel(narrow); + } + + private bool TryGetResponsiveWidth(out double width) + { + width = 0; + if (LogList is null || LogListHost is null || LogLayout is null || LogDetailsPanel is null || LogContentSurface is null) + { + return false; + } + + width = LogContentSurface.Bounds.Width; + return width > 0; + } + + private void ConfigureDetailsPanel(bool narrow) + { + LogDetailsPanel!.HorizontalAlignment = HorizontalAlignment.Stretch; + LogDetailsPanel.Width = double.NaN; + LogDetailsPanel.MaxWidth = narrow ? double.PositiveInfinity : InspectorWidth; + } + + private void UpdateRawEditor() + { + if (rawEditor is null) + { + return; + } + + var text = (DataContext as LogToolViewModel)?.SelectedEntry?.RawText ?? string.Empty; + if (!string.Equals(rawEditor.Text, text, StringComparison.Ordinal)) + { + rawEditor.Document = new TextDocument(text); + } + + } + + private void FocusInspector() + { + if (LogDetailsCloseButton.IsVisible) + { + LogDetailsCloseButton.Focus(); + } + } + + private void OnKeyDown(object? sender, KeyEventArgs args) + { + if (DataContext is not LogToolViewModel viewModel) + { + return; + } + + if (args.Key == Key.Escape && viewModel.ShowDetails) + { + args.Handled = true; + viewModel.CloseDetailsCommand.Execute(null); + return; + } + + if (args.Key is not (Key.Enter or Key.Space) + || viewModel.SelectedEntry is not { } selected + || !IsListInput(args.Source as Visual)) + { + return; + } + + args.Handled = true; + viewModel.OpenDetailsCommand.Execute(selected); + } + + private bool IsListInput(Visual? source) + { + return source is not null + && (ReferenceEquals(source, LogList) + || (source.FindAncestorOfType() is { } list && ReferenceEquals(list, LogList))); + } + + private void FocusEntry(LogEntryViewModel? entry) + { + if (entry is not null && LogList.ContainerFromItem(entry) is { } container) + { + var detailsAction = container.GetVisualDescendants() + .OfType + + + + + + + + diff --git a/src/ChapterTool.Avalonia.UI/Views/Tools/SettingsToolView.axaml.cs b/src/ChapterTool.Avalonia.UI/Views/Tools/SettingsToolView.axaml.cs index 42aad63d..56ffc507 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Tools/SettingsToolView.axaml.cs +++ b/src/ChapterTool.Avalonia.UI/Views/Tools/SettingsToolView.axaml.cs @@ -1,4 +1,6 @@ using Avalonia.Controls; +using Avalonia.Input; +using ChapterTool.Avalonia.UI.ViewModels; namespace ChapterTool.Avalonia.UI.Views.Tools; @@ -9,4 +11,30 @@ public SettingsToolView() { InitializeComponent(); } + + private void OnShortcutKeyDown(object? sender, KeyEventArgs args) + { + if (sender is not TextBox { DataContext: ShortcutRowViewModel { IsEditable: true } row }) + { + return; + } + + switch (args.Key) + { + case Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt + or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin: + args.Handled = true; + return; + case Key.Back: + row.Gesture = string.Empty; + args.Handled = true; + return; + } + + var gesture = new KeyGesture(args.Key, args.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift | KeyModifiers.Meta)); + row.Gesture = gesture.ToString() + .Replace("Command+", "Meta+", StringComparison.Ordinal) + .Replace("Cmd+", "Meta+", StringComparison.Ordinal); + args.Handled = true; + } } diff --git a/src/ChapterTool.Avalonia.UI/Views/Tools/TemplateNamesToolView.axaml b/src/ChapterTool.Avalonia.UI/Views/Tools/TemplateNamesToolView.axaml index c995162b..0d084fa1 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Tools/TemplateNamesToolView.axaml +++ b/src/ChapterTool.Avalonia.UI/Views/Tools/TemplateNamesToolView.axaml @@ -1,23 +1,37 @@ + + + - + + + + + + +@code { + [Parameter, EditorRequired] public string Operation { get; set; } = "naming"; + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + private int mode; + private int shift; + private string shiftText = "0"; + private string framesText = "1"; + private int originalMode; + private int originalShift; + private string originalTemplate = string.Empty; + private string originalStatus = string.Empty; + private bool applying; + private bool applied; + private bool disposed; + private string? error; + private string Title => L.T(Operation switch { "shift" => "Modal.ForwardTitle", "fps" => "Menu.ChangeFps", _ => "Parity.NamingTitle" }); + private bool CanApply => !applying && error is null && !Workspace.IsContentPreviewStale + && Workspace.ContentPreview is { IsValid: true } && Workspace.ContentPreviewProjection is { HasChanges: true }; + + protected override void OnInitialized() + { + originalMode = mode = Workspace.ChapterNameModeIndex; + originalShift = shift = Workspace.OrderShift; + shiftText = shift.ToString(CultureInfo.InvariantCulture); + originalTemplate = Workspace.ChapterNameTemplateText ?? string.Empty; + originalStatus = Workspace.ChapterNameTemplateStatus ?? string.Empty; + if (Operation == "fps") Prepare(); + } + + private void Invalidate() { error = null; Workspace.CancelContentPreview(); } + + private void Prepare() + { + error = null; + if (Operation == "naming" && (!int.TryParse(shiftText, NumberStyles.Integer, CultureInfo.InvariantCulture, out shift) || shift is < 0 or > 1000)) + { + error = L.T("Parity.InvalidNumber"); + Workspace.CancelContentPreview(); + return; + } + var frames = 0; + if (Operation == "shift" && (!int.TryParse(framesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out frames) || frames is < -1000000 or > 1000000)) + { + error = L.T("Parity.InvalidShift"); + Workspace.CancelContentPreview(); + return; + } + Workspace.ChapterNameModeIndex = mode; + Workspace.OrderShift = shift; + if (Operation == "shift") Workspace.PrepareFrameShiftPreview(frames); + else if (Operation == "fps") Workspace.PrepareFrameRatePreview(); + else Workspace.PrepareNamingPreview(); + } + + private async Task LoadTemplateAsync(InputFileChangeEventArgs args) + { + Invalidate(); + try + { + await using var stream = args.File.OpenReadStream(2 * 1024 * 1024); + using var reader = new StreamReader(stream, Encoding.UTF8, true); + var text = await reader.ReadToEndAsync(); + if (disposed) return; + if (Workspace.SetChapterNameTemplate(args.File.Name, text)) mode = 2; + else error = L.T("Status.TemplateFailed"); + } + catch (IOException) { error = L.T("Status.TemplateFailed"); } + } + + private async Task ApplyAsync() + { + if (!CanApply) return; + applying = true; + try + { + if (await Workspace.ApplyContentPreviewAsync()) { applied = true; await OnClose.InvokeAsync(); } + else error = Workspace.StatusText; + } + finally { applying = false; } + } + + private Task CancelAsync() => applying ? Task.CompletedTask : OnClose.InvokeAsync(); + + private string ChapterLabel(ExpressionChapterComparison row) + { + var chapter = row.Candidate ?? row.Before; + return chapter is null ? L.T("Expression.Preview.Chapter") : $"{chapter.DisplayNumber} · {chapter.Name}"; + } + + private string ChapterNumber(EditableChapter? chapter) => chapter?.DisplayNumber.ToString(CultureInfo.InvariantCulture) + ?? L.T("Expression.Value.Missing"); + + private string ChapterName(EditableChapter? chapter) => chapter?.Name ?? L.T("Expression.Value.Missing"); + + private string ChapterTime(EditableChapter? chapter) => chapter is null + ? L.T("Expression.Value.Missing") + : Workspace.FormatExpressionTimeTicks(chapter.StartTicks); + + private RenderFragment FrameValue(ExpressionChapterComparison row, bool candidate) => builder => + { + var chapter = candidate ? row.Candidate : row.Before; + var frame = candidate ? row.CandidateFrames : row.BeforeFrames; + if (chapter is null || frame is null || frame.IsMissing) + { + builder.AddContent(0, L.T("Expression.Value.NotCalculated")); + return; + } + + var value = Workspace.FormatPreviewFramePresentation(row, candidate, showRepeatingDecimals: true); + var accuracy = Workspace.GetPreviewFrameAccuracy(chapter.StartTicks); + var accuracyClass = accuracy switch + { + FrameAccuracy.Accurate => "accurate", + FrameAccuracy.Inexact => "inexact", + _ => "neutral" + }; + var accuracyText = L.T(accuracy switch + { + FrameAccuracy.Accurate => "Expression.FrameAccuracy.Accurate", + FrameAccuracy.Inexact => "Expression.FrameAccuracy.Inexact", + _ => "Expression.FrameAccuracy.NotCalculated" + }); + var accessibleName = $"{(candidate ? L.T("Expression.Preview.AfterFrames") : L.T("Expression.Preview.BeforeFrames"))}: {value.AccessibleDescription}, {accuracyText}"; + builder.OpenComponent(0); + builder.AddAttribute(1, "Value", value); + builder.AddAttribute(2, "CssClass", $"frame-text {accuracyClass}"); + builder.AddAttribute(3, "AccessibleName", accessibleName); + builder.CloseComponent(); + }; + + private string FormatDelta(long ticks) + { + var sign = ticks > 0 ? "+" : "−"; + return sign + Workspace.FormatExpressionTimeTicks(Math.Abs(ticks)); + } + + private FrameRateValue ScopeFrameRate(EditableChapterDocument document) => + document.Tracks.FirstOrDefault(track => track.Id == Workspace.ContentPreviewProjection?.ScopeTrackId) + ?.FrameRate ?? document.FrameRate; + + private string FrameRateValue(FrameRateValue rate) => rate.IsKnown + ? $"{rate} {L.T("Expression.Unit.FramesPerSecond")}" + : L.T("Expression.Value.NotSet"); + + private string PropertyValue(string property, object? value) + { + if (value is null) return L.T("Expression.Value.NotSet"); + if (value is ChapterFrameRate rate) return $"{rate.Numerator}/{rate.Denominator} {L.T("Expression.Unit.FramesPerSecond")}"; + if (value is ChapterDuration duration) + { + return duration.IsKnown ? Workspace.FormatExpressionTimeTicks(duration.Ticks) : L.T("Expression.Value.NotSet"); + } + if (property.EndsWith("Ticks", StringComparison.Ordinal) && value is long ticks) + { + return Workspace.FormatExpressionTimeTicks(ticks); + } + if (value is string text) return text; + if (value is Enum enumeration) return enumeration.ToString(); + if (value is Guid id) return id.ToString("D", CultureInfo.InvariantCulture); + if (value is IFormattable formatted) return formatted.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty; + if (value is IEnumerable) + { + try { return JsonSerializer.Serialize(value, value.GetType()); } + catch (NotSupportedException) { } + } + return value.ToString() ?? string.Empty; + } + + private string OwnerLabel(ExpressionPropertyOwnerKind owner) => owner switch + { + ExpressionPropertyOwnerKind.Document => L.T("Expression.PropertyOwner.Document"), + ExpressionPropertyOwnerKind.Track => L.T("Expression.PropertyOwner.Track"), + ExpressionPropertyOwnerKind.Segment => L.T("Expression.PropertyOwner.Segment"), + _ => L.T("Expression.PropertyOwner.Chapter") + }; + + public void Dispose() + { + disposed = true; + if (!applied) Workspace.RestoreNamingDraft(originalMode, originalShift, originalTemplate, originalStatus); + else Workspace.CancelContentPreview(); + } +} diff --git a/src/ChapterTool.Wasm/Components/ContentOperationDialog.razor.css b/src/ChapterTool.Wasm/Components/ContentOperationDialog.razor.css new file mode 100644 index 00000000..6f3910fb --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ContentOperationDialog.razor.css @@ -0,0 +1,90 @@ +.candidate-review { + display: grid; + gap: 0.65rem; + min-width: 0; +} + +.content-review-row, +.content-review-metadata { + min-width: 0; + padding: 0.65rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + background: var(--panel-bg); +} + +.content-review-row h3, +.content-review-row h4, +.content-review-metadata h4 { + margin: 0 0 0.45rem; +} + +.content-review-sides { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.content-review-sides section + section { + padding-inline-start: 0.75rem; + border-inline-start: 1px solid var(--border); +} + +.content-review-sides dl, +.metadata-list dl { + display: grid; + grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr); + gap: 0.35rem 0.65rem; + margin: 0; +} + +.content-review-sides dd, +.metadata-list dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.review-time { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.content-review-delta { + margin: 0.45rem 0 0; + color: var(--muted); +} + +.content-review-metadata summary { + cursor: pointer; + font-weight: 600; +} + +.metadata-list { + display: grid; + gap: 0.5rem; + margin-block-start: 0.5rem; +} + +.metadata-list article { + min-width: 0; + padding: 0.5rem 0; + border-block-start: 1px solid var(--border); +} + +.metadata-list h4 { + overflow-wrap: anywhere; +} + +@media (max-width: 560px) { + .content-review-sides { + grid-template-columns: minmax(0, 1fr); + } + + .content-review-sides section + section { + padding-inline-start: 0; + padding-block-start: 0.65rem; + border-inline-start: 0; + border-block-start: 1px solid var(--border); + } +} diff --git a/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor b/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor new file mode 100644 index 00000000..37d422c1 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor @@ -0,0 +1,74 @@ +@inject WasmWorkspace Workspace +@inject WasmLocalizer L + + + +
+ + + +
+
+ +@code { + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + [Parameter, EditorRequired] public EventCallback OnApply { get; set; } + private string xmlLanguage = string.Empty; + private int encodingIndex; + private bool emitBom; + private bool applying; + private string? error; + + protected override void OnInitialized() + { + xmlLanguage = Workspace.XmlLanguage; + encodingIndex = OutputTextEncodings.All.ToList().IndexOf(Workspace.TextEncoding); + emitBom = Workspace.EmitBom; + } + + private async Task ApplyAsync() + { + var draft = new ExportOptionsDraft(xmlLanguage, encodingIndex, emitBom); + if (!draft.IsValid(Workspace.XmlLanguages, OutputTextEncodings.All.Count, Workspace.IsXmlLanguageEnabled)) + { + error = L.T("Parity.ExportOptionsInvalid"); + return; + } + + error = null; + applying = true; + try + { + await OnApply.InvokeAsync(draft); + } + catch (JSException) + { + error = L.T("Parity.SettingsSaveFailed"); + } + finally + { + applying = false; + } + } + + private Task CancelAsync() => applying ? Task.CompletedTask : OnClose.InvokeAsync(); +} diff --git a/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor.css b/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor.css new file mode 100644 index 00000000..22f4ace9 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ExportOptionsDialog.razor.css @@ -0,0 +1,29 @@ +.export-options-grid { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(10rem, 2fr); + align-items: center; + gap: 0.65rem 0.85rem; +} + +.export-options-grid select { + width: 100%; + min-width: 0; +} + +.settings-check { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.5rem; +} + +@media (max-width: 520px) { + .export-options-grid { + grid-template-columns: minmax(0, 1fr); + gap: 0.35rem; + } + + .export-options-grid label:not(.settings-check) { + margin-block-start: 0.45rem; + } +} diff --git a/src/ChapterTool.Wasm/Components/ExportOptionsDraft.cs b/src/ChapterTool.Wasm/Components/ExportOptionsDraft.cs new file mode 100644 index 00000000..1d2b814d --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ExportOptionsDraft.cs @@ -0,0 +1,12 @@ +namespace ChapterTool.Wasm.Components; + +public sealed record ExportOptionsDraft( + string XmlLanguage, + int EncodingIndex, + bool EmitBom) +{ + public bool IsValid(IReadOnlyList xmlLanguages, int encodingCount, bool xmlLanguageEnabled) => + EncodingIndex >= 0 + && EncodingIndex < encodingCount + && (!xmlLanguageEnabled || xmlLanguages.Contains(XmlLanguage, StringComparer.OrdinalIgnoreCase)); +} diff --git a/src/ChapterTool.Wasm/Components/ExpressionDialog.razor b/src/ChapterTool.Wasm/Components/ExpressionDialog.razor new file mode 100644 index 00000000..dc840e2a --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ExpressionDialog.razor @@ -0,0 +1,564 @@ +@implements IDisposable +@inject WasmWorkspace Workspace +@inject WasmLocalizer L +@inject IJSRuntime JSRuntime +@using ChapterTool.Core.Models +@using ChapterTool.Core.Session +@using ChapterTool.Core.Transform +@using System.Text + + + +
+ @if (applying) + { + @L.T("Expression.State.Applying") + } + + +
+
+ +@code { + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + private string expression = string.Empty; + private string presetId = string.Empty; + private string originalExpression = string.Empty; + private string originalPresetId = string.Empty; + private string originalSourceName = string.Empty; + private string? scriptError; + private ExpressionAnalysisResult? analysis; + private bool completionOpen; + private int completionIndex; + private CancellationTokenSource? cancellation; + private bool pending; + private bool applied; + private bool applying; + private bool disposed; + private bool composing; + private bool applyFailed; + private string applyError = string.Empty; + private ElementReference expressionEditorElement; + private IJSObjectReference? compositionModule; + private DotNetObjectReference? compositionCallback; + private bool showAll; + private bool showFrames; + private long revision; + private long inputRevision; + private bool CanApply => !pending && !composing && !applying && !Workspace.IsBusy + && !Workspace.IsExpressionPreviewStale + && Workspace.ExpressionPreview is { IsValid: true } + && Workspace.ExpressionPreviewProjection is { HasChanges: true }; + + protected override void OnInitialized() + { + originalExpression = expression = Workspace.Expression; + originalPresetId = presetId = Workspace.ExpressionPresetId; + originalSourceName = Workspace.ExpressionSourceName; + analysis = Workspace.AuthoringService.Analyze(expression, expression.Length, frameRate: Workspace.ComputationFrameRate); + Workspace.PrepareExpressionPreview(expression); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + var module = await JSRuntime.InvokeAsync("import", "./js/expression-composition.js"); + var callback = DotNetObjectReference.Create(this); + if (disposed) + { + callback.Dispose(); + await DetachCompositionListenersAsync(module, expressionEditorElement); + return; + } + + try + { + await module.InvokeVoidAsync("attach", expressionEditorElement, callback); + } + catch (JSException) when (disposed) + { + callback.Dispose(); + await DetachCompositionListenersAsync(module, expressionEditorElement); + return; + } + + if (disposed) + { + await DetachCompositionListenersAsync(module, expressionEditorElement); + callback.Dispose(); + return; + } + + compositionModule = module; + compositionCallback = callback; + } + + private async Task OnInput(ChangeEventArgs args) + { + if (disposed) return; + var currentInput = ++inputRevision; + pending = true; + Workspace.CancelExpressionPreview(); + completionOpen = false; + var value = args.Value?.ToString() ?? string.Empty; + if (!composing) + { + var caret = compositionModule is null + ? value.Length + : await compositionModule.InvokeAsync("caret", expressionEditorElement); + if (disposed || currentInput != inputRevision) return; + analysis = Workspace.AuthoringService.Analyze(value, caret, frameRate: Workspace.ComputationFrameRate); + } + await ScheduleAsync(value, schedule: !composing); + if (!disposed) await UpdateCompletionStateAsync(); + } + + private async Task LoadScriptAsync(InputFileChangeEventArgs args) + { + scriptError = null; + try + { + await using var stream = args.File.OpenReadStream(2 * 1024 * 1024); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true); + var value = await reader.ReadToEndAsync(); + if (disposed) return; + if (string.IsNullOrWhiteSpace(value)) throw new IOException(); + var result = Workspace.AuthoringService.Analyze(value, value.Length, frameRate: Workspace.ComputationFrameRate); + if (result.Diagnostics.Count > 0) { scriptError = result.Diagnostics[0].Diagnostic.Message; return; } + Workspace.ClearExpressionPresetSelection(); + Workspace.ExpressionSourceName = args.File.Name; + analysis = result; + await ScheduleAsync(value, true); + } + catch (Exception exception) when (exception is IOException or DecoderFallbackException) + { scriptError = L.T("Parity.ScriptLoadFailed"); } + } + + [JSInvokable] + public async Task EditorKey(string key, int caret) + { + if (disposed || composing || applying) return; + if (key == "Complete") + { + analysis = Workspace.AuthoringService.Analyze(expression, caret, frameRate: Workspace.ComputationFrameRate); + completionIndex = 0; + completionOpen = analysis.Completions.Count > 0; + } + else if (key == "Escape") completionOpen = false; + else if (completionOpen && key is "Enter" or "Tab") { await AcceptCompletionAsync(completionIndex); return; } + else if (completionOpen && analysis is not null && key is "ArrowDown" or "ArrowUp") + completionIndex = Math.Clamp(completionIndex + (key == "ArrowDown" ? 1 : -1), 0, analysis.Completions.Count - 1); + await UpdateCompletionStateAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task AcceptCompletionAsync(int index) + { + if (analysis is null || index < 0 || index >= analysis.Completions.Count) return; + var completion = analysis.Completions[index]; + completionOpen = false; + await UpdateCompletionStateAsync(); + await compositionModule!.InvokeVoidAsync("replace", expressionEditorElement, + completion.ReplacementStart, completion.ReplacementLength, completion.InsertText); + } + + private Task UpdateCompletionStateAsync() => disposed || compositionModule is null ? Task.CompletedTask + : compositionModule.InvokeVoidAsync("completionState", expressionEditorElement, completionOpen).AsTask(); + + private Task SelectDiagnosticAsync(ExpressionAuthoringDiagnostic diagnostic) => + compositionModule!.InvokeVoidAsync("select", expressionEditorElement, diagnostic.Start, diagnostic.Length).AsTask(); + + private RenderFragment HighlightScript() => builder => + { + var position = 0; + var sequence = 0; + foreach (var span in analysis?.Spans ?? []) + { + if (span.Start < position || span.Start + span.Length > expression.Length) continue; + builder.AddContent(sequence++, expression[position..span.Start]); + builder.OpenElement(sequence++, "span"); + builder.AddAttribute(sequence++, "class", "lua-token-" + span.Kind.ToString().ToLowerInvariant()); + builder.AddContent(sequence++, expression.Substring(span.Start, span.Length)); + builder.CloseElement(); + position = span.Start + span.Length; + } + builder.AddContent(sequence++, expression[position..] + "\n"); + }; + + private async Task OnPresetChanged(ChangeEventArgs args) + { + var selected = args.Value?.ToString() ?? string.Empty; + if (selected.Length == 0) Workspace.ClearExpressionPresetSelection(); + else Workspace.ApplyExpressionPreset(selected); + presetId = Workspace.ExpressionPresetId; + expression = selected.Length == 0 ? expression : Workspace.Expression; + analysis = Workspace.AuthoringService.Analyze(expression, expression.Length, frameRate: Workspace.ComputationFrameRate); + await ScheduleAsync(expression, schedule: !composing); + } + + [JSInvokable] + public Task CompositionStarted() => InvokeAsync(() => + { + composing = true; + completionOpen = false; + StateHasChanged(); + }); + + [JSInvokable] + public Task CompositionEnded() => InvokeAsync(async () => + { + composing = false; + analysis = Workspace.AuthoringService.Analyze(expression, expression.Length, frameRate: Workspace.ComputationFrameRate); + await ScheduleAsync(expression, schedule: true); + }); + + private async Task ScheduleAsync(string value, bool schedule) + { + var previousCancellation = cancellation; + var source = cancellation = new CancellationTokenSource(); + var token = source.Token; + applyFailed = false; + applyError = string.Empty; + var currentRevision = ++revision; + pending = schedule; + expression = value; + Workspace.Expression = value; + presetId = Workspace.ExpressionPresetId; + Workspace.CancelExpressionPreview(); + if (previousCancellation is not null) + { + await previousCancellation.CancelAsync(); + previousCancellation.Dispose(); + } + if (disposed || currentRevision != revision || !ReferenceEquals(cancellation, source)) return; + if (!schedule) + { + await InvokeAsync(StateHasChanged); + return; + } + + try + { + await Task.Delay(250, token); + if (disposed || currentRevision != revision || composing) return; + Workspace.PrepareExpressionPreview(value); + pending = false; + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + } + + private void RefreshPreview() + { + if (applying || string.IsNullOrWhiteSpace(expression)) return; + Workspace.PrepareExpressionPreview(expression); + pending = false; + } + + private async Task ApplyAsync() + { + if (!CanApply) return; + applying = true; + try + { + if (await Workspace.ApplyExpressionPreviewAsync()) + { + applied = true; + Workspace.Expression = expression; + await OnClose.InvokeAsync(); + } + else if (Workspace.IsExpressionPreviewStale) + { + pending = false; + } + else + { + applyFailed = true; + applyError = Workspace.StatusText; + } + } + catch (Exception exception) when (exception is not StackOverflowException) + { + applyFailed = true; + applyError = exception.Message; + } + finally + { + applying = false; + } + } + + private async Task CloseAsync() + { + if (applying) return; + RestoreDraft(); + await OnClose.InvokeAsync(); + } + + private void RestoreDraft() + { + cancellation?.Cancel(); + revision++; + Workspace.CancelExpressionPreview(); + if (originalPresetId.Length > 0) Workspace.ApplyExpressionPreset(originalPresetId); + else Workspace.ClearExpressionPresetSelection(); + Workspace.Expression = originalExpression; + Workspace.ExpressionSourceName = originalSourceName; + } + + private IEnumerable VisibleChapters(ExpressionPreviewProjection projection) => + showAll ? projection.Chapters : projection.Chapters.Where(static row => row.Changes != ExpressionChapterChangeKind.None); + + private string ChapterLabel(ExpressionChapterComparison row) + { + var chapter = row.Candidate ?? row.Before; + return chapter is null ? L.T("Expression.Preview.Chapter") : $"{chapter.DisplayNumber} · {chapter.Name}"; + } + + private string Value(EditableChapter? chapter, ExpressionFrameValue? frames) + { + if (chapter is null) return L.T("Expression.Value.Missing"); + return showFrames ? FrameDescription(frames) : Workspace.FormatExpressionTimeTicks(chapter.StartTicks); + } + + private string Delta(ExpressionChapterComparison row) + { + if (showFrames) + { + return row.DeltaFrames is { } frames ? $"{frames:+#;-#;0} {L.T("Expression.Unit.FramesShort")}" : L.T("Expression.Value.IncompatibleFrames"); + } + + if (row.DeltaTicks is not { } ticks) return L.T("Expression.Value.Missing"); + var sign = ticks > 0 ? "+" : ticks < 0 ? "−" : string.Empty; + return sign + Workspace.FormatExpressionTimeTicks(Math.Abs(ticks)); + } + + private string FrameDescription(ExpressionFrameValue? value) + { + if (value is null || value.IsMissing) return L.T("Expression.Value.NotCalculated"); + var rate = value.FrameRate.IsKnown + ? $"{value.FrameRate} {L.T("Expression.Unit.FramesPerSecond")}" + : L.T("Expression.Value.NotSet"); + return $"{value.Text} {L.T("Expression.Unit.FramesShort")} · {rate}"; + } + + private string Accuracy(ExpressionFrameValue? value) => value?.Accuracy switch + { + FrameAccuracy.Accurate => L.T("Expression.FrameAccuracy.Accurate"), + FrameAccuracy.Inexact => L.T("Expression.FrameAccuracy.Inexact"), + _ => L.T("Expression.FrameAccuracy.NotCalculated") + }; + + private string OwnerLabel(ExpressionPropertyOwnerKind owner) => owner switch + { + ExpressionPropertyOwnerKind.Document => L.T("Expression.PropertyOwner.Document"), + ExpressionPropertyOwnerKind.Track => L.T("Expression.PropertyOwner.Track"), + ExpressionPropertyOwnerKind.Segment => L.T("Expression.PropertyOwner.Segment"), + _ => L.T("Expression.PropertyOwner.Chapter") + }; + + private string PropertyValue(string property, object? value) + { + if (value is null) return L.T("Expression.Value.NotSet"); + if (value is ChapterFrameRate rate) return $"{rate.Numerator}/{rate.Denominator} {L.T("Expression.Unit.FramesPerSecond")}"; + if (value is ChapterDuration duration) + { + return duration.IsKnown ? Workspace.FormatExpressionTimeTicks(duration.Ticks) : L.T("Expression.Value.NotSet"); + } + return property switch + { + "Title" or "SourceName" or "Name" when value is string text => text, + _ => L.T("Expression.Property.ValueChanged") + }; + } + + public void Dispose() + { + disposed = true; + if (!applied) RestoreDraft(); + cancellation?.Cancel(); + cancellation?.Dispose(); + compositionCallback?.Dispose(); + if (compositionModule is not null) + { + _ = DetachCompositionListenersAsync(compositionModule, expressionEditorElement); + } + } + + private static async Task DetachCompositionListenersAsync(IJSObjectReference module, ElementReference element) + { + try + { + await module.InvokeVoidAsync("detach", element); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } +} diff --git a/src/ChapterTool.Wasm/Components/FrameValueView.razor b/src/ChapterTool.Wasm/Components/FrameValueView.razor new file mode 100644 index 00000000..30368757 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/FrameValueView.razor @@ -0,0 +1,22 @@ +@using ChapterTool.Core.Transform + + + @Value.SignedIntegerDigits + @if (Value.HasFractionalDigits) + { + . + @Value.NonRepeatingDigits + } + @if (Value.HasRepeatingDigits) + { + @Value.RepeatingDigits + } + + +@code { + [Parameter, EditorRequired] public FramePresentationParts Value { get; set; } = null!; + [Parameter] public string? AccessibleName { get; set; } + [Parameter] public string CssClass { get; set; } = string.Empty; + [Parameter] public bool Decorative { get; set; } +} diff --git a/src/ChapterTool.Wasm/Components/HistoryDialog.razor b/src/ChapterTool.Wasm/Components/HistoryDialog.razor new file mode 100644 index 00000000..79ff070f --- /dev/null +++ b/src/ChapterTool.Wasm/Components/HistoryDialog.razor @@ -0,0 +1,470 @@ +@inject WasmWorkspace Workspace +@inject WasmLocalizer L +@inject IJSRuntime JS +@using ChapterTool.Core.Session +@implements IDisposable + + + +
+ @if (navigationStatus is not null) { @navigationStatus } + + + +
+
+ +@code { + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + private readonly HashSet expanded = []; + private Guid? selectedId; + private HistoryNodeDetails? details; + private WasmHistoryDetailsProjection? detailProjection; + private string? detailError; + private string? navigationStatus; + private bool detailLoading; + private bool focusCurrentAfterRender; + private bool focusDetailsAfterRender; + private bool focusPageTabAfterRender; + private Guid? lastCurrentId; + private Guid? lastRootId; + private long detailGeneration; + private CancellationTokenSource? detailCancellation; + private int narrowPage; + private string selectedFilter = "All"; + private string lastCulture = string.Empty; + private ElementReference currentEntryElement; + private ElementReference detailsPanel; + private ElementReference historyPageTab; + private ElementReference detailsPageTab; + + private Guid? CurrentId => Workspace.CurrentHistoryId; + private IReadOnlyList Entries => Workspace.GetHistoryEntries(expanded).ToArray(); + private IReadOnlyList RedoChoices => Workspace.HistoryRedoChoices; + private string SelectedRedoId => Workspace.SelectedHistoryRedoBranchId?.ToString() ?? string.Empty; + private IReadOnlyList<(string Value, string Label)> ChangeFilters => + [ + ("All", L.T("History.Filter.All")), + ("Added", L.T("History.Filter.Added")), + ("Removed", L.T("History.Filter.Removed")), + ("Modified", L.T("History.Filter.Modified")) + ]; + private IReadOnlyList VisibleChanges => detailProjection is null ? [] + : WasmHistoryProjection.FilterChanges(detailProjection.Changes, + Enum.TryParse(selectedFilter, out var filter) ? filter : WasmHistoryChangeFilter.All); + + protected override void OnInitialized() + { + Workspace.Changed += OnWorkspaceChanged; + lastRootId = Workspace.HistoryRootId; + lastCulture = L.Culture; + } + + protected override async Task OnParametersSetAsync() + { + SyncTree(); + if (selectedId is null || Workspace.HistoryEntries.All(row => row.Id != selectedId)) + { + selectedId = CurrentId ?? Workspace.HistoryEntries.FirstOrDefault()?.Id; + await LoadDetailsAsync(selectedId); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (focusCurrentAfterRender) + { + focusCurrentAfterRender = false; + await currentEntryElement.FocusAsync(); + } + if (focusDetailsAfterRender) + { + focusDetailsAfterRender = false; + await detailsPanel.FocusAsync(preventScroll: true); + } + if (focusPageTabAfterRender) + { + focusPageTabAfterRender = false; + await (narrowPage == 0 ? historyPageTab : detailsPageTab).FocusAsync(preventScroll: true); + } + } + + private void SyncTree() + { + var snapshot = Workspace.HistorySnapshot; + if (snapshot is null) return; + if (snapshot.RootId != lastRootId) + { + CancelDetailsRequest(); + expanded.Clear(); + selectedId = null; + details = null; + detailProjection = null; + lastRootId = snapshot.RootId; + lastCurrentId = null; + } + if (snapshot.CursorId == lastCurrentId) return; + lastCurrentId = snapshot.CursorId; + var nodes = snapshot.Nodes.ToDictionary(static node => node.Id); + var nodeId = snapshot.CursorId; + while (nodes.TryGetValue(nodeId, out var node) && node.ParentId is Guid parentId) + { + expanded.Add(parentId); + nodeId = parentId; + } + } + + private void OnWorkspaceChanged() => _ = InvokeAsync(async () => + { + var oldRoot = lastRootId; + var oldCurrent = lastCurrentId; + var cultureChanged = !string.Equals(lastCulture, L.Culture, StringComparison.OrdinalIgnoreCase); + lastCulture = L.Culture; + SyncTree(); + if (Workspace.HistoryRootId != oldRoot || CurrentId != oldCurrent) + { + selectedId = CurrentId; + await LoadDetailsAsync(selectedId); + } + else if (cultureChanged && details is { } selectedDetails) + { + detailProjection = WasmHistoryProjection.Project(L, selectedDetails); + } + StateHasChanged(); + }); + + public void Dispose() + { + Workspace.Changed -= OnWorkspaceChanged; + CancelDetailsRequest(); + } + + private string LocalizedDescription(WasmHistoryEntry entry) => Workspace.LocalizeHistoryEntry(entry); + + private async Task Toggle(Guid id) + { + if (!expanded.Add(id)) + { + expanded.Remove(id); + if (selectedId is { } selected && IsDescendant(selected, id)) + { + await SelectAsync(id); + } + } + } + + private bool IsDescendant(Guid nodeId, Guid ancestorId) + { + var nodes = Workspace.HistorySnapshot?.Nodes.ToDictionary(static node => node.Id); + while (nodes is not null && nodes.TryGetValue(nodeId, out var node) && node.ParentId is Guid parentId) + { + if (parentId == ancestorId) return true; + nodeId = parentId; + } + return false; + } + + private async Task LocateCurrentAsync() + { + var current = CurrentId; + if (current is null) return; + expanded.Clear(); + lastCurrentId = null; + SyncTree(); + selectedId = current; + narrowPage = 0; + await LoadDetailsAsync(selectedId); + focusCurrentAfterRender = true; + await InvokeAsync(StateHasChanged); + } + + private async Task SelectAsync(Guid? id) + { + selectedId = id; + narrowPage = 1; + await LoadDetailsAsync(id); + focusDetailsAfterRender = id is not null; + } + + private async Task LoadDetailsAsync(Guid? id) + { + CancelDetailsRequest(); + var generation = ++detailGeneration; + details = null; + detailProjection = null; + detailError = null; + if (id is null) + { + detailLoading = false; + return; + } + var rootId = Workspace.HistoryRootId; + var request = detailCancellation = new CancellationTokenSource(); + detailLoading = true; + try + { + var result = await Workspace.InspectHistoryAsync(id.Value, request.Token); + if (request.IsCancellationRequested || generation != detailGeneration || selectedId != id + || rootId is null || Workspace.HistoryRootId != rootId || Workspace.IsHistoryEnded) return; + if (result.Kind == HistoryInspectionOutcomeKind.Available && result.Details is { } available) + { + details = available; + detailProjection = WasmHistoryProjection.Project(L, available); + } + else if (result.Kind is HistoryInspectionOutcomeKind.ResourceFailure or HistoryInspectionOutcomeKind.NotFound or HistoryInspectionOutcomeKind.Ended) + { + detailError = result.Kind.ToString(); + } + else if (result.Kind != HistoryInspectionOutcomeKind.Cancelled) + { + detailError = "Failed"; + } + } + catch (OperationCanceledException) when (request.IsCancellationRequested) { } + catch (OutOfMemoryException) + { + if (generation == detailGeneration && selectedId == id && Workspace.HistoryRootId == rootId) detailError = "ResourceFailure"; + } + catch (Exception) + { + if (generation == detailGeneration && selectedId == id && Workspace.HistoryRootId == rootId) detailError = "Failed"; + } + finally + { + if (generation == detailGeneration) + { + detailLoading = false; + if (ReferenceEquals(detailCancellation, request)) detailCancellation = null; + request.Dispose(); + } + } + } + + private void CancelDetailsRequest() + { + detailGeneration++; + detailCancellation?.Cancel(); + detailCancellation?.Dispose(); + detailCancellation = null; + } + + private Task RetryDetailsAsync() => LoadDetailsAsync(selectedId); + + private void OnFilterChanged(ChangeEventArgs args) + { + selectedFilter = args.Value?.ToString() ?? "All"; + } + + private void SelectRedoBranch(ChangeEventArgs args) + { + if (Guid.TryParse(args.Value?.ToString(), out var id)) Workspace.SelectHistoryRedoBranch(id); + } + + private async Task UndoAsync() => await Workspace.UndoAsync(); + + private async Task RedoAsync() => await Workspace.RedoSelectedHistoryBranchAsync(); + + private async Task RestoreAsync() + { + if (selectedId is not { } id) return; + var oldCursor = CurrentId; + try + { + await Workspace.RestoreHistoryAsync(id); + navigationStatus = CurrentId != oldCursor ? L.T("History.RestoreApplied") : L.T("History.NavigationFailed"); + } + catch (Exception) + { + navigationStatus = L.T("History.NavigationFailed"); + } + } + + private void ShowHistoryPage() => narrowPage = 0; + + private void ShowDetailsPage() => narrowPage = 1; + + private void OnHistoryPageKeyDown(KeyboardEventArgs args) + { + var nextPage = args.Key switch + { + "ArrowRight" => (narrowPage + 1) % 2, + "ArrowLeft" => (narrowPage + 1) % 2, + "Home" => 0, + "End" => 1, + _ => narrowPage + }; + if (nextPage == narrowPage) + { + return; + } + + narrowPage = nextPage; + focusPageTabAfterRender = true; + } +} diff --git a/src/ChapterTool.Wasm/Components/HistoryDialog.razor.css b/src/ChapterTool.Wasm/Components/HistoryDialog.razor.css new file mode 100644 index 00000000..f4046c01 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/HistoryDialog.razor.css @@ -0,0 +1,128 @@ +.history-body { + display: flex; + flex-direction: column; + gap: 0.65rem; + width: 100%; + height: auto; + min-height: 0; +} + +.history-toolbar, +.history-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.6rem; +} + +.history-actions { + justify-content: flex-end; + flex-wrap: wrap; +} + +.history-note { margin: 0; color: var(--muted); } +.history-redo-choice, +.history-filter { display: grid; gap: 0.2rem; } +.history-redo-choice select, +.history-filter select { min-width: 10rem; } + +.history-pages { display: none; gap: 0.4rem; } +.history-content { + display: grid; + grid-template-columns: minmax(15rem, 2fr) minmax(20rem, 3fr); + gap: 0.75rem; + flex: 1; + min-height: 0; +} + +.history-list, +.history-details { + min-width: 0; + min-height: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: 0.35rem; +} + +.history-list { padding: 0.25rem; } +.history-row { display: flex; align-items: stretch; min-height: 2.5rem; } +.history-toggle { + flex: 0 0 2rem; + width: 2rem; + min-height: 2.5rem; + padding: 0; + border: 0; + background: transparent; + color: var(--control-fg); +} +.history-entry { + flex: 1; + min-width: 0; + min-height: 2.5rem; + padding: 0.35rem 0.5rem; + border: 0; + background: transparent; + color: var(--control-fg); + text-align: left; + overflow-wrap: anywhere; +} +.history-entry.current { font-weight: 650; } +.history-row.selected, +.history-entry:focus-visible { background: var(--hover); } +.history-current { color: var(--accent); } +.history-hidden-current { color: var(--muted); } + +.history-details { padding: 0.8rem; overflow-wrap: anywhere; } +.history-details h3 { margin-block: 0 0.5rem; } +.history-details h4 { margin-block: 0.8rem 0.4rem; } +.history-summary { margin: 0.4rem 0; } +.history-parameters { display: grid; grid-template-columns: minmax(8rem, auto) minmax(0, 1fr); gap: 0.35rem 0.75rem; } +.history-parameters dt { font-weight: 600; } +.history-parameters dd { margin: 0; overflow-wrap: anywhere; } +.history-filter { margin-block: 0.7rem; max-width: 22rem; } + +.history-change-table, +.history-root-rows { min-width: 0; max-width: 100%; overflow: auto; border-top: 1px solid var(--border); } +.history-change-row { + display: grid; + grid-template-columns: minmax(8rem, 1.15fr) minmax(7rem, 1fr) minmax(7rem, 1fr) minmax(5rem, auto); + gap: 0.45rem; + padding: 0.45rem 0.25rem; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; +} +.history-change-header, +.history-root-header { font-weight: 600; } +.history-change-context { grid-column: 1 / -1; } +.history-root-row { + display: grid; + grid-template-columns: minmax(6rem, auto) minmax(3rem, auto) minmax(8rem, 1fr) minmax(8rem, auto) minmax(6rem, auto); + gap: 0.5rem; + min-width: 36rem; + padding: 0.35rem 0.25rem; + border-bottom: 1px solid var(--border); +} +.history-root-row > *, +.history-change-row > * { min-width: 0; } +.history-footer { align-items: center; } +.history-status { color: var(--muted); } + +@media (max-width: 760px) { + .history-body { gap: 0.45rem; } + .history-toolbar { flex-direction: column; } + .history-actions { justify-content: flex-start; width: 100%; } + .history-pages { display: flex; } + .history-pages button { flex: 1; min-height: 2.75rem; } + .history-pages [aria-selected="true"] { border-color: var(--accent); font-weight: 600; } + .history-content { display: block; min-height: 0; flex: 1; } + .history-list, + .history-details { height: 100%; } + .history-content[data-page="history"] .history-details, + .history-content[data-page="details"] .history-list { display: none; } + .history-content[data-page="history"] .history-list { display: block; } + .history-content[data-page="details"] .history-details { display: block; } + .history-footer button { min-height: 2.75rem; } + .history-change-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .history-change-header { display: none; } + .history-change-row > span:nth-of-type(1)::before { content: ""; } +} diff --git a/src/ChapterTool.Wasm/Components/LogDialog.razor b/src/ChapterTool.Wasm/Components/LogDialog.razor new file mode 100644 index 00000000..43da6578 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/LogDialog.razor @@ -0,0 +1,144 @@ +@inject WasmWorkspace Workspace +@inject WasmLocalizer L +@inject IJSRuntime Js +@implements IDisposable + + + +
+ +
@L.T("Parity.LogExport") + + +
+ + +
+
+ +@code { + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + private WasmLogView _view = null!; + private string? _status; + private string? _pendingFocus; + protected override void OnInitialized() { _view = new WasmLogView(Workspace.Logs); Workspace.Changed += Changed; } + private void Changed() => InvokeAsync(() => { _view.Refresh(); StateHasChanged(); }); + public void Dispose() => Workspace.Changed -= Changed; + protected override void OnParametersSet() => _view?.Refresh(); + private void Refresh() => _view.Refresh(); + private void ResetFilters() + { + _view.Search = string.Empty; + _view.Severity = string.Empty; + _view.Refresh(); + } + private void Inspect(WasmLogEntry entry) { _view.SelectedId = entry.Id; _view.InspectorOpen = true; _pendingFocus = "log-back"; } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pendingFocus is not { } target) return; + _pendingFocus = null; + await Js.InvokeVoidAsync("chapterToolWasm.focus", target); + } + private string LevelName(string level) => L.T("Parity.LogLevel." + level); + private void Clear() { Workspace.ClearLogs(); _view.ClearSelection(); _view.Refresh(); } + private Task CloseOrBackAsync() => _view.InspectorOpen ? BackAsync() : OnClose.InvokeAsync(); + private Task BackAsync() + { + _view.InspectorOpen = false; + _pendingFocus = _view.SelectedId is { } id && _view.Visible.Any(entry => entry.Id == id) + ? $"log-select-{id}" + : "log-search"; + return Task.CompletedTask; + } + private async Task CopyAsync() + { + if (_view.Selected is not { } selected) return; + await CopyTextAsync(System.Text.Json.JsonSerializer.Serialize(selected)); + } + private async Task CopyTextAsync(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return; + try { await Js.InvokeVoidAsync("chapterToolWasm.copyText", text); _status = L.T("Copy.Preview"); } + catch (JSException) { _status = L.T("Parity.LogFailed"); } + } + private async Task ExportAsync(bool csv) + { + var content = _view.Export(csv); + try + { + await Js.InvokeVoidAsync("chapterToolWasm.downloadText", csv ? "chaptertool-log.csv" : "chaptertool-log.json", content, "utf8", false); + _status = L.T("Parity.LogExported"); + } + catch (JSException) { _status = L.T("Parity.LogFailed"); } + } + private RenderFragment Highlight(string text) => builder => + { + var match = _view.Search.Length == 0 ? -1 : text.IndexOf(_view.Search, StringComparison.OrdinalIgnoreCase); + if (match < 0) { builder.AddContent(0, text); return; } + builder.AddContent(0, text[..match]); + builder.OpenElement(1, "mark"); builder.AddContent(2, text.Substring(match, _view.Search.Length)); builder.CloseElement(); + builder.AddContent(3, text[(match + _view.Search.Length)..]); + }; +} diff --git a/src/ChapterTool.Wasm/Components/LogDialog.razor.css b/src/ChapterTool.Wasm/Components/LogDialog.razor.css new file mode 100644 index 00000000..0ca8d46f --- /dev/null +++ b/src/ChapterTool.Wasm/Components/LogDialog.razor.css @@ -0,0 +1,113 @@ +.log-surfaces { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.75rem; + min-height: 0; +} + +.log-surfaces.inspecting { + grid-template-columns: minmax(0, 1fr) minmax(15rem, 40%); +} + +.log-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; + min-height: 8rem; + overflow: auto; +} + +.log-entry { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + gap: 0.35rem; + min-width: 0; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 0.35rem; +} + +.log-entry.selected { + background: var(--active); +} + +.log-select { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 0.6rem; + min-width: 0; + border: 0; + padding: 0.35rem; + background: transparent; + color: inherit; + text-align: left; +} + +.log-select:focus-visible, +.log-details-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.log-select time { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.log-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log-detail-match { + grid-column: 3; + color: var(--muted); + font-size: 0.85em; +} + +.log-inspector { + min-width: 0; + overflow: auto; + padding-inline-start: 0.75rem; + border-inline-start: 1px solid var(--border); +} + +.log-inspector pre { + max-height: 16rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.log-export summary { + cursor: pointer; + padding: 0.5rem; +} + +@media (max-width: 700px) { + .log-surfaces.inspecting { + grid-template-columns: minmax(0, 1fr); + } + + .log-surfaces.inspecting > .log-list { + display: none; + } + + .log-inspector { + padding-inline-start: 0; + border-inline-start: 0; + } + + .log-entry { + grid-template-columns: minmax(0, 1fr); + } + + .log-details-button { + justify-self: start; + } +} diff --git a/src/ChapterTool.Wasm/Components/ShortcutSettingsEditor.razor b/src/ChapterTool.Wasm/Components/ShortcutSettingsEditor.razor new file mode 100644 index 00000000..163108c6 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/ShortcutSettingsEditor.razor @@ -0,0 +1,29 @@ +@using ChapterTool.Contracts.Shortcuts +@inject WasmLocalizer L + +
+ @foreach (var action in ShortcutCatalog.EditableActions) + { + var id = action.Id; +
+ + + + + @if (Invalid.Contains(id)) { @L.T("Parity.InvalidShortcut") } +
+ } +
+ +@code { + [Parameter, EditorRequired] public ShortcutSettings Draft { get; set; } = new(); + [Parameter] public EventCallback OnChanged { get; set; } + private IReadOnlySet Invalid => WasmShortcuts.InvalidActions(Draft); + private Task Capture(string id, KeyboardEventArgs args) + { + if (args.Key is "Control" or "Alt" or "Meta" or "Shift") return Task.CompletedTask; + return SetAsync(id, WasmShortcuts.Gesture(args.Key, args.CtrlKey, args.AltKey, args.MetaKey, args.ShiftKey)); + } + private Task SetAsync(string id, string value) { Draft[id] = value; return OnChanged.InvokeAsync(); } +} diff --git a/src/ChapterTool.Wasm/Components/WasmDialog.razor b/src/ChapterTool.Wasm/Components/WasmDialog.razor new file mode 100644 index 00000000..c8afed81 --- /dev/null +++ b/src/ChapterTool.Wasm/Components/WasmDialog.razor @@ -0,0 +1,49 @@ +@implements IAsyncDisposable +@inject IJSRuntime Js +@inject WasmLocalizer L + + + + @ChildContent + + +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public string? CssClass { get; set; } + [Parameter] public bool LightDismiss { get; set; } + [Parameter] public bool DismissEnabled { get; set; } = true; + [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + private readonly string titleId = $"dialog-{Guid.NewGuid():N}"; + private ElementReference element; + private DotNetObjectReference? reference; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + reference = DotNetObjectReference.Create(this); + await Js.InvokeVoidAsync("chapterToolWasm.openDialog", element, reference, LightDismiss); + } + + [JSInvokable] + public Task CancelAsync() => DismissEnabled ? OnClose.InvokeAsync() : Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + try + { + await Js.InvokeVoidAsync("chapterToolWasm.disposeDialog", titleId); + } + catch (JSDisconnectedException) + { + // The page can release the dialog after the JavaScript runtime disconnects. + } + finally + { + reference?.Dispose(); + } + } +} diff --git a/src/ChapterTool.Wasm/Pages/Home.razor b/src/ChapterTool.Wasm/Pages/Home.razor index c5b45d9b..3087dc5b 100644 --- a/src/ChapterTool.Wasm/Pages/Home.razor +++ b/src/ChapterTool.Wasm/Pages/Home.razor @@ -2,11 +2,16 @@ @implements IDisposable @using System.Text @using System.Text.Json +@using System.Globalization @using DeleteRowsTimingModes = ChapterTool.Contracts.Configuration.DeleteRowsTimingModes @using ContractDeleteRowsTimingMode = ChapterTool.Contracts.Configuration.DeleteRowsTimingMode @using FrameDisplayModes = ChapterTool.Contracts.Configuration.FrameDisplayModes @using ContractFrameDisplayMode = ChapterTool.Contracts.Configuration.FrameDisplayMode @using ChapterTool.Core.Editing +@using ChapterTool.Core.Models +@using ChapterTool.Core.Session +@using ChapterTool.Core.Transform +@using ChapterTool.Contracts.Shortcuts @inject WasmWorkspace Workspace @inject WasmLocalizer L @inject IJSRuntime Js @@ -14,32 +19,63 @@ @L.T("App.Title")
+ @{ + var mainOptionsProjection = Workspace.MainOptionsPreviewProjection; + var mainOptionsComparisons = mainOptionsProjection?.Chapters.ToDictionary(comparison => comparison.Id); + var preserveMainOptionsCandidateInExpressionDialog = _activeDialog == DialogKind.Expression + && _mainOptionsCandidateAtExpressionOpen; + var showMainOptionsCandidate = !_mainExpressionPreviewPending + && (!Workspace.IsMainOptionsPreviewStale || preserveMainOptionsCandidateInExpressionDialog) + && Workspace.MainOptionsPreview is { IsValid: true } + && mainOptionsProjection is { HasChanges: true }; + var mainOptionsStateVisible = _activeDialog != DialogKind.Expression; + var mainOptionsInvalid = mainOptionsStateVisible && !_mainExpressionPreviewPending + && !Workspace.IsMainOptionsPreviewStale && Workspace.MainOptionsPreview is { IsValid: false }; + var mainOptionsErrors = mainOptionsInvalid ? string.Join("\n", Workspace.MainOptionsPreview!.Errors) : null; + var mainOptionsActionsVisible = mainOptionsStateVisible + && (_mainExpressionPreviewPending || Workspace.MainOptionsPreview is not null); + } +
-
+
+ + @if (_loadMenuOpen) + { + + }
+ -
@@ -76,7 +108,7 @@
- + @if (Workspace.FrameRateChoices.Count == 0) { - + } - } - + else + { + @foreach (var choice in Workspace.FrameRateChoices) + { + + } + } + + +
@@ -165,7 +210,7 @@ } else { - +
@@ -179,36 +224,67 @@ { var index = i; var row = Workspace.Rows[i]; - - + } @@ -218,12 +294,12 @@ - +
@for (var i = 0; i < L.ChapterNameModes.Count; i++) @@ -245,91 +321,81 @@ } -
-
- - -
- -
- - @foreach (var language in Workspace.XmlLanguages) { - + }
-
-
- @L.T("Label.Expression") - +
+ + +
+ +
+ + +
+
+ +
+ + @if (mainOptionsInvalid) + { + + } + + @(mainOptionsStateVisible && _mainExpressionPreviewPending ? L.T("Expression.Preview.Pending") + : mainOptionsStateVisible && Workspace.IsMainOptionsPreviewStale ? L.T("Expression.State.Stale") : null) +
-
- - + + + +
+ @if (mainOptionsStateVisible && !_mainExpressionPreviewPending && Workspace.IsMainOptionsPreviewStale) + { + + } + else + { + + } +
-
+
- @Workspace.StatusText - + @Workspace.StatusText + -@if (contextMenuTarget == "load") -{ -
- - -
-} - -@if (contextMenuTarget == "row" && contextMenuIndex >= 0) +@if (_contextMenuTarget == "row" && _contextMenuIndex >= 0) { -
+
@@ -362,110 +420,54 @@
} -@if (contextMenuTarget == "clip") +@if (_contextMenuTarget == "clip") { -
+
} -@if (contextMenuTarget == "fps") +@if (_contextMenuTarget == "fps") { -
+
} -@if (showLogModal) +@if (_activeDialog == DialogKind.Logs) { - + } -@if (showPreviewModal) +@if (_activeDialog == DialogKind.Preview) { - + } -@if (showForwardModal) +@if (_activeDialog == DialogKind.Forward) { - + } -@if (showRelatedModal) +@if (_activeDialog == DialogKind.Related) { - + } -@if (showSettingsModal) +@if (_activeDialog == DialogKind.Settings) { - }
+ @if (_settingsError is not null) { }
- - + +
- -
+ +} + +@if (_activeDialog == DialogKind.History) +{ + +} +@if (_activeDialog == DialogKind.Expression) +{ + +} +@if (_activeDialog == DialogKind.ExportOptions) +{ + } @code { private const string SettingsStorageKey = "chaptertool.wasm.settings"; - private static readonly string[] LegacySettingsStorageKeys = - [ - "chaptertool.core.wasm-demo.settings", - "chaptertool.core.wasm-demo.settings.v1", - "chaptertool.core.wasm-demo.settings.v2", - "chaptertool.core.wasm.settings" - ]; private static readonly JsonSerializerOptions SettingsJsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true @@ -711,61 +734,67 @@ new("ayu-mirage", "Ayu Mirage", ["#1F2430", "#242936", "#2A3141", "#F3F4F5", "#FFCC66", "#8A919F", "#343D50", "#475268"]), new("ayu-dark", "Ayu Dark", ["#0B0E14", "#11151C", "#151A23", "#E6E1CF", "#FFB454", "#7A8494", "#242B38", "#343D4D"]) ]; - private int saveFormatIndex; - private int chapterNameModeIndex; - private int orderShift; - private int selectedFrameRateIndex; - private string xmlLanguage = "eng"; - private bool applyExpression; - private string expression = "t"; - private string expressionPresetId = string.Empty; - private bool roundFrames = true; - private string uiLanguage = "en-US"; - private int outputTextEncodingIndex; - private bool emitBom; - private decimal frameAccuracyTolerance = 0.15m; - private string themePresetId = "avalonia-default"; - private string uiFontFamily = "system-ui"; - private string monospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; - private bool showLogModal; - private bool showSettingsModal; - private bool showPreviewModal; - private bool showForwardModal; - private bool showRelatedModal; - private bool isDragOver; - private int settingsTab; - private int draftSaveFormatIndex; - private int draftChapterNameModeIndex; - private int draftOrderShift; - private int draftFrameRateIndex; - private string draftXmlLanguage = "eng"; - private bool draftApplyExpression; - private string draftExpression = "t"; - private bool draftRoundFrames = true; - private string draftUiLanguage = "en-US"; - private int draftOutputTextEncodingIndex; - private bool draftEmitBom; - private decimal draftFrameAccuracyTolerance = 0.15m; - private int draftFrameDisplayModeIndex; - private int draftFrameDecimalPlaces = 3; - private int draftDeleteRowsTimingModeIndex; - private string draftThemePresetId = "avalonia-default"; - private string draftUiFontFamily = "system-ui"; - private string draftMonospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; - private int contextMenuIndex = -1; - private string contextMenuXpx = "0px"; - private string contextMenuYpx = "0px"; - private string? contextMenuTarget; - private string previewText = string.Empty; - private string? previewFileName; - private int forwardFrames = 1; - private DotNetObjectReference? dropZoneRef; + private int _saveFormatIndex; + private int _chapterNameModeIndex; + private int _orderShift; + private int _selectedFrameRateIndex; + private string _xmlLanguage = "eng"; + private string _defaultXmlLanguage = "eng"; + private bool _roundFrames = true; + private string _uiLanguage = "en-US"; + private int _outputTextEncodingIndex; + private bool _emitBom; + private decimal _frameAccuracyTolerance = 0.15m; + private string _themePresetId = "avalonia-default"; + private string _uiFontFamily = "system-ui"; + private string _monospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + private bool _appReady; + private enum DialogKind { None, History, Expression, ExportOptions, Settings, Logs, Preview, Forward, Related } + private DialogKind _activeDialog; + private string _contentOperation = "naming"; + private bool _isDragOver; + private bool _mainExpressionPreviewPending; + private bool _mainOptionsCandidateAtExpressionOpen; + private CancellationTokenSource? _mainExpressionPreviewCancellation; + private int _settingsTab; + private ShortcutSettings _shortcuts = WasmShortcuts.Normalize(null); + private ShortcutSettings _draftShortcuts = WasmShortcuts.Normalize(null); + private string? _entrySettingsJson; + private WasmSettings? _entryRuntimeSettings; + private WasmSettings? _lastAppliedRuntimeSettings; + private bool _entryRuntimeRoundFrames; + private bool _confirmSettingsDiscard; + private string? _settingsError; + private int _defaultSaveFormatIndex; + private int _draftSaveFormatIndex; + private string _draftXmlLanguage = "eng"; + private string _draftUiLanguage = "en-US"; + private int _draftOutputTextEncodingIndex; + private bool _draftEmitBom; + private decimal _draftFrameAccuracyTolerance = 0.15m; + private int _draftFrameDisplayModeIndex; + private string _draftFrameDecimalPlaces = "3"; + private bool _draftShowRepeatingFrameDecimals = true; + private bool _showRepeatingFrameDecimals = true; + private int _draftDeleteRowsTimingModeIndex; + private string _draftThemePresetId = "avalonia-default"; + private string _draftUiFontFamily = "system-ui"; + private string _draftMonospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + private int _contextMenuIndex = -1; + private string _contextMenuXpx = "0px"; + private string _contextMenuYpx = "0px"; + private string? _contextMenuTarget; + private bool _loadMenuOpen; + private string _previewText = string.Empty; + private string? _previewFileName; + private DotNetObjectReference? _dropZoneRef; private WasmThemeOption SelectedTheme => - ThemeOptions.FirstOrDefault(theme => string.Equals(theme.Id, draftThemePresetId, StringComparison.OrdinalIgnoreCase)) ?? ThemeOptions[0]; + ThemeOptions.FirstOrDefault(theme => string.Equals(theme.Id, _draftThemePresetId, StringComparison.OrdinalIgnoreCase)) ?? ThemeOptions[0]; protected override void OnInitialized() { + Workspace.SessionLossConfirmation = ConfirmSessionLossAsync; Workspace.Changed += OnWorkspaceChanged; L.CultureChanged += OnCultureChanged; PullOptionsFromWorkspace(); @@ -779,32 +808,13 @@ return; } - dropZoneRef = DotNetObjectReference.Create(this); + _dropZoneRef = DotNetObjectReference.Create(this); try { await Js.InvokeVoidAsync("chapterToolWasm.registerShortcutGuard"); - await Js.InvokeVoidAsync("chapterToolWasm.registerDropZone", "chaptertool-shell", dropZoneRef, WasmWorkspace.MaxLoadBytes); + await Js.InvokeVoidAsync("chapterToolWasm.registerDropZone", "chaptertool-shell", _dropZoneRef, WasmWorkspace.MaxLoadBytes); var json = await Js.InvokeAsync("chapterToolWasm.getLocalStorage", SettingsStorageKey); - if (string.IsNullOrWhiteSpace(json)) - { - foreach (var legacyKey in LegacySettingsStorageKeys) - { - var legacyJson = await Js.InvokeAsync("chapterToolWasm.getLocalStorage", legacyKey); - if (!string.IsNullOrWhiteSpace(legacyJson)) - { - json = legacyJson; - await Js.InvokeVoidAsync("chapterToolWasm.setLocalStorage", SettingsStorageKey, legacyJson); - break; - } - } - } - - foreach (var legacyKey in LegacySettingsStorageKeys) - { - await Js.InvokeVoidAsync("chapterToolWasm.removeLocalStorage", legacyKey); - } - if (!string.IsNullOrWhiteSpace(json)) { try @@ -813,7 +823,6 @@ if (settings is { SchemaVersion: 1, Application: not null, Theme: not null, Font: not null }) { ApplySettingsToWorkspace(settings); - await ApplyAppearanceAsync(); PullOptionsFromWorkspace(); StateHasChanged(); } @@ -832,24 +841,36 @@ { // Privacy mode or a missing JavaScript host must not block first render. } + finally + { + try { await ApplyAppearanceAsync(); } + catch (JSException) { } + try { await SyncShortcutGuardAsync(); } + catch (JSException) { } + _appReady = true; + await InvokeAsync(StateHasChanged); + } } public void Dispose() { Workspace.Changed -= OnWorkspaceChanged; L.CultureChanged -= OnCultureChanged; - dropZoneRef?.Dispose(); + _mainExpressionPreviewCancellation?.Cancel(); + _mainExpressionPreviewCancellation?.Dispose(); + _dropZoneRef?.Dispose(); } [JSInvokable] public void OnDragStateChanged(bool over) { - if (isDragOver == over) + if (_activeDialog != DialogKind.None) return; + if (_isDragOver == over) { return; } - isDragOver = over; + _isDragOver = over; if (over) { Workspace.SetStatusMessage(L.T("Status.DropReady")); @@ -861,7 +882,8 @@ [JSInvokable] public async Task OnBrowserFileDropped(string name, byte[]? bytes) { - isDragOver = false; + if (_activeDialog != DialogKind.None) return; + _isDragOver = false; CloseContextMenu(); if (string.IsNullOrWhiteSpace(name) || bytes is null || bytes.Length == 0) { @@ -889,7 +911,8 @@ [JSInvokable] public void OnBrowserFileDropRejected(string reason) { - isDragOver = false; + if (_activeDialog != DialogKind.None) return; + _isDragOver = false; var message = string.Equals(reason, "too-large", StringComparison.OrdinalIgnoreCase) ? L.T("Status.DropTooLarge") : L.T("Status.DropBlocked"); @@ -908,129 +931,64 @@ private void PullOptionsFromWorkspace() { - saveFormatIndex = Workspace.SaveFormatIndex; - chapterNameModeIndex = Workspace.ChapterNameModeIndex; - orderShift = Workspace.OrderShift; - xmlLanguage = Workspace.XmlLanguage; - applyExpression = Workspace.ApplyExpression; - expression = Workspace.Expression; - expressionPresetId = Workspace.ExpressionPresetId; - roundFrames = Workspace.RoundFrames; - selectedFrameRateIndex = Workspace.SelectedFrameRateIndex; - outputTextEncodingIndex = OutputTextEncodings.All.ToList().IndexOf(Workspace.TextEncoding); - emitBom = Workspace.EmitBom; - frameAccuracyTolerance = Workspace.FrameAccuracyTolerance; + _saveFormatIndex = Workspace.SaveFormatIndex; + _chapterNameModeIndex = Workspace.ChapterNameModeIndex; + _orderShift = Workspace.OrderShift; + _xmlLanguage = Workspace.XmlLanguage; + _roundFrames = Workspace.RoundFrames; + _selectedFrameRateIndex = Workspace.SelectedFrameRateIndex; + _outputTextEncodingIndex = OutputTextEncodings.All.ToList().IndexOf(Workspace.TextEncoding); + _emitBom = Workspace.EmitBom; + _frameAccuracyTolerance = Workspace.FrameAccuracyTolerance; } private void PushExportOptions() { - Workspace.SaveFormatIndex = saveFormatIndex; - Workspace.XmlLanguage = xmlLanguage; - Workspace.TextEncoding = OutputTextEncodings.All[Math.Clamp(outputTextEncodingIndex, 0, OutputTextEncodings.All.Count - 1)]; - Workspace.EmitBom = emitBom; - Workspace.FrameAccuracyTolerance = frameAccuracyTolerance; + Workspace.SaveFormatIndex = _saveFormatIndex; + Workspace.XmlLanguage = _xmlLanguage; + Workspace.TextEncoding = OutputTextEncodings.All[Math.Clamp(_outputTextEncodingIndex, 0, OutputTextEncodings.All.Count - 1)]; + Workspace.EmitBom = _emitBom; + Workspace.FrameAccuracyTolerance = _frameAccuracyTolerance; } private void PushProjectionOptions() { - Workspace.ChapterNameModeIndex = chapterNameModeIndex; - Workspace.OrderShift = orderShift; - Workspace.ApplyExpression = applyExpression; - Workspace.Expression = expression; - Workspace.RoundFrames = roundFrames; + Workspace.ChapterNameModeIndex = _chapterNameModeIndex; + Workspace.OrderShift = _orderShift; + Workspace.RoundFrames = _roundFrames; } private async Task OnExportOptionsChanged() { PushExportOptions(); StateHasChanged(); - await PersistSettingsAsync(); + await PersistSettingsAsync(ignoreErrors: true); } private async Task OnProjectionOptionsChanged() { PushProjectionOptions(); - Workspace.ApplyOptionsAndRefresh(); - await PersistSettingsAsync(); + await PrepareMainOptionsPreviewAsync(); } - private async Task OnRoundFramesChanged(ChangeEventArgs args) + private void OnRoundFramesChanged(ChangeEventArgs args) { - roundFrames = args.Value is bool b + _roundFrames = args.Value is bool b ? b : string.Equals(args.Value?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - Workspace.RoundFrames = roundFrames; + Workspace.RoundFrames = _roundFrames; Workspace.ApplyOptionsAndRefresh(); - await PersistSettingsAsync(); } private async Task OnFrameRateChanged(ChangeEventArgs args) { if (int.TryParse(args.Value?.ToString(), out var index)) { - selectedFrameRateIndex = index; + _selectedFrameRateIndex = index; Workspace.PreferredFrameRateIndex = index; Workspace.SelectedFrameRateIndex = index; - await PersistSettingsAsync(); - } - } - - private async Task OnApplyExpressionChanged(ChangeEventArgs args) - { - applyExpression = args.Value is bool b - ? b - : string.Equals(args.Value?.ToString(), "true", StringComparison.OrdinalIgnoreCase); - Workspace.ApplyExpression = applyExpression; - Workspace.Expression = expression; - Workspace.ApplyOptionsAndRefresh(); - await PersistSettingsAsync(); - } - - private async Task OnExpressionPresetChanged(ChangeEventArgs args) - { - var presetId = args.Value?.ToString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(presetId)) - { - Workspace.ClearExpressionPresetSelection(); - expressionPresetId = string.Empty; - StateHasChanged(); - await PersistSettingsAsync(); - return; - } - - if (Workspace.ApplyExpressionPreset(presetId)) - { - applyExpression = true; - expression = Workspace.Expression; - expressionPresetId = Workspace.ExpressionPresetId; - } - - StateHasChanged(); - await PersistSettingsAsync(); - } - - private void OnExpressionInput(ChangeEventArgs args) - { - expression = args.Value?.ToString() ?? "t"; - Workspace.Expression = expression; - expressionPresetId = Workspace.ExpressionPresetId; - if (applyExpression) - { - Workspace.ApplyOptionsAndRefresh(); - } - } - - private async Task OnExpressionCommit(ChangeEventArgs args) - { - expression = args.Value?.ToString() ?? "t"; - Workspace.Expression = expression; - expressionPresetId = Workspace.ExpressionPresetId; - if (applyExpression) - { - Workspace.ApplyOptionsAndRefresh(); + await PersistSettingsAsync(ignoreErrors: true); } - - await PersistSettingsAsync(); } private async Task TriggerLoad() @@ -1045,11 +1003,6 @@ await Js.InvokeVoidAsync("chapterToolWasm.click", "appendMplsInput"); } - private async Task TriggerTemplateLoad() - { - await Js.InvokeVoidAsync("chapterToolWasm.click", "templateFileInput"); - } - private async Task OnFileSelected(InputFileChangeEventArgs args) { var file = args.File; @@ -1064,32 +1017,15 @@ await AppendMplsFileAsync(file); } - private async Task OnTemplateSelected(InputFileChangeEventArgs args) + private async Task LoadFileAsync(IBrowserFile file) { - var file = args.File; - try { - await using var stream = file.OpenReadStream(maxAllowedSize: 2 * 1024 * 1024); - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - var text = await reader.ReadToEndAsync(); - if (Workspace.SetChapterNameTemplate(file.Name, text)) + if (file.Size is < 0 or > WasmWorkspace.MaxLoadBytes) { - chapterNameModeIndex = 2; - PullOptionsFromWorkspace(); + throw new IOException(); } - } - catch (Exception) - { - Workspace.SetStatusMessage(L.T("Status.TemplateFailed")); - Workspace.RecordAction(L.T("Status.TemplateFailed")); - } - } - private async Task LoadFileAsync(IBrowserFile file) - { - try - { await using var stream = file.OpenReadStream(maxAllowedSize: WasmWorkspace.MaxLoadBytes); var bytes = new byte[file.Size]; var read = 0; @@ -1130,6 +1066,11 @@ { try { + if (file.Size is < 0 or > WasmWorkspace.MaxLoadBytes) + { + throw new IOException(); + } + await using var stream = file.OpenReadStream(maxAllowedSize: WasmWorkspace.MaxLoadBytes); using var memory = new MemoryStream(); await stream.CopyToAsync(memory); @@ -1165,7 +1106,6 @@ private async Task SaveAsync() { PushExportOptions(); - PushProjectionOptions(); var result = Workspace.Save(); if (result is { Success: true, Content: not null, FileName: not null }) { @@ -1181,13 +1121,12 @@ private Task PreviewAsync() { PushExportOptions(); - PushProjectionOptions(); var result = Workspace.Preview(); if (result.Success) { - previewText = result.Content; - previewFileName = result.FileName; - showPreviewModal = true; + _previewText = result.Content; + _previewFileName = result.FileName; + OpenDialog(DialogKind.Preview); } return Task.CompletedTask; @@ -1199,36 +1138,225 @@ await PreviewAsync(); } + private async ValueTask ConfirmSessionLossAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var confirmed = await Js.InvokeAsync("confirm", cancellationToken, L.T("SessionLoss.Message")); + cancellationToken.ThrowIfCancellationRequested(); + return confirmed; + } + + private void ToggleHistory() => OpenDialog(DialogKind.History); + + private void OpenDialog(DialogKind kind) + { + if (_activeDialog != DialogKind.None) return; + CloseContextMenu(); + _isDragOver = false; + _activeDialog = kind; + } + + private void CloseDialog() + { + if (_activeDialog == DialogKind.Expression) + { + _mainOptionsCandidateAtExpressionOpen = false; + } + _activeDialog = DialogKind.None; + } + + private async Task ApplyExportOptionsAsync(ExportOptionsDraft draft) + { + var previous = new ExportOptionsDraft(_xmlLanguage, _outputTextEncodingIndex, _emitBom); + _xmlLanguage = draft.XmlLanguage; + _outputTextEncodingIndex = draft.EncodingIndex; + _emitBom = draft.EmitBom; + PushExportOptions(); + try + { + await PersistSettingsAsync(); + } + catch (JSException) + { + _xmlLanguage = previous.XmlLanguage; + _outputTextEncodingIndex = previous.EncodingIndex; + _emitBom = previous.EmitBom; + PushExportOptions(); + throw; + } + PullOptionsFromWorkspace(); + CloseDialog(); + } + private void RefreshRows() { PushExportOptions(); - PushProjectionOptions(); Workspace.RefreshRows(); } - private void ClosePreview() => showPreviewModal = false; + private bool ShowMainOptionsCandidate => _activeDialog != DialogKind.Expression + && !_mainExpressionPreviewPending + && !Workspace.IsMainOptionsPreviewStale + && Workspace.MainOptionsPreview is { IsValid: true } + && Workspace.MainOptionsPreviewProjection is { HasChanges: true }; + + private string FormatExpressionDelta(long? ticks) + { + if (ticks is null) + { + return L.T("Expression.Value.Missing"); + } + + var value = Workspace.FormatExpressionTimeTicks(Math.Abs(ticks.Value)); + return ticks > 0 ? "+" + value : ticks < 0 ? "−" + value : value; + } + + private string FrameAccessibleName(string label, FramePresentationParts parts) => + $"{label}: {parts.AccessibleDescription}"; + + private async Task OnMainExpressionInput(ChangeEventArgs args) + { + var previousCancellation = _mainExpressionPreviewCancellation; + var cancellation = _mainExpressionPreviewCancellation = new CancellationTokenSource(); + Workspace.Expression = args.Value?.ToString() ?? string.Empty; + if (previousCancellation is not null) + { + await previousCancellation.CancelAsync(); + previousCancellation.Dispose(); + } + if (!ReferenceEquals(_mainExpressionPreviewCancellation, cancellation)) + { + return; + } + if (Workspace.IsChapterGridEmpty) + { + _mainExpressionPreviewPending = false; + if (ReferenceEquals(_mainExpressionPreviewCancellation, cancellation)) + { + _mainExpressionPreviewCancellation = null; + cancellation.Dispose(); + } + return; + } + + _mainExpressionPreviewPending = true; + try + { + await Task.Delay(250, cancellation.Token); + if (!cancellation.IsCancellationRequested) + { + PushProjectionOptions(); + Workspace.PrepareMainOptionsPreview(); + _mainExpressionPreviewPending = false; + } + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + } + if (!cancellation.IsCancellationRequested) + { + await InvokeAsync(StateHasChanged); + } + } + + private void OpenExpressionDialog() + { + _mainExpressionPreviewCancellation?.Cancel(); + _mainExpressionPreviewPending = false; + _mainOptionsCandidateAtExpressionOpen = Workspace.MainOptionsPreview is { IsValid: true } + && !Workspace.IsMainOptionsPreviewStale; + if (!string.IsNullOrWhiteSpace(Workspace.Expression) && !Workspace.IsChapterGridEmpty) + { + Workspace.PrepareExpressionPreview(Workspace.Expression); + } + OpenDialog(DialogKind.Expression); + } + + private async Task LoadMainExpressionScriptAsync(InputFileChangeEventArgs args) + { + try + { + await using var stream = args.File.OpenReadStream(2 * 1024 * 1024); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true); + var script = await reader.ReadToEndAsync(); + Workspace.ClearExpressionPresetSelection(); + Workspace.ExpressionSourceName = args.File.Name; + await OnMainExpressionInput(new ChangeEventArgs { Value = script }); + } + catch (Exception exception) when (exception is IOException or DecoderFallbackException) + { + Workspace.SetStatusMessage(L.T("Parity.ScriptLoadFailed")); + } + } + + private async Task ApplyMainExpressionPreviewAsync() + { + if (!ShowMainOptionsCandidate || Workspace.IsBusy) + { + return; + } + + var scrollTop = await Js.InvokeAsync("chapterToolWasm.getGridScrollTop"); + if (await Workspace.ApplyMainOptionsPreviewAsync()) + { + PullOptionsFromWorkspace(); + } + await Task.Yield(); + await Js.InvokeVoidAsync("chapterToolWasm.setGridScrollTop", scrollTop); + } + + private void DiscardMainExpressionPreview() + { + _mainExpressionPreviewCancellation?.Cancel(); + _mainExpressionPreviewPending = false; + Workspace.CancelMainOptionsPreview(); + } + + private void RefreshMainExpressionPreview() + { + if (Workspace.IsBusy || Workspace.IsChapterGridEmpty) + { + return; + } + + PushProjectionOptions(); + Workspace.PrepareMainOptionsPreview(); + _mainExpressionPreviewPending = false; + } + + private Task PrepareMainOptionsPreviewAsync() + { + if (Workspace.IsBusy || Workspace.IsChapterGridEmpty) return Task.CompletedTask; + Workspace.ChapterNameModeIndex = _chapterNameModeIndex; + Workspace.OrderShift = _orderShift; + Workspace.PrepareMainOptionsPreview(); + _mainExpressionPreviewPending = false; + return InvokeAsync(StateHasChanged); + } + + private void ClosePreview() => CloseDialog(); private async Task CopyPreviewAsync() { - if (previewText.Length == 0) + if (_previewText.Length == 0) { return; } - await CopyTextAsync(previewText, L.T("Copy.Preview")); + await CopyTextAsync(_previewText, L.T("Copy.Preview")); } private async Task DownloadPreviewAsync() { - if (previewText.Length == 0) + if (_previewText.Length == 0) { return; } await Js.InvokeVoidAsync( "chapterToolWasm.downloadText", - previewFileName ?? "preview.txt", - previewText, + _previewFileName ?? "preview.txt", + _previewText, Workspace.OutputTextEncodingId, Workspace.EmitBom); Workspace.RecordAction(L.T("Action.Download")); @@ -1245,76 +1373,173 @@ Workspace.SelectRow(index, ctrl: args.CtrlKey || args.MetaKey, shift: args.ShiftKey); } - private void OnTimeChanged(int index, ChangeEventArgs args) => - Workspace.UpdateRow(index, args.Value?.ToString(), null); + private void OpenLogs() => OpenDialog(DialogKind.Logs); - private void OnNameChanged(int index, ChangeEventArgs args) => - Workspace.UpdateRow(index, null, args.Value?.ToString()); - private void OpenLogs() => showLogModal = true; + private void OpenSettings() + { + _draftSaveFormatIndex = _defaultSaveFormatIndex; + _draftXmlLanguage = _defaultXmlLanguage; + _draftUiLanguage = _uiLanguage; + _draftOutputTextEncodingIndex = _outputTextEncodingIndex; + _draftEmitBom = _emitBom; + _draftFrameAccuracyTolerance = _frameAccuracyTolerance; + _draftFrameDisplayModeIndex = (int)Workspace.EditingOptions.FrameDisplay; + _draftFrameDecimalPlaces = Workspace.EditingOptions.FrameDecimalPlaces.ToString(CultureInfo.InvariantCulture); + _draftShowRepeatingFrameDecimals = _showRepeatingFrameDecimals; + _draftDeleteRowsTimingModeIndex = Workspace.EditingOptions.DeleteRowsTiming == DeleteRowsTimingMode.Preserve ? 0 : 1; + _draftThemePresetId = _themePresetId; + _draftUiFontFamily = _uiFontFamily; + _draftMonospaceFontFamily = _monospaceFontFamily; + _draftShortcuts = new ShortcutSettings(_shortcuts); + _entryRuntimeSettings = CreateSettingsDocument(); + _lastAppliedRuntimeSettings = _entryRuntimeSettings; + _entryRuntimeRoundFrames = _roundFrames; + _settingsError = null; + _confirmSettingsDiscard = false; + _entrySettingsJson = JsonSerializer.Serialize(CreateSettingsDocumentFromDraft(), SettingsJsonOptions); + _settingsTab = 0; + OpenDialog(DialogKind.Settings); + } + + private async Task CancelSettings() + { + _confirmSettingsDiscard = false; + if (_entryRuntimeSettings is { } snapshot) + { + await RestoreRuntimeSettingsAsync(snapshot); + RestoreRoundFrames(_entryRuntimeRoundFrames); + } + CloseDialog(); + } + + private async Task RequestSettingsClose() + { + if (JsonSerializer.Serialize(CreateSettingsDocumentFromDraft(), SettingsJsonOptions) == _entrySettingsJson) await CancelSettings(); + else _confirmSettingsDiscard = true; + } - private void CloseLogs() => showLogModal = false; + private async Task KeepEditingSettingsAsync() + { + _confirmSettingsDiscard = false; + await Js.InvokeVoidAsync("chapterToolWasm.focus", $"settings-tab-{_settingsTab}"); + } - private void OpenSettings() + private async Task ResetSettings() { - draftSaveFormatIndex = saveFormatIndex; - draftChapterNameModeIndex = chapterNameModeIndex; - draftOrderShift = orderShift; - draftFrameRateIndex = selectedFrameRateIndex; - draftXmlLanguage = xmlLanguage; - draftApplyExpression = applyExpression; - draftExpression = expression; - draftRoundFrames = roundFrames; - draftUiLanguage = uiLanguage; - draftOutputTextEncodingIndex = outputTextEncodingIndex; - draftEmitBom = emitBom; - draftFrameAccuracyTolerance = frameAccuracyTolerance; - draftFrameDisplayModeIndex = Workspace.EditingOptions.FrameDisplay == FrameDisplayMode.Round ? 0 : 1; - draftFrameDecimalPlaces = Workspace.EditingOptions.EffectiveFrameDecimalPlaces; - draftDeleteRowsTimingModeIndex = Workspace.EditingOptions.DeleteRowsTiming == DeleteRowsTimingMode.Preserve ? 0 : 1; - draftThemePresetId = themePresetId; - draftUiFontFamily = uiFontFamily; - draftMonospaceFontFamily = monospaceFontFamily; - settingsTab = 0; - showSettingsModal = true; - } - - private void CancelSettings() => showSettingsModal = false; - - private void ResetSettings() - { - draftSaveFormatIndex = 0; - draftChapterNameModeIndex = 0; - draftOrderShift = 0; - draftFrameRateIndex = 0; - draftXmlLanguage = Workspace.XmlLanguages.FirstOrDefault("und"); - draftApplyExpression = false; - draftExpression = "t"; - draftRoundFrames = true; - draftUiLanguage = "en-US"; - draftOutputTextEncodingIndex = 0; - draftEmitBom = true; - draftFrameAccuracyTolerance = 0.15m; - draftFrameDisplayModeIndex = 0; - draftFrameDecimalPlaces = 3; - draftDeleteRowsTimingModeIndex = 0; - draftThemePresetId = "avalonia-default"; - draftUiFontFamily = "system-ui"; - draftMonospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + _draftShortcuts = WasmShortcuts.Normalize(null); + _draftSaveFormatIndex = 0; + _draftXmlLanguage = Workspace.XmlLanguages.FirstOrDefault("und"); + _draftUiLanguage = "en-US"; + _draftOutputTextEncodingIndex = 0; + _draftEmitBom = true; + _draftFrameAccuracyTolerance = 0.15m; + _draftFrameDisplayModeIndex = 0; + _draftFrameDecimalPlaces = "3"; + _draftShowRepeatingFrameDecimals = true; + _draftDeleteRowsTimingModeIndex = 0; + _draftThemePresetId = "avalonia-default"; + _draftUiFontFamily = "system-ui"; + _draftMonospaceFontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + await ApplySettingsDraftAsync(); } + private bool SettingsDraftValid => WasmShortcuts.InvalidActions(_draftShortcuts).Count == 0 + && int.TryParse(_draftFrameDecimalPlaces, NumberStyles.Integer, CultureInfo.InvariantCulture, out var decimals) && decimals is >= 1 and <= 6 + && _draftFrameAccuracyTolerance is >= 0.01m and <= 0.30m + && _draftSaveFormatIndex >= 0 && _draftSaveFormatIndex < Workspace.SaveFormats.Count + && _draftOutputTextEncodingIndex >= 0 && _draftOutputTextEncodingIndex < OutputTextEncodings.All.Count + && _draftFrameDisplayModeIndex is 0 or 1 or 2 && _draftDeleteRowsTimingModeIndex is 0 or 1 + && UiLanguages.Any(language => language.Code == _draftUiLanguage) + && Workspace.XmlLanguages.Contains(_draftXmlLanguage, StringComparer.OrdinalIgnoreCase) + && ThemeOptions.Any(option => option.Id == _draftThemePresetId); + private async Task ApplySettingsAsync() { + if (!SettingsDraftValid) return; + if (!await TryApplySettingsDraftAsync()) return; var settings = CreateSettingsDocumentFromDraft(); - ApplySettingsToWorkspace(settings); - ApplySessionDraft(); - await ApplyAppearanceAsync(); - await PersistSettingsAsync(); - showSettingsModal = false; - StateHasChanged(); + try + { + await Js.InvokeVoidAsync("chapterToolWasm.setLocalStorage", SettingsStorageKey, JsonSerializer.Serialize(settings, SettingsJsonOptions)); + _defaultSaveFormatIndex = _draftSaveFormatIndex; + _defaultXmlLanguage = _draftXmlLanguage; + _shortcuts = WasmShortcuts.Normalize(_draftShortcuts); + CloseDialog(); + } + catch (JSException) + { + _settingsError = L.T("Parity.SettingsSaveFailed"); + } + } + + private async Task ApplySettingsDraftAsync() + { + await TryApplySettingsDraftAsync(); } - private void ApplySettingsToWorkspace(WasmSettings settings) + private async Task TryApplySettingsDraftAsync() + { + if (_activeDialog != DialogKind.Settings || !SettingsDraftValid) return false; + var settings = CreateSettingsDocumentFromDraft(); + if (_lastAppliedRuntimeSettings is { } applied + && JsonSerializer.Serialize(settings, SettingsJsonOptions) == JsonSerializer.Serialize(applied, SettingsJsonOptions)) + { + _settingsError = null; + return true; + } + + var previous = _lastAppliedRuntimeSettings ?? _entryRuntimeSettings; + var previousRoundFrames = _roundFrames; + var frameDisplayChanged = previous is null + || FrameDisplayModes.ParseOrDefault(previous.Application?.FrameDisplayMode) + != FrameDisplayModes.ParseOrDefault(settings.Application?.FrameDisplayMode); + try + { + await Js.InvokeVoidAsync("chapterToolWasm.applyAppearance", _draftThemePresetId, _draftUiLanguage, _draftUiFontFamily.Trim(), _draftMonospaceFontFamily.Trim()); + await Js.InvokeVoidAsync("chapterToolWasm.setShortcutBindings", WasmShortcuts.Bindings(_draftShortcuts).Keys.ToArray()); + ApplySettingsToWorkspace(settings, preserveSessionOutput: true); + if (!frameDisplayChanged) + { + RestoreRoundFrames(previousRoundFrames); + } + _lastAppliedRuntimeSettings = settings; + _settingsError = null; + return true; + } + catch (JSException) + { + if (previous is not null) + { + await RestoreRuntimeSettingsAsync(previous); + RestoreRoundFrames(previousRoundFrames); + } + _settingsError = L.T("Parity.SettingsApplyFailed"); + return false; + } + } + + private async Task RestoreRuntimeSettingsAsync(WasmSettings settings) + { + ApplySettingsToWorkspace(settings, preserveSessionOutput: true); + try + { + await ApplyAppearanceAsync(); + await SyncShortcutGuardAsync(); + } + catch (JSException) { } + _lastAppliedRuntimeSettings = settings; + } + + private void RestoreRoundFrames(bool value) + { + if (_roundFrames == value && Workspace.RoundFrames == value) return; + _roundFrames = value; + Workspace.RoundFrames = value; + Workspace.ApplyOptionsAndRefresh(); + } + + private void ApplySettingsToWorkspace(WasmSettings settings, bool preserveSessionOutput = false) { var application = settings.Application ?? new WasmApplicationSettings(); var theme = settings.Theme ?? new WasmThemeSettings(); @@ -1323,136 +1548,204 @@ ? format : ChapterExportFormat.Txt; - saveFormatIndex = Math.Clamp(ChapterExportFormats.IndexOf(parsedFormat), 0, Math.Max(0, Workspace.SaveFormats.Count - 1)); - xmlLanguage = Workspace.XmlLanguages.Contains(application.DefaultXmlLanguage, StringComparer.OrdinalIgnoreCase) + _defaultSaveFormatIndex = Math.Clamp(ChapterExportFormats.IndexOf(parsedFormat), 0, Math.Max(0, Workspace.SaveFormats.Count - 1)); + if (!preserveSessionOutput && Workspace.IsChapterGridEmpty) _saveFormatIndex = _defaultSaveFormatIndex; + _shortcuts = WasmShortcuts.Normalize(settings.Shortcuts); + _defaultXmlLanguage = Workspace.XmlLanguages.Contains(application.DefaultXmlLanguage, StringComparer.OrdinalIgnoreCase) ? application.DefaultXmlLanguage : Workspace.XmlLanguages.FirstOrDefault("und"); - uiLanguage = UiLanguages.Any(language => string.Equals(language.Code, application.Language, StringComparison.OrdinalIgnoreCase)) + if (!preserveSessionOutput && Workspace.IsChapterGridEmpty) _xmlLanguage = _defaultXmlLanguage; + _uiLanguage = UiLanguages.Any(language => string.Equals(language.Code, application.Language, StringComparison.OrdinalIgnoreCase)) ? application.Language : "en-US"; - L.SetCulture(uiLanguage); - outputTextEncodingIndex = Math.Max(0, OutputTextEncodings.All.ToList().IndexOf(OutputTextEncodings.ParseOrDefault(application.OutputTextEncoding))); - emitBom = application.EmitBom; - frameAccuracyTolerance = Math.Clamp(application.FrameAccuracyTolerance <= 0 ? 0.15m : application.FrameAccuracyTolerance, 0.01m, 0.30m); - draftFrameDisplayModeIndex = FrameDisplayModes.ParseOrDefault(application.FrameDisplayMode) == ContractFrameDisplayMode.Round ? 0 : 1; - draftFrameDecimalPlaces = FrameDisplayModes.NormalizeDecimalPlaces(application.FrameDecimalPlaces); - draftDeleteRowsTimingModeIndex = DeleteRowsTimingModes.ParseOrDefault(application.DeleteRowsTimingMode) == ContractDeleteRowsTimingMode.Preserve ? 0 : 1; - Workspace.EditingOptions = new ChapterEditingOptions(draftDeleteRowsTimingModeIndex == 0 ? DeleteRowsTimingMode.Preserve : DeleteRowsTimingMode.Normalize, draftFrameDisplayModeIndex == 0 ? FrameDisplayMode.Round : FrameDisplayMode.DecimalPlaces, draftFrameDecimalPlaces); - draftRoundFrames = draftFrameDisplayModeIndex == 0; - themePresetId = ThemeOptions.Any(option => string.Equals(option.Id, theme.PresetId, StringComparison.OrdinalIgnoreCase)) + L.SetCulture(_uiLanguage); + _outputTextEncodingIndex = Math.Max(0, OutputTextEncodings.All.ToList().IndexOf(OutputTextEncodings.ParseOrDefault(application.OutputTextEncoding))); + _emitBom = application.EmitBom; + _frameAccuracyTolerance = Math.Clamp(application.FrameAccuracyTolerance <= 0 ? 0.15m : application.FrameAccuracyTolerance, 0.01m, 0.30m); + _showRepeatingFrameDecimals = application.ShowRepeatingFrameDecimals; + _draftFrameDisplayModeIndex = (int)FrameDisplayModes.ParseOrDefault(application.FrameDisplayMode); + _draftFrameDecimalPlaces = FrameDisplayModes.NormalizeDecimalPlaces(application.FrameDecimalPlaces).ToString(CultureInfo.InvariantCulture); + _draftDeleteRowsTimingModeIndex = DeleteRowsTimingModes.ParseOrDefault(application.DeleteRowsTimingMode) == ContractDeleteRowsTimingMode.Preserve ? 0 : 1; + Workspace.EditingOptions = new ChapterEditingOptions(_draftDeleteRowsTimingModeIndex == 0 ? DeleteRowsTimingMode.Preserve : DeleteRowsTimingMode.Normalize, (FrameDisplayMode)_draftFrameDisplayModeIndex, FrameDisplayModes.NormalizeDecimalPlaces(application.FrameDecimalPlaces)); + _roundFrames = Workspace.RoundFrames = _draftFrameDisplayModeIndex == 0; + _themePresetId = ThemeOptions.Any(option => string.Equals(option.Id, theme.PresetId, StringComparison.OrdinalIgnoreCase)) ? theme.PresetId : "avalonia-default"; - uiFontFamily = string.IsNullOrWhiteSpace(font.UiFontFamily) ? "system-ui" : font.UiFontFamily; - monospaceFontFamily = string.IsNullOrWhiteSpace(font.MonospaceFontFamily) + _uiFontFamily = string.IsNullOrWhiteSpace(font.UiFontFamily) ? "system-ui" : font.UiFontFamily; + _monospaceFontFamily = string.IsNullOrWhiteSpace(font.MonospaceFontFamily) ? "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" : font.MonospaceFontFamily; PushExportOptions(); Workspace.ApplyOptionsAndRefresh(); } - private async Task PersistSettingsAsync() + private async Task PersistSettingsAsync(bool ignoreErrors = false) { var settings = CreateSettingsDocument(); - await Js.InvokeVoidAsync("chapterToolWasm.setLocalStorage", SettingsStorageKey, JsonSerializer.Serialize(settings, SettingsJsonOptions)); + try + { + await Js.InvokeVoidAsync("chapterToolWasm.setLocalStorage", SettingsStorageKey, JsonSerializer.Serialize(settings, SettingsJsonOptions)); + } + catch (JSException) when (ignoreErrors) + { + // Automatic preference persistence must not interrupt the active document. + } } private WasmSettings CreateSettingsDocumentFromDraft() => new( SchemaVersion: 1, Application: new WasmApplicationSettings( - Language: draftUiLanguage, - DefaultSaveFormat: ChapterExportFormats.AtIndex(draftSaveFormatIndex).ToString(), - DefaultXmlLanguage: draftXmlLanguage, - OutputTextEncoding: OutputTextEncodings.Id(OutputTextEncodings.All[Math.Clamp(draftOutputTextEncodingIndex, 0, OutputTextEncodings.All.Count - 1)]), - EmitBom: draftEmitBom, - FrameAccuracyTolerance: draftFrameAccuracyTolerance, - DeleteRowsTimingMode: draftDeleteRowsTimingModeIndex == 0 ? "preserve" : "normalize", - FrameDisplayMode: draftFrameDisplayModeIndex == 0 ? "round" : "decimal-places", - FrameDecimalPlaces: draftFrameDecimalPlaces), - Theme: new WasmThemeSettings(draftThemePresetId), + Language: _draftUiLanguage, + DefaultSaveFormat: ChapterExportFormats.AtIndex(_draftSaveFormatIndex).ToString(), + DefaultXmlLanguage: _draftXmlLanguage, + OutputTextEncoding: OutputTextEncodings.Id(OutputTextEncodings.All[Math.Clamp(_draftOutputTextEncodingIndex, 0, OutputTextEncodings.All.Count - 1)]), + EmitBom: _draftEmitBom, + FrameAccuracyTolerance: _draftFrameAccuracyTolerance, + DeleteRowsTimingMode: _draftDeleteRowsTimingModeIndex == 0 ? "preserve" : "normalize", + FrameDisplayMode: FrameDisplayModes.Id((ContractFrameDisplayMode)_draftFrameDisplayModeIndex), + FrameDecimalPlaces: int.TryParse(_draftFrameDecimalPlaces, NumberStyles.Integer, CultureInfo.InvariantCulture, out var decimals) ? decimals : 0, + ShowRepeatingFrameDecimals: _draftShowRepeatingFrameDecimals), + Theme: new WasmThemeSettings(_draftThemePresetId), Font: new WasmFontSettings( - UiFontFamily: draftUiFontFamily == "system-ui" ? string.Empty : draftUiFontFamily, - MonospaceFontFamily: draftMonospaceFontFamily == "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" ? string.Empty : draftMonospaceFontFamily)); + UiFontFamily: _draftUiFontFamily.Trim() == "system-ui" ? string.Empty : _draftUiFontFamily.Trim(), + MonospaceFontFamily: _draftMonospaceFontFamily.Trim() == "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" ? string.Empty : _draftMonospaceFontFamily.Trim()), + Shortcuts: new ShortcutSettings(_draftShortcuts)); private WasmSettings CreateSettingsDocument() => new( SchemaVersion: 1, Application: new WasmApplicationSettings( - Language: uiLanguage, - DefaultSaveFormat: ChapterExportFormats.AtIndex(saveFormatIndex).ToString(), - DefaultXmlLanguage: xmlLanguage, + Language: _uiLanguage, + DefaultSaveFormat: ChapterExportFormats.AtIndex(_defaultSaveFormatIndex).ToString(), + DefaultXmlLanguage: _defaultXmlLanguage, OutputTextEncoding: Workspace.OutputTextEncodingId, - EmitBom: emitBom, - FrameAccuracyTolerance: frameAccuracyTolerance, + EmitBom: _emitBom, + FrameAccuracyTolerance: _frameAccuracyTolerance, DeleteRowsTimingMode: Workspace.EditingOptions.DeleteRowsTiming == DeleteRowsTimingMode.Preserve ? "preserve" : "normalize", - FrameDisplayMode: Workspace.EditingOptions.FrameDisplay == FrameDisplayMode.Round ? "round" : "decimal-places", - FrameDecimalPlaces: Workspace.EditingOptions.EffectiveFrameDecimalPlaces), - Theme: new WasmThemeSettings(themePresetId), + FrameDisplayMode: FrameDisplayModes.Id((ContractFrameDisplayMode)Workspace.EditingOptions.FrameDisplay), + FrameDecimalPlaces: Workspace.EditingOptions.FrameDecimalPlaces, + ShowRepeatingFrameDecimals: _showRepeatingFrameDecimals), + Theme: new WasmThemeSettings(_themePresetId), Font: new WasmFontSettings( - UiFontFamily: uiFontFamily == "system-ui" ? string.Empty : uiFontFamily, - MonospaceFontFamily: monospaceFontFamily == "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" ? string.Empty : monospaceFontFamily)); + UiFontFamily: _uiFontFamily == "system-ui" ? string.Empty : _uiFontFamily, + MonospaceFontFamily: _monospaceFontFamily == "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" ? string.Empty : _monospaceFontFamily), + Shortcuts: new ShortcutSettings(_shortcuts)); - private void ApplySessionDraft() + private async Task ApplyAppearanceAsync() { - chapterNameModeIndex = Math.Clamp(draftChapterNameModeIndex, 0, 2); - orderShift = Math.Max(0, draftOrderShift); - selectedFrameRateIndex = Math.Max(0, draftFrameRateIndex); - applyExpression = draftApplyExpression; - expression = string.IsNullOrWhiteSpace(draftExpression) ? "t" : draftExpression.Trim(); - roundFrames = draftRoundFrames; - PushProjectionOptions(); - Workspace.SelectedFrameRateIndex = selectedFrameRateIndex; - Workspace.PreferredFrameRateIndex = selectedFrameRateIndex; - Workspace.ApplyOptionsAndRefresh(); + await Js.InvokeVoidAsync("chapterToolWasm.applyAppearance", _themePresetId, _uiLanguage, _uiFontFamily, _monospaceFontFamily); } - private async Task ApplyAppearanceAsync() + private void ToggleLoadMenu() { - await Js.InvokeVoidAsync("chapterToolWasm.applyAppearance", themePresetId, uiLanguage, uiFontFamily, monospaceFontFamily); + if (_loadMenuOpen) + { + CloseContextMenu(); + return; + } + _contextMenuTarget = null; + _contextMenuIndex = -1; + _loadMenuOpen = true; } - private void OpenLoadContextMenu(MouseEventArgs args) + private void OpenLoadContextMenu(MouseEventArgs args) => ToggleLoadMenu(); + + private async Task OnLoadMenuKeyDown(KeyboardEventArgs args) { - contextMenuTarget = "load"; - contextMenuIndex = -1; - contextMenuXpx = $"{args.ClientX}px"; - contextMenuYpx = $"{args.ClientY}px"; + if (args.Key == "Escape") + { + CloseContextMenu(); + await Js.InvokeVoidAsync("chapterToolWasm.focus", "load-menu-toggle"); + } + else if (args.Key is "ArrowDown" or "ArrowUp") + { + await Js.InvokeVoidAsync("chapterToolWasm.focusLoadMenuItem", args.Key == "ArrowDown" ? 1 : -1); + } + } + + private async Task TriggerTemplateLoad() => await Js.InvokeVoidAsync("chapterToolWasm.click", "chapter-name-template-input"); + + private async Task TriggerExpressionScriptLoad() => await Js.InvokeVoidAsync("chapterToolWasm.click", "main-expression-script"); + + private async Task OnChapterNameTemplateSelected(InputFileChangeEventArgs args) + { + try + { + await using var stream = args.File.OpenReadStream(1024 * 1024); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true); + var template = await reader.ReadToEndAsync(); + if (Workspace.SetChapterNameTemplate(args.File.Name, template)) + { + _chapterNameModeIndex = Workspace.ChapterNameModeIndex; + _orderShift = Workspace.OrderShift; + await PrepareMainOptionsPreviewAsync(); + } + } + catch (Exception exception) when (exception is IOException or DecoderFallbackException) + { + Workspace.SetStatusMessage(L.T("Status.TemplateLoadFailed")); + } + } + + private async Task OnXmlLanguageChanged(ChangeEventArgs args) + { + var code = args.Value?.ToString(); + if (!string.IsNullOrWhiteSpace(code) && Workspace.XmlLanguages.Contains(code, StringComparer.OrdinalIgnoreCase)) + { + _xmlLanguage = code; + PushExportOptions(); + await PersistSettingsAsync(ignoreErrors: true); + StateHasChanged(); + } + } + + private async Task OnOrderShiftChanged(ChangeEventArgs args) + { + if (int.TryParse(args.Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + _orderShift = Math.Clamp(value, 0, 1000); + Workspace.OrderShift = _orderShift; + await PrepareMainOptionsPreviewAsync(); + StateHasChanged(); + } } private void OpenContextMenu(int index, MouseEventArgs args) { - contextMenuTarget = "row"; - contextMenuIndex = index; - contextMenuXpx = $"{args.ClientX}px"; - contextMenuYpx = $"{args.ClientY}px"; + _contextMenuTarget = "row"; + _contextMenuIndex = index; + _contextMenuXpx = $"{args.ClientX}px"; + _contextMenuYpx = $"{args.ClientY}px"; Workspace.EnsureRowSelected(index); } private void OpenClipContextMenu(MouseEventArgs args) { - contextMenuTarget = "clip"; - contextMenuIndex = -1; - contextMenuXpx = $"{args.ClientX}px"; - contextMenuYpx = $"{args.ClientY}px"; + _contextMenuTarget = "clip"; + _contextMenuIndex = -1; + _contextMenuXpx = $"{args.ClientX}px"; + _contextMenuYpx = $"{args.ClientY}px"; } private void OpenFrameRateContextMenu(MouseEventArgs args) { - contextMenuTarget = "fps"; - contextMenuIndex = -1; - contextMenuXpx = $"{args.ClientX}px"; - contextMenuYpx = $"{args.ClientY}px"; + _contextMenuTarget = "fps"; + _contextMenuIndex = -1; + _contextMenuXpx = $"{args.ClientX}px"; + _contextMenuYpx = $"{args.ClientY}px"; } private void CloseContextMenu() { - contextMenuTarget = null; - contextMenuIndex = -1; + _contextMenuTarget = null; + _contextMenuIndex = -1; + _loadMenuOpen = false; } private void OnShellClick() { - if (contextMenuTarget is not null) + if (_contextMenuTarget is not null || _loadMenuOpen) { CloseContextMenu(); } @@ -1466,25 +1759,26 @@ private void ChangeFrameRateContext() { - Workspace.ChangeSelectedFrameRate(); CloseContextMenu(); + _contentOperation = "fps"; + OpenDialog(DialogKind.Forward); } private void SelectContextRow() { - Workspace.SelectRow(contextMenuIndex); + Workspace.SelectRow(_contextMenuIndex); CloseContextMenu(); } private void InsertContextRow() { - Workspace.InsertBefore(contextMenuIndex); + Workspace.InsertBefore(_contextMenuIndex); CloseContextMenu(); } private void DuplicateContextRow() { - Workspace.DuplicateRow(contextMenuIndex); + Workspace.DuplicateRow(_contextMenuIndex); CloseContextMenu(); } @@ -1517,16 +1811,13 @@ private void OpenForwardModal() { CloseContextMenu(); - forwardFrames = 1; - showForwardModal = true; + _contentOperation = "shift"; + OpenDialog(DialogKind.Forward); } - - private void CloseForwardModal() => showForwardModal = false; - - private void ApplyForwardShift() + private void OpenNaming() { - Workspace.ShiftFramesForward(forwardFrames); - showForwardModal = false; + _contentOperation = "naming"; + OpenDialog(DialogKind.Forward); } private void OpenRelatedMedia() @@ -1539,11 +1830,11 @@ return; } - showRelatedModal = true; + OpenDialog(DialogKind.Related); Workspace.RecordAction(L.T("Status.RelatedMediaNote")); } - private void CloseRelatedModal() => showRelatedModal = false; + private void CloseRelatedModal() => CloseDialog(); private async Task CopyTextAsync(string text, string action) { @@ -1558,6 +1849,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs args) { + if (_activeDialog != DialogKind.None) return; bool typing; try { @@ -1573,56 +1865,30 @@ return; } - if (showSettingsModal || showLogModal || showPreviewModal || showForwardModal || showRelatedModal) + var gesture = WasmShortcuts.Gesture(args.Key, args.CtrlKey, args.AltKey, args.MetaKey, args.ShiftKey); + var action = WasmShortcuts.Bindings(_shortcuts).GetValueOrDefault(gesture); + switch (action) { - if (args.Key is "Escape") - { - showSettingsModal = false; - showLogModal = false; - showPreviewModal = false; - showForwardModal = false; - showRelatedModal = false; - CloseContextMenu(); - } - - return; - } - - var key = args.Key; - var ctrl = args.CtrlKey || args.MetaKey; - if (ctrl && string.Equals(key, "o", StringComparison.OrdinalIgnoreCase)) - { - await TriggerLoad(); - return; - } - - if (ctrl && string.Equals(key, "s", StringComparison.OrdinalIgnoreCase)) - { - await SaveAsync(); - return; - } - - if ((ctrl && string.Equals(key, "r", StringComparison.OrdinalIgnoreCase)) || string.Equals(key, "F5", StringComparison.OrdinalIgnoreCase)) - { - await ReloadAsync(); - return; + case ShortcutCatalog.LoadId: await TriggerLoad(); break; + case ShortcutCatalog.SaveId: await SaveAsync(); break; + case ShortcutCatalog.ReloadId: await ReloadAsync(); break; + case ShortcutCatalog.PreviewId: await PreviewAsync(); break; + case ShortcutCatalog.LogId: OpenLogs(); break; + case ShortcutCatalog.UndoId: await Workspace.UndoAsync(); break; + case ShortcutCatalog.RedoId: await Workspace.RedoAsync(); break; + case ShortcutCatalog.InsertId: + if (Workspace.CanRefreshRows) Workspace.InsertBefore(Math.Max(0, Workspace.SelectedRowIndex)); + break; + case ShortcutCatalog.DeleteId: Workspace.DeleteSelectedRows(); break; + case ShortcutCatalog.PreviousClipId: Workspace.SelectAdjacentClip(-1); break; + case ShortcutCatalog.NextClipId: Workspace.SelectAdjacentClip(1); break; + case "refresh-rows": RefreshRows(); break; } + } - if (string.Equals(key, "F11", StringComparison.OrdinalIgnoreCase)) - { - await PreviewAsync(); - return; - } + private Task SyncShortcutGuardAsync() => Js.InvokeVoidAsync("chapterToolWasm.setShortcutBindings", + (object)WasmShortcuts.Bindings(_shortcuts).Keys.ToArray()).AsTask(); - if (ctrl && string.Equals(key, "l", StringComparison.OrdinalIgnoreCase)) - { - OpenLogs(); - return; - } - - if (!ctrl && string.Equals(key, "F9", StringComparison.OrdinalIgnoreCase)) - { - RefreshRows(); - } - } + private string ShortcutHint(string id, string label) => + _shortcuts.TryGetValue(id, out var gesture) && gesture.Length > 0 ? $"{label} ({gesture})" : label; } diff --git a/src/ChapterTool.Wasm/README.md b/src/ChapterTool.Wasm/README.md index 461fa800..1996ce7e 100644 --- a/src/ChapterTool.Wasm/README.md +++ b/src/ChapterTool.Wasm/README.md @@ -6,7 +6,7 @@ The app provides chapter import, editing, time transforms, preview, and export. 1. **Top** — Load / Save, optional clip selector, frame rate readout 2. **Center** — chapter grid (`#`, Time, Name, Frames) -3. **Bottom** — save format, chapter name mode, order shift, XML language, expression +3. **Bottom** — save format, chapter name mode, expression and advanced export actions 4. **Status strip** — status text + progress Load imports data into the grid. Save and Preview use the Core projection and export options. Reload reuses the last successful file bytes. Append MPLS combines another playlist through the Core segment service. @@ -39,40 +39,67 @@ Default URL: `http://localhost:5261` | **Reload / Append MPLS** | Load context menu reuses the last file or appends another MPLS group | | **Clip combo** | Shown when import has multiple entries; switches active `ChapterSet` | | **Clip combo context menu** | Combine MPLS/IFO entries or restore separate clips | -| **Grid edit** | Time / Name editable; used on save | +| **Grid edit** | Time, name, and frame drafts use Core cell edits. Enter, Tab, and valid blur commit once. Escape cancels. Invalid drafts retain an accessible error. | | **Save** | `ChapterExportService` with bottom options → browser download | | **Round frames + FPS** | `FrameRateService.UpdateFrames` fills Frames column (Auto detect or fixed rate) | -| **Frame rate context menu** | Change chapter timing from the current frame rate to the selected valid rate | -| **Expression + Use** | `ChapterOutputProjectionService` / Lua engine rewrites times + frames in the grid; built-in Core presets; invalid expressions surface Core diagnostics | +| **Frame rate context menu** | Review source and target FPS before applying one captured conversion candidate | +| **Expression** | Edit expressions inline and review candidate values in the grid before Apply or Discard. The advanced editor loads bounded UTF-8 Lua files and provides shared highlighting, completion, and positioned diagnostics. | +| **Edit history** | Expand or collapse retained branches, locate the current node, inspect its field changes, and restore any node. Undo and redo remain available. | +| **Advanced export options** | Stage XML language, encoding, and BOM preferences. Apply updates output preferences. | +| **Naming and numbering** | Review automatic names, loaded templates, and integer numbering offsets from 0 to 1000. Apply commits once and clears the active intent. | | **Save as** | TXT, XML, QPFile, TimeCodes, … | | **Chapter name** | As is / Auto generate | | **Order +** | Display number shift | | **XML lang** | Enabled only for XML export | -| **Settings** | Modal settings pages with browser-persisted output, appearance, and WASM capability preferences | +| **Settings** | Draft output, appearance, and shared shortcuts. Save persists before activation. Close can discard a changed draft or keep editing. Settings do not replay content operations. | +| **Log** | Filter the bounded list by severity and text, then reset both filters together. Open details explicitly. Copy an entry or download a captured filtered JSON/CSV snapshot. | | **Selection / context actions** | Ctrl/Shift multi-select; batch delete, `--zones`, Preview, forward translation, and related-media references | | **Drag and drop / language** | Drop-to-load with size/read errors; `en-US`, `zh-CN`, and `ja-JP` UI dictionaries | Empty grid offers **Load OGM sample** for a quick smoke path. -## Main Components +All tools use the native browser dialog lifecycle. Dialogs contain focus and make the main page inert. Escape closes a dialog from focused inputs. Close returns focus to the opener when it still exists. Dialog bodies scroll independently. Short pages can scroll to retain the toolbar, grid, bottom controls, and status strip. -| Piece | Role | -|-------|------| -| `Services/WasmWorkspace` | Browser presentation state for load/reload/append, clip selection, multi-select editing, projection, diagnostics, logs, and save/preview. `ChapterWorkspace` owns the shared clip session and edit buffer. | -| `Services/WasmChapterService` | Byte-based Core importers + `ChapterExportService` | -| `Services/WasmLocalizer` | `en-US` / `zh-CN` / `ja-JP` UI and workspace status strings | -| `Pages/Home.razor` | Main shell UI zones | -| `wwwroot/js/download.js` | File picker trigger, encoded export download, appearance application, and localStorage persistence | - -Pass chapter bytes through `ChapterImportRequest.Content`. The browser app does not use local file paths for import. +The browser app imports file bytes. It does not use local file paths for import. Portable browser imports use the shared 64 MiB byte limit in `ChapterTool.Core.Boundaries.PortableInputPolicy`. The limit applies to load, reload, and MPLS append. +## Browser end-to-end tests + +`tests/ChapterTool.Wasm.E2E` owns real-browser coverage for the Blazor page and +its JavaScript boundary. The suite uses Playwright Test and TypeScript. It does +not replace the workspace or Core test projects. + +Run these commands from the repository root in a clean checkout: + +```bash +dotnet restore src/ChapterTool.Wasm/ChapterTool.Wasm.csproj +dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj --configuration Release --no-restore --output artifacts/wasm-e2e/publish +npm --prefix tests/ChapterTool.Wasm.E2E ci +npm --prefix tests/ChapterTool.Wasm.E2E exec -- playwright install chromium +npm --prefix tests/ChapterTool.Wasm.E2E run prepare:site +npm --prefix tests/ChapterTool.Wasm.E2E run typecheck +npm --prefix tests/ChapterTool.Wasm.E2E run test:e2e -- --project=chromium +``` + +The default configuration tests the prepared Release output at +`http://127.0.0.1:5261/ChapterTool/`. Run `npm --prefix +tests/ChapterTool.Wasm.E2E run test:e2e:dev` to use the local development +server at `/`. Use `test:e2e:headed` or `test:e2e:ui` for interactive diagnosis. +Run all installed engines with `test:e2e`. Layout tests verify bounds, focus, +real clicks, and scrolling. Screenshots support failure diagnosis. + +Playwright writes reports, traces, screenshots, and downloaded files under +`artifacts/wasm-e2e/`. Open the HTML report with +`npm --prefix tests/ChapterTool.Wasm.E2E run report`. +The first multi-browser and visual acceptance results are in +`docs/testing/wasm-browser-e2e-acceptance.md`. + ## Feature boundaries The browser app imports text, XML, CUE, WebVTT, MPLS, IFO, HD-DVD XPL, and embedded FLAC/TAK CUE data from bytes. It supports chapter editing, managed Lua expressions with Core presets, frame transforms, templates, export formats, settings persistence, drag and drop, and browser downloads. -The browser intentionally does not expose desktop-only behavior: choosing a local save directory, running `mkvtoolnix`/`ffprobe`, importing external-tool media sources, opening local Related Media through a desktop shell, system font catalog, desktop Sentry telemetry, or loading Lua script files and the Lua editor/completion workflow. +The browser does not expose choosing a local save directory, running `mkvtoolnix`/`ffprobe`, importing external-tool media sources, opening local Related Media through a desktop shell, system font enumeration, or desktop Sentry telemetry. Related Media paths are informational. Relative paths are rendered as browser links when present, but local filesystem paths are not made accessible by WASM. @@ -105,23 +132,13 @@ Published URL (project pages): `https://tautcony.github.io/ChapterTool/` +The generated Core API reference is published beside the browser app at +`https://tautcony.github.io/ChapterTool/docs/`. + ### One-time repository settings 1. **Settings → Pages → Build and deployment → Source**: **GitHub Actions** 2. Ensure Actions can run workflows (default GITHUB_TOKEN is enough for `pages: write`) 3. First deploy: Actions → **Deploy WASM (GitHub Pages)** → **Run workflow**, or merge to `master` -The workflow: - -1. `dotnet publish` the Blazor WASM app (Release) -2. Writes `.nojekyll` so `_framework` is not ignored by Jekyll -3. Copies `index.html` → `404.html` for SPA deep-link fallback -4. Rewrites `` to `/ChapterTool/` for project-page hosting -5. Uploads and deploys via `actions/deploy-pages` - -Local smoke of the same publish layout: - -```bash -dotnet publish src/ChapterTool.Wasm/ChapterTool.Wasm.csproj -c Release -# serve src/.../bin/Release/net10.0/publish/wwwroot with any static host -``` +The workflow publishes the browser app and deploys its static files through GitHub Pages. diff --git a/src/ChapterTool.Wasm/Resources/Locales/en-US.json b/src/ChapterTool.Wasm/Resources/Locales/en-US.json index 1c82a666..cc3e3865 100644 --- a/src/ChapterTool.Wasm/Resources/Locales/en-US.json +++ b/src/ChapterTool.Wasm/Resources/Locales/en-US.json @@ -5,12 +5,7 @@ "Action.AppendMpls": "Append MPLS…", "Action.BrowseTemplate": "Browse template…", "Action.Close": "Close", - "Action.CombineSegments": "Combine segments: entries={entries}, sourceType={sourceType}", - "Action.DeleteRows": "Delete rows: indexes={indexes}", "Action.Download": "Download", - "Action.EditCell": "Edit {kind}: row={row}, value='{value}'", - "Action.EditChapters": "Edit chapters", - "Action.InsertRow": "Insert row: index={index}", "Action.Load": "Load", "Action.LoadSample": "Load OGM sample", "Action.Log": "Log", @@ -19,8 +14,6 @@ "Action.Reload": "Reload", "Action.RestoreDefaults": "Restore defaults", "Action.Settings": "Settings", - "Action.ShiftFramesForward": "Shift frames forward: frames={frames}", - "Action.SplitCombinedSegments": "Split combined segments: entries={entries}, sourceType={sourceType}", "App.Title": "[VCB-Studio] ChapterTool", "Common.Apply": "Apply", "Common.Cancel": "Cancel", @@ -31,6 +24,19 @@ "Tool.Log.FilterWarning": "Warnings", "Tool.Log.FilterError": "Errors", "Tool.Log.Filter": "Filter by level", + "Tool.Log.FilterLevelLabel": "Level", + "Tool.Log.ResetFilters": "Reset filters", + "Tool.Log.MoreActions": "More actions", + "Tool.Log.ExportFormat": "Export format", + "Tool.Log.OpenDetails": "Open details", + "Tool.Log.SearchMatchAdditional": "Match found in details.", + "Tool.Log.EntryAccessibleName": "{level}: {summary}, {time}. {context}", + "Tool.Log.NoEntries": "No log entries yet.", + "Tool.Log.NoMatches": "No entries match the current search or filter.", + "Tool.Log.Message": "Message", + "Tool.Log.Context": "Context", + "Tool.Log.NoDetails": "No additional details for this entry.", + "Tool.Log.BackToList": "Back to log list", "Tool.Log.CopySummary": "Copy summary", "Tool.Log.CopyDetails": "Copy details", "Tool.Log.Details": "Technical details", @@ -44,6 +50,13 @@ "Tool.Log.Properties": "Properties", "Tool.Log.Raw": "Raw data", "Tool.Log.SearchPlaceholder": "Search log entries…", + "Tool.Log.ExportJson": "JSON", + "Tool.Log.ExportCsv": "CSV", + "Tool.Log.ExportSucceeded": "Exported to {value}", + "Tool.Log.ExportFailed": "Export failed: {value}", + "Tool.Log.CloseDetails": "Close details", + "Tool.Log.OpenFolder": "Open log folder", + "Tool.Log.Export": "Export logs", "Tool.Log.NoStructuredData": "This entry has no nested structured data.", "Tool.Log.NoExceptionData": "This entry has no exception data.", "Common.Save": "Save", @@ -109,7 +122,10 @@ "Diagnostic.FlacEmbeddedCue.NotFound": "No Vorbis cuesheet comment was found.", "Diagnostic.FrameRate.Invalid": "Frame rate must be greater than zero.", "Diagnostic.FrameText.Invalid": "Frame text did not contain a frame number or the frame rate was invalid.", + "Diagnostic.Ifo.Available": "DVD loaded: titles={titleCount}, chapters={chapterCount}, title sets={titleSetCount}.", "Diagnostic.Ifo.Invalid": "The IFO file is invalid: {message}", + "Diagnostic.Ifo.Captured": "DVD chapter times for {source} were read from VOB navigation timestamps.", + "Diagnostic.Ifo.Fallback": "VOB navigation timestamps for {source} could not be verified. DVD chapters retain the original IFO time estimates without frame alignment.", "Diagnostic.ImporterFallback.Used": "The primary importer was unavailable; a fallback importer was used.", "Diagnostic.LuaExpression.Cancelled": "Lua expression execution was cancelled.", "Diagnostic.LuaExpression.CompileFailed": "Lua expression syntax error: {message}", @@ -156,8 +172,6 @@ "Diagnostic.XmlRoot.Invalid": "Expected Chapters root, got {name}.", "Diagnostic.XplChapters.None": "No HD-DVD chapters were parsed.", "Diagnostic.XplParse.Failed": "The XPL playlist could not be parsed: {message}", - "EditKind.Frame": "frame", - "EditKind.Name": "name", "Empty.Hint": "Load a file, drop one here, or load the sample to compute frames and expressions.", "Empty.Title": "No chapters loaded", "Expression.Suggestion.AddOperand": "Add the missing operand before applying this token.", @@ -177,10 +191,12 @@ "Grid.Frames": "Frames", "Grid.Name": "Name", "Grid.Number": "#", + "Grid.TableLabel": "Chapters", "Grid.Time": "Time", "Label.ChapterName": "Chapter name", "Label.Clip": "Clip / playlist entry", "Label.ClipOption": "{0} ({1} chapters)", + "Label.DvdTitle": "Title {number}", "Label.Expression": "Custom expression", "Label.ExpressionPreset": "Preset", "Label.FrameRate": "Frame rate used for Frames column", @@ -192,31 +208,14 @@ "Language.ChineseSimplified": "Simplified Chinese", "Language.English": "English", "Language.Japanese": "Japanese", - "Log.AppendingMpls": "Appending MPLS: path='{path}'", - "Log.AutoFrameRateDetection": "Auto frame-rate detection: option={option}, confidence={confidence}, accurate={accurate}/{evaluated}, deviation={deviation}", - "Log.ChangeFps": "Convert to current FPS: source={sourceFps}, target={targetFps}, chapters {before} -> {after}", - "Log.CreateZones": "Create zones: selectedRows={selectedRows}, chapters={chapters}", - "Log.Diagnostic": "{operation} diagnostic: severity={severity}, code={code}{location} message='{message}'{details}", - "Log.EditChapters": "{action}: chapters {before} -> {after}", - "Log.FrameInfoUpdated": "Frame info updated: option={option}, fps={fps}, round={round}, chapters={chapters}", - "Log.ImportSummary": "{operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics}", - "Log.LanguageSet": "Language set to {language}", - "Log.LoadingSource": "Loading source: path='{path}'", - "Log.OpenedPath": "{status}: path='{path}'", - "Log.RelatedMediaNotFound": "{status}: reference='{reference}', resolved='{resolved}'", - "Log.SavingChapters": "Saving chapters: format={format}, directory='{directory}', source='{source}', chapters={chapters}, applyExpression={applyExpression}, expression='{expression}'", - "Log.SelectedSourceOption": "Selected source option: index={index}, label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, fps={fps}", - "Log.SettingsLoaded": "Settings loaded: savingPath='{savingPath}', language='{language}'", - "Log.Status": "{status}", - "Log.UnexpectedError": "Unexpected UI operation failure", - "Log.StatusFromPath": "{status} from {path}", - "Log.TemplateLoadFailed": "Failed to load chapter name template from '{path}'", - "Log.TemplateLoaded": "Loaded chapter name template '{name}' from '{path}'", "Main.AppendMpls": "Append MPLS", "Main.AutoFrameRate": "Auto", "Main.ChapterCount": "{count} chapters", + "Main.ChapterCountSingular": "{count} chapter", "Main.ChangeFps": "Convert to current FPS", "Main.Combine": "Combine", + "Main.SplitByBoundaries": "Split by boundaries", + "Menu.SplitByBoundaries": "Split by boundaries", "Main.CreateZones": "Create Zones", "Main.ForwardTranslation": "Forward Translation", "Main.Insert": "Insert", @@ -270,14 +269,14 @@ "Settings.Appearance.MonospaceFont": "Monospace font", "Settings.Appearance.MonospaceFontPreviewFor": "Monospace font preview for {name}", "Settings.Appearance.Preset": "Theme preset", - "Settings.Appearance.Preset.avalonia-default": "Avalonia Default", - "Settings.Appearance.Preset.ayu-dark": "Ayu Dark", - "Settings.Appearance.Preset.ayu-light": "Ayu Light", - "Settings.Appearance.Preset.ayu-mirage": "Ayu Mirage", - "Settings.Appearance.Preset.gruvbox-dark": "Gruvbox Dark", - "Settings.Appearance.Preset.gruvbox-light": "Gruvbox Light", - "Settings.Appearance.Preset.solarized-dark": "Solarized Dark", - "Settings.Appearance.Preset.solarized-light": "Solarized Light", + "Settings.Appearance.Preset.AvaloniaDefault": "Avalonia Default", + "Settings.Appearance.Preset.AyuDark": "Ayu Dark", + "Settings.Appearance.Preset.AyuLight": "Ayu Light", + "Settings.Appearance.Preset.AyuMirage": "Ayu Mirage", + "Settings.Appearance.Preset.GruvboxDark": "Gruvbox Dark", + "Settings.Appearance.Preset.GruvboxLight": "Gruvbox Light", + "Settings.Appearance.Preset.SolarizedDark": "Solarized Dark", + "Settings.Appearance.Preset.SolarizedLight": "Solarized Light", "Settings.Appearance.Preview": "Palette preview", "Settings.Appearance.PreviewFor": "Palette preview for {name}", "Settings.Appearance.SystemMonospaceFont": "System monospace default", @@ -297,7 +296,10 @@ "Settings.FrameDisplay": "Frame display", "Settings.FrameDisplayRound": "Round to integer", "Settings.FrameDisplayDecimalPlaces": "Keep decimal places", + "Settings.FrameDisplayFullPrecision": "Do not truncate", "Settings.FrameDecimalPlaces": "Decimal places", + "Settings.ShowRepeatingFrameDecimals": "Show repeating frame decimals", + "Settings.ShowRepeatingFrameDecimals.Help": "In Do not truncate mode, use an overline for repeating digits. Turn this off to show ordinary decimal text.", "Settings.DeleteRowsTiming": "Delete row timing", "Settings.PreserveChapterTimes": "Preserve chapter times", "Settings.NormalizeChapterTimes": "Normalize chapter times", @@ -331,6 +333,10 @@ "Settings.Unsaved.Discard": "Discard", "Settings.Unsaved.Message": "Settings changes are already applied to this session but have not been saved. Save them before closing?", "Settings.Unsaved.Title": "Unsaved Settings", + "SessionLoss.Title": "End document session?", + "SessionLoss.Message": "Continuing will end this document session and its undo history. Exported chapter files do not include history. Continue?", + "SessionLoss.Continue": "Continue", + "Status.SessionReplacementCanceled": "The current document session is unchanged.", "Settings.ValidateTools": "Check Tools", "Status.AppendFailed": "Append failed", "Status.Appended": "Appended {0} MPLS segment(s) from {1}.", @@ -352,6 +358,7 @@ "Status.DropTooLarge": "File is too large (max 64 MB).", "Status.Duplicated": "Duplicated row {0}.", "Status.ExpressionApplied": "Expression applied · {0} fps · {1} row(s)", + "Status.ExpressionApplyFailed": "Could not apply the expression.", "Status.ExpressionPresetApplied": "Applied expression preset: {0}", "Status.ExpressionPresetUnknown": "Unknown expression preset: {0}", "Status.FramesUpdated": "Frames updated · {0} fps · {1} row(s)", @@ -359,6 +366,7 @@ "Status.LoadFailed": "Load failed", "Status.Loaded": "Loaded {0} chapter(s) from {1}.", "Status.LoadedChapters": "Loaded {count} chapters", + "Status.LoadedChapter": "Loaded {count} chapter", "Status.Loading": "Loading…", "Status.LoadingSource": "Loading source...", "Status.LoadingSource.Discover": "Discovering titles...", @@ -381,7 +389,7 @@ "Status.Ready": "Ready", "Status.RelatedMediaNotFound": "Related media file was not found", "Status.RelatedMediaNote": "Browser cannot open local media paths; references are shown for download/open when available.", - "Status.RestoredClips": "Restored separate clips.", + "Status.RestoredClips": "Split by source boundaries.", "Status.RowsRefreshed": "Rows refreshed · {0} fps · {1} row(s)", "Status.SaveFailed": "Save failed", "Status.Saved": "Saved", @@ -394,6 +402,50 @@ "Status.TemplateLoaded": "Loaded template {name}", "Status.TemplateNotSelected": "Not selected", "Status.Updated": "Updated", + "History.Title": "Edit history", + "History.SessionLifetime": "History is available until this document session closes.", + "History.SessionLifetimeShort": "Current session only", + "History.Undo": "Undo", + "History.Redo": "Redo", + "History.Close": "Close", + "History.Current": "Current history position", + "History.Restore": "Restore to this node", + "History.Retry": "Retry", + "History.Loading": "Loading details…", + "History.RootOverview": "Initial document overview", + "History.PendingPreviewNotice": "History navigation will discard the pending preview.", + "History.Added": "Added", + "History.Removed": "Removed", + "History.Modified": "Modified", + "History.DetailResourceFailure": "Details could not be loaded because resources are low.", + "History.DetailNotFound": "This history node is no longer available.", + "History.DetailEnded": "This document session has ended.", + "History.DetailFailed": "History details could not be loaded.", + "History.RestoreApplied": "Document restored to the selected node.", + "History.LocateCurrent": "Locate current", + "History.CurrentHidden": "Current node is hidden below this branch", + "History.PreferredRedo": "Preferred redo branch", + "History.RedoChoices": "Redo branch choices", + "History.RedoChoice": "Redo selected branch", + "History.Summary": "Summary", + "History.Changes": "Changes", + "History.Parameters": "Parameters", + "History.Operation.Generic": "Edit", + "History.Operation.Load": "Initial document", + "History.Operation.Edit": "Edit chapter", + "History.Operation.Delete": "Delete chapters", + "History.Operation.Insert": "Insert chapter", + "History.Operation.FrameShift": "Shift chapters", + "History.Operation.FrameRateChange": "Change frame rate", + "History.Operation.Expression": "Apply expression", + "History.Operation.Template": "Apply template names", + "History.Operation.Options": "Apply chapter options", + "History.Operation.Merge": "Merge tracks", + "History.Operation.Split": "Split tracks", + "History.Operation.Append": "Append clips", + "History.UndoApplied": "Edit undone", + "History.RedoApplied": "Edit redone", + "History.NavigationFailed": "History navigation failed.", "Status.UnexpectedError": "The operation failed unexpectedly. Check the log for details.", "Status.ZonesEmpty": "No zones generated.", "Status.ZonesGenerated": "Zones generated", @@ -403,6 +455,78 @@ "Title.Refresh": "Refresh grid from current options", "Title.Save": "Export with current options and download", "Tool.Expression.Apply": "Apply expression", + "Expression.Review.TimeSummary": "Time changes: {times}/{count}", + "Expression.Review.OtherSummary": "Affected: {affected} · Frames: {frames} · Properties: {properties}", + "Expression.Review.Details": "Other changes", + "Expression.Preview.Title": "Expression preview", + "Expression.Preview.NoChanges": "No chapter values will change.", + "Expression.Preview.Cancelled": "Expression preview canceled.", + "Expression.Preview.Pending": "Updating expression preview…", + "Expression.Preview.Options": "Preview options", + "Expression.Preview.Update": "Update preview", + "Expression.Preview.Discard": "Discard", + "Expression.Preview.Stale": "Preview is out of date. Update it before applying.", + "Expression.Preview.ComparisonTooltip": "The muted value is committed. The arrow points to the preview value.", + "Expression.Preview.ComparisonAccessibleName": "{field}: committed {before}; preview {candidate}; change {delta}.", + "Expression.Preview.Scope": "Current track: {track} · {count} chapters", + "Expression.Preview.Separators": "{count} separator rows excluded", + "Expression.Preview.Summary": "Affected chapters: {affected} · time changes: {times} · frame updates: {frames} · properties: {properties}", + "Expression.Preview.DeltaFrames": "{value} frames", + "Expression.Preview.Applied": "Applied expression: {affected} affected chapters, {times} time changes, {frames} frame updates. One Undo restores the previous values.", + "Expression.Preview.TimesUnchanged": "Chapter times are unchanged.", + "Expression.Preview.Properties": "Document and track properties", + "Expression.Preview.Filter": "Show", + "Expression.Preview.ChangedOnly": "Changed chapters", + "Expression.Preview.AllChapters": "All chapters", + "Expression.Preview.Units": "Units", + "Expression.Preview.TimeUnits": "Time", + "Expression.Preview.FrameUnits": "Frames", + "Expression.Preview.Chapter": "Chapter", + "Expression.Preview.Before": "Before", + "Expression.Preview.After": "After", + "Expression.Preview.Delta": "Change", + "Expression.Preview.FrameDetails": "Frame details", + "Expression.Preview.BeforeFrames": "Before frames", + "Expression.Preview.AfterFrames": "After frames", + "Expression.Preview.Accuracy": "Frame accuracy", + "Expression.State.Unavailable": "No chapters are available for preview.", + "Expression.State.Waiting": "Waiting to update the preview…", + "Expression.State.Computing": "Computing the preview…", + "Expression.State.Ready": "Preview ready.", + "Expression.State.Empty": "Enter an expression or choose a preset.", + "Expression.State.Composing": "Finish text composition to update the preview.", + "Expression.State.Stale": "The chapters changed. Update the preview before applying.", + "Expression.State.Invalid": "This expression cannot be applied.", + "Expression.State.Unchanged": "The complete result has no changes to apply.", + "Expression.State.Applying": "Applying reviewed changes…", + "Expression.Action.Refresh": "Update preview", + "Expression.Action.Apply": "Apply changes", + "Expression.Value.Missing": "Not available", + "Expression.Value.NotCalculated": "Not calculated", + "Expression.Value.NotSet": "Not set", + "Expression.Value.IncompatibleFrames": "Frame rates differ", + "Expression.Unit.FramesShort": "frames", + "Expression.Unit.FramesPerSecond": "fps", + "Expression.FrameAccuracy.Accurate": "Frame accurate", + "Expression.FrameAccuracy.Inexact": "Between frames", + "Expression.FrameAccuracy.NotCalculated": "Not calculated", + "Expression.FrameAccuracy.Policy": "Frame accurate means the distance from the nearest frame is under 0.01 frame.", + "Expression.PropertyOwner.Document": "Document", + "Expression.PropertyOwner.Track": "Track", + "Expression.PropertyOwner.Segment": "Segment", + "Expression.PropertyOwner.Chapter": "Chapter", + "Expression.Property.Title": "Title", + "Expression.Property.Source": "Source", + "Expression.Property.ImportFormat": "Import format", + "Expression.Property.Duration": "Duration", + "Expression.Property.FrameRate": "Frame rate", + "Expression.Property.Name": "Name", + "Expression.Property.Number": "Chapter number", + "Expression.Property.Type": "Chapter type", + "Expression.Property.Presence": "Chapter entry", + "Expression.Property.TrackOrder": "Track order", + "Expression.Property.Additional": "Additional field", + "Expression.Property.ValueChanged": "Updated", "Tool.Expression.LoadLuaScript": "Load Lua...", "Tool.Expression.Preset": "Preset", "Tool.Expression.Title": "Expression editor", @@ -414,5 +538,169 @@ "Tool.TemplateNames.Title": "Template Names", "Tool.TemplateNames.UseStandard": "Use standard template names", "Tool.Zones.Title": "Zones", - "XmlLanguage.Undetermined": "Undetermined" + "XmlLanguage.Undetermined": "Undetermined", + "Settings.Shortcuts": "Shortcuts", + "Settings.Shortcuts.Description": "Customize keyboard gestures for primary actions.", + "Settings.Shortcuts.Reset": "Reset shortcut", + "Settings.Shortcuts.CaptureHint": "Press a key combination to assign this shortcut.", + "Settings.Shortcuts.FixedHint": "This shortcut is fixed by the application.", + "Shortcuts.Action.Save": "Save", + "Shortcuts.Action.Reload": "Reload", + "Shortcuts.Action.Refresh": "Refresh", + "Shortcuts.Action.Log": "Log", + "Shortcuts.Action.Preview": "Preview", + "Shortcuts.Action.ClipSelection": "Select clip", + "Shortcuts.Action.Insert": "Insert row", + "Shortcuts.Action.Delete": "Delete row", + "Shortcuts.Action.Load": "Load", + "Shortcuts.Action.PreviousClip": "Previous clip", + "Shortcuts.Action.NextClip": "Next clip", + "Shortcuts.Action.Undo": "Undo edit", + "Shortcuts.Action.Redo": "Redo edit", + "Shortcuts.Action.SaveFormat": "Select save format", + "Modal.ExpressionTitle": "Expression", + "Modal.ExportOptionsTitle": "Advanced export options", + "Parity.NamingTitle": "Naming and numbering", + "Parity.InvalidNumber": "Enter an integer offset from 0 to 1000.", + "Parity.InvalidShift": "Enter an integer frame shift from -1000000 to 1000000.", + "Parity.Completions": "Completions", + "Parity.Position": "Position", + "Parity.ScriptLoadFailed": "Could not load this Lua script. The previous draft was kept.", + "Parity.UnsavedSettings": "Discard unsaved settings?", + "Parity.Discard": "Discard", + "Parity.KeepEditing": "Keep editing", + "Parity.SettingsSaveFailed": "Settings could not be saved. Keep editing or retry.", + "Parity.InvalidShortcut": "Invalid, reserved, or conflicting shortcut.", + "Parity.LogSearch": "Search logs", + "Parity.LogSeverity": "Severity", + "Parity.LogAll": "All levels", + "Parity.LogCount": "{count} entries", + "Parity.LogDetails": "Details", + "Parity.LogBack": "Back to logs", + "Parity.LogRaw": "Raw entry", + "Parity.LogExport": "Export logs", + "Parity.LogDetailMatch": "Match in details", + "Parity.LogFailed": "The log action failed. You can retry.", + "Parity.LogExported": "Log download started.", + "Parity.LogLevel.Info": "Info", + "Parity.LogLevel.Information": "Info", + "Parity.LogLevel.Warning": "Warning", + "Parity.LogLevel.Error": "Error", + "Parity.LogLevel.Debug": "Debug", + "Parity.LogLevel.Trace": "Trace", + "Parity.SourceFps": "Source FPS", + "Parity.TargetFps": "Target FPS", + "Parity.InvalidCandidate": "This operation cannot be applied. Review the values and details.", + "XmlLanguage.Chinese": "Chinese", + "XmlLanguage.German": "German", + "XmlLanguage.French": "French", + "XmlLanguage.Italian": "Italian", + "XmlLanguage.Spanish": "Spanish", + "History.HistoryPage": "History", + "History.DetailsPage": "Details", + "History.Filter.All": "All changes", + "History.NoFilteredChanges": "No changes match this filter.", + "History.Filter.Added": "Added", + "History.Filter.Removed": "Removed", + "History.Filter.Modified": "Modified", + "History.FieldHeader": "Field", + "History.BeforeHeader": "Before", + "History.AfterHeader": "After", + "History.DeltaHeader": "Delta", + "History.DeltaTicks": "{0} ticks", + "History.Presence.Absent": "Not present", + "History.Presence.Null": "Null", + "History.Presence.Empty": "Empty", + "History.Parameter.Field": "Field", + "History.Parameter.ChapterId": "Chapter", + "History.Parameter.ChapterIds": "Chapters", + "History.Parameter.Value": "Value", + "History.Parameter.DisplayFps": "Display Frame Rate", + "History.Parameter.Frames": "Frames", + "History.Parameter.BeforeChapterId": "Before Chapter", + "History.Parameter.SourcePath": "Source", + "History.Parameter.Script": "Expression", + "History.Parameter.Expression": "Expression", + "History.Parameter.AutoGenerateNames": "Generate Names", + "History.Parameter.UseTemplateNames": "Use Template Names", + "History.Parameter.Template": "Template", + "History.Parameter.OrderShift": "Order Shift", + "History.Parameter.SourceFps": "Source Frame Rate", + "History.Parameter.TargetFps": "Target Frame Rate", + "History.Field.Title": "Title", + "History.Field.SourceName": "Source name", + "History.Field.ImportFormat": "Import format", + "History.Field.Duration": "Duration", + "History.Field.FrameRate": "Frame rate", + "History.Field.TrackOrder": "Track order", + "History.Field.Presence": "Presence", + "History.Field.Name": "Name", + "History.Field.ChapterOrder": "Chapter order", + "History.Field.SegmentOrder": "Segment order", + "History.Field.Track": "Track", + "History.Field.Position": "Position", + "History.Field.DisplayNumber": "Chapter number", + "History.Field.StartTicks": "Start time", + "History.Field.EndTicks": "End time", + "History.Field.FramesInfo": "Frames", + "History.Field.FrameAccuracy": "Frame accuracy", + "History.Field.Kind": "Type", + "History.Field.SourceTrackId": "Source track", + "History.Field.SourceEntryId": "Source entry", + "History.Field.ReferencedMediaFiles": "Media files", + "History.Field.MediaTracks": "Media tracks", + "History.Field.ChapterCount": "Chapter count", + "History.Field.ImportDisplayName": "Import name", + "History.Field.DiscTitleNumber": "Disc title", + "History.Field.SourceFrameRate": "Source frame rate", + "History.Field.SourceChapterCount": "Source chapter count", + "History.Parameter.TargetScope": "Scope", + "History.Parameter.TrackId": "Track", + "History.Parameter.TrackIds": "Tracks", + "History.Parameter.TrackCount": "Track Count", + "History.Parameter.DeleteTiming": "Delete Timing", + "History.Parameter.FrameDisplay": "Frame Display", + "History.Parameter.FrameDecimalPlaces": "Frame Decimals", + "History.Enum.Field.Name": "Name", + "History.Enum.Field.StartTime": "Start time", + "History.Enum.Field.Frame": "Frame", + "History.Presence.Unknown": "Unknown", + "History.Unit.Fps": "fps", + "History.FrameRateContext": "frame rate {Before} → {After}", + "History.Track.Unknown": "Unavailable track", + "History.ChapterContext": "{Track} · chapter position {Position}", + "History.Enum.ImportFormat.Unknown": "Unknown", + "History.Enum.ImportFormat.Ogm": "OGM", + "History.Enum.ImportFormat.MatroskaXml": "Matroska XML", + "History.Enum.ImportFormat.WebVtt": "WebVTT", + "History.Enum.ImportFormat.Cue": "CUE", + "History.Enum.ImportFormat.PremiereMarkers": "Premiere markers", + "History.Enum.ImportFormat.Mpls": "Blu-ray playlist (MPLS)", + "History.Enum.ImportFormat.DvdIfo": "DVD IFO", + "History.Enum.ImportFormat.HdDvdXpl": "HD-DVD XPL", + "History.Enum.ImportFormat.Media": "Media container", + "History.Enum.ImportFormat.Bdmv": "Blu-ray disc", + "History.Enum.ImportFormat.Text": "Text", + "History.Enum.ImportFormat.Xml": "XML", + "History.Enum.ImportFormat.Json": "JSON", + "History.Enum.ChapterKind.Marker": "Marker", + "History.Enum.ChapterKind.Separator": "Separator", + "History.Enum.FrameAccuracy.Neutral": "Neutral", + "History.Enum.FrameAccuracy.Early": "Early", + "History.Enum.FrameAccuracy.Late": "Late", + "History.Parameter.TrackName": "Track Name", + "History.ChangedTracks": "Changed tracks", + "History.ChangedSegments": "Changed segments", + "History.ComparisonNotice": "Changes below compare this node with its parent.", + "History.CurrentStateNote": "Inspecting history does not move the current saved state.", + "History.Boolean.True": "Yes", + "History.Boolean.False": "No", + "History.Enum.DeleteTiming.Preserve": "Preserve timestamps", + "History.Enum.DeleteTiming.Normalize": "Normalize remaining times", + "History.Enum.FrameDisplay.Round": "Whole frames", + "History.Enum.FrameDisplay.DecimalPlaces": "Decimal frames", + "History.ToggleBranch": "Toggle history branch", + "History.Empty": "No history entry is available.", + "Parity.ExportOptionsInvalid": "Choose valid export options.", + "Parity.SettingsApplyFailed": "Could not apply these settings. The draft is still available." } diff --git a/src/ChapterTool.Wasm/Resources/Locales/ja-JP.json b/src/ChapterTool.Wasm/Resources/Locales/ja-JP.json index 2d78e483..485774ce 100644 --- a/src/ChapterTool.Wasm/Resources/Locales/ja-JP.json +++ b/src/ChapterTool.Wasm/Resources/Locales/ja-JP.json @@ -5,12 +5,7 @@ "Action.AppendMpls": "MPLS を追加…", "Action.BrowseTemplate": "テンプレートを参照…", "Action.Close": "閉じる", - "Action.CombineSegments": "セグメントを結合: オプション数={entries}, ソース種別={sourceType}", - "Action.DeleteRows": "行を削除: インデックス={indexes}", "Action.Download": "ダウンロード", - "Action.EditCell": "{kind}を編集: 行={row}, 値='{value}'", - "Action.EditChapters": "チャプターを編集", - "Action.InsertRow": "行を挿入: インデックス={index}", "Action.Load": "読み込み", "Action.LoadSample": "OGM サンプルを読み込む", "Action.Log": "ログ", @@ -19,8 +14,6 @@ "Action.Reload": "再読み込み", "Action.RestoreDefaults": "既定に戻す", "Action.Settings": "設定", - "Action.ShiftFramesForward": "チャプターを前へ移動: フレーム数={frames}", - "Action.SplitCombinedSegments": "結合済みセグメントを分割: オプション数={entries}, ソース種別={sourceType}", "App.Title": "[VCB-Studio] ChapterTool", "Common.Apply": "適用", "Common.Cancel": "キャンセル", @@ -31,6 +24,19 @@ "Tool.Log.FilterWarning": "警告", "Tool.Log.FilterError": "エラー", "Tool.Log.Filter": "レベルでフィルター", + "Tool.Log.FilterLevelLabel": "レベル", + "Tool.Log.ResetFilters": "フィルターをリセット", + "Tool.Log.MoreActions": "その他の操作", + "Tool.Log.ExportFormat": "エクスポート形式", + "Tool.Log.OpenDetails": "詳細を開く", + "Tool.Log.SearchMatchAdditional": "詳細に一致する内容があります。", + "Tool.Log.EntryAccessibleName": "{level}:{summary}、{time}。{context}", + "Tool.Log.NoEntries": "ログはまだありません。", + "Tool.Log.NoMatches": "検索またはフィルターに一致するログはありません。", + "Tool.Log.Message": "メッセージ", + "Tool.Log.Context": "コンテキスト", + "Tool.Log.NoDetails": "このログに追加の詳細はありません。", + "Tool.Log.BackToList": "ログ一覧に戻る", "Tool.Log.CopySummary": "概要をコピー", "Tool.Log.CopyDetails": "詳細をコピー", "Tool.Log.Details": "技術詳細", @@ -44,6 +50,13 @@ "Tool.Log.Properties": "プロパティ", "Tool.Log.Raw": "生データ", "Tool.Log.SearchPlaceholder": "ログを検索…", + "Tool.Log.ExportJson": "JSON", + "Tool.Log.ExportCsv": "CSV", + "Tool.Log.ExportSucceeded": "{value} にエクスポートしました", + "Tool.Log.ExportFailed": "エクスポートに失敗しました: {value}", + "Tool.Log.CloseDetails": "詳細を閉じる", + "Tool.Log.OpenFolder": "ログフォルダーを開く", + "Tool.Log.Export": "ログをエクスポート", "Tool.Log.NoStructuredData": "このログにネストされた構造化データはありません。", "Tool.Log.NoExceptionData": "このログに例外データはありません。", "Common.Save": "保存", @@ -109,7 +122,10 @@ "Diagnostic.FlacEmbeddedCue.NotFound": "Vorbis cuesheet コメントが見つかりませんでした。", "Diagnostic.FrameRate.Invalid": "フレームレートは 0 より大きくなければなりません。", "Diagnostic.FrameText.Invalid": "フレームテキストにフレーム番号が含まれていないか、フレームレートが無効です。", + "Diagnostic.Ifo.Available": "DVD を読み込みました:{titleCount} 個のタイトル、{chapterCount} 個のチャプター、{titleSetCount} 個のタイトルセット。", "Diagnostic.Ifo.Invalid": "IFO ファイルが無効です: {message}", + "Diagnostic.Ifo.Captured": "{source} に対応する VOB のナビゲーションデータから DVD チャプター時刻を読み込みました。", + "Diagnostic.Ifo.Fallback": "{source} に対応する VOB のナビゲーション時刻を検証できませんでした。DVD チャプターは元の IFO 推定時刻を保持し、フレーム位置への調整は行っていません。", "Diagnostic.ImporterFallback.Used": "プライマリ インポーターが利用できないため、フォールバック インポーターを使用しました。", "Diagnostic.LuaExpression.Cancelled": "Lua 式の実行がキャンセルされました。", "Diagnostic.LuaExpression.CompileFailed": "Lua 式の構文エラー: {message}", @@ -156,8 +172,6 @@ "Diagnostic.XmlRoot.Invalid": "ルート要素は Chapters である必要がありますが、実際は {name} です。", "Diagnostic.XplChapters.None": "HD-DVD のチャプターは 1 件も解析されませんでした。", "Diagnostic.XplParse.Failed": "XPL プレイリストの解析に失敗しました: {message}", - "EditKind.Frame": "フレーム", - "EditKind.Name": "名前", "Empty.Hint": "ファイルを読み込むか、ここにドロップするか、サンプルを読み込むと、フレーム数と式の結果が計算されます。", "Empty.Title": "チャプターはまだ読み込まれていません", "Expression.Suggestion.AddOperand": "このトークンを使用する前に、不足しているオペランドを追加してください。", @@ -177,10 +191,12 @@ "Grid.Frames": "フレーム", "Grid.Name": "名前", "Grid.Number": "#", + "Grid.TableLabel": "チャプター", "Grid.Time": "時刻", "Label.ChapterName": "チャプター名", "Label.Clip": "クリップ / プレイリスト項目", "Label.ClipOption": "{0}({1} 個のチャプター)", + "Label.DvdTitle": "タイトル {number}", "Label.Expression": "カスタム式", "Label.ExpressionPreset": "プリセット", "Label.FrameRate": "フレーム列で使用するフレームレート", @@ -192,31 +208,14 @@ "Language.ChineseSimplified": "簡体字中国語", "Language.English": "英語", "Language.Japanese": "日本語", - "Log.AppendingMpls": "MPLS を追加中: path='{path}'", - "Log.AutoFrameRateDetection": "自動フレームレート検出: オプション={option}, 信頼度={confidence}, 正確={accurate}/{evaluated}, 偏差={deviation}", - "Log.ChangeFps": "現在の FPS に変換: 変換元={sourceFps}, 変換先={targetFps}, チャプター数 {before} -> {after}", - "Log.CreateZones": "Zones を生成: 選択行={selectedRows}, チャプター数={chapters}", - "Log.Diagnostic": "{operation} diagnostic: severity={severity}, code={code}{location} message='{message}'{details}", - "Log.EditChapters": "{action}: チャプター数 {before} -> {after}", - "Log.FrameInfoUpdated": "フレーム情報を更新: オプション={option}, fps={fps}, 丸め={round}, チャプター数={chapters}", - "Log.ImportSummary": "{operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics}", - "Log.LanguageSet": "言語を {language} に設定しました", - "Log.LoadingSource": "ソースを読み込み中: path='{path}'", - "Log.OpenedPath": "{status}: path='{path}'", - "Log.RelatedMediaNotFound": "{status}: reference='{reference}', resolved='{resolved}'", - "Log.SavingChapters": "チャプターを保存中: 形式={format}, ディレクトリ='{directory}', ソース='{source}', チャプター数={chapters}, 式を適用={applyExpression}, 式='{expression}'", - "Log.SelectedSourceOption": "ソースオプションを選択: インデックス={index}, ラベル='{label}', ソース='{source}', ソース種別={sourceType}, チャプター数={chapters}, fps={fps}", - "Log.SettingsLoaded": "設定を読み込みました: savingPath='{savingPath}', language='{language}'", - "Log.Status": "{status}", - "Log.UnexpectedError": "ユーザーインターフェイスの操作中に予期しないエラーが発生しました", - "Log.StatusFromPath": "{status}、ソース: {path}", - "Log.TemplateLoadFailed": "'{path}' からチャプター名テンプレートを読み込めませんでした", - "Log.TemplateLoaded": "チャプター名テンプレート '{name}' を '{path}' から読み込みました", "Main.AppendMpls": "MPLS を追加して結合", "Main.AutoFrameRate": "自動", "Main.ChapterCount": "{count} 個のチャプター", + "Main.ChapterCountSingular": "{count} 個のチャプター", "Main.ChangeFps": "現在の FPS に変換", "Main.Combine": "結合", + "Main.SplitByBoundaries": "境界で分割", + "Menu.SplitByBoundaries": "境界で分割", "Main.CreateZones": "Zones を生成", "Main.ForwardTranslation": "フレームを前へ移動", "Main.Insert": "挿入", @@ -270,14 +269,14 @@ "Settings.Appearance.MonospaceFont": "等幅フォント", "Settings.Appearance.MonospaceFontPreviewFor": "{name} の等幅フォントプレビュー", "Settings.Appearance.Preset": "テーマプリセット", - "Settings.Appearance.Preset.avalonia-default": "Avalonia デフォルト", - "Settings.Appearance.Preset.ayu-dark": "Ayu ダーク", - "Settings.Appearance.Preset.ayu-light": "Ayu ライト", - "Settings.Appearance.Preset.ayu-mirage": "Ayu ミラージュ", - "Settings.Appearance.Preset.gruvbox-dark": "Gruvbox ダーク", - "Settings.Appearance.Preset.gruvbox-light": "Gruvbox ライト", - "Settings.Appearance.Preset.solarized-dark": "Solarized ダーク", - "Settings.Appearance.Preset.solarized-light": "Solarized ライト", + "Settings.Appearance.Preset.AvaloniaDefault": "Avalonia デフォルト", + "Settings.Appearance.Preset.AyuDark": "Ayu ダーク", + "Settings.Appearance.Preset.AyuLight": "Ayu ライト", + "Settings.Appearance.Preset.AyuMirage": "Ayu ミラージュ", + "Settings.Appearance.Preset.GruvboxDark": "Gruvbox ダーク", + "Settings.Appearance.Preset.GruvboxLight": "Gruvbox ライト", + "Settings.Appearance.Preset.SolarizedDark": "Solarized ダーク", + "Settings.Appearance.Preset.SolarizedLight": "Solarized ライト", "Settings.Appearance.Preview": "パレットプレビュー", "Settings.Appearance.PreviewFor": "{name} のパレットプレビュー", "Settings.Appearance.SystemMonospaceFont": "システム既定の等幅フォント", @@ -297,7 +296,10 @@ "Settings.FrameDisplay": "フレーム表示", "Settings.FrameDisplayRound": "整数に丸める", "Settings.FrameDisplayDecimalPlaces": "小数点以下を保持", + "Settings.FrameDisplayFullPrecision": "桁を切り捨てない", "Settings.FrameDecimalPlaces": "小数点以下の桁数", + "Settings.ShowRepeatingFrameDecimals": "循環小数を表示", + "Settings.ShowRepeatingFrameDecimals.Help": "桁を切り捨てないモードでは、循環する桁を上線で表示できます。無効にすると通常の小数形式を使います。", "Settings.DeleteRowsTiming": "行削除時の時間処理", "Settings.PreserveChapterTimes": "チャプター時間を保持", "Settings.NormalizeChapterTimes": "チャプター時間を正規化", @@ -331,6 +333,10 @@ "Settings.Unsaved.Discard": "破棄", "Settings.Unsaved.Message": "設定の変更は現在のセッションにすでに反映されていますが、まだ保存されていません。閉じる前に保存しますか?", "Settings.Unsaved.Title": "未保存の設定", + "SessionLoss.Title": "ドキュメントセッションを終了しますか?", + "SessionLoss.Message": "続行すると、このドキュメントセッションと取り消し履歴を終了します。エクスポートした章ファイルに履歴は含まれません。続行しますか?", + "SessionLoss.Continue": "続行", + "Status.SessionReplacementCanceled": "現在のドキュメントセッションは変更されていません。", "Settings.ValidateTools": "ツールを確認", "Status.AppendFailed": "MPLS の追加に失敗しました", "Status.Appended": "{1} から {0} 個の MPLS セグメントを追加しました。", @@ -352,6 +358,7 @@ "Status.DropTooLarge": "ファイルが大きすぎます(最大 64 MB)。", "Status.Duplicated": "{0} 行目を複製しました。", "Status.ExpressionApplied": "式を適用しました · {0} fps · {1} 行", + "Status.ExpressionApplyFailed": "式を適用できませんでした。", "Status.ExpressionPresetApplied": "式プリセットを適用しました: {0}", "Status.ExpressionPresetUnknown": "不明な式プリセット: {0}", "Status.FramesUpdated": "フレームを更新しました · {0} fps · {1} 行", @@ -359,6 +366,7 @@ "Status.LoadFailed": "読み込みに失敗しました", "Status.Loaded": "{1} から {0} 個のチャプターを読み込みました。", "Status.LoadedChapters": "{count} 個のチャプターを読み込みました", + "Status.LoadedChapter": "{count} 個のチャプターを読み込みました", "Status.Loading": "読み込み中…", "Status.LoadingSource": "読み込み中…", "Status.LoadingSource.Discover": "タイトルを検出中…", @@ -381,7 +389,7 @@ "Status.Ready": "準備完了", "Status.RelatedMediaNotFound": "関連メディアファイルが見つかりません", "Status.RelatedMediaNote": "ブラウザーではローカルメディアのパスを開けません。参照先を利用できる場合は、ダウンロードまたはリンクとしてのみ表示します。", - "Status.RestoredClips": "結合前の個別クリップに戻しました。", + "Status.RestoredClips": "ソース境界で分割しました。", "Status.RowsRefreshed": "行を更新しました · {0} fps · {1} 行", "Status.SaveFailed": "保存に失敗しました", "Status.Saved": "保存しました", @@ -394,6 +402,50 @@ "Status.TemplateLoaded": "テンプレート {name} を読み込みました", "Status.TemplateNotSelected": "未選択", "Status.Updated": "更新しました", + "History.Title": "編集履歴", + "History.SessionLifetime": "履歴は現在のドキュメントセッション中のみ保持されます。", + "History.SessionLifetimeShort": "現在のセッションのみ", + "History.Undo": "元に戻す", + "History.Redo": "やり直す", + "History.Close": "閉じる", + "History.Current": "現在の履歴位置", + "History.Restore": "このノードに復元", + "History.Retry": "再試行", + "History.Loading": "詳細を読み込み中…", + "History.RootOverview": "初期ドキュメントの概要", + "History.PendingPreviewNotice": "履歴を移動すると保留中のプレビューは破棄されます。", + "History.Added": "追加", + "History.Removed": "削除", + "History.Modified": "変更", + "History.DetailResourceFailure": "リソース不足のため詳細を読み込めません。", + "History.DetailNotFound": "この履歴ノードは利用できません。", + "History.DetailEnded": "ドキュメントセッションは終了しました。", + "History.DetailFailed": "履歴の詳細を読み込めませんでした。", + "History.RestoreApplied": "選択したノードに復元しました。", + "History.LocateCurrent": "現在の位置を表示", + "History.CurrentHidden": "現在のノードはこの分岐の下にあります", + "History.PreferredRedo": "優先するやり直し分岐", + "History.RedoChoices": "やり直し分岐の選択肢", + "History.RedoChoice": "選択した分岐をやり直す", + "History.Summary": "概要", + "History.Changes": "変更", + "History.Parameters": "パラメーター", + "History.Operation.Generic": "編集", + "History.Operation.Load": "初期ドキュメント", + "History.Operation.Edit": "チャプターを編集", + "History.Operation.Delete": "チャプターを削除", + "History.Operation.Insert": "チャプターを挿入", + "History.Operation.FrameShift": "チャプターを移動", + "History.Operation.FrameRateChange": "フレームレートを変更", + "History.Operation.Expression": "式を適用", + "History.Operation.Template": "テンプレート名を適用", + "History.Operation.Options": "チャプター設定を適用", + "History.Operation.Merge": "トラックを結合", + "History.Operation.Split": "トラックを分割", + "History.Operation.Append": "クリップを追加", + "History.UndoApplied": "編集を元に戻しました", + "History.RedoApplied": "編集をやり直しました", + "History.NavigationFailed": "履歴を移動できませんでした。", "Status.UnexpectedError": "操作中に予期しないエラーが発生しました。詳細はログを確認してください。", "Status.ZonesEmpty": "zones は生成されませんでした。", "Status.ZonesGenerated": "Zones を生成しました", @@ -403,6 +455,78 @@ "Title.Refresh": "現在のオプションでグリッドを更新", "Title.Save": "現在のオプションで書き出してダウンロード", "Tool.Expression.Apply": "式を適用", + "Expression.Review.TimeSummary": "時刻変更 {times}/{count} 件", + "Expression.Review.OtherSummary": "対象 {affected} 件 · フレーム {frames} 件 · プロパティ {properties} 件", + "Expression.Review.Details": "その他の変更", + "Expression.Preview.Title": "式のプレビュー", + "Expression.Preview.NoChanges": "チャプターの値は変更されません。", + "Expression.Preview.Cancelled": "式のプレビューをキャンセルしました。", + "Expression.Preview.Pending": "式のプレビューを更新しています…", + "Expression.Preview.Options": "オプションをプレビュー", + "Expression.Preview.Update": "プレビューを更新", + "Expression.Preview.Discard": "破棄", + "Expression.Preview.Stale": "プレビューが古くなっています。更新してから適用してください。", + "Expression.Preview.ComparisonTooltip": "薄い値は確定済みです。矢印はプレビュー値を示します。", + "Expression.Preview.ComparisonAccessibleName": "{field}: 確定値 {before}、プレビュー {candidate}、変更 {delta}。", + "Expression.Preview.Scope": "現在のトラック:{track} · {count} 件のチャプター", + "Expression.Preview.Separators": "区切り行 {count} 件を除外", + "Expression.Preview.Summary": "影響するチャプター:{affected} · 時刻の変更:{times} · フレーム情報の更新:{frames} · プロパティの変更:{properties}", + "Expression.Preview.DeltaFrames": "{value} フレーム", + "Expression.Preview.Applied": "式を適用しました:影響するチャプター {affected} 件、時刻の変更 {times} 件、フレーム情報の更新 {frames} 件。1 回の取り消しで戻せます。", + "Expression.Preview.TimesUnchanged": "チャプター時刻は変わりません。", + "Expression.Preview.Properties": "ドキュメントとトラックのプロパティ", + "Expression.Preview.Filter": "表示", + "Expression.Preview.ChangedOnly": "変更したチャプター", + "Expression.Preview.AllChapters": "すべてのチャプター", + "Expression.Preview.Units": "単位", + "Expression.Preview.TimeUnits": "時刻", + "Expression.Preview.FrameUnits": "フレーム", + "Expression.Preview.Chapter": "チャプター", + "Expression.Preview.Before": "変更前", + "Expression.Preview.After": "変更後", + "Expression.Preview.Delta": "差分", + "Expression.Preview.FrameDetails": "フレーム情報の詳細", + "Expression.Preview.BeforeFrames": "変更前のフレーム", + "Expression.Preview.AfterFrames": "変更後のフレーム", + "Expression.Preview.Accuracy": "フレーム精度", + "Expression.State.Unavailable": "プレビューできるチャプターがありません。", + "Expression.State.Waiting": "プレビューの更新を待っています…", + "Expression.State.Computing": "プレビューを計算しています…", + "Expression.State.Ready": "プレビューを確認できます。", + "Expression.State.Empty": "式を入力するか、プリセットを選択してください。", + "Expression.State.Composing": "文字入力が完了するとプレビューを更新します。", + "Expression.State.Stale": "チャプターが変更されました。適用前にプレビューを更新してください。", + "Expression.State.Invalid": "この式は適用できません。", + "Expression.State.Unchanged": "適用できる変更はありません。", + "Expression.State.Applying": "確認済みの変更を適用しています…", + "Expression.Action.Refresh": "プレビューを更新", + "Expression.Action.Apply": "変更を適用", + "Expression.Value.Missing": "利用できません", + "Expression.Value.NotCalculated": "未計算", + "Expression.Value.NotSet": "未設定", + "Expression.Value.IncompatibleFrames": "フレームレートが異なります", + "Expression.Unit.FramesShort": "フレーム", + "Expression.Unit.FramesPerSecond": "fps", + "Expression.FrameAccuracy.Accurate": "フレーム境界に一致", + "Expression.FrameAccuracy.Inexact": "フレーム間", + "Expression.FrameAccuracy.NotCalculated": "未計算", + "Expression.FrameAccuracy.Policy": "最も近いフレームとの差が 0.01 フレーム未満の場合、フレーム境界に一致と表示します。", + "Expression.PropertyOwner.Document": "ドキュメント", + "Expression.PropertyOwner.Track": "トラック", + "Expression.PropertyOwner.Segment": "セグメント", + "Expression.PropertyOwner.Chapter": "チャプター", + "Expression.Property.Title": "タイトル", + "Expression.Property.Source": "ソース", + "Expression.Property.ImportFormat": "インポート形式", + "Expression.Property.Duration": "長さ", + "Expression.Property.FrameRate": "フレームレート", + "Expression.Property.Name": "名前", + "Expression.Property.Number": "チャプター番号", + "Expression.Property.Type": "チャプター種別", + "Expression.Property.Presence": "チャプター項目", + "Expression.Property.TrackOrder": "トラック順序", + "Expression.Property.Additional": "追加フィールド", + "Expression.Property.ValueChanged": "更新済み", "Tool.Expression.LoadLuaScript": "Lua スクリプトを読み込む…", "Tool.Expression.Preset": "プリセット", "Tool.Expression.Title": "式エディター", @@ -414,5 +538,169 @@ "Tool.TemplateNames.Title": "テンプレート名", "Tool.TemplateNames.UseStandard": "標準テンプレート名を使用", "Tool.Zones.Title": "Zones", - "XmlLanguage.Undetermined": "未確定" + "XmlLanguage.Undetermined": "未確定", + "Settings.Shortcuts": "ショートカット", + "Settings.Shortcuts.Description": "主な操作のキーボードジェスチャーを設定します。", + "Settings.Shortcuts.Reset": "ショートカットをリセット", + "Settings.Shortcuts.CaptureHint": "キーの組み合わせを押してショートカットを設定します。", + "Settings.Shortcuts.FixedHint": "このショートカットはアプリケーションで固定されています。", + "Shortcuts.Action.Save": "保存", + "Shortcuts.Action.Reload": "再読み込み", + "Shortcuts.Action.Refresh": "更新", + "Shortcuts.Action.Log": "ログ", + "Shortcuts.Action.Preview": "プレビュー", + "Shortcuts.Action.ClipSelection": "クリップを選択", + "Shortcuts.Action.Insert": "行を挿入", + "Shortcuts.Action.Delete": "行を削除", + "Shortcuts.Action.Load": "読み込み", + "Shortcuts.Action.PreviousClip": "前のクリップ", + "Shortcuts.Action.NextClip": "次のクリップ", + "Shortcuts.Action.Undo": "編集を元に戻す", + "Shortcuts.Action.Redo": "編集をやり直す", + "Shortcuts.Action.SaveFormat": "保存形式を選択", + "Modal.ExpressionTitle": "式", + "Modal.ExportOptionsTitle": "詳細なエクスポート設定", + "Parity.NamingTitle": "名前と番号", + "Parity.InvalidNumber": "0 から 1000 の整数を入力してください。", + "Parity.InvalidShift": "-1000000 から 1000000 の整数フレームを入力してください。", + "Parity.Completions": "入力候補", + "Parity.Position": "位置", + "Parity.ScriptLoadFailed": "Lua スクリプトを読み込めません。元の下書きを保持しました。", + "Parity.UnsavedSettings": "未保存の設定を破棄しますか?", + "Parity.Discard": "破棄", + "Parity.KeepEditing": "編集を続ける", + "Parity.SettingsSaveFailed": "設定を保存できません。編集を続けるか再試行してください。", + "Parity.InvalidShortcut": "無効、予約済み、または競合するショートカットです。", + "Parity.LogSearch": "ログを検索", + "Parity.LogSeverity": "レベル", + "Parity.LogAll": "すべてのレベル", + "Parity.LogCount": "{count} 件", + "Parity.LogDetails": "詳細", + "Parity.LogBack": "ログに戻る", + "Parity.LogRaw": "元のログ", + "Parity.LogExport": "ログをエクスポート", + "Parity.LogDetailMatch": "詳細に一致", + "Parity.LogFailed": "ログ操作に失敗しました。再試行できます。", + "Parity.LogExported": "ログのダウンロードを開始しました。", + "Parity.LogLevel.Info": "情報", + "Parity.LogLevel.Information": "情報", + "Parity.LogLevel.Warning": "警告", + "Parity.LogLevel.Error": "エラー", + "Parity.LogLevel.Debug": "デバッグ", + "Parity.LogLevel.Trace": "トレース", + "Parity.SourceFps": "変換元のフレームレート", + "Parity.TargetFps": "変換先のフレームレート", + "Parity.InvalidCandidate": "この操作を適用できません。入力値と詳細を確認してください。", + "XmlLanguage.Chinese": "中国語", + "XmlLanguage.German": "ドイツ語", + "XmlLanguage.French": "フランス語", + "XmlLanguage.Italian": "イタリア語", + "XmlLanguage.Spanish": "スペイン語", + "History.HistoryPage": "履歴", + "History.DetailsPage": "詳細", + "History.Filter.All": "すべての変更", + "History.NoFilteredChanges": "この条件に一致する変更はありません。", + "History.Filter.Added": "追加", + "History.Filter.Removed": "削除", + "History.Filter.Modified": "変更", + "History.FieldHeader": "フィールド", + "History.BeforeHeader": "変更前", + "History.AfterHeader": "変更後", + "History.DeltaHeader": "差分", + "History.DeltaTicks": "{0} ティック", + "History.Presence.Absent": "存在しない", + "History.Presence.Null": "Null", + "History.Presence.Empty": "空のテキスト", + "History.Parameter.Field": "フィールド", + "History.Parameter.ChapterId": "チャプター", + "History.Parameter.ChapterIds": "チャプター", + "History.Parameter.Value": "値", + "History.Parameter.DisplayFps": "表示フレームレート", + "History.Parameter.Frames": "フレーム数", + "History.Parameter.BeforeChapterId": "前のチャプター", + "History.Parameter.SourcePath": "ソース", + "History.Parameter.Script": "式", + "History.Parameter.Expression": "式", + "History.Parameter.AutoGenerateNames": "名前を生成", + "History.Parameter.UseTemplateNames": "テンプレート名を使用", + "History.Parameter.Template": "テンプレート", + "History.Parameter.OrderShift": "順序の移動", + "History.Parameter.SourceFps": "ソースフレームレート", + "History.Parameter.TargetFps": "目標フレームレート", + "History.Field.Title": "タイトル", + "History.Field.SourceName": "ソース名", + "History.Field.ImportFormat": "インポート形式", + "History.Field.Duration": "時間", + "History.Field.FrameRate": "フレームレート", + "History.Field.TrackOrder": "トラック順", + "History.Field.Presence": "有無", + "History.Field.Name": "名前", + "History.Field.ChapterOrder": "チャプター順", + "History.Field.SegmentOrder": "セグメント順", + "History.Field.Track": "トラック", + "History.Field.Position": "位置", + "History.Field.DisplayNumber": "チャプター番号", + "History.Field.StartTicks": "開始時間", + "History.Field.EndTicks": "終了時間", + "History.Field.FramesInfo": "フレーム", + "History.Field.FrameAccuracy": "フレーム精度", + "History.Field.Kind": "種類", + "History.Field.SourceTrackId": "ソーストラック", + "History.Field.SourceEntryId": "ソース項目", + "History.Field.ReferencedMediaFiles": "メディアファイル", + "History.Field.MediaTracks": "メディアトラック", + "History.Field.ChapterCount": "チャプター数", + "History.Field.ImportDisplayName": "インポート名", + "History.Field.DiscTitleNumber": "ディスクタイトル", + "History.Field.SourceFrameRate": "ソースフレームレート", + "History.Field.SourceChapterCount": "ソースチャプター数", + "History.Parameter.TargetScope": "範囲", + "History.Parameter.TrackId": "トラック", + "History.Parameter.TrackIds": "トラック", + "History.Parameter.TrackCount": "トラック数", + "History.Parameter.DeleteTiming": "削除タイミング", + "History.Parameter.FrameDisplay": "フレーム表示", + "History.Parameter.FrameDecimalPlaces": "フレーム小数位", + "History.Enum.Field.Name": "名前", + "History.Enum.Field.StartTime": "開始時間", + "History.Enum.Field.Frame": "フレーム", + "History.Presence.Unknown": "不明", + "History.Unit.Fps": "fps", + "History.FrameRateContext": "フレームレート {Before} → {After}", + "History.Track.Unknown": "利用できないトラック", + "History.ChapterContext": "{Track} · チャプター位置 {Position}", + "History.Enum.ImportFormat.Unknown": "不明", + "History.Enum.ImportFormat.Ogm": "OGM", + "History.Enum.ImportFormat.MatroskaXml": "Matroska XML", + "History.Enum.ImportFormat.WebVtt": "WebVTT", + "History.Enum.ImportFormat.Cue": "CUE", + "History.Enum.ImportFormat.PremiereMarkers": "Premiere マーカー", + "History.Enum.ImportFormat.Mpls": "Blu-ray プレイリスト (MPLS)", + "History.Enum.ImportFormat.DvdIfo": "DVD IFO", + "History.Enum.ImportFormat.HdDvdXpl": "HD-DVD XPL", + "History.Enum.ImportFormat.Media": "メディアコンテナー", + "History.Enum.ImportFormat.Bdmv": "Blu-ray ディスク", + "History.Enum.ImportFormat.Text": "テキスト", + "History.Enum.ImportFormat.Xml": "XML", + "History.Enum.ImportFormat.Json": "JSON", + "History.Enum.ChapterKind.Marker": "マーカー", + "History.Enum.ChapterKind.Separator": "区切り", + "History.Enum.FrameAccuracy.Neutral": "標準", + "History.Enum.FrameAccuracy.Early": "早め", + "History.Enum.FrameAccuracy.Late": "遅め", + "History.Parameter.TrackName": "トラック名", + "History.ChangedTracks": "変更されたトラック", + "History.ChangedSegments": "変更されたセグメント", + "History.ComparisonNotice": "以下の変更はこのノードと親ノードを比較します。", + "History.CurrentStateNote": "履歴を確認しても現在の保存状態は移動しません。", + "History.Boolean.True": "はい", + "History.Boolean.False": "いいえ", + "History.Enum.DeleteTiming.Preserve": "タイムスタンプを保持", + "History.Enum.DeleteTiming.Normalize": "残りの時間を正規化", + "History.Enum.FrameDisplay.Round": "整数フレーム", + "History.Enum.FrameDisplay.DecimalPlaces": "小数フレーム", + "History.ToggleBranch": "履歴分岐を展開または折りたたむ", + "History.Empty": "利用できる履歴項目はありません。", + "Parity.ExportOptionsInvalid": "有効なエクスポート設定を選択してください。", + "Parity.SettingsApplyFailed": "設定を適用できませんでした。下書きは引き続き編集できます。" } diff --git a/src/ChapterTool.Wasm/Resources/Locales/zh-CN.json b/src/ChapterTool.Wasm/Resources/Locales/zh-CN.json index 1fabc2d3..7ba880df 100644 --- a/src/ChapterTool.Wasm/Resources/Locales/zh-CN.json +++ b/src/ChapterTool.Wasm/Resources/Locales/zh-CN.json @@ -5,12 +5,7 @@ "Action.AppendMpls": "追加 MPLS…", "Action.BrowseTemplate": "浏览模板…", "Action.Close": "关闭", - "Action.CombineSegments": "合并段落:选项数={entries},源类型={sourceType}", - "Action.DeleteRows": "删除行:索引={indexes}", "Action.Download": "下载", - "Action.EditCell": "编辑{kind}:行={row},值='{value}'", - "Action.EditChapters": "编辑章节", - "Action.InsertRow": "插入行:索引={index}", "Action.Load": "加载", "Action.LoadSample": "加载 OGM 示例", "Action.Log": "日志", @@ -19,8 +14,6 @@ "Action.Reload": "重新加载", "Action.RestoreDefaults": "恢复默认", "Action.Settings": "设置", - "Action.ShiftFramesForward": "帧数前移:帧数={frames}", - "Action.SplitCombinedSegments": "拆分已合并段落:选项数={entries},源类型={sourceType}", "App.Title": "[VCB-Studio] ChapterTool", "Common.Apply": "应用", "Common.Cancel": "取消", @@ -31,6 +24,19 @@ "Tool.Log.FilterWarning": "警告", "Tool.Log.FilterError": "错误", "Tool.Log.Filter": "按级别筛选", + "Tool.Log.FilterLevelLabel": "级别", + "Tool.Log.ResetFilters": "重置筛选", + "Tool.Log.MoreActions": "更多操作", + "Tool.Log.ExportFormat": "导出格式", + "Tool.Log.OpenDetails": "打开详情", + "Tool.Log.SearchMatchAdditional": "详情中有匹配内容。", + "Tool.Log.EntryAccessibleName": "{level}:{summary},{time}。{context}", + "Tool.Log.NoEntries": "暂时没有日志记录。", + "Tool.Log.NoMatches": "没有符合搜索或筛选条件的记录。", + "Tool.Log.Message": "消息", + "Tool.Log.Context": "上下文", + "Tool.Log.NoDetails": "该记录没有更多详情。", + "Tool.Log.BackToList": "返回日志列表", "Tool.Log.CopySummary": "复制摘要", "Tool.Log.CopyDetails": "复制详情", "Tool.Log.Details": "技术详情", @@ -44,6 +50,13 @@ "Tool.Log.Properties": "属性", "Tool.Log.Raw": "原始数据", "Tool.Log.SearchPlaceholder": "搜索日志…", + "Tool.Log.ExportJson": "JSON", + "Tool.Log.ExportCsv": "CSV", + "Tool.Log.ExportSucceeded": "已导出到 {value}", + "Tool.Log.ExportFailed": "导出失败:{value}", + "Tool.Log.CloseDetails": "关闭详情", + "Tool.Log.OpenFolder": "打开日志文件夹", + "Tool.Log.Export": "导出日志", "Tool.Log.NoStructuredData": "该日志没有嵌套结构化数据。", "Tool.Log.NoExceptionData": "该日志没有异常数据。", "Common.Save": "保存", @@ -109,7 +122,10 @@ "Diagnostic.FlacEmbeddedCue.NotFound": "未找到 Vorbis cuesheet 注释。", "Diagnostic.FrameRate.Invalid": "帧率必须大于零。", "Diagnostic.FrameText.Invalid": "帧文本未包含帧编号,或帧率无效。", + "Diagnostic.Ifo.Available": "已读取 DVD 光盘:{titleCount} 个标题,{chapterCount} 个章节,{titleSetCount} 个标题集。", "Diagnostic.Ifo.Invalid": "IFO 文件无效:{message}", + "Diagnostic.Ifo.Captured": "已从 {source} 对应的 VOB 导航数据读取 DVD 章节时间。", + "Diagnostic.Ifo.Fallback": "无法验证 {source} 对应的 VOB 导航时间戳。DVD 章节保留原始 IFO 估算时间,未作对齐调整。", "Diagnostic.ImporterFallback.Used": "主导入器不可用,已使用备用导入器。", "Diagnostic.LuaExpression.Cancelled": "Lua 表达式已取消。", "Diagnostic.LuaExpression.CompileFailed": "Lua 表达式语法错误:{message}", @@ -156,8 +172,6 @@ "Diagnostic.XmlRoot.Invalid": "应为 Chapters 根节点,实际为 {name}。", "Diagnostic.XplChapters.None": "未解析到 HD-DVD 章节。", "Diagnostic.XplParse.Failed": "无法解析 XPL 播放列表:{message}", - "EditKind.Frame": "帧", - "EditKind.Name": "名称", "Empty.Hint": "加载文件、拖放到此处,或加载示例以计算帧与表达式。", "Empty.Title": "尚未加载章节", "Expression.Suggestion.AddOperand": "在应用此记号前补充缺失的操作数。", @@ -177,10 +191,12 @@ "Grid.Frames": "帧", "Grid.Name": "名称", "Grid.Number": "#", + "Grid.TableLabel": "章节", "Grid.Time": "时间", "Label.ChapterName": "章节名", "Label.Clip": "片段 / 播放列表项", "Label.ClipOption": "{0}({1} 个章节)", + "Label.DvdTitle": "标题 {number}", "Label.Expression": "表达式", "Label.ExpressionPreset": "预设", "Label.FrameRate": "用于帧列的帧率", @@ -192,31 +208,14 @@ "Language.ChineseSimplified": "简体中文", "Language.English": "英语", "Language.Japanese": "日语", - "Log.AppendingMpls": "正在追加 MPLS:path='{path}'", - "Log.AutoFrameRateDetection": "自动帧率检测:option={option},confidence={confidence},accurate={accurate}/{evaluated},deviation={deviation}", - "Log.ChangeFps": "转换至当前帧率:源配置={sourceFps},目标配置={targetFps},章节数 {before} -> {after}", - "Log.CreateZones": "生成 Zones:选中行={selectedRows},章节数={chapters}", - "Log.Diagnostic": "{operation} 诊断:severity={severity},code={code}{location} message='{message}'{details}", - "Log.EditChapters": "{action}:章节数 {before} -> {after}", - "Log.FrameInfoUpdated": "帧信息已更新:option={option},fps={fps},round={round},chapters={chapters}", - "Log.ImportSummary": "{operation} {result}: groups={groups}, entries={entries}, chapters={chapters}, diagnostics={diagnostics}", - "Log.LanguageSet": "语言已设置为 {language}", - "Log.LoadingSource": "正在载入源:path='{path}'", - "Log.OpenedPath": "{status}:path='{path}'", - "Log.RelatedMediaNotFound": "{status}:reference='{reference}',resolved='{resolved}'", - "Log.SavingChapters": "正在保存章节:格式={format},目录='{directory}',来源='{source}',章节数={chapters},应用表达式={applyExpression},表达式='{expression}'", - "Log.SelectedSourceOption": "已选择源选项:index={index},label='{label}',source='{source}',sourceType={sourceType},chapters={chapters},fps={fps}", - "Log.SettingsLoaded": "已载入设置:保存目录='{savingPath}',语言='{language}'", - "Log.Status": "{status}", - "Log.UnexpectedError": "用户界面操作意外失败", - "Log.StatusFromPath": "{status},来源:{path}", - "Log.TemplateLoadFailed": "无法从 '{path}' 加载章节名称模板", - "Log.TemplateLoaded": "已从 '{path}' 加载章节名称模板 '{name}'", "Main.AppendMpls": "追加合并 MPLS", "Main.AutoFrameRate": "自动", "Main.ChapterCount": "{count} 个章节", + "Main.ChapterCountSingular": "{count} 个章节", "Main.ChangeFps": "转换至当前帧率", "Main.Combine": "合并", + "Main.SplitByBoundaries": "按边界拆分", + "Menu.SplitByBoundaries": "按边界拆分", "Main.CreateZones": "生成 Zones", "Main.ForwardTranslation": "前移帧", "Main.Insert": "插入", @@ -270,14 +269,14 @@ "Settings.Appearance.MonospaceFont": "等宽字体", "Settings.Appearance.MonospaceFontPreviewFor": "{name} 的等宽字体预览", "Settings.Appearance.Preset": "主题预设", - "Settings.Appearance.Preset.avalonia-default": "Avalonia 默认", - "Settings.Appearance.Preset.ayu-dark": "Ayu 深色", - "Settings.Appearance.Preset.ayu-light": "Ayu 浅色", - "Settings.Appearance.Preset.ayu-mirage": "Ayu 幻景", - "Settings.Appearance.Preset.gruvbox-dark": "Gruvbox 深色", - "Settings.Appearance.Preset.gruvbox-light": "Gruvbox 浅色", - "Settings.Appearance.Preset.solarized-dark": "Solarized 深色", - "Settings.Appearance.Preset.solarized-light": "Solarized 浅色", + "Settings.Appearance.Preset.AvaloniaDefault": "Avalonia 默认", + "Settings.Appearance.Preset.AyuDark": "Ayu 深色", + "Settings.Appearance.Preset.AyuLight": "Ayu 浅色", + "Settings.Appearance.Preset.AyuMirage": "Ayu 幻景", + "Settings.Appearance.Preset.GruvboxDark": "Gruvbox 深色", + "Settings.Appearance.Preset.GruvboxLight": "Gruvbox 浅色", + "Settings.Appearance.Preset.SolarizedDark": "Solarized 深色", + "Settings.Appearance.Preset.SolarizedLight": "Solarized 浅色", "Settings.Appearance.Preview": "配色预览", "Settings.Appearance.PreviewFor": "{name} 的配色预览", "Settings.Appearance.SystemMonospaceFont": "系统等宽默认", @@ -297,7 +296,10 @@ "Settings.FrameDisplay": "帧显示", "Settings.FrameDisplayRound": "取整", "Settings.FrameDisplayDecimalPlaces": "保留小数位", + "Settings.FrameDisplayFullPrecision": "不截取", "Settings.FrameDecimalPlaces": "小数位数", + "Settings.ShowRepeatingFrameDecimals": "显示小数循环节", + "Settings.ShowRepeatingFrameDecimals.Help": "选择“不截取”时,可用上划线显示循环节;关闭后显示普通小数格式。", "Settings.DeleteRowsTiming": "删除行时的时间处理", "Settings.PreserveChapterTimes": "保留章节时间", "Settings.NormalizeChapterTimes": "归一化章节时间", @@ -331,6 +333,10 @@ "Settings.Unsaved.Discard": "放弃", "Settings.Unsaved.Message": "设置修改已实时应用到当前会话,但尚未保存。关闭前要保存吗?", "Settings.Unsaved.Title": "设置尚未保存", + "SessionLoss.Title": "结束当前文档会话?", + "SessionLoss.Message": "继续后将结束当前文档会话及其撤销历史。导出的章节文件不包含历史记录。要继续吗?", + "SessionLoss.Continue": "继续", + "Status.SessionReplacementCanceled": "当前文档会话未更改。", "Settings.ValidateTools": "检查工具", "Status.AppendFailed": "追加失败", "Status.Appended": "已从 {1} 追加 {0} 个 MPLS 片段。", @@ -352,6 +358,7 @@ "Status.DropTooLarge": "文件过大(最大 64 MB)。", "Status.Duplicated": "已复制第 {0} 行。", "Status.ExpressionApplied": "已应用表达式 · {0} fps · {1} 行", + "Status.ExpressionApplyFailed": "无法应用表达式。", "Status.ExpressionPresetApplied": "已应用表达式预设:{0}", "Status.ExpressionPresetUnknown": "未知表达式预设:{0}", "Status.FramesUpdated": "已更新帧 · {0} fps · {1} 行", @@ -359,6 +366,7 @@ "Status.LoadFailed": "载入失败", "Status.Loaded": "已从 {1} 加载 {0} 个章节。", "Status.LoadedChapters": "已载入 {count} 个章节", + "Status.LoadedChapter": "已载入 {count} 个章节", "Status.Loading": "加载中…", "Status.LoadingSource": "正在加载...", "Status.LoadingSource.Discover": "正在发现标题...", @@ -381,7 +389,7 @@ "Status.Ready": "就绪", "Status.RelatedMediaNotFound": "未找到相关媒体文件", "Status.RelatedMediaNote": "浏览器无法打开本地媒体路径;可用时仅以下载/链接方式展示引用。", - "Status.RestoredClips": "已恢复分开的剪辑。", + "Status.RestoredClips": "已按源边界拆分。", "Status.RowsRefreshed": "已刷新行 · {0} fps · {1} 行", "Status.SaveFailed": "保存失败", "Status.Saved": "已保存", @@ -394,6 +402,50 @@ "Status.TemplateLoaded": "已加载模板 {name}", "Status.TemplateNotSelected": "未选择", "Status.Updated": "已更新", + "History.Title": "编辑历史", + "History.SessionLifetime": "历史记录仅在当前文档会话期间保留。", + "History.SessionLifetimeShort": "仅当前会话", + "History.Undo": "撤销", + "History.Redo": "重做", + "History.Close": "关闭", + "History.Current": "当前历史位置", + "History.Restore": "恢复到此节点", + "History.Retry": "重试", + "History.Loading": "正在加载详细信息…", + "History.RootOverview": "初始文档概览", + "History.PendingPreviewNotice": "历史导航会丢弃待处理的预览。", + "History.Added": "新增", + "History.Removed": "删除", + "History.Modified": "修改", + "History.DetailResourceFailure": "资源不足,无法加载详细信息。", + "History.DetailNotFound": "此历史节点已不可用。", + "History.DetailEnded": "此文档会话已结束。", + "History.DetailFailed": "无法加载历史详细信息。", + "History.RestoreApplied": "已恢复到所选节点。", + "History.LocateCurrent": "定位当前节点", + "History.CurrentHidden": "当前节点隐藏在此分支下", + "History.PreferredRedo": "首选重做分支", + "History.RedoChoices": "重做分支选项", + "History.RedoChoice": "重做所选分支", + "History.Summary": "摘要", + "History.Changes": "变更", + "History.Parameters": "参数", + "History.Operation.Generic": "编辑", + "History.Operation.Load": "初始文档", + "History.Operation.Edit": "编辑章节", + "History.Operation.Delete": "删除章节", + "History.Operation.Insert": "插入章节", + "History.Operation.FrameShift": "偏移章节", + "History.Operation.FrameRateChange": "更改帧率", + "History.Operation.Expression": "应用表达式", + "History.Operation.Template": "应用模板名称", + "History.Operation.Options": "应用章节选项", + "History.Operation.Merge": "合并轨道", + "History.Operation.Split": "拆分轨道", + "History.Operation.Append": "追加片段", + "History.UndoApplied": "已撤销编辑", + "History.RedoApplied": "已重做编辑", + "History.NavigationFailed": "无法切换历史记录。", "Status.UnexpectedError": "操作意外失败。请查看日志了解详细信息。", "Status.ZonesEmpty": "未生成 zones。", "Status.ZonesGenerated": "Zones 已生成", @@ -403,6 +455,78 @@ "Title.Refresh": "按当前选项刷新表格", "Title.Save": "按当前选项导出并下载", "Tool.Expression.Apply": "应用表达式", + "Expression.Review.TimeSummary": "时间修改 {times}/{count} 个章节", + "Expression.Review.OtherSummary": "影响 {affected} 章 · 帧更新 {frames} 项 · 属性 {properties} 项", + "Expression.Review.Details": "其他修改", + "Expression.Preview.Title": "表达式预览", + "Expression.Preview.NoChanges": "章节值不会发生变化。", + "Expression.Preview.Cancelled": "已取消表达式预览。", + "Expression.Preview.Pending": "正在更新表达式预览…", + "Expression.Preview.Options": "预览其他选项", + "Expression.Preview.Update": "更新预览", + "Expression.Preview.Discard": "放弃", + "Expression.Preview.Stale": "预览已过期。请更新后再应用。", + "Expression.Preview.ComparisonTooltip": "浅色值为已提交内容;箭头指向预览值。", + "Expression.Preview.ComparisonAccessibleName": "{field}:已提交 {before};预览 {candidate};变化 {delta}。", + "Expression.Preview.Scope": "当前轨道:{track} · {count} 个章节", + "Expression.Preview.Separators": "已排除 {count} 个分隔行", + "Expression.Preview.Summary": "受影响章节:{affected} · 时间变化:{times} · 帧信息更新:{frames} · 属性变化:{properties}", + "Expression.Preview.DeltaFrames": "{value} 帧", + "Expression.Preview.Applied": "已应用表达式:{affected} 个受影响章节,{times} 处时间变化,{frames} 处帧信息更新。撤销一次即可恢复。", + "Expression.Preview.TimesUnchanged": "章节时间未变化。", + "Expression.Preview.Properties": "文档和轨道属性", + "Expression.Preview.Filter": "显示", + "Expression.Preview.ChangedOnly": "仅显示变化", + "Expression.Preview.AllChapters": "所有章节", + "Expression.Preview.Units": "单位", + "Expression.Preview.TimeUnits": "时间", + "Expression.Preview.FrameUnits": "帧数", + "Expression.Preview.Chapter": "章节", + "Expression.Preview.Before": "修改前", + "Expression.Preview.After": "修改后", + "Expression.Preview.Delta": "变化量", + "Expression.Preview.FrameDetails": "帧信息详情", + "Expression.Preview.BeforeFrames": "修改前帧数", + "Expression.Preview.AfterFrames": "修改后帧数", + "Expression.Preview.Accuracy": "帧精度", + "Expression.State.Unavailable": "当前没有可预览的章节。", + "Expression.State.Waiting": "等待更新预览…", + "Expression.State.Computing": "正在计算预览…", + "Expression.State.Ready": "预览已就绪。", + "Expression.State.Empty": "请输入表达式或选择预设。", + "Expression.State.Composing": "完成文字输入后再更新预览。", + "Expression.State.Stale": "章节已变化。请先更新预览再应用。", + "Expression.State.Invalid": "此表达式无法应用。", + "Expression.State.Unchanged": "结果没有可应用的变化。", + "Expression.State.Applying": "正在应用已检查的更改…", + "Expression.Action.Refresh": "更新预览", + "Expression.Action.Apply": "应用更改", + "Expression.Value.Missing": "不可用", + "Expression.Value.NotCalculated": "未计算", + "Expression.Value.NotSet": "未设置", + "Expression.Value.IncompatibleFrames": "帧率不同", + "Expression.Unit.FramesShort": "帧", + "Expression.Unit.FramesPerSecond": "帧/秒", + "Expression.FrameAccuracy.Accurate": "帧精确", + "Expression.FrameAccuracy.Inexact": "位于帧之间", + "Expression.FrameAccuracy.NotCalculated": "未计算", + "Expression.FrameAccuracy.Policy": "与最近帧的距离小于 0.01 帧时,标记为帧精确。", + "Expression.PropertyOwner.Document": "文档", + "Expression.PropertyOwner.Track": "轨道", + "Expression.PropertyOwner.Segment": "片段", + "Expression.PropertyOwner.Chapter": "章节", + "Expression.Property.Title": "标题", + "Expression.Property.Source": "来源", + "Expression.Property.ImportFormat": "导入格式", + "Expression.Property.Duration": "时长", + "Expression.Property.FrameRate": "帧率", + "Expression.Property.Name": "名称", + "Expression.Property.Number": "章节编号", + "Expression.Property.Type": "章节类型", + "Expression.Property.Presence": "章节条目", + "Expression.Property.TrackOrder": "轨道顺序", + "Expression.Property.Additional": "其他字段", + "Expression.Property.ValueChanged": "已更新", "Tool.Expression.LoadLuaScript": "加载 Lua...", "Tool.Expression.Preset": "预设", "Tool.Expression.Title": "表达式", @@ -414,5 +538,169 @@ "Tool.TemplateNames.Title": "模板名称", "Tool.TemplateNames.UseStandard": "使用标准模板名称", "Tool.Zones.Title": "Zones", - "XmlLanguage.Undetermined": "未确定" + "XmlLanguage.Undetermined": "未确定", + "Settings.Shortcuts": "快捷键", + "Settings.Shortcuts.Description": "自定义主要操作的键盘手势。", + "Settings.Shortcuts.Reset": "恢复快捷键", + "Settings.Shortcuts.CaptureHint": "按下组合键即可设置快捷键。", + "Settings.Shortcuts.FixedHint": "此快捷键由应用程序固定。", + "Shortcuts.Action.Save": "保存", + "Shortcuts.Action.Reload": "重新加载", + "Shortcuts.Action.Refresh": "刷新", + "Shortcuts.Action.Log": "日志", + "Shortcuts.Action.Preview": "预览", + "Shortcuts.Action.ClipSelection": "选择片段", + "Shortcuts.Action.Insert": "插入行", + "Shortcuts.Action.Delete": "删除行", + "Shortcuts.Action.Load": "加载", + "Shortcuts.Action.PreviousClip": "上一个片段", + "Shortcuts.Action.NextClip": "下一个片段", + "Shortcuts.Action.Undo": "撤销编辑", + "Shortcuts.Action.Redo": "重做编辑", + "Shortcuts.Action.SaveFormat": "选择保存格式", + "Modal.ExpressionTitle": "表达式", + "Modal.ExportOptionsTitle": "高级导出选项", + "Parity.NamingTitle": "命名与编号", + "Parity.InvalidNumber": "请输入 0 至 1000 的整数偏移量。", + "Parity.InvalidShift": "请输入 -1000000 至 1000000 的整数帧平移量。", + "Parity.Completions": "补全建议", + "Parity.Position": "位置", + "Parity.ScriptLoadFailed": "无法加载此 Lua 脚本,已保留原草稿。", + "Parity.UnsavedSettings": "放弃未保存的设置?", + "Parity.Discard": "放弃", + "Parity.KeepEditing": "继续编辑", + "Parity.SettingsSaveFailed": "设置保存失败,请继续编辑或重试。", + "Parity.InvalidShortcut": "快捷键无效、被保留或存在冲突。", + "Parity.LogSearch": "搜索日志", + "Parity.LogSeverity": "级别", + "Parity.LogAll": "全部级别", + "Parity.LogCount": "{count} 条日志", + "Parity.LogDetails": "详情", + "Parity.LogBack": "返回日志", + "Parity.LogRaw": "原始条目", + "Parity.LogExport": "导出日志", + "Parity.LogDetailMatch": "详情匹配", + "Parity.LogFailed": "日志操作失败,可以重试。", + "Parity.LogExported": "已发起日志下载。", + "Parity.LogLevel.Info": "信息", + "Parity.LogLevel.Information": "信息", + "Parity.LogLevel.Warning": "警告", + "Parity.LogLevel.Error": "错误", + "Parity.LogLevel.Debug": "调试", + "Parity.LogLevel.Trace": "跟踪", + "Parity.SourceFps": "源帧率", + "Parity.TargetFps": "目标帧率", + "Parity.InvalidCandidate": "无法应用此操作。请检查输入值与详细信息。", + "XmlLanguage.Chinese": "中文", + "XmlLanguage.German": "德语", + "XmlLanguage.French": "法语", + "XmlLanguage.Italian": "意大利语", + "XmlLanguage.Spanish": "西班牙语", + "History.HistoryPage": "历史", + "History.DetailsPage": "详情", + "History.Filter.All": "所有更改", + "History.NoFilteredChanges": "没有符合此筛选条件的更改。", + "History.Filter.Added": "新增", + "History.Filter.Removed": "移除", + "History.Filter.Modified": "修改", + "History.FieldHeader": "字段", + "History.BeforeHeader": "更改前", + "History.AfterHeader": "更改后", + "History.DeltaHeader": "差值", + "History.DeltaTicks": "{0} 个刻度", + "History.Presence.Absent": "不存在", + "History.Presence.Null": "空值", + "History.Presence.Empty": "空文本", + "History.Parameter.Field": "字段", + "History.Parameter.ChapterId": "章节", + "History.Parameter.ChapterIds": "章节", + "History.Parameter.Value": "值", + "History.Parameter.DisplayFps": "显示帧率", + "History.Parameter.Frames": "帧数", + "History.Parameter.BeforeChapterId": "前置章节", + "History.Parameter.SourcePath": "来源", + "History.Parameter.Script": "表达式", + "History.Parameter.Expression": "表达式", + "History.Parameter.AutoGenerateNames": "生成名称", + "History.Parameter.UseTemplateNames": "使用模板名称", + "History.Parameter.Template": "模板", + "History.Parameter.OrderShift": "顺序偏移", + "History.Parameter.SourceFps": "源帧率", + "History.Parameter.TargetFps": "目标帧率", + "History.Field.Title": "标题", + "History.Field.SourceName": "来源名称", + "History.Field.ImportFormat": "导入格式", + "History.Field.Duration": "时长", + "History.Field.FrameRate": "帧率", + "History.Field.TrackOrder": "轨道顺序", + "History.Field.Presence": "存在状态", + "History.Field.Name": "名称", + "History.Field.ChapterOrder": "章节顺序", + "History.Field.SegmentOrder": "片段顺序", + "History.Field.Track": "轨道", + "History.Field.Position": "位置", + "History.Field.DisplayNumber": "章节编号", + "History.Field.StartTicks": "开始时间", + "History.Field.EndTicks": "结束时间", + "History.Field.FramesInfo": "帧数", + "History.Field.FrameAccuracy": "帧精度", + "History.Field.Kind": "类型", + "History.Field.SourceTrackId": "来源轨道", + "History.Field.SourceEntryId": "来源条目", + "History.Field.ReferencedMediaFiles": "媒体文件", + "History.Field.MediaTracks": "媒体轨道", + "History.Field.ChapterCount": "章节数", + "History.Field.ImportDisplayName": "导入名称", + "History.Field.DiscTitleNumber": "光盘标题", + "History.Field.SourceFrameRate": "源帧率", + "History.Field.SourceChapterCount": "源章节数", + "History.Parameter.TargetScope": "范围", + "History.Parameter.TrackId": "轨道", + "History.Parameter.TrackIds": "轨道", + "History.Parameter.TrackCount": "轨道数", + "History.Parameter.DeleteTiming": "删除时间处理", + "History.Parameter.FrameDisplay": "帧显示", + "History.Parameter.FrameDecimalPlaces": "帧小数位", + "History.Enum.Field.Name": "名称", + "History.Enum.Field.StartTime": "开始时间", + "History.Enum.Field.Frame": "帧", + "History.Presence.Unknown": "未知", + "History.Unit.Fps": "帧/秒", + "History.FrameRateContext": "帧率 {Before} → {After}", + "History.Track.Unknown": "不可用的轨道", + "History.ChapterContext": "{Track} · 章节位置 {Position}", + "History.Enum.ImportFormat.Unknown": "未知", + "History.Enum.ImportFormat.Ogm": "OGM", + "History.Enum.ImportFormat.MatroskaXml": "Matroska XML", + "History.Enum.ImportFormat.WebVtt": "WebVTT", + "History.Enum.ImportFormat.Cue": "CUE", + "History.Enum.ImportFormat.PremiereMarkers": "Premiere 标记", + "History.Enum.ImportFormat.Mpls": "蓝光播放列表 (MPLS)", + "History.Enum.ImportFormat.DvdIfo": "DVD IFO", + "History.Enum.ImportFormat.HdDvdXpl": "HD-DVD XPL", + "History.Enum.ImportFormat.Media": "媒体容器", + "History.Enum.ImportFormat.Bdmv": "蓝光光盘", + "History.Enum.ImportFormat.Text": "文本", + "History.Enum.ImportFormat.Xml": "XML", + "History.Enum.ImportFormat.Json": "JSON", + "History.Enum.ChapterKind.Marker": "标记", + "History.Enum.ChapterKind.Separator": "分隔符", + "History.Enum.FrameAccuracy.Neutral": "标准", + "History.Enum.FrameAccuracy.Early": "偏早", + "History.Enum.FrameAccuracy.Late": "偏晚", + "History.Parameter.TrackName": "轨道名称", + "History.ChangedTracks": "变更轨道", + "History.ChangedSegments": "变更片段", + "History.ComparisonNotice": "以下更改将此节点与其父节点进行比较。", + "History.CurrentStateNote": "查看历史记录不会移动当前保存状态。", + "History.Boolean.True": "是", + "History.Boolean.False": "否", + "History.Enum.DeleteTiming.Preserve": "保留时间戳", + "History.Enum.DeleteTiming.Normalize": "规范剩余时间", + "History.Enum.FrameDisplay.Round": "整数帧", + "History.Enum.FrameDisplay.DecimalPlaces": "小数帧", + "History.ToggleBranch": "展开或折叠历史分支", + "History.Empty": "没有可用的历史记录。", + "Parity.ExportOptionsInvalid": "请选择有效的导出选项。", + "Parity.SettingsApplyFailed": "无法应用这些设置。草稿仍可继续编辑。" } diff --git a/src/ChapterTool.Wasm/Services/WasmBrowserShortcutGuard.cs b/src/ChapterTool.Wasm/Services/WasmBrowserShortcutGuard.cs index 91ac5f97..8f5157a1 100644 --- a/src/ChapterTool.Wasm/Services/WasmBrowserShortcutGuard.cs +++ b/src/ChapterTool.Wasm/Services/WasmBrowserShortcutGuard.cs @@ -21,7 +21,6 @@ public static bool IsReloadKey(string key, bool ctrlOrMeta) => || (ctrlOrMeta && string.Equals(key, "r", StringComparison.OrdinalIgnoreCase)); public static bool IsAppShortcut(string key, bool ctrlOrMeta) => - (ctrlOrMeta && key.ToLowerInvariant() is "s" or "o" or "l") - || string.Equals(key, "F11", StringComparison.OrdinalIgnoreCase) - || string.Equals(key, "F9", StringComparison.OrdinalIgnoreCase); + WasmShortcuts.Bindings(WasmShortcuts.Normalize(null)) + .ContainsKey(WasmShortcuts.Gesture(key, ctrlOrMeta, false, false, false)); } diff --git a/src/ChapterTool.Wasm/Services/WasmHistoryEntry.cs b/src/ChapterTool.Wasm/Services/WasmHistoryEntry.cs new file mode 100644 index 00000000..bf224ac5 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmHistoryEntry.cs @@ -0,0 +1,8 @@ +namespace ChapterTool.Wasm.Services; + +/// A virtualized browser history row projected from Core history metadata. +public sealed record WasmHistoryEntry(Guid Id, Guid? ParentId, string Description, int Depth, bool IsCurrent, + bool HasChildren, bool HasCurrentDescendant) +{ + public string DisplayDescription => $"{new string(' ', Depth * 2)}{(IsCurrent ? "● " : " ")}{Description}"; +} diff --git a/src/ChapterTool.Wasm/Services/WasmHistoryProjection.cs b/src/ChapterTool.Wasm/Services/WasmHistoryProjection.cs new file mode 100644 index 00000000..8a55e230 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmHistoryProjection.cs @@ -0,0 +1,320 @@ +using System.Collections; +using System.Globalization; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; +using ChapterTool.Core.Transform; + +namespace ChapterTool.Wasm.Services; + +public static class WasmHistoryProjection +{ + public static IReadOnlyList FilterChanges(IEnumerable changes, WasmHistoryChangeFilter filter) => + changes.Where(change => filter switch + { + WasmHistoryChangeFilter.Added => !change.Before.IsPresent && change.After.IsPresent, + WasmHistoryChangeFilter.Removed => change.Before.IsPresent && !change.After.IsPresent, + WasmHistoryChangeFilter.Modified => change.Before.IsPresent && change.After.IsPresent, + _ => true + }).ToArray(); + + public static string OperationTitle(WasmLocalizer localizer, HistoryOperationDescriptor? operation, string? fallbackDescription = null) + { + if ((operation is null || operation.Kind == "generic") && !string.IsNullOrWhiteSpace(fallbackDescription)) + { + return fallbackDescription; + } + var kind = operation?.Kind; + var key = kind switch + { + "load" => "History.Operation.Load", + "chapter.cell-edit" => "History.Operation.Edit", + "chapter.delete" => "History.Operation.Delete", + "chapter.insert" => "History.Operation.Insert", + "chapter.frame-shift" => "History.Operation.FrameShift", + "chapter.frame-rate-change" => "History.Operation.FrameRateChange", + "expression.apply" => "History.Operation.Expression", + "template.apply" => "History.Operation.Template", + "chapter.options-apply" => "History.Operation.Options", + "workspace.merge" => "History.Operation.Merge", + "workspace.split" => "History.Operation.Split", + "workspace.append" => "History.Operation.Append", + _ => "History.Operation.Generic" + }; + var title = localizer.T(key); + if (kind == "chapter.cell-edit") + { + var field = operation?.Parameters.FirstOrDefault(static parameter => parameter.Name == "field")?.Value; + if (!string.IsNullOrWhiteSpace(field)) + { + title = $"{title} · {localizer.T($"History.Enum.Field.{field}")}"; + } + } + return title; + } + + public static WasmHistoryDetailsProjection Project(WasmLocalizer localizer, HistoryNodeDetails details) + { + var culture = CultureInfo.GetCultureInfo(localizer.Culture); + var parameters = ProjectParameters(localizer, details.Operation); + var changes = details.Changes.Select(change => ProjectChange(localizer, details, change, culture)).ToArray(); + var summaryParts = new List(); + Add(details.Summary.AddedChapters, "History.Added"); + Add(details.Summary.RemovedChapters, "History.Removed"); + Add(details.Summary.ModifiedChapters, "History.Modified"); + Add(details.Summary.ChangedTracks, "History.ChangedTracks"); + Add(details.Summary.ChangedSegments, "History.ChangedSegments"); + var rootRows = details.RootDocument?.Tracks.SelectMany(track => track.Chapters.Select(chapter => + new WasmHistoryRootChapterRow(track.Name, chapter.DisplayNumber, chapter.Name, + FormatTimeSpan(TimeSpan.FromTicks(chapter.StartTicks)), chapter.FramesInfo))).ToArray() ?? []; + + return new WasmHistoryDetailsProjection( + OperationTitle(localizer, details.Operation, details.Description), + details.PublishedAt?.ToLocalTime().ToString("g", culture), + string.Join(" · ", summaryParts), + parameters, + changes, + rootRows, + details.RootDocument?.Title, + details.RootDocument?.Tracks.Length ?? 0, + details.RootDocument?.Tracks.Sum(static track => track.Chapters.Length) ?? 0); + + void Add(int count, string key) + { + if (count > 0) + { + summaryParts.Add($"{count} {localizer.T(key)}"); + } + } + } + + private static IReadOnlyList ProjectParameters(WasmLocalizer localizer, HistoryOperationDescriptor? operation) + { + if (operation is null) + { + return []; + } + var cellEditField = operation.Parameters.FirstOrDefault(static parameter => parameter.Name == "field")?.Value; + return operation.Parameters + .Where(parameter => IsVisibleParameter(parameter, operation.Kind, cellEditField)) + .Select(parameter => new WasmHistoryParameterRow( + localizer.T($"History.Parameter.{CapitalizeKeySegment(parameter.Name)}"), + FormatParameter(localizer, parameter), + parameter.ValueKind)) + .ToArray(); + } + + private static bool IsVisibleParameter(HistoryOperationParameter parameter, string operationKind, string? cellEditField) + { + if (parameter.Name is "chapterId" or "chapterIds" or "trackId" or "trackIds" or "targetChapterIds") + { + return false; + } + + return operationKind != "chapter.cell-edit" + || (parameter.Name is not ("field" or "value" or "trackName") + && (parameter.Name != "displayFps" || cellEditField is "Frame" or "StartTime")); + } + + private static WasmHistoryChangeRow ProjectChange(WasmLocalizer localizer, HistoryNodeDetails details, + HistoryFieldChange change, CultureInfo culture) + { + var context = change.Group == "Chapter" + ? localizer.Format("History.ChapterContext", change.Context ?? string.Empty, change.ObjectLabel) + : change.Context is null ? change.ObjectLabel : $"{change.Context} · {change.ObjectLabel}"; + if (change.Field is "StartTicks" or "EndTicks" or "FramesInfo") + { + var displayRate = details.Operation?.Parameters + .FirstOrDefault(static parameter => parameter.Name == "displayFps")?.Value; + if (decimal.TryParse(displayRate, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate) && rate > 0) + { + context += $" · {localizer.T("History.Parameter.DisplayFps")}: {FormatFrameRate(localizer, rate)}"; + } + else if (change.BeforeFrameRate.IsKnown || change.AfterFrameRate.IsKnown) + { + var beforeText = change.BeforeFrameRate.IsKnown ? FormatFrameRate(localizer, change.BeforeFrameRate) : localizer.T("History.Presence.Absent"); + var afterText = change.AfterFrameRate.IsKnown ? FormatFrameRate(localizer, change.AfterFrameRate) : localizer.T("History.Presence.Absent"); + context += $" · {localizer.Format("History.FrameRateContext", beforeText, afterText)}"; + } + } + + var beforeTextValue = FormatHistoryValue(localizer, details, change, change.Before, true, culture); + var afterTextValue = FormatHistoryValue(localizer, details, change, change.After, false, culture); + return new WasmHistoryChangeRow(change.Group, change.ObjectId, + localizer.T($"History.Field.{change.Field}"), context, change.Before, change.After, + beforeTextValue, afterTextValue, FormatDelta(change)); + } + + private static string FormatHistoryValue(WasmLocalizer localizer, HistoryNodeDetails details, + HistoryFieldChange change, HistoryFieldValue value, bool before, CultureInfo culture) + { + var rateText = details.Operation?.Parameters.FirstOrDefault(static parameter => parameter.Name == "displayFps")?.Value; + if (value.IsPresent && change.Field == "FramesInfo" && value.Value is string frameText + && string.IsNullOrWhiteSpace(frameText) + && TryGetRelatedStartTicks(details, change, before, out var startTicks)) + { + FramePresentationInput? input = null; + var exactRate = before ? change.BeforeFrameRate : change.AfterFrameRate; + if (exactRate.IsKnown) + { + input = new FramePresentationInput(startTicks, exactRate, frameText); + } + else if (decimal.TryParse(rateText, NumberStyles.Number, CultureInfo.InvariantCulture, out var displayRate) && displayRate > 0) + { + input = new FramePresentationInput(startTicks, FrameRateValue.Approximate(displayRate), frameText); + } + if (input is not null) + { + var frameDisplay = details.Operation?.Parameters.FirstOrDefault(static parameter => parameter.Name == "frameDisplay")?.Value; + var placesText = details.Operation?.Parameters.FirstOrDefault(static parameter => parameter.Name == "frameDecimalPlaces")?.Value; + var places = int.TryParse(placesText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedPlaces) ? parsedPlaces : -1; + var policy = new FramePresentationPolicy(frameDisplay == "Round", false, places); + return FrameValueFormatter.Format(input, policy).PlainNumericText; + } + } + return FormatValue(localizer, value, change.Field, culture); + } + + private static bool TryGetRelatedStartTicks(HistoryNodeDetails details, HistoryFieldChange change, bool before, out long ticks) + { + var start = details.Changes.FirstOrDefault(candidate => candidate.Group == change.Group + && candidate.ObjectId == change.ObjectId && candidate.Field == "StartTicks"); + var value = before ? start?.Before : start?.After; + if (value is { IsPresent: true, Value: long startTicks }) + { + ticks = startTicks; + return true; + } + ticks = 0; + return false; + } + + private static string FormatValue(WasmLocalizer localizer, HistoryFieldValue value, string field, CultureInfo culture) + { + if (!value.IsPresent) + { + return localizer.T("History.Presence.Absent"); + } + return value.Value switch + { + null => localizer.T("History.Presence.Null"), + string { Length: 0 } => localizer.T("History.Presence.Empty"), + long ticks when field is "StartTicks" or "EndTicks" => FormatTimeSpan(TimeSpan.FromTicks(ticks)), + ChapterDuration duration => duration.IsKnown + ? FormatTimeSpan(TimeSpan.FromTicks(duration.Ticks)) + : localizer.T("History.Presence.Unknown"), + ChapterFrameRate frameRate => FormatFrameRate(localizer, frameRate), + HistoryTrackReference reference => string.IsNullOrEmpty(reference.Label) + ? localizer.T("History.Track.Unknown") + : reference.Label, + ChapterImportFormat format => localizer.T($"History.Enum.ImportFormat.{format}"), + ChapterKind kind => localizer.T($"History.Enum.ChapterKind.{kind}"), + FrameAccuracy accuracy => localizer.T($"History.Enum.FrameAccuracy.{accuracy}"), + IEnumerable sequence and not string => FormatSequence(sequence, culture), + IFormattable formattable => formattable.ToString(null, culture) ?? string.Empty, + _ => value.Value.ToString() ?? string.Empty + }; + } + + private static string FormatSequence(IEnumerable sequence, CultureInfo culture) + { + var items = new List(); + foreach (var item in sequence) + { + items.Add(item is HistoryOrderItem order ? order.Label : Convert.ToString(item, culture) ?? string.Empty); + } + + return string.Join(", ", items); + } + + private static string? FormatDelta(HistoryFieldChange change) + { + if (!change.Before.IsPresent || !change.After.IsPresent) + { + return null; + } + if (change.Before.Value is long before && change.After.Value is long after && change.Field is "StartTicks" or "EndTicks") + { + return FormatTimeSpan(TimeSpan.FromTicks(checked(after - before)), true); + } + if (change.Before.Value is ChapterDuration previous && change.After.Value is ChapterDuration next && previous.IsKnown && next.IsKnown) + { + return FormatTimeSpan(TimeSpan.FromTicks(checked(next.Ticks - previous.Ticks)), true); + } + return null; + } + + private static string FormatParameter(WasmLocalizer localizer, HistoryOperationParameter parameter) + { + if (parameter.Value is null) + { + return localizer.T("History.Presence.Null"); + } + if (parameter.Name == "field") + { + return localizer.T($"History.Enum.Field.{parameter.Value}"); + } + if (parameter.ValueKind == "boolean" && bool.TryParse(parameter.Value, out var boolean)) + { + return localizer.T(boolean ? "History.Boolean.True" : "History.Boolean.False"); + } + if (parameter.ValueKind == "integer" && long.TryParse(parameter.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) + { + return integer.ToString(CultureInfo.GetCultureInfo(localizer.Culture)); + } + if (parameter.ValueKind == "decimal" && decimal.TryParse(parameter.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue)) + { + return parameter.Name == "displayFps" ? FormatFrameRate(localizer, decimalValue) : decimalValue.ToString(CultureInfo.GetCultureInfo(localizer.Culture)); + } + if (parameter.Name is "deleteTiming" or "frameDisplay") + { + return localizer.T($"History.Enum.{CapitalizeKeySegment(parameter.Name)}.{parameter.Value}"); + } + return parameter.Value; + } + + private static string CapitalizeKeySegment(string value) => + string.IsNullOrEmpty(value) ? value : char.ToUpperInvariant(value[0]) + value[1..]; + + private static string FormatFrameRate(WasmLocalizer localizer, decimal rate) + { + var culture = CultureInfo.GetCultureInfo(localizer.Culture); + return $"{rate.ToString("0.###", culture)} {localizer.T("History.Unit.Fps")}"; + } + + private static string FormatFrameRate(WasmLocalizer localizer, FrameRateValue rate) => + $"{rate} {localizer.T("History.Unit.Fps")}"; + + private static string FormatFrameRate(WasmLocalizer localizer, ChapterFrameRate rate) + { + var culture = CultureInfo.GetCultureInfo(localizer.Culture); + var value = rate.Denominator == 1 ? rate.Numerator.ToString(culture) : $"{rate.Numerator.ToString(culture)}/{rate.Denominator.ToString(culture)}"; + return $"{value} {localizer.T("History.Unit.Fps")}"; + } + + private static string FormatTimeSpan(TimeSpan value, bool signed = false) + { + var totalMilliseconds = (long)decimal.Round(value.Ticks / (decimal)TimeSpan.TicksPerMillisecond, 0, MidpointRounding.ToEven); + var absoluteMilliseconds = Math.Abs(totalMilliseconds); + var body = $"{absoluteMilliseconds / 3_600_000:00}:{absoluteMilliseconds / 60_000 % 60:00}:{absoluteMilliseconds / 1_000 % 60:00}.{absoluteMilliseconds % 1_000:000}"; + return value < TimeSpan.Zero ? "−" + body : signed && value > TimeSpan.Zero ? "+" + body : body; + } +} + +public sealed record WasmHistoryDetailsProjection(string OperationTitle, string? PublishedAtText, string EffectSummary, + IReadOnlyList Parameters, IReadOnlyList Changes, + IReadOnlyList RootChapterRows, string? RootTitle, int RootTrackCount, int RootChapterCount); + +public sealed record WasmHistoryParameterRow(string Name, string? Value, string ValueKind); + +public sealed record WasmHistoryChangeRow(string Group, string ObjectId, string Field, string ObjectLabel, + HistoryFieldValue Before, HistoryFieldValue After, string BeforeText, string AfterText, string? DeltaText); + +public sealed record WasmHistoryRootChapterRow(string TrackName, int DisplayNumber, string Name, string StartTime, string FramesInfo); + +public enum WasmHistoryChangeFilter +{ + All, + Added, + Removed, + Modified +} diff --git a/src/ChapterTool.Wasm/Services/WasmLocalizer.cs b/src/ChapterTool.Wasm/Services/WasmLocalizer.cs index f4950040..f265f58e 100644 --- a/src/ChapterTool.Wasm/Services/WasmLocalizer.cs +++ b/src/ChapterTool.Wasm/Services/WasmLocalizer.cs @@ -74,6 +74,7 @@ public string Format(string key, params object[] args) T("Settings.OutputPreferences"), T("Settings.Editing"), T("Settings.Appearance"), + T("Settings.Shortcuts"), T("Settings.About") ]; diff --git a/src/ChapterTool.Wasm/Services/WasmLogView.cs b/src/ChapterTool.Wasm/Services/WasmLogView.cs new file mode 100644 index 00000000..9f03af55 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmLogView.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.Json; + +namespace ChapterTool.Wasm.Services; + +public sealed class WasmLogView(IReadOnlyList entries) +{ + private const int MaxSummaryLength = 180; + + public string Search { get; set; } = string.Empty; + + public string Severity { get; set; } = string.Empty; + + public Guid? SelectedId { get; set; } + + public bool InspectorOpen { get; set; } + + public bool HasAnyEntries => entries.Count > 0; + + public IReadOnlyList Visible => entries.Where(entry => + MatchesSeverity(entry.Level, Severity) && MatchesSearch(entry, Search)).ToArray(); + + public WasmLogEntry? Selected => SelectedId is { } id + ? entries.FirstOrDefault(entry => entry.Id == id) + : null; + + public static string CompactSummary(string message) => message.Length <= MaxSummaryLength + ? message + : message[..(MaxSummaryLength - 1)].TrimEnd() + "…"; + + public static bool SearchesAdditionalFields(WasmLogEntry entry, string query, string summary, string time, string level) + { + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + var fullTimestamp = entry.Timestamp.ToString("O", CultureInfo.InvariantCulture); + return (entry.Message.Contains(query, StringComparison.OrdinalIgnoreCase) + && !summary.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (entry.Details?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || entry.Id.ToString("D").Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Id.ToString("N").Contains(query, StringComparison.OrdinalIgnoreCase) + || (fullTimestamp.Contains(query, StringComparison.OrdinalIgnoreCase) + && !time.Contains(query, StringComparison.OrdinalIgnoreCase)) + || (entry.Level.Contains(query, StringComparison.OrdinalIgnoreCase) + && !level.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + public void Refresh() + { + if (Selected is null) + { + SelectedId = null; + InspectorOpen = false; + } + } + + public void ClearSelection() + { + SelectedId = null; + InspectorOpen = false; + } + + public string Export(bool csv) + { + var snapshot = Visible.OrderBy(entry => entry.Timestamp).ToArray(); + if (!csv) + { + return JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + } + return "Timestamp,Level,Message,Details\r\n" + string.Join("\r\n", snapshot.Select(entry => + string.Join(",", Quote(entry.Timestamp.ToString("O", CultureInfo.InvariantCulture)), + Quote(entry.Level), Quote(entry.Message), Quote(entry.Details ?? string.Empty)))) + "\r\n"; + } + + private static string Quote(string value) => "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + + private static bool MatchesSeverity(string level, string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return true; + } + + return filter.ToUpperInvariant() switch + { + "INFO" or "INFORMATION" => level.Equals("Info", StringComparison.OrdinalIgnoreCase) + || level.Equals("Information", StringComparison.OrdinalIgnoreCase), + "WARNING" => level.Equals("Warning", StringComparison.OrdinalIgnoreCase), + "ERROR" => level.Equals("Error", StringComparison.OrdinalIgnoreCase) + || level.Equals("Critical", StringComparison.OrdinalIgnoreCase) + || level.Equals("Fatal", StringComparison.OrdinalIgnoreCase), + _ => level.Equals(filter, StringComparison.OrdinalIgnoreCase) + }; + } + + private static bool MatchesSearch(WasmLogEntry entry, string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var timestamp = entry.Timestamp.ToString("O", CultureInfo.InvariantCulture); + var localTime = entry.Timestamp.ToLocalTime().ToString("HH:mm:ss.fff"); + return entry.Id.ToString("D").Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Id.ToString("N").Contains(query, StringComparison.OrdinalIgnoreCase) + || timestamp.Contains(query, StringComparison.OrdinalIgnoreCase) + || localTime.Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Level.Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Message.Contains(query, StringComparison.OrdinalIgnoreCase) + || (entry.Details?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); + } +} diff --git a/src/ChapterTool.Wasm/Services/WasmModels.cs b/src/ChapterTool.Wasm/Services/WasmModels.cs index f53afab7..13eada9c 100644 --- a/src/ChapterTool.Wasm/Services/WasmModels.cs +++ b/src/ChapterTool.Wasm/Services/WasmModels.cs @@ -5,6 +5,8 @@ namespace ChapterTool.Wasm.Services; /// public sealed class ChapterRowModel { + public Core.Models.ChapterId Id { get; set; } + public int Number { get; set; } public string TimeText { get; set; } = string.Empty; @@ -41,13 +43,18 @@ public sealed record WasmLogEntry( DateTimeOffset Timestamp, string Level, string Message, - string? Details = null); + string? Details = null) +{ + public Guid Id { get; } = Guid.NewGuid(); +} public sealed record WasmSettings( int SchemaVersion = 1, WasmApplicationSettings? Application = null, WasmThemeSettings? Theme = null, - WasmFontSettings? Font = null); + WasmFontSettings? Font = null, + [property: System.Text.Json.Serialization.JsonConverter(typeof(WasmShortcutSettingsConverter))] + Contracts.Shortcuts.ShortcutSettings? Shortcuts = null); public sealed record WasmApplicationSettings( string? SavingPath = null, @@ -61,7 +68,8 @@ public sealed record WasmApplicationSettings( decimal FrameAccuracyTolerance = 0.15m, string DeleteRowsTimingMode = "preserve", string FrameDisplayMode = "round", - int FrameDecimalPlaces = 3); + int FrameDecimalPlaces = 3, + bool ShowRepeatingFrameDecimals = true); public sealed record WasmThemeSettings(string PresetId = "avalonia-default"); diff --git a/src/ChapterTool.Wasm/Services/WasmShortcutSettingsConverter.cs b/src/ChapterTool.Wasm/Services/WasmShortcutSettingsConverter.cs new file mode 100644 index 00000000..37151707 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmShortcutSettingsConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ChapterTool.Contracts.Shortcuts; + +namespace ChapterTool.Wasm.Services; + +public sealed class WasmShortcutSettingsConverter : JsonConverter +{ + public override ShortcutSettings Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var settings = new ShortcutSettings(); + if (document.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String) + { + settings[property.Name] = property.Value.GetString()!; + } + } + } + return settings; + } + + public override void Write(Utf8JsonWriter writer, ShortcutSettings value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (var (id, gesture) in value) + { + writer.WriteString(id, gesture); + } + writer.WriteEndObject(); + } +} diff --git a/src/ChapterTool.Wasm/Services/WasmShortcuts.cs b/src/ChapterTool.Wasm/Services/WasmShortcuts.cs new file mode 100644 index 00000000..793dedad --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmShortcuts.cs @@ -0,0 +1,92 @@ +using ChapterTool.Contracts.Shortcuts; + +namespace ChapterTool.Wasm.Services; + +public static class WasmShortcuts +{ + public static bool IsSupported(string gesture) => gesture.Length == 0 + || (ShortcutGestureText.IsValid(gesture) + && gesture is not ("Ctrl+W" or "Ctrl+T" or "Ctrl+N" or "Ctrl+Shift+T" + or "Meta+W" or "Meta+T" or "Meta+N" or "Meta+Shift+T")); + + public static IReadOnlySet InvalidActions(ShortcutSettings settings) + { + var invalid = ShortcutConflictValidator.FindConflicts(settings).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (id, gesture) in settings) + { + if (!IsSupported(gesture)) + { + invalid.Add(id); + } + } + var claims = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["F9"] = "refresh-rows" }; + foreach (var action in ShortcutCatalog.All) + { + var gestures = action.FixedGestures.ToList(); + if (action.IsEditable && settings.TryGetValue(action.Id, out var gesture) && gesture.Length > 0) + { + gestures.Add(gesture); + if (gesture.StartsWith("Ctrl+", StringComparison.Ordinal)) + { + gestures.Add("Meta+" + gesture[5..]); + } + } + foreach (var binding in gestures) + { + if (claims.TryGetValue(binding, out var other) && other != action.Id) + { + invalid.Add(action.Id); + invalid.Add(other); + } + else + { + claims[binding] = action.Id; + } + } + } + return invalid; + } + + public static ShortcutSettings Normalize(ShortcutSettings? settings) + { + var normalized = ShortcutSettings.Normalize(settings); + return InvalidActions(normalized).Count == 0 ? normalized : ShortcutSettings.Normalize(null); + } + + public static IReadOnlyDictionary Bindings(ShortcutSettings settings) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var action in ShortcutCatalog.All) + { + if (action.IsEditable && settings.TryGetValue(action.Id, out var gesture) && gesture.Length > 0) + { + result.TryAdd(gesture, action.Id); + if (gesture.StartsWith("Ctrl+", StringComparison.Ordinal)) + { + result.TryAdd("Meta+" + gesture[5..], action.Id); + } + } + foreach (var fixedGesture in action.FixedGestures) + { + result[fixedGesture] = action.Id; + } + } + result.TryAdd("F9", "refresh-rows"); + return result; + } + + public static string Gesture(string key, bool ctrl, bool alt, bool meta, bool shift) + { + var normalizedKey = key switch + { + "ArrowUp" => "Up", + "ArrowDown" => "Down", + "ArrowLeft" => "Left", + "ArrowRight" => "Right", + " " => "Space", + _ => key.Length == 1 ? key.ToUpperInvariant() : key + }; + return (ctrl ? "Ctrl+" : string.Empty) + (alt ? "Alt+" : string.Empty) + + (meta ? "Meta+" : string.Empty) + (shift ? "Shift+" : string.Empty) + normalizedKey; + } +} diff --git a/src/ChapterTool.Wasm/Services/WasmWorkspace.History.cs b/src/ChapterTool.Wasm/Services/WasmWorkspace.History.cs new file mode 100644 index 00000000..d98f9e8d --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmWorkspace.History.cs @@ -0,0 +1,95 @@ +using ChapterTool.Core.Session; + +namespace ChapterTool.Wasm.Services; + +/// Provides retained history inspection and navigation for the browser workspace. +public sealed partial class WasmWorkspace +{ + private Guid? selectedHistoryRedoBranchId; + private Guid? selectedHistoryRedoRootId; + private Guid? selectedHistoryRedoCursorId; + + public SessionHistorySnapshot? HistorySnapshot => session.ContentSession?.GetHistorySnapshot(); + + public Guid? HistoryRootId => HistorySnapshot?.RootId; + + public Guid? CurrentHistoryId => HistorySnapshot?.CursorId; + + public IReadOnlyList HistoryRedoChoices + { + get + { + var snapshot = HistorySnapshot; + if (selectedHistoryRedoBranchId is not null + && (snapshot?.RootId != selectedHistoryRedoRootId || snapshot?.CursorId != selectedHistoryRedoCursorId)) + { + selectedHistoryRedoBranchId = null; + selectedHistoryRedoRootId = null; + selectedHistoryRedoCursorId = null; + } + var nodes = snapshot?.Nodes.ToDictionary(static node => node.Id); + if (snapshot is null || nodes is null || !nodes.TryGetValue(snapshot.CursorId, out var cursor)) + { + return []; + } + + var preferredId = cursor.PreferredChildId ?? cursor.ChildIds.FirstOrDefault(); + return cursor.ChildIds + .Where(nodes.ContainsKey) + .Select(id => nodes[id]) + .Select(node => new WasmHistoryRedoChoice(node.Id, + WasmHistoryProjection.OperationTitle(localizer, node.Operation, node.Description), node.Description, + selectedHistoryRedoBranchId == node.Id || (selectedHistoryRedoBranchId is null && preferredId == node.Id))) + .ToArray(); + } + } + + public Guid? SelectedHistoryRedoBranchId + { + get + { + var choices = HistoryRedoChoices; + return choices.FirstOrDefault(choice => choice.Id == selectedHistoryRedoBranchId)?.Id + ?? choices.FirstOrDefault(static choice => choice.IsPreferred)?.Id; + } + } + + public bool CanRedoHistoryBranch => HistoryRedoChoices.Count > 0 && !IsBusy && !IsHistoryEnded; + + public bool SelectHistoryRedoBranch(Guid? nodeId) + { + if (nodeId is null || HistoryRedoChoices.All(choice => choice.Id != nodeId) || IsBusy || IsHistoryEnded) + { + return false; + } + + selectedHistoryRedoBranchId = nodeId; + selectedHistoryRedoRootId = HistoryRootId; + selectedHistoryRedoCursorId = CurrentHistoryId; + Notify(); + return true; + } + + public async ValueTask RedoSelectedHistoryBranchAsync(CancellationToken cancellationToken = default) + { + var choices = HistoryRedoChoices; + var target = choices.FirstOrDefault(choice => choice.Id == selectedHistoryRedoBranchId)?.Id + ?? choices.FirstOrDefault(static choice => choice.IsPreferred)?.Id + ?? choices.FirstOrDefault()?.Id; + if (target is { } nodeId) + { + await NavigateHistoryAsync("navigate", nodeId, cancellationToken); + selectedHistoryRedoBranchId = null; + selectedHistoryRedoRootId = null; + selectedHistoryRedoCursorId = null; + } + } + + public string LocalizeHistoryEntry(WasmHistoryEntry entry) + { + var node = HistorySnapshot?.Nodes.FirstOrDefault(candidate => candidate.Id == entry.Id); + return node is null ? entry.Description : WasmHistoryProjection.OperationTitle(localizer, node.Operation, node.Description); + } +} + +public sealed record WasmHistoryRedoChoice(Guid Id, string Title, string Description, bool IsPreferred); diff --git a/src/ChapterTool.Wasm/Services/WasmWorkspace.MainOptions.cs b/src/ChapterTool.Wasm/Services/WasmWorkspace.MainOptions.cs new file mode 100644 index 00000000..20c71258 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmWorkspace.MainOptions.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using ChapterTool.Core.Editing; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; +using ChapterTool.Core.Transform; + +namespace ChapterTool.Wasm.Services; + +/// Provides one reviewed candidate for the browser main-window content drafts. +public sealed partial class WasmWorkspace +{ + private const string MainOptionsOperation = "Apply main-window options"; + + /// Gets the main-window candidate, separate from tool-dialog candidates. + public ChapterContentPreview? MainOptionsPreview => ContentPreview?.Operation == MainOptionsOperation ? ContentPreview : null; + + /// Gets the main-window comparison using the active frame display basis. + public ExpressionPreviewProjection? MainOptionsPreviewProjection => MainOptionsPreview is null ? null : ContentPreviewProjection; + + /// Gets whether the main-window candidate needs an explicit refresh. + public bool IsMainOptionsPreviewStale => MainOptionsPreview is not null && IsContentPreviewStale; + + /// Prepares naming, numbering, and expression drafts as one atomic candidate. + public ChapterContentPreview? PrepareMainOptionsPreview() + { + CancelExpressionPreview(); + var expression = Expression?.Trim() ?? string.Empty; + var applyExpression = expression.Length > 0 && !IsExpressionAlreadyApplied(expression); + return PrepareContentPreview(MainOptionsOperation, document => + { + if (OrderShift is < 0 or > 1000) + { + return new ChapterCandidateBuildResult(false, document, [], [localizer.T("Parity.InvalidNumber")]); + } + + return candidateBuilder.ApplyOutputOptions(document, AutoGenerateNames, UseTemplateNames, + ChapterNameTemplateText ?? string.Empty, OrderShift, applyExpression, expression, SelectedComputationRate); + }, new HistoryOperationDescriptor("chapter.options-apply", + [ + new("autoGenerateNames", AutoGenerateNames.ToString(CultureInfo.InvariantCulture), "boolean"), + new("useTemplateNames", UseTemplateNames.ToString(CultureInfo.InvariantCulture), "boolean"), + new("orderShift", OrderShift.ToString(CultureInfo.InvariantCulture), "number"), + new("script", applyExpression ? expression : string.Empty), + new("trackId", CurrentTrackId?.ToString()) + ])); + } + + private bool IsExpressionAlreadyApplied(string expression) + { + var snapshot = HistorySnapshot; + if (snapshot is null || CurrentTrackId is not { } trackId) + { + return false; + } + var nodes = snapshot.Nodes.ToDictionary(static node => node.Id); + var cursor = snapshot.CursorId; + while (nodes.TryGetValue(cursor, out var node)) + { + if (node.Operation is { Kind: "expression.apply" or "chapter.options-apply" } operation + && operation.Parameters.Any(parameter => parameter.Name == "trackId" && parameter.Value == trackId.ToString()) + && operation.Parameters.FirstOrDefault(static parameter => parameter.Name == "script")?.Value is { Length: > 0 } script) + { + return script == expression; + } + if (node.ParentId is not { } parentId) + { + break; + } + cursor = parentId; + } + return false; + } + + /// Commits the current main-window candidate without recalculating its values. + /// A token that can cancel submission. + public ValueTask ApplyMainOptionsPreviewAsync(CancellationToken cancellationToken = default) => + MainOptionsPreview is null ? ValueTask.FromResult(false) : ApplyContentPreviewAsync(cancellationToken); + + /// Discards the main-window candidate without changing committed content. + public void CancelMainOptionsPreview() + { + if (MainOptionsPreview is not null) + { + CancelContentPreview(); + } + } + + /// Formats either side of a candidate with the same policy as the chapter table. + /// The reviewed chapter comparison. + /// Whether to format the candidate value. + /// Whether to mark repeating digits. + public FramePresentationParts FormatPreviewFramePresentation(ExpressionChapterComparison row, bool candidate, bool showRepeatingDecimals) + { + var chapter = candidate ? row.Candidate : row.Before; + var frames = candidate ? row.CandidateFrames : row.BeforeFrames; + return FormatFramePresentation(chapter?.StartTicks ?? 0, frames?.FrameRate, + frames?.Text ?? string.Empty, showRepeatingDecimals); + } + + /// Calculates candidate frame accuracy through the shared frame service. + /// The candidate timestamp ticks. + public FrameAccuracy GetPreviewFrameAccuracy(long ticks) + { + if (!RoundFrames || BaseChapterSet is not { } source || ExactFramesPerSecond <= 0) + { + return FrameAccuracy.Neutral; + } + + var single = source with { Chapters = [new Chapter(1, TimeSpan.FromTicks(ticks), string.Empty)] }; + return frameRateService.UpdateFrames(single, EffectiveFrameRateOption, true, FrameAccuracyTolerance) + .Info.Chapters[0].FrameAccuracy; + } +} diff --git a/src/ChapterTool.Wasm/Services/WasmWorkspace.Operations.cs b/src/ChapterTool.Wasm/Services/WasmWorkspace.Operations.cs new file mode 100644 index 00000000..c79905e7 --- /dev/null +++ b/src/ChapterTool.Wasm/Services/WasmWorkspace.Operations.cs @@ -0,0 +1,328 @@ +using ChapterTool.Core.Editing; +using ChapterTool.Core.Models; +using ChapterTool.Core.Session; + +namespace ChapterTool.Wasm.Services; + +/// Provides chapter-content preview and editing operations for the browser workspace. +public sealed partial class WasmWorkspace +{ + private static readonly IReadOnlyDictionary LanguageCultures = + System.Globalization.CultureInfo.GetCultures(System.Globalization.CultureTypes.NeutralCultures) + .SelectMany(culture => new[] { (Code: culture.TwoLetterISOLanguageName, Culture: culture), (Code: culture.ThreeLetterISOLanguageName, Culture: culture) }) + .GroupBy(item => item.Code, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Culture, StringComparer.OrdinalIgnoreCase); + + private ChapterTrackId? contentPreviewTrackId; + private object? contentPreviewDraft; + + /// Gets the current chapter-content preview, if one is prepared. + public ChapterContentPreview? ContentPreview { get; private set; } + + /// Gets the projected changes and diagnostics for the current preview. + public ExpressionPreviewProjection? ContentPreviewProjection => ContentPreview is { } preview + ? ExpressionPreviewProjector.Build(preview, contentPreviewTrackId, EffectiveFrameRateOption.Rate, + RoundFrames ? 0 : EditingOptions.EffectiveFrameDecimalPlaces) : null; + + /// Gets whether the current preview no longer matches the workspace state. + public bool IsContentPreviewStale => ContentPreview is { } preview + && (session.ContentSession?.Snapshot.BaseToken != preview.BaseToken + || CurrentTrackId != contentPreviewTrackId + || !Equals(contentPreviewDraft, CaptureContentDraft(preview.Operation)) + || preview.ComputationRate != CaptureComputationRate(preview.Operation)); + + private object CaptureContentDraft(string operation) => operation switch + { + "Apply chapter options" => (ChapterNameModeIndex, OrderShift, ChapterNameTemplateText), + MainOptionsOperation => (ChapterNameModeIndex, OrderShift, ChapterNameTemplateText, Expression, SelectedComputationRate), + "Shift chapter frames" => ComputationFrameRate, + "Change chapter frame rate" => (ResolveSourceFrameRate(), ResolveSelectedFrameRateOption().Rate), + _ => operation + }; + + private FrameRateValue? CaptureComputationRate(string operation) => operation switch + { + "Shift chapter frames" => ComputationFrameRate, + "Change chapter frame rate" => ResolveSelectedFrameRateOption().Rate, + "Update chapter content" => ComputationFrameRate, + MainOptionsOperation => SelectedComputationRate, + _ => null + }; + + private ChapterTrackId? CurrentTrackId => session.ContentSession?.Snapshot.Document.Tracks + .ElementAtOrDefault(session.CurrentTrackIndex)?.Id; + + /// Prepares a preview of the current chapter naming and ordering options. + public ChapterContentPreview? PrepareNamingPreview() => PrepareContentPreview("Apply chapter options", document => + OrderShift is < 0 or > 1000 + ? new(false, document, [], [localizer.T("Parity.InvalidNumber")]) + : candidateBuilder.ApplyOutputOptions(document, AutoGenerateNames, UseTemplateNames, + ChapterNameTemplateText ?? string.Empty, OrderShift, false, "t"), + new HistoryOperationDescriptor("template.apply", + [ + new HistoryOperationParameter("autoGenerateNames", AutoGenerateNames.ToString(System.Globalization.CultureInfo.InvariantCulture), "boolean"), + new HistoryOperationParameter("useTemplateNames", UseTemplateNames.ToString(System.Globalization.CultureInfo.InvariantCulture), "boolean"), + new HistoryOperationParameter("template", ChapterNameTemplateText), + new HistoryOperationParameter("orderShift", OrderShift.ToString(System.Globalization.CultureInfo.InvariantCulture), "integer") + ])); + + /// Prepares a preview that shifts chapter frames by the specified amount. + /// The signed number of frames to shift. + public ChapterContentPreview? PrepareFrameShiftPreview(int frames) => PrepareContentPreview("Shift chapter frames", document => + frames is < -1000000 or > 1000000 + ? new(false, document, [], [localizer.T("Parity.InvalidShift")]) + : candidateBuilder.ShiftFrames(document, + document.Tracks[0].Chapters.Where(chapter => chapter.Kind != ChapterKind.Separator) + .Select(chapter => chapter.Id).ToHashSet(), frames, ComputationFrameRate), + new HistoryOperationDescriptor("chapter.frame-shift", + [ + new HistoryOperationParameter("frames", frames.ToString(System.Globalization.CultureInfo.InvariantCulture), "integer"), + new HistoryOperationParameter("displayFps", ExactFramesPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture), "decimal"), + new HistoryOperationParameter("trackName", session.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(session.CurrentTrackIndex)?.Name) + ])); + + /// Prepares a preview that changes the chapter frame rate to the selected rate. + public ChapterContentPreview? PrepareFrameRatePreview() => PrepareContentPreview("Change chapter frame rate", document => + candidateBuilder.ChangeFrameRate(document, ResolveSourceFrameRate(), + ResolveSelectedFrameRateOption().Rate), + new HistoryOperationDescriptor("chapter.frame-rate-change", + [ + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackName", session.ContentSession?.Snapshot.Document.Tracks.ElementAtOrDefault(session.CurrentTrackIndex)?.Name), + new HistoryOperationParameter("sourceFps", ResolveSourceFrameRate().ToString(), ResolveSourceFrameRate().Kind.ToString()), + new HistoryOperationParameter("targetFps", ResolveSelectedFrameRateOption().Rate.ToString(), ResolveSelectedFrameRateOption().Rate.Kind.ToString()) + ])); + + private FrameRateValue ResolveSourceFrameRate() => BaseChapterSet?.FrameRate is { IsKnown: true } rate + ? rate : ResolveSelectedFrameRateOption().IsValid ? ResolveSelectedFrameRateOption().Rate : FrameRateValue.Unknown; + + /// Gets a rate from explicit selection or uniform content. Detection is display-only. + public FrameRateValue ComputationFrameRate => ResolveSelectedFrameRateOption().IsValid + ? ResolveSelectedFrameRateOption().Rate : BaseChapterSet?.FrameRate ?? FrameRateValue.Unknown; + + private FrameRateValue? SelectedComputationRate => ResolveSelectedFrameRateOption().IsValid + ? ResolveSelectedFrameRateOption().Rate : null; + + private ChapterContentPreview? PrepareContentPreview(string operation, + Func build, + HistoryOperationDescriptor? operationDescriptor = null) + { + var content = session.ContentSession; + if (content is null || IsBusy) + { + return null; + } + var trackIndex = session.CurrentTrackIndex; + contentPreviewTrackId = CurrentTrackId; + contentPreviewDraft = CaptureContentDraft(operation); + ContentPreview = ChapterContentOperationSession.Prepare(content, operation, document => + { + var focused = FocusTrack(document, trackIndex); + var built = build(focused); + return built with { Candidate = built.IsValid + ? ReplaceFocusedTrack(document, trackIndex, built.Candidate) : document }; + }, operationDescriptor, CaptureComputationRate(operation)); + Notify(); + return ContentPreview; + } + + /// Applies the current valid preview as a content-session transaction. + /// A token that can cancel the operation. + /// if the preview was committed or made no changes. + public async ValueTask ApplyContentPreviewAsync(CancellationToken cancellationToken = default) + { + if (!TryGetApplicablePreview(out var preview, out var content)) + { + return false; + } + + var outcome = await ChapterContentOperationSession.ApplyAsync(content, preview, cancellationToken); + return CompletePreviewApplication(preview, outcome); + } + + private bool TryGetApplicablePreview(out ChapterContentPreview preview, out SessionState content) + { + if (ContentPreview is { IsValid: true } candidate && !IsBusy && !IsContentPreviewStale + && ContentPreviewProjection is { HasChanges: true } && session.ContentSession is { } activeContent) + { + preview = candidate; + content = activeContent; + return true; + } + + preview = null!; + content = null!; + return false; + } + + private bool CompletePreviewApplication(ChapterContentPreview preview, TransactionOutcome outcome) + { + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) + { + StatusText = outcome.Errors.FirstOrDefault() ?? localizer.T("History.NavigationFailed"); + Notify(); + return false; + } + session.PublishContentDocument(outcome.Snapshot.Document); + if (preview.Operation is "Apply chapter options" or MainOptionsOperation) + { + ChapterNameModeIndex = 0; + OrderShift = 0; + } + CancelContentPreview(); + CompleteCandidate(outcome, "Status.Updated"); + return true; + } + + /// Discards the current chapter-content preview. + public void CancelContentPreview() + { + ContentPreview = null; + contentPreviewTrackId = null; + contentPreviewDraft = null; + Notify(); + } + + /// Begins editing a chapter cell and captures the current session version. + /// The chapter row index. + /// The cell field to edit. + /// A draft for the cell, or if editing cannot begin. + public WasmCellDraft? BeginCellEdit(int index, ChapterCellField field) + { + if (IsBusy || session.ContentSession is not { } content + || index < 0 || index >= rows.Count || rows[index].IsSeparator || CurrentTrackId is not { } trackId) + { + return null; + } + var chapter = content.Snapshot.Document.Tracks[session.CurrentTrackIndex].Chapters[index]; + var original = field switch + { + ChapterCellField.Name => rows[index].Name, + ChapterCellField.StartTime => rows[index].TimeText, + ChapterCellField.Frame => rows[index].FramesInfo, + _ => throw new ArgumentOutOfRangeException(nameof(field)) + }; + return new(chapter.Id, trackId, content.Snapshot.BaseToken, field, original); + } + + /// Validates and commits an edited chapter cell. + /// The draft created when editing began. + /// The proposed cell value. + /// A localized validation error, or when the edit succeeds. + public async ValueTask CommitCellEditAsync(WasmCellDraft draft, string value) + { + ArgumentNullException.ThrowIfNull(draft); + if (IsBusy || session.ContentSession is not { } content || CurrentTrackId != draft.TrackId + || content.Snapshot.BaseToken != draft.BaseToken) + { + return localizer.T("Expression.State.Stale"); + } + if (value == draft.OriginalValue) + { + return null; + } + var trackIndex = session.CurrentTrackIndex; + var preview = ChapterContentOperationSession.Prepare(content, "Update chapter content", document => + { + var focused = FocusTrack(document, trackIndex); + var built = candidateBuilder.EditCell(focused, draft.ChapterId, draft.Field, value, ComputationFrameRate); + return built with { Candidate = built.IsValid ? ReplaceFocusedTrack(document, trackIndex, built.Candidate) : document }; + }, new HistoryOperationDescriptor("chapter.cell-edit", + [ + new("field", draft.Field.ToString(), "enum"), + new("value", value), + new("displayFps", ExactFramesPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture), "number") + ])); + var outcome = await ChapterContentOperationSession.ApplyAsync(content, preview); + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) + { + return localizer.T(draft.Field switch + { + ChapterCellField.Frame => "Diagnostic.FrameText.Invalid", + ChapterCellField.StartTime => "Diagnostic.TimeText.Invalid", + _ => "History.NavigationFailed" + }); + } + session.PublishContentDocument(outcome.Snapshot.Document); + RefreshDisplay(updateStatus: false, statusKey: null); + return null; + } + + /// Selects the next or previous clip option in the requested direction. + /// A positive value selects forward; a negative value selects backward. + public void SelectAdjacentClip(int direction) + { + if (IsBusy || ClipOptions.Count == 0) + { + return; + } + var index = ClipOptions.ToList().FindIndex(clip => clip.Id == SelectedClipId); + var next = index + Math.Sign(direction); + if (next >= 0 && next < ClipOptions.Count) + { + SelectClip(ClipOptions[next].Id); + } + } + + /// Restores a saved naming draft and clears any prepared preview. + /// The naming mode index. + /// The chapter ordering shift. + /// The chapter naming template. + /// The template validation status. + public void RestoreNamingDraft(int mode, int shift, string template, string status) + { + ChapterNameModeIndex = mode; + OrderShift = shift; + ChapterNameTemplateText = template; + ChapterNameTemplateStatus = status; + CancelContentPreview(); + } + + /// Returns a language code with its localized display name when available. + /// The language code to resolve. + public string XmlLanguageName(string code) + { + var resource = code switch + { + "zh" or "zho" => "XmlLanguage.Chinese", + "ja" or "jpn" => "Language.Japanese", + "en" or "eng" => "Language.English", + "de" or "deu" => "XmlLanguage.German", + "fr" or "fra" => "XmlLanguage.French", + "it" or "ita" => "XmlLanguage.Italian", + "es" or "spa" => "XmlLanguage.Spanish", + _ => null + }; + if (resource is not null) + { + return $"{code} — {localizer.T(resource)}"; + } + if (code == "und") + { + return $"{code} — {localizer.T("XmlLanguage.Undetermined")}"; + } + if (!LanguageCultures.TryGetValue(code, out var culture)) + { + return code; + } + var previous = System.Globalization.CultureInfo.CurrentUICulture; + try + { + System.Globalization.CultureInfo.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(localizer.Culture); + return $"{code} — {culture.DisplayName}"; + } + finally + { + System.Globalization.CultureInfo.CurrentUICulture = previous; + } + } +} + +/// Captures the chapter cell and session version being edited. +/// The identifier of the edited chapter. +/// The identifier of the track containing the chapter. +/// The session version captured when editing began. +/// The chapter cell field being edited. +/// The cell value captured when editing began. +public sealed record WasmCellDraft(ChapterId ChapterId, ChapterTrackId TrackId, + SessionBaseToken BaseToken, ChapterCellField Field, string OriginalValue); diff --git a/src/ChapterTool.Wasm/Services/WasmWorkspace.cs b/src/ChapterTool.Wasm/Services/WasmWorkspace.cs index 2b7413ac..2d5c7154 100644 --- a/src/ChapterTool.Wasm/Services/WasmWorkspace.cs +++ b/src/ChapterTool.Wasm/Services/WasmWorkspace.cs @@ -15,7 +15,7 @@ namespace ChapterTool.Wasm.Services; /// Browser-side workspace that mirrors Avalonia main-window load / grid / frames / expression / save flow. /// Clip combine/append/select transitions use the shared Core session kernel. /// -public sealed class WasmWorkspace : IDisposable +public sealed partial class WasmWorkspace : IDisposable { public const long MaxLoadBytes = PortableInputPolicy.MaxBytes; @@ -24,19 +24,23 @@ public sealed class WasmWorkspace : IDisposable private readonly WasmChapterService wasmChapterService; private readonly FrameRateService frameRateService = new(); private readonly IChapterExpressionEngine expressionEngine; - private readonly ChapterOutputProjectionService projectionService; private readonly ChapterEditingService editingService; + private readonly ChapterContentCandidateBuilder candidateBuilder; private readonly ChapterWorkspace session = new(); private readonly WasmLocalizer localizer; private readonly long maxLoadBytes; private readonly List logs = []; private readonly HashSet selectedRowIndexes = []; private IReadOnlyList diagnostics = []; + private ChapterTrackId? expressionPreviewTrackId; + private FrameRateValue? expressionPreviewComputationRate; + private bool expressionPreviewStale; private ChapterImportResult? importResult; private int activeGroupIndex; private List rows = []; private int selectedFrameRateIndex; + private FrameRateOption? appliedFrameRateOption; private int selectionAnchor = -1; private LoadedSourceSnapshot? lastLoadedSource; private string chapterNameTemplateText = string.Empty; @@ -44,11 +48,7 @@ public sealed class WasmWorkspace : IDisposable private string? statusLocalizationKey; private object[] statusLocalizationArgs = []; - private ChapterSet? BaseChapterSet - { - get => session.CurrentChapterSet; - set => session.SetCurrentChapterSet(value); - } + private ChapterSet? BaseChapterSet => session.CurrentChapterSet; private ClipSession? ClipSessionState { @@ -73,8 +73,9 @@ public WasmWorkspace(WasmChapterService wasmChapterService, WasmLocalizer? local this.localizer.CultureChanged += OnCultureChanged; this.maxLoadBytes = maxLoadBytes is > 0 and var limit ? limit : MaxLoadBytes; expressionEngine = new LuaExpressionScriptService(); - projectionService = new ChapterOutputProjectionService(expressionEngine); + AuthoringService = new ExpressionAuthoringService(expressionEngine); editingService = new ChapterEditingService(wasmChapterService.TimeFormatter); + candidateBuilder = new ChapterContentCandidateBuilder(editingService); SaveFormatIndex = 0; ChapterNameModeIndex = 0; XmlLanguage = wasmChapterService.XmlLanguages.Contains("und", StringComparer.OrdinalIgnoreCase) @@ -82,7 +83,6 @@ public WasmWorkspace(WasmChapterService wasmChapterService, WasmLocalizer? local : wasmChapterService.XmlLanguages.FirstOrDefault() ?? "und"; Expression = "t"; ExpressionPresetId = string.Empty; - RoundFrames = true; TextEncoding = OutputTextEncoding.Utf8; EmitBom = false; FrameAccuracyTolerance = DefaultFrameAccuracyTolerance; @@ -94,6 +94,10 @@ public WasmWorkspace(WasmChapterService wasmChapterService, WasmLocalizer? local public string SourcePath { get; private set; } = string.Empty; + public IExpressionAuthoringService AuthoringService { get; } + + public string ExpressionSourceName { get; set; } = string.Empty; + public string StatusText { get; private set; } = string.Empty; public double Progress { get; private set; } @@ -104,6 +108,125 @@ public WasmWorkspace(WasmChapterService wasmChapterService, WasmLocalizer? local public bool CanPreview => CanSave; + public bool CanUndo + { + get + { + var history = session.ContentSession?.GetHistorySnapshot(); + return history is not null && history.CursorId != history.RootId && !IsBusy; + } + } + + public bool HasHistory => session.ContentSession is not null; + + public bool HasUnexportedChanges + { + get + { + if (BaseChapterSet is null) + { + return false; + } + + var snapshot = session.CaptureExportSnapshot(BaseChapterSet, CreateSerializationOptions(), session.CurrentTrackIndex); + return !session.IsExported(snapshot); + } + } + + public bool HasActiveDraft => ContentPreview is not null || ExpressionPreview is not null; + + public bool RequiresSessionLossConfirmation + { + get + { + var history = session.ContentSession?.GetHistorySnapshot(); + return (history is not null && history.Nodes.Length > 1) || HasActiveDraft || HasUnexportedChanges; + } + } + + public Func>? SessionLossConfirmation { get; set; } + + public bool CanRedo => PreferredRedoNode is not null && !IsBusy; + + public ICollection HistoryEntries + => GetHistoryEntries(null); + + public ICollection GetHistoryEntries(IReadOnlySet? expandedNodeIds) + { + var history = session.ContentSession?.GetHistorySnapshot(); + if (history is null) + { + return []; + } + + var nodes = history.Nodes.ToDictionary(static node => node.Id); + var wasmHistoryEntries = new List(nodes.Count); + var pending = new Stack<(Guid Id, int Depth)>(); + pending.Push((history.RootId, 0)); + while (pending.TryPop(out var item)) + { + if (!nodes.TryGetValue(item.Id, out var node)) + { + continue; + } + + var hiddenCurrent = expandedNodeIds?.Contains(node.Id) == false + && node.Id != history.CursorId + && IsAncestorOf(node.Id, history.CursorId, nodes); + wasmHistoryEntries.Add(new WasmHistoryEntry(node.Id, node.ParentId, node.Description, item.Depth, node.Id == history.CursorId, + !node.ChildIds.IsEmpty, hiddenCurrent)); + if (expandedNodeIds?.Contains(node.Id) == false) + { + continue; + } + for (var index = node.ChildIds.Length - 1; index >= 0; index--) + { + pending.Push((node.ChildIds[index], item.Depth + 1)); + } + } + + return wasmHistoryEntries; + } + + public bool HasPendingContentPreview => ContentPreview is not null || ExpressionPreview is not null; + + public bool IsHistoryEnded => session.ContentSession?.IsEnded ?? true; + + public async ValueTask InspectHistoryAsync(Guid nodeId, CancellationToken cancellationToken = default) => + session.ContentSession is { } content + ? await content.InspectHistoryNodeAsync(nodeId, cancellationToken) + : new HistoryInspectionOutcome(HistoryInspectionOutcomeKind.Ended, null, []); + + public async ValueTask RestoreHistoryAsync(Guid nodeId, CancellationToken cancellationToken = default) => + await NavigateHistoryAsync("restore", nodeId, cancellationToken); + + private static bool IsAncestorOf(Guid ancestorId, Guid nodeId, + IReadOnlyDictionary nodes) + { + while (nodes.TryGetValue(nodeId, out var node) && node.ParentId is Guid parentId) + { + if (parentId == ancestorId) + { + return true; + } + nodeId = parentId; + } + return false; + } + + private SessionHistoryNodeSnapshot? PreferredRedoNode + { + get + { + var history = session.ContentSession?.GetHistorySnapshot(); + var cursor = history?.Nodes.FirstOrDefault(node => node.Id == history.CursorId); + var childId = cursor is null + ? (Guid?)null + : cursor.PreferredChildId ?? (cursor.ChildIds.IsEmpty ? null : cursor.ChildIds[0]); + return childId is null ? null : history?.Nodes.FirstOrDefault(node => node.Id == childId.Value); + } + } + public bool CanRefreshRows => BaseChapterSet is not null && !IsBusy; public bool CanReload => lastLoadedSource is not null && !IsBusy; @@ -161,7 +284,55 @@ public string? ChapterNameTemplateStatus public string XmlLanguage { get; set; } - public bool ApplyExpression { get; set; } + /// Gets the read-only candidate prepared from the current committed document. + public ChapterContentPreview? ExpressionPreview { get; private set; } + + public ExpressionPreviewProjection? ExpressionPreviewProjection + { + get + { + if (ExpressionPreview is not { } preview) + { + return null; + } + + var appliedRate = EffectiveFrameRateOption; + return ExpressionPreviewProjector.Build(preview, expressionPreviewTrackId, appliedRate.Rate, + RoundFrames ? 0 : EditingOptions.EffectiveFrameDecimalPlaces); + } + } + + public bool IsExpressionPreviewStale => expressionPreviewStale + || (ExpressionPreview is { } preview + && (session.ContentSession?.Snapshot.BaseToken != preview.BaseToken + || CurrentTrackId != expressionPreviewTrackId + || expressionPreviewComputationRate != SelectedComputationRate)); + + public string FormatExpressionTimeTicks(long ticks) + { + var culture = System.Globalization.CultureInfo.GetCultureInfo(localizer.Culture); + var totalHours = ticks / TimeSpan.TicksPerHour; + var minutes = (ticks / TimeSpan.TicksPerMinute) % 60; + var seconds = (ticks / TimeSpan.TicksPerSecond) % 60; + var fraction = Math.Abs(ticks % TimeSpan.TicksPerSecond).ToString("D7", System.Globalization.CultureInfo.InvariantCulture); + var precision = ticks % 10_000 == 0 ? 3 : ticks % 10 == 0 ? 6 : 7; + return string.Create(culture, $"{totalHours:00}:{minutes:00}:{seconds:00}{culture.NumberFormat.NumberDecimalSeparator}{fraction[..precision]}"); + } + + public string FormatExpressionPropertyName(string property) => property switch + { + "Title" => localizer.T("Expression.Property.Title"), + "SourceName" => localizer.T("Expression.Property.Source"), + "ImportFormat" => localizer.T("Expression.Property.ImportFormat"), + "Duration" => localizer.T("Expression.Property.Duration"), + "FrameRate" => localizer.T("Expression.Property.FrameRate"), + "Name" => localizer.T("Expression.Property.Name"), + "DisplayNumber" => localizer.T("Expression.Property.Number"), + "Kind" => localizer.T("Expression.Property.Type"), + "Presence" => localizer.T("Expression.Property.Presence"), + "TrackOrder" => localizer.T("Expression.Property.TrackOrder"), + _ => localizer.T("Expression.Property.Additional") + }; public string Expression { @@ -190,7 +361,21 @@ public string Expression /// Gets the selected expression preset id, or empty when the expression is free-form. public string ExpressionPresetId { get; private set; } - public bool RoundFrames { get; set; } + public bool RoundFrames + { + get => EditingOptions.FrameDisplay == FrameDisplayMode.Round; + set + { + if (value) + { + EditingOptions = EditingOptions with { FrameDisplay = FrameDisplayMode.Round }; + } + else if (EditingOptions.FrameDisplay == FrameDisplayMode.Round) + { + EditingOptions = EditingOptions with { FrameDisplay = FrameDisplayMode.FullPrecision }; + } + } + } public OutputTextEncoding TextEncoding { get; set; } @@ -204,7 +389,7 @@ public decimal FrameAccuracyTolerance : Math.Clamp(value, 0.01m, 0.30m); } - public ChapterEditingOptions EditingOptions { get; set; } = ChapterEditingOptions.Default; + public ChapterEditingOptions EditingOptions { get; set; } public string OutputTextEncodingId => OutputTextEncodings.Id(TextEncoding); @@ -213,7 +398,7 @@ public int SelectedFrameRateIndex get => selectedFrameRateIndex; set { - var clamped = Math.Clamp(value, 0, Math.Max(0, FrameRateChoices.Count - 1)); + var clamped = FrameRateChoices.Any(choice => choice.Index == value) ? value : 0; if (selectedFrameRateIndex == clamped) { return; @@ -226,13 +411,53 @@ public int SelectedFrameRateIndex public double FramesPerSecond { get; private set; } + internal decimal ExactFramesPerSecond { get; private set; } + + public FramePresentationParts FormatFramePresentation( + long startTicks, + FrameRateValue? sourceRate, + string numericFallback, + bool showRepeatingDecimals) + { + var selected = EffectiveFrameRateOption; + var rate = sourceRate ?? BaseChapterSet?.FrameRate ?? FrameRateValue.Unknown; + if (ResolveSelectedFrameRateOption().IsValid && selected.Rate.IsKnown) + { + rate = selected.Rate; + } + else if (!rate.IsKnown && selected.Rate.IsKnown) + { + rate = selected.Rate; + } + + var input = new FramePresentationInput(startTicks, rate, numericFallback); + var policy = new FramePresentationPolicy(RoundFrames, + showRepeatingDecimals && EditingOptions.FrameDisplay == FrameDisplayMode.FullPrecision, + EditingOptions.EffectiveFrameDecimalPlaces); + return FrameValueFormatter.Format(input, policy); + } + + public FramePresentationParts FormatRowFramePresentation(int index, string numericFallback, bool showRepeatingDecimals) + { + if (BaseChapterSet is null || index < 0 || index >= BaseChapterSet.Chapters.Count) + { + return FrameValueFormatter.Format(FramePresentationInput.FromNumericText(numericFallback), + new FramePresentationPolicy(RoundFrames, + showRepeatingDecimals && EditingOptions.FrameDisplay == FrameDisplayMode.FullPrecision, + EditingOptions.EffectiveFrameDecimalPlaces)); + } + + return FormatFramePresentation(BaseChapterSet.Chapters[index].StartTime.Ticks, null, + numericFallback, showRepeatingDecimals); + } + public string FramesPerSecondDisplay => FramesPerSecond > 0 ? FramesPerSecond.ToString("0.######") : "—"; public bool IsXmlLanguageEnabled => - wasmChapterService.FormatAt(SaveFormatIndex) == ChapterExportFormat.Xml; + ChapterContentService.FormatAt(SaveFormatIndex) == ChapterExportFormat.Xml; public IReadOnlyList Logs => logs; @@ -286,9 +511,50 @@ public void SetStatusMessage(string message) Notify(); } + public async ValueTask UndoAsync(CancellationToken cancellationToken = default) => + await NavigateHistoryAsync("undo", null, cancellationToken); + + public async ValueTask RedoAsync(CancellationToken cancellationToken = default) => + await NavigateHistoryAsync("redo", null, cancellationToken); + + public async ValueTask NavigateHistoryAsync(Guid nodeId, CancellationToken cancellationToken = default) => + await NavigateHistoryAsync("navigate", nodeId, cancellationToken); + + private async ValueTask NavigateHistoryAsync(string action, Guid? nodeId, CancellationToken cancellationToken) + { + var content = session.ContentSession; + if (content is null || IsBusy) + { + return; + } + + var outcome = action switch + { + "undo" => await content.UndoAsync(cancellationToken), + "redo" => await content.RedoAsync(cancellationToken), + _ => await content.NavigateToAsync(nodeId!.Value, cancellationToken) + }; + if (outcome.Kind == HistoryNavigationOutcomeKind.Committed) + { + session.PublishContentDocument(outcome.Snapshot.Document); + var statusKey = action switch + { + "undo" => "History.UndoApplied", + "restore" => "History.RestoreApplied", + _ => "History.RedoApplied" + }; + RefreshDisplay(updateStatus: true, statusKey); + } + else if (outcome.Kind is not HistoryNavigationOutcomeKind.NoChange) + { + SetRawStatus(outcome.Errors.FirstOrDefault() ?? localizer.T("History.NavigationFailed")); + } + } + public void Dispose() { localizer.CultureChanged -= OnCultureChanged; + session.ClearSession(); } public void SelectRow(int index, bool ctrl = false, bool shift = false) @@ -359,9 +625,17 @@ public void InsertBefore(int index) return; } - var result = editingService.InsertBefore(BaseChapterSet, Math.Clamp(index, 0, BaseChapterSet.Chapters.Count)); + var track = session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex]; + var insertIndex = Math.Clamp(index, 0, track.Chapters.Length); + var beforeId = insertIndex < track.Chapters.Length ? track.Chapters[insertIndex].Id : (ChapterId?)null; + var outcome = session.ExecuteTrackCandidate("Insert chapter", document => candidateBuilder.InsertBefore(document, beforeId), + operationDescriptor: new HistoryOperationDescriptor("chapter.insert", + [ + new HistoryOperationParameter("beforeChapterId", beforeId?.ToString()), + new HistoryOperationParameter("trackName", track.Name) + ])); + CompleteCandidate(outcome, "Status.Inserted"); SelectRow(Math.Clamp(index, 0, Math.Max(0, rows.Count))); - ApplyEditResult(result, "Status.Inserted"); } public void DuplicateRow(int index) @@ -371,11 +645,43 @@ public void DuplicateRow(int index) return; } - var source = BaseChapterSet.Chapters[index]; - var result = editingService.InsertBefore(BaseChapterSet, index + 1); - var chapters = result.ChapterSet.Chapters.ToList(); - chapters[index + 1] = source with { DisplayNumber = 0 }; - SetBaseChapterSet(result.ChapterSet with { Chapters = chapters }); + var track = session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex]; + var source = track.Chapters[index]; + var beforeId = index + 1 < track.Chapters.Length ? track.Chapters[index + 1].Id : (ChapterId?)null; + var sourceId = source.Id; + var outcome = session.ExecuteTrackCandidate("Duplicate chapter", document => + { + var inserted = candidateBuilder.InsertBefore(document, beforeId); + if (!inserted.IsValid) + { + return inserted; + } + + var chapters = inserted.Candidate.Tracks[0].Chapters; + var newIndex = Array.FindIndex(chapters.ToArray(), chapter => chapter.Id != sourceId && !document.Tracks[0].Chapters.Any(original => original.Id == chapter.Id)); + if (newIndex < 0) + { + return new ChapterCandidateBuildResult(false, document, [], ["The duplicate candidate was not created."]); + } + + var duplicate = chapters[newIndex] with + { + DisplayNumber = chapters[newIndex].DisplayNumber, + StartTicks = source.StartTicks, + Name = source.Name, + FramesInfo = source.FramesInfo, + EndTicks = source.EndTicks, + FrameAccuracy = source.FrameAccuracy, + Kind = source.Kind + }; + var updated = chapters.SetItem(newIndex, duplicate); + var updatedTrack = new EditableChapterTrack(inserted.Candidate.Tracks[0].Id, + inserted.Candidate.Tracks[0].Name, updated, inserted.Candidate.Tracks[0].Segments); + return inserted with { Candidate = new EditableChapterDocument(inserted.Candidate.Id, + inserted.Candidate.Title, inserted.Candidate.SourceName, inserted.Candidate.ImportFormat, + inserted.Candidate.Duration, [updatedTrack]) }; + }); + CompleteCandidate(outcome, "Status.Duplicated", index + 1); SelectRow(index + 1); AddLog("Info", localizer.Format("Status.Duplicated", index + 1)); RefreshDisplay(updateStatus: true, statusKey: "Status.Duplicated", statusArgs: [index + 1]); @@ -396,12 +702,22 @@ public void DeleteSelectedRows() return; } - var result = editingService.Delete(BaseChapterSet, indexes, EditingOptions); + var track = session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex]; + var targetIds = indexes.Where(index => index >= 0 && index < track.Chapters.Length) + .Select(index => track.Chapters[index].Id).ToHashSet(); selectedRowIndexes.Clear(); SelectedRowIndex = -1; selectionAnchor = -1; - AddLog("Info", localizer.Format("Status.Deleted", indexes.Count)); - ApplyEditResult(result, "Status.Deleted", indexes.Count); + var outcome = session.ExecuteTrackCandidate("Delete chapters", document => candidateBuilder.Delete(document, targetIds, EditingOptions), + operationDescriptor: new HistoryOperationDescriptor("chapter.delete", + [ + new HistoryOperationParameter("chapterIds", string.Join(",", targetIds)), + new HistoryOperationParameter("trackName", track.Name), + new HistoryOperationParameter("deleteTiming", EditingOptions.DeleteRowsTiming.ToString()), + new HistoryOperationParameter("frameDisplay", EditingOptions.FrameDisplay.ToString()), + new HistoryOperationParameter("frameDecimalPlaces", EditingOptions.EffectiveFrameDecimalPlaces.ToString(System.Globalization.CultureInfo.InvariantCulture), "integer") + ])); + CompleteCandidate(outcome, "Status.Deleted", indexes.Count); } public void DeleteRow(int index) @@ -449,7 +765,7 @@ public string CreateZonesForSelection() return string.Empty; } - var result = editingService.CreateZones(BaseChapterSet, indexes, (decimal)FramesPerSecond); + var result = editingService.CreateZones(BaseChapterSet, indexes, ComputationFrameRate); RecordDiagnostics(result.Diagnostics); if (result.Zones.Length > 0) { @@ -486,18 +802,18 @@ public void ShiftFramesForward(int frames) return; } - var fps = FramesPerSecond > 0 ? (decimal)FramesPerSecond : (decimal)BaseChapterSet.FramesPerSecond; - var result = editingService.ShiftFramesForward(BaseChapterSet, frames, fps); - if (result.Diagnostics.Count > 0 && result.ChapterSet.Chapters.Count == BaseChapterSet.Chapters.Count - && result.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error)) - { - RecordDiagnostics(result.Diagnostics); - StatusText = WasmWorkspaceProjection.FirstError(result.Diagnostics) ?? localizer.T("Status.CannotShift"); - Notify(); - return; - } - - ApplyEditResult(result, "Status.Shifted", frames); + var track = session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex]; + var fps = ComputationFrameRate; + var targets = track.Chapters.Select(static chapter => chapter.Id).ToHashSet(); + var outcome = session.ExecuteTrackCandidate("Shift chapter frames", document => candidateBuilder.ShiftFrames(document, targets, frames, fps), + operationDescriptor: new HistoryOperationDescriptor("chapter.frame-shift", + [ + new HistoryOperationParameter("frames", frames.ToString(System.Globalization.CultureInfo.InvariantCulture), "integer"), + new HistoryOperationParameter("computationRate", fps.ToString(), fps.Kind.ToString()), + new HistoryOperationParameter("trackName", track.Name), + new HistoryOperationParameter("chapterIds", string.Join(",", targets)) + ])); + CompleteCandidate(outcome, "Status.Shifted", frames); } public async Task LoadAsync(string fileName, byte[] content, CancellationToken cancellationToken = default) @@ -508,62 +824,110 @@ public async Task LoadAsync(string fileName, byte[] content, CancellationToken c { Progress = 0.2; Notify(); - - if (content.Length == 0) + if (ValidateLoadContent(content)) { - SetLocalizedStatus("Status.DropEmpty"); - AddLog("Error", StatusText); - return; + await ImportAndApplyLoadAsync(fileName, content, operationRevision, cancellationToken); } + } + catch (Exception ex) + { + StatusText = ex.Message; + diagnostics = []; + AddLog("Error", localizer.T("Status.LoadFailed"), ex.ToString()); + } + finally + { + EndBusy(); + } + } - if (!PortableInputPolicy.IsWithinLimit(content.LongLength) || content.LongLength > maxLoadBytes) - { - SetLocalizedStatus("Status.DropTooLarge"); - AddLog("Error", StatusText); - return; - } + private bool ValidateLoadContent(byte[] content) + { + if (content.Length == 0) + { + SetLocalizedStatus("Status.DropEmpty"); + AddLog("Error", StatusText); + return false; + } - var result = await wasmChapterService.ImportAsync(fileName, content, cancellationToken); - AddLog("Info", $"Loading {fileName} ({content.Length:N0} bytes)."); - Progress = 0.8; - Notify(); + if (PortableInputPolicy.IsWithinLimit(content.LongLength) && content.LongLength <= maxLoadBytes) + { + return true; + } - if (!result.Success || result.Groups.Count == 0) - { - if (session.IsCurrentRevision(operationRevision)) - { - ClearSession(); - } + SetLocalizedStatus("Status.DropTooLarge"); + AddLog("Error", StatusText); + return false; + } - diagnostics = WasmWorkspaceProjection.ToDiagnostics(result.Diagnostics); - StatusText = WasmWorkspaceProjection.FirstError(result.Diagnostics) ?? localizer.T("Status.LoadFailed"); - AddLog("Error", StatusText); - return; - } + private async Task ImportAndApplyLoadAsync( + string fileName, + byte[] content, + int operationRevision, + CancellationToken cancellationToken) + { + var result = await wasmChapterService.ImportAsync(fileName, content, cancellationToken); + AddLog("Info", $"Loading {fileName} ({content.Length:N0} bytes)."); + Progress = 0.8; + Notify(); + if (!HandleLoadImportResult(result)) + { + return; + } - if (!ApplySuccessfulLoad(fileName, content, result, operationRevision)) - { - return; - } + var candidate = result.Groups[0]; + var candidateSession = ClipSessionTransitions.FromLoad(candidate); + var candidateDocument = EditableChapterDocumentAdapter.FromChapterImportSource(candidate); + var confirmation = await ConfirmSessionReplacementAsync(session.CaptureContentToken(), cancellationToken); + if (!confirmation.Confirmed) + { + return; + } - Progress = 1; - AddLog("Info", StatusText); + if (!ApplySuccessfulLoad(fileName, content, result, operationRevision, candidateSession, candidateDocument, confirmation.Token)) + { + return; } - catch (Exception ex) + + Progress = 1; + AddLog("Info", StatusText); + } + + private bool HandleLoadImportResult(ChapterImportResult result) + { + if (result.Success && result.Groups.Count > 0) { - if (session.IsCurrentRevision(operationRevision)) + return true; + } + + diagnostics = WasmWorkspaceProjection.ToDiagnostics(result.Diagnostics); + StatusText = WasmWorkspaceProjection.FirstError(result.Diagnostics) ?? localizer.T("Status.LoadFailed"); + AddLog("Error", StatusText); + return false; + } + + private async Task<(bool Confirmed, SessionBaseToken? Token)> ConfirmSessionReplacementAsync( + SessionBaseToken? initialToken, + CancellationToken cancellationToken) + { + var confirmedToken = initialToken; + while (RequiresSessionLossConfirmation && SessionLossConfirmation is not null) + { + if (!await SessionLossConfirmation(cancellationToken)) { - ClearSession(); + SetLocalizedStatus("Status.SessionReplacementCanceled"); + return (false, confirmedToken); } - StatusText = ex.Message; - diagnostics = []; - AddLog("Error", localizer.T("Status.LoadFailed"), ex.ToString()); - } - finally - { - EndBusy(); + if (session.IsContentTokenCurrent(confirmedToken)) + { + return (true, confirmedToken); + } + + confirmedToken = session.CaptureContentToken(); } + + return (true, confirmedToken); } public async Task ReloadAsync(CancellationToken cancellationToken = default) @@ -589,9 +953,20 @@ public async Task AppendMplsAsync(string fileName, byte[] content, CancellationT return; } + var contentSession = session.ContentSession; + if (contentSession is null) + { + // Resolve the content session before this operation owns the busy flag. An early + // return after BeginBusy would leave IsBusy set and disable every later action. + SetLocalizedStatus("Status.CannotAppend"); + Notify(); + return; + } + BeginBusy("Status.Appending"); var operationRevision = session.CaptureRevision(); var expectedSessionId = ClipSessionState.SessionId; + var capturedContent = contentSession.Snapshot; try { Progress = 0.2; @@ -624,18 +999,18 @@ public async Task AppendMplsAsync(string fileName, byte[] content, CancellationT } var appendedGroup = result.Groups[0]; - var transition = ClipSessionTransitions.Append(ClipSessionState, appendedGroup); - if (!transition.Succeeded || transition.Session is null) + var append = session.AppendClipSource(operationRevision, expectedSessionId, capturedContent, appendedGroup, + cancellationToken: cancellationToken); + if (append.IsStale) { - // Keep current session on append failure. - RecordDiagnostics(transition.EditResult.Diagnostics); - StatusText = WasmWorkspaceProjection.FirstError(transition.EditResult.Diagnostics) ?? localizer.T("Status.AppendFailed"); - AddLog("Error", StatusText); return; } - if (!session.TryCommitAppend(operationRevision, expectedSessionId, transition.Session)) + if (!append.Succeeded || append.Session is null || append.Document is null) { + var message = string.Join("; ", append.Errors); + StatusText = message.Length == 0 ? localizer.T("Status.AppendFailed") : message; + AddLog("Error", StatusText); return; } @@ -694,7 +1069,10 @@ public void SelectClip(string? clipId) } activeGroupIndex = option.GroupIndex; - ClipSessionState = ClipSessionTransitions.FromLoad(importResult.Groups[activeGroupIndex]); + var sourceGroup = importResult.Groups[activeGroupIndex]; + var newSession = ClipSessionTransitions.FromLoad(sourceGroup); + session.ReplaceSession(newSession, EditableChapterDocumentAdapter.FromChapterImportSource(sourceGroup)); + ClipSessionState = newSession; if (option.EntryIndex >= 0) { ClipSessionState = ClipSessionTransitions.Select(ClipSessionState, option.EntryIndex); @@ -720,7 +1098,7 @@ public void ToggleClipCombine() return; } - var transition = ClipSessionTransitions.ToggleCombine(ClipSessionState); + var transition = session.ToggleClipStructure(); if (!transition.Succeeded || transition.Session is null) { RecordDiagnostics(transition.EditResult.Diagnostics); @@ -753,50 +1131,56 @@ public void ToggleClipCombine() public void ChangeSelectedFrameRate() { - if (!CanChangeSelectedFrameRate || BaseChapterSet is null) + var content = session.ContentSession; + if (!CanChangeSelectedFrameRate || BaseChapterSet is null || content is null) + { + return; + } + + var track = content.Snapshot.Document.Tracks.ElementAtOrDefault(session.CurrentTrackIndex); + if (track is null) { return; } - var sourceFps = (decimal)BaseChapterSet.FramesPerSecond; + var sourceFps = ResolveSourceFrameRate(); var target = ResolveSelectedFrameRateOption(); - var result = ChapterFpsTransformService.ChangeFps(BaseChapterSet, sourceFps, target.Value); - if (!result.Success) + var outcome = session.ExecuteTrackCandidate("Change chapter frame rate", + document => candidateBuilder.ChangeFrameRate(document, sourceFps, target.Rate), + operationDescriptor: new HistoryOperationDescriptor("chapter.frame-rate-change", + [ + new HistoryOperationParameter("trackName", track.Name), + new HistoryOperationParameter("sourceFps", sourceFps.ToString(), sourceFps.Kind.ToString()), + new HistoryOperationParameter("targetFps", target.Rate.ToString(), target.Rate.Kind.ToString()) + ])); + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) { - RecordDiagnostics(result.Diagnostics); - StatusText = WasmWorkspaceProjection.FirstError(result.Diagnostics) ?? localizer.T("Status.ChangeFpsFailed"); + StatusText = outcome.Errors.FirstOrDefault() ?? localizer.T("Status.ChangeFpsFailed"); Notify(); return; } - SetBaseChapterSet(result.Info); PreferredFrameRateIndex = selectedFrameRateIndex; - AddLog("Info", localizer.Format("Status.ChangedFps", sourceFps, target.Value)); + AddLog("Info", localizer.Format("Status.ChangedFps", sourceFps, target.Rate.ToDecimal())); RefreshDisplay(updateStatus: true, statusKey: "Status.ChangedFpsTo", statusArgs: [target.DisplayName]); } public bool CanChangeSelectedFrameRate => BaseChapterSet is not null - && BaseChapterSet.FramesPerSecond > 0 + && BaseChapterSet.FrameRate.IsKnown && selectedFrameRateIndex > 0 && ResolveSelectedFrameRateOption().IsValid; /// - /// Applies current option state (round frames, expression, order shift, naming) and refreshes the grid. + /// Refreshes derived display without applying content drafts. /// public void ApplyOptionsAndRefresh() { - if (BaseChapterSet is null) - { - Notify(); - return; - } - RefreshDisplay(updateStatus: false, statusKey: null); } /// - /// Applies a built-in Core expression preset and refreshes the projected rows. + /// Selects a built-in Core expression preset without changing the committed document. /// /// The preset identifier from . /// when the preset was found and applied. @@ -820,19 +1204,147 @@ public bool ApplyExpressionPreset(string? presetId) Expression = preset.ScriptText; ExpressionPresetId = preset.Id; - ApplyExpression = true; AddLog("Info", localizer.Format("Status.ExpressionPresetApplied", preset.DisplayName)); if (BaseChapterSet is null) { SetLocalizedStatus("Status.ExpressionPresetApplied", preset.DisplayName); Notify(); - return true; } - RefreshDisplay(updateStatus: true, statusKey: "Status.ExpressionPresetApplied", statusArgs: [preset.DisplayName]); return true; } + /// Prepares a read-only expression candidate from the active session snapshot. + public ChapterContentPreview? PrepareExpressionPreview(string expressionText) + { + Expression = expressionText ?? string.Empty; + var content = session.ContentSession; + if (string.IsNullOrWhiteSpace(Expression) || content is null || IsBusy) + { + ExpressionPreview = null; + expressionPreviewTrackId = null; + expressionPreviewStale = false; + Notify(); + return null; + } + + var trackIndex = session.CurrentTrackIndex; + if (trackIndex < 0 || trackIndex >= content.Snapshot.Document.Tracks.Length) + { + ExpressionPreview = null; + expressionPreviewTrackId = null; + Notify(); + return null; + } + expressionPreviewTrackId = content.Snapshot.Document.Tracks[trackIndex].Id; + expressionPreviewComputationRate = SelectedComputationRate; + var track = content.Snapshot.Document.Tracks[trackIndex]; + expressionPreviewStale = false; + ExpressionPreview = ChapterContentOperationSession.Prepare(content, "Apply chapter expression", document => + { + var focused = FocusTrack(document, trackIndex); + var selectedRate = ResolveSelectedFrameRateOption(); + var built = candidateBuilder.ApplyExpression(focused, Expression.Trim(), selectedRate.IsValid ? selectedRate.Rate : null); + return built.IsValid + ? built with { Candidate = ReplaceFocusedTrack(document, trackIndex, built.Candidate) } + : built with { Candidate = document }; + }, new HistoryOperationDescriptor("expression.apply", + [ + new HistoryOperationParameter("script", Expression.Trim()), + new HistoryOperationParameter("trackId", expressionPreviewTrackId?.ToString()), + new HistoryOperationParameter("targetScope", "track"), + new HistoryOperationParameter("trackId", track.Id.ToString()), + new HistoryOperationParameter("trackName", track.Name), + new HistoryOperationParameter("targetChapterIds", string.Join(",", track.Chapters.Select(static chapter => chapter.Id))), + new HistoryOperationParameter("displayFps", ExactFramesPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture), "decimal") + ]), expressionPreviewComputationRate); + Notify(); + return ExpressionPreview; + } + + /// Commits the latest valid expression candidate as one history transaction. + public async ValueTask ApplyExpressionPreviewAsync(CancellationToken cancellationToken = default) + { + var preview = ExpressionPreview; + var content = session.ContentSession; + if (preview is null + || !preview.IsValid + || content is null + || IsBusy + || !ExpressionPreviewProjector.Build(preview, expressionPreviewTrackId).HasChanges) + { + return false; + } + + var currentTrackIndex = session.CurrentTrackIndex; + var currentTrackId = currentTrackIndex >= 0 && currentTrackIndex < content.Snapshot.Document.Tracks.Length + ? content.Snapshot.Document.Tracks[currentTrackIndex].Id + : (ChapterTrackId?)null; + if (content.Snapshot.BaseToken != preview.BaseToken || currentTrackId != expressionPreviewTrackId || IsExpressionPreviewStale) + { + expressionPreviewStale = true; + Notify(); + return false; + } + + var projection = ExpressionPreviewProjector.Build(preview, expressionPreviewTrackId); + var outcome = await ChapterContentOperationSession.ApplyAsync(content, preview, cancellationToken); + if (outcome.Kind is TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange) + { + session.PublishContentDocument(outcome.Snapshot.Document); + ExpressionPreview = null; + expressionPreviewTrackId = null; + expressionPreviewStale = false; + RefreshDisplay(updateStatus: false, statusKey: null); + StatusText = localizer.Format("Expression.Preview.Applied", + projection.AffectedChapterCount, + projection.TimeChangedChapterCount, + projection.FrameInformationChangedChapterCount); + AddLog("Info", StatusText); + Notify(); + return true; + } + + if (outcome.Kind == TransactionOutcomeKind.Conflict) + { + expressionPreviewStale = true; + Notify(); + return false; + } + + StatusText = outcome.Errors.FirstOrDefault() ?? localizer.T("Status.ExpressionApplyFailed"); + Notify(); + return false; + } + + /// Discards the current expression candidate without changing the document. + public void CancelExpressionPreview() + { + ExpressionPreview = null; + expressionPreviewTrackId = null; + expressionPreviewStale = false; + Notify(); + } + + private static EditableChapterDocument FocusTrack(EditableChapterDocument source, int trackIndex) + { + var track = source.Tracks[trackIndex]; + var segment = track.Segments.FirstOrDefault(); + return new EditableChapterDocument(source.Id, segment?.Name ?? track.Name, + segment?.SourceName ?? source.SourceName, segment?.ImportFormat ?? source.ImportFormat, + EditableChapterDocumentAdapter.TrackDuration(source, trackIndex), [track]); + } + + private static EditableChapterDocument ReplaceFocusedTrack( + EditableChapterDocument source, + int trackIndex, + EditableChapterDocument focused) + { + var tracks = source.Tracks.SetItem(trackIndex, focused.Tracks[0]); + return new EditableChapterDocument(source.Id, source.Title, source.SourceName, source.ImportFormat, + EditableChapterDocumentAdapter.ReplacedTrackDuration(source, trackIndex, focused), tracks); + } + /// /// Clears the selected expression preset while keeping the current free-form expression text. /// @@ -863,7 +1375,7 @@ public bool SetChapterNameTemplate(string fileName, string text) ChapterNameTemplateStatus = Path.GetFileName(fileName); ChapterNameModeIndex = 2; AddLog("Info", localizer.Format("Status.TemplateLoaded", ChapterNameTemplateStatus)); - RefreshDisplay(updateStatus: true, statusKey: "Status.TemplateLoaded", statusArgs: [ChapterNameTemplateStatus]); + Notify(); return true; } @@ -889,13 +1401,9 @@ public PreviewResult Preview() try { - var framed = ApplyFrames(BaseChapterSet); - SetBaseChapterSet(framed.Info); - FramesPerSecond = BaseChapterSet.FramesPerSecond; - - var format = wasmChapterService.FormatAt(SaveFormatIndex); - var options = CreateExportOptions(); - var export = wasmChapterService.Export(BaseChapterSet, options); + var format = ChapterContentService.FormatAt(SaveFormatIndex); + var options = CreateSerializationOptions(); + var export = wasmChapterService.Serialize(BaseChapterSet, options); diagnostics = WasmWorkspaceProjection.ToDiagnostics(export.Diagnostics); if (!export.Success) { @@ -938,13 +1446,10 @@ public SaveResult Save() try { // Ensure frames/FPS on the base set are current before export projection. - var framed = ApplyFrames(BaseChapterSet); - SetBaseChapterSet(framed.Info); - FramesPerSecond = BaseChapterSet.FramesPerSecond; - - var format = wasmChapterService.FormatAt(SaveFormatIndex); - var options = CreateExportOptions(); - var export = wasmChapterService.Export(BaseChapterSet, options); + var format = ChapterContentService.FormatAt(SaveFormatIndex); + var options = CreateSerializationOptions(); + var exportSnapshot = session.CaptureExportSnapshot(BaseChapterSet, options, session.CurrentTrackIndex); + var export = wasmChapterService.Serialize(BaseChapterSet, options); diagnostics = WasmWorkspaceProjection.ToDiagnostics(export.Diagnostics); if (!export.Success) { @@ -953,6 +1458,11 @@ public SaveResult Save() return new SaveResult(false, StatusText); } + if (exportSnapshot is not null) + { + session.RecordSuccessfulExport(exportSnapshot); + } + var baseName = Path.GetFileNameWithoutExtension(SourcePath); if (string.IsNullOrWhiteSpace(baseName)) { @@ -983,33 +1493,69 @@ public void UpdateRow(int index, string? timeText, string? name) return; } - var chapters = BaseChapterSet.Chapters.ToList(); - var chapter = chapters[index]; - if (chapter.IsSeparator) + var track = session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex]; + var chapterId = track.Chapters[index].Id; + var editedField = GetEditedField(timeText, name); + var editedValue = editedField switch { - if (name is not null) - { - chapters[index] = chapter with { Name = name }; - SetBaseChapterSet(BaseChapterSet with { Chapters = chapters }); - RefreshDisplay(updateStatus: false, statusKey: null); - } + ChapterCellField.Name => name, + ChapterCellField.StartTime => timeText, + _ => null + }; + var outcome = session.ExecuteTrackCandidate("Update chapter content", + document => BuildRowEditCandidate(document, track.Chapters[index].Kind, chapterId, timeText, name), + operationDescriptor: CreateCellEditDescriptor(editedField, chapterId, track.Name, editedValue)); + HandleRowEditOutcome(outcome, index); + } - return; + private static ChapterCellField? GetEditedField(string? timeText, string? name) => + timeText is null && name is not null + ? ChapterCellField.Name + : name is null && timeText is not null ? ChapterCellField.StartTime : null; + + private ChapterCandidateBuildResult BuildRowEditCandidate( + EditableChapterDocument document, + ChapterKind chapterKind, + ChapterId chapterId, + string? timeText, + string? name) + { + var candidate = new ChapterCandidateBuildResult(true, document, [chapterId], []); + if (name is not null) + { + candidate = candidateBuilder.EditCell(candidate.Candidate, chapterId, ChapterCellField.Name, name, ComputationFrameRate); } - if (timeText is not null) + if (candidate.IsValid && timeText is not null && chapterKind != ChapterKind.Separator) { - var start = wasmChapterService.TimeFormatter.ParseOrZero(timeText); - chapter = chapter with { StartTime = start }; + candidate = candidateBuilder.EditCell(candidate.Candidate, chapterId, ChapterCellField.StartTime, timeText, ComputationFrameRate); } - if (name is not null) + return candidate; + } + + private HistoryOperationDescriptor CreateCellEditDescriptor( + ChapterCellField? editedField, + ChapterId chapterId, + string trackName, + string? editedValue) => new("chapter.cell-edit", + [ + new HistoryOperationParameter("field", editedField?.ToString()), + new HistoryOperationParameter("chapterId", chapterId.ToString()), + new HistoryOperationParameter("trackName", trackName), + new HistoryOperationParameter("value", editedValue), + new HistoryOperationParameter("displayFps", ExactFramesPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture), "decimal") + ]); + + private void HandleRowEditOutcome(TransactionOutcome outcome, int index) + { + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) { - chapter = chapter with { Name = name }; + StatusText = outcome.Errors.FirstOrDefault() ?? "The chapter edit was rejected."; + Notify(); + return; } - chapters[index] = chapter; - SetBaseChapterSet(BaseChapterSet with { Chapters = chapters }); AddLog("Info", $"Edited row {index + 1}."); RefreshDisplay(updateStatus: false, statusKey: null); } @@ -1031,25 +1577,29 @@ private bool ApplySuccessfulLoad( string fileName, byte[] content, ChapterImportResult result, - int operationRevision) + int operationRevision, + ClipSession newSession, + EditableChapterDocument document, + SessionBaseToken? expectedToken) { - var newSession = result.Groups.Count > 0 - ? ClipSessionTransitions.FromLoad(result.Groups[0]) - : null; - if (newSession is null || !session.TryCommitLoad(operationRevision, fileName, newSession)) + var sourceGroup = result.Groups.Count > 0 ? result.Groups[0] : null; + if (sourceGroup is null + || !session.TryCommitLoad(operationRevision, new LocalPathChapterSource(fileName), newSession, document, expectedToken)) { return false; } lastLoadedSource = new LoadedSourceSnapshot(fileName, content); - importResult = result; + var groups = result.Groups.ToList(); + groups[0] = newSession.OriginalGroup; + importResult = result with { Groups = groups }; activeGroupIndex = 0; SourcePath = fileName; selectedFrameRateIndex = Math.Max(0, PreferredFrameRateIndex); ClearSelection(); diagnostics = []; SyncUiFromClipSession(rebuildAllGroupOptions: true); - RebuildFrameRateChoices(BaseChapterSet ?? new ChapterSet(string.Empty, null, ChapterImportFormat.Unknown, 0, TimeSpan.Zero, [])); + RebuildFrameRateChoices(BaseChapterSet ?? new ChapterSet(string.Empty, null, ChapterImportFormat.Unknown, FrameRateValue.Unknown, TimeSpan.Zero, [])); RefreshDisplay( updateStatus: true, statusKey: "Status.Loaded", @@ -1063,7 +1613,6 @@ private void SyncUiFromClipSession(bool rebuildAllGroupOptions = false) { ClipOptions = []; SelectedClipId = null; - BaseChapterSet = null; return; } @@ -1087,19 +1636,21 @@ private void SyncUiFromClipSession(bool rebuildAllGroupOptions = false) ?? ClipOptions.FirstOrDefault()?.Id; } - BaseChapterSet = ClipSessionState.CurrentChapterSet; if (BaseChapterSet is not null) { - FramesPerSecond = BaseChapterSet.FramesPerSecond; + FramesPerSecond = BaseChapterSet.FrameRate.ToDouble(); } } private void RefreshDisplay(bool updateStatus, string? statusKey, params object[] statusArgs) { + session.ExportPreferences.SetComputationRate(SelectedComputationRate); if (BaseChapterSet is null) { rows = []; FramesPerSecond = 0; + ExactFramesPerSecond = 0; + appliedFrameRateOption = null; if (updateStatus && statusKey is not null) { SetLocalizedStatus(statusKey, statusArgs); @@ -1109,49 +1660,36 @@ private void RefreshDisplay(bool updateStatus, string? statusKey, params object[ return; } + var selectedIds = selectedRowIndexes.Where(index => index >= 0 && index < rows.Count) + .Select(index => rows[index].Id).ToHashSet(); var framed = ApplyFrames(BaseChapterSet); - SetBaseChapterSet(framed.Info); - FramesPerSecond = BaseChapterSet.FramesPerSecond; + appliedFrameRateOption = framed.SelectedOption; + ExactFramesPerSecond = framed.Rate.ToDecimal(); + FramesPerSecond = framed.Rate.ToDouble(); RebuildFrameRateChoices(BaseChapterSet); - - var projection = projectionService.Project(BaseChapterSet, CreateExportOptions()); + var displayContent = framed.Info; rows = [ - .. projection.Info.Chapters - .Select(chapter => WasmWorkspaceProjection.ToRow(chapter, wasmChapterService.TimeFormatter)) + .. displayContent.Chapters + .Select((chapter, index) => WasmWorkspaceProjection.ToRow(chapter, wasmChapterService.TimeFormatter, + session.ContentSession!.Snapshot.Document.Tracks[session.CurrentTrackIndex].Chapters[index].Id)) ]; - - // Drop selection indexes that no longer exist after edits. - selectedRowIndexes.RemoveWhere(index => index < 0 || index >= rows.Count); - if (SelectedRowIndex >= rows.Count) - { - SelectedRowIndex = selectedRowIndexes.Count > 0 ? selectedRowIndexes.Max() : -1; - } - - var projectionDiagnostics = WasmWorkspaceProjection.ToDiagnostics(projection.Diagnostics); - diagnostics = projectionDiagnostics; - if (projectionDiagnostics.Count > 0) + selectedRowIndexes.Clear(); + for (var index = 0; index < rows.Count; index++) { - AddLog( - "Warning", - $"{projectionDiagnostics.Count} diagnostic(s) reported.", - string.Join(Environment.NewLine, projectionDiagnostics.Select(d => $"{d.Code}: {d.Message}"))); + if (selectedIds.Contains(rows[index].Id)) + { + selectedRowIndexes.Add(index); + } } + SelectedRowIndex = selectedRowIndexes.Count > 0 ? selectedRowIndexes.Max() : -1; + selectionAnchor = SelectedRowIndex; - // Expression failures always surface Core diagnostics to status/log even when a success key was requested. - if (ApplyExpression && projectionDiagnostics.Count > 0) - { - var first = projectionDiagnostics[0]; - SetRawStatus($"{first.Severity}: {first.Message}"); - } - else if (updateStatus && statusKey is not null) + diagnostics = []; + if (updateStatus && statusKey is not null) { SetLocalizedStatus(statusKey, statusArgs); } - else if (ApplyExpression) - { - SetLocalizedStatus("Status.ExpressionApplied", FramesPerSecondDisplay, rows.Count); - } else if (updateStatus) { SetLocalizedStatus("Status.FramesUpdated", FramesPerSecondDisplay, rows.Count); @@ -1164,21 +1702,22 @@ private FrameInfoResult ApplyFrames(ChapterSet info) { var option = ResolveSelectedFrameRateOption(); - // Auto (LegacyMplsCode == 0): detect when rounding, otherwise still need a valid option for fps. - return frameRateService.UpdateFrames(info, option, RoundFrames ? 0 : EditingOptions.EffectiveFrameDecimalPlaces, FrameAccuracyTolerance); + return frameRateService.UpdateFrames(info, option, EditingOptions.EffectiveFrameDecimalPlaces, FrameAccuracyTolerance); } private FrameRateOption ResolveSelectedFrameRateOption() { var options = frameRateService.Options; - if (selectedFrameRateIndex <= 0 || selectedFrameRateIndex >= options.Count) + if (selectedFrameRateIndex <= 0) { return options[0]; // Auto } - return options[selectedFrameRateIndex]; + return FrameRateChoices.FirstOrDefault(choice => choice.Index == selectedFrameRateIndex)?.Option ?? options[0]; } + private FrameRateOption EffectiveFrameRateOption => appliedFrameRateOption ?? ResolveSelectedFrameRateOption(); + private void RebuildFrameRateChoices(ChapterSet info) { var options = frameRateService.Options; @@ -1188,11 +1727,11 @@ private void RebuildFrameRateChoices(ChapterSet info) var option = options[i]; if (i == 0) { - choices.Add(new FrameRateChoice(i, "Auto", option)); + choices.Add(new FrameRateChoice(i, localizer.T("Main.AutoFrameRate"), option)); continue; } - if (option is { IsValid: false, LegacyMplsCode: 5 }) + if (option is { IsValid: false, MplsFrameRateCode: 5 }) { // Skip reserved placeholder (matches Avalonia combo useful entries). continue; @@ -1201,6 +1740,12 @@ private void RebuildFrameRateChoices(ChapterSet info) choices.Add(new FrameRateChoice(i, option.DisplayName, option)); } + if (info.FrameRate.IsKnown && options.All(option => option.Rate != info.FrameRate)) + { + var custom = frameRateService.FindByRate(info.FrameRate); + choices.Add(new FrameRateChoice(options.Count, custom.DisplayName, custom)); + } + FrameRateChoices = choices; // Keep selected index on a still-valid option. @@ -1213,37 +1758,24 @@ private void RebuildFrameRateChoices(ChapterSet info) _ = info; } - private ChapterExportOptions CreateExportOptions() => + private ChapterSerializationOptions CreateSerializationOptions() => new( - Format: wasmChapterService.FormatAt(SaveFormatIndex), + Format: ChapterContentService.FormatAt(SaveFormatIndex), XmlLanguage: XmlLanguage, - SourceFileName: SourcePath, - AutoGenerateNames: ChapterNameModeIndex == 1, - UseTemplateNames: ChapterNameModeIndex == 2, - ChapterNameTemplateText: ChapterNameModeIndex == 2 ? ChapterNameTemplateText : string.Empty, - OrderShift: OrderShift, - ApplyExpression: ApplyExpression, - Expression: string.IsNullOrWhiteSpace(Expression) ? "t" : Expression.Trim(), - ExpressionPresetId: ExpressionPresetId, - ExpressionSourceName: !string.IsNullOrWhiteSpace(ExpressionPresetId) - ? ExpressionPresets.FirstOrDefault(preset => - string.Equals(preset.Id, ExpressionPresetId, StringComparison.OrdinalIgnoreCase))?.DisplayName - ?? ExpressionPresetId - : string.Empty, + SourceFileName: BaseChapterSet?.SourceName, TextEncoding: TextEncoding, - EmitBom: EmitBom, - ProjectOutput: true); + EmitBom: EmitBom) { ComputationRate = SelectedComputationRate }; private void ClearSession(bool keepPath = false, bool keepReload = false) { importResult = null; - BaseChapterSet = null; ClipSessionState = null; activeGroupIndex = 0; rows = []; ClipOptions = []; SelectedClipId = null; FramesPerSecond = 0; + ExactFramesPerSecond = 0; FrameRateChoices = []; ClearSelection(); diagnostics = []; @@ -1301,10 +1833,15 @@ private void EndBusy() private void Notify() => Changed?.Invoke(); - private void ApplyEditResult(ChapterEditResult result, string statusKey, params object[] statusArgs) + private void CompleteCandidate(TransactionOutcome outcome, string statusKey, params object[] statusArgs) { - SetBaseChapterSet(result.ChapterSet); - RecordDiagnostics(result.Diagnostics); + if (outcome.Kind is not (TransactionOutcomeKind.Committed or TransactionOutcomeKind.NoChange)) + { + StatusText = outcome.Errors.FirstOrDefault() ?? localizer.T("Status.CannotShift"); + Notify(); + return; + } + var status = localizer.Format(statusKey, statusArgs); AddLog("Info", status); RefreshDisplay(updateStatus: true, statusKey: statusKey, statusArgs: statusArgs); @@ -1326,6 +1863,13 @@ private void SetRawStatus(string message) private void OnCultureChanged() { + FrameRateChoices = + [ + .. FrameRateChoices.Select(choice => choice.Index == 0 + ? choice with { DisplayName = localizer.T("Main.AutoFrameRate") } + : choice) + ]; + if (ClipSessionState is not null) { ClipOptions = importResult is { Groups.Count: > 1 } && !ClipSessionState.IsCombined @@ -1359,17 +1903,6 @@ private void AddLog(string level, string message, string? details = null) } } - private void SetBaseChapterSet(ChapterSet value) - { - if (ClipSessionState is not null) - { - session.WriteBackCurrentChapterSet(value); - } - else - { - session.SetCurrentChapterSet(value); - } - } } public sealed record FrameRateChoice(int Index, string DisplayName, FrameRateOption Option); diff --git a/src/ChapterTool.Wasm/Services/WasmWorkspaceProjection.cs b/src/ChapterTool.Wasm/Services/WasmWorkspaceProjection.cs index 91180522..8bae756d 100644 --- a/src/ChapterTool.Wasm/Services/WasmWorkspaceProjection.cs +++ b/src/ChapterTool.Wasm/Services/WasmWorkspaceProjection.cs @@ -49,13 +49,14 @@ internal static IReadOnlyList BuildClipOptionsFromSession( return [ .. session.ClipOptions - .Select((entry, index) => ToClipOption(entry, $"{groupIndex}:{entry.Id}", groupIndex, index, localizer)) + .Select((entry, index) => ToClipOption(entry, $"{groupIndex}:{index}:{entry.Id}", groupIndex, index, localizer)) ]; } - internal static ChapterRowModel ToRow(Chapter chapter, IChapterTimeFormatter formatter) => + internal static ChapterRowModel ToRow(Chapter chapter, IChapterTimeFormatter formatter, ChapterId id) => new() { + Id = id, Number = chapter.DisplayNumber, TimeText = chapter.IsSeparator ? string.Empty : formatter.Format(chapter.StartTime), Name = chapter.Name, diff --git a/src/ChapterTool.Wasm/_Imports.razor b/src/ChapterTool.Wasm/_Imports.razor index 6c859244..1d6720df 100644 --- a/src/ChapterTool.Wasm/_Imports.razor +++ b/src/ChapterTool.Wasm/_Imports.razor @@ -9,4 +9,5 @@ @using ChapterTool.Wasm @using ChapterTool.Wasm.Layout @using ChapterTool.Wasm.Services +@using ChapterTool.Wasm.Components @using ChapterTool.Core.Exporting diff --git a/src/ChapterTool.Wasm/wwwroot/css/app.css b/src/ChapterTool.Wasm/wwwroot/css/app.css index a738817a..af630312 100644 --- a/src/ChapterTool.Wasm/wwwroot/css/app.css +++ b/src/ChapterTool.Wasm/wwwroot/css/app.css @@ -35,10 +35,13 @@ html, body { height: 100%; } +#app { height: 100dvh; } + .shell { display: flex; flex-direction: column; min-height: 100vh; + min-height: 100dvh; background: var(--window-bg); outline: none; position: relative; @@ -62,10 +65,11 @@ html, body { .main { flex: 1; display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(10rem, 1fr) auto; gap: 0.7rem; padding: 0.9rem; - min-height: 0; + min-height: min-content; + min-width: 0; } /* —— Top load/save zone —— */ @@ -85,6 +89,79 @@ html, body { .load-group { display: inline-flex; + position: relative; +} + +.load-menu-toggle { min-width: 2rem; padding-inline: 0.4rem; } +.load-menu { + position: absolute; + z-index: 20; + top: calc(100% + 0.25rem); + left: 0; + display: grid; + min-width: 12rem; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + background: var(--control-bg); + box-shadow: 0 0.5rem 1.25rem #0003; +} +.load-menu button { min-height: 2.5rem; border: 0; background: transparent; color: var(--control-fg); text-align: left; } +.load-menu button:hover:not(:disabled), .load-menu button:focus-visible { background: var(--hover); } + + + + +.history-list { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: 0.35rem; +} + +.history-entry { + display: block; + flex: 1; + width: 100%; + height: auto; + min-width: 0; + min-height: 2.2rem; + overflow: hidden; + padding: 0.3rem 0.4rem; + border: 0; + background: transparent; + color: var(--control-fg); + text-align: left; + text-overflow: clip; + white-space: normal; +} + +.history-entry.current { + background: var(--hover); + font-weight: 600; +} + +.modal-dialog.history-modal { width: min(78rem, calc(100vw - 2 * max(1rem, env(safe-area-inset-left), env(safe-area-inset-right)))); } +.history-body { display: flex; flex-direction: column; height: min(24rem, 50dvh); gap: 0.75rem; } +.history-toolbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } +.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.4rem; } +.history-content { display: grid; grid-template-columns: minmax(15rem, 2fr) minmax(18rem, 3fr); gap: 0.75rem; min-height: 0; flex: 1; } +.history-row { display: flex; align-items: stretch; min-height: 2.2rem; } +.history-row.selected { background: var(--hover); } +.history-toggle { flex: 0 0 2rem; width: 2rem; border: 0; background: transparent; color: var(--control-fg); padding: 0; } +.history-toggle:disabled { opacity: 0.55; } +.history-current { color: var(--accent); } +.history-hidden-current { color: var(--muted); } +.history-details { overflow: auto; min-width: 0; padding: 0.8rem; border: 1px solid var(--border); border-radius: 0.35rem; } +.history-details h3 { margin-top: 0; } +.history-summary { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; padding: 0.5rem 0; } +.history-change { display: grid; grid-template-columns: minmax(6rem, 1fr) auto minmax(4rem, 1fr) auto minmax(4rem, 1fr); gap: 0.35rem; padding: 0.4rem 0; border-top: 1px solid var(--border); overflow-wrap: anywhere; } +.history-change strong { grid-column: 1 / -1; } +@media (max-width: 760px) { + .history-toolbar { flex-direction: column; } + .history-actions { justify-content: flex-start; } + .history-content { grid-template-columns: 1fr; grid-template-rows: minmax(10rem, 1fr) minmax(10rem, 1.2fr); } } .top-clip { @@ -115,6 +192,12 @@ html, body { flex-wrap: wrap; } +.fps-controls { + display: flex; + align-items: center; + gap: 0.35rem; +} + .fps-box { width: 9.5rem; text-align: left; @@ -215,11 +298,11 @@ button.secondary { /* —— Chapter grid zone —— */ .grid-zone { - min-height: 13rem; + min-height: 0; display: flex; flex-direction: column; gap: 0.35rem; - min-height: 0; + min-width: 0; } .path-line { @@ -232,7 +315,7 @@ button.secondary { .grid-wrap { flex: 1; - min-height: 13rem; + min-height: 0; border: 1px solid var(--border); background: #fff; overflow: auto; @@ -241,6 +324,7 @@ button.secondary { .chapter-grid { width: 100%; + min-width: 52rem; border-collapse: collapse; table-layout: fixed; font-size: 0.9rem; @@ -277,8 +361,8 @@ button.secondary { } .col-num { width: 3.5rem; } -.col-time { width: 8.5rem; } -.col-frames { width: 7.5rem; } +.col-time { width: 21rem; min-width: 21rem; white-space: nowrap; } +.col-frames { width: 16rem; } .col-name { width: auto; } .cell-edit { @@ -296,6 +380,61 @@ button.secondary { background: #f8fbff; } +.frame-editor { + position: relative; + min-height: 1.8rem; +} + +.frame-editor .cell-edit { + position: absolute; + inset: 0; + z-index: 1; + opacity: 1; +} + +.frame-editor .frame-edit-idle { + opacity: 0; +} + +.frame-editor:focus-within .frame-display { + visibility: hidden; +} + +.frame-display { + display: flex; + justify-content: center; + gap: 0; + width: 100%; + min-height: 1.8rem; + align-items: center; + font-family: var(--monospace-font, ui-monospace, monospace); + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +.frame-integer, .frame-decimal-point, .frame-fraction, .frame-repetend { flex: none; } +.frame-repetend { text-decoration: overline; text-decoration-thickness: from-font; } + +.expression-cell-preview.frame-expression-preview { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + align-items: center; + gap: 0.15rem; + font-family: var(--monospace-font, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} + +.frame-expression-preview .frame-display { + min-width: 0; +} + +.frame-expression-preview > .frame-display { grid-column: 2; } +.frame-expression-preview > .frame-display:last-child { grid-row: 2; } +.frame-expression-preview > span:not(.frame-display) { grid-column: 1; grid-row: 2; } + +.frame-expression-preview strong { font-weight: 600; } + .frame-text.accurate { color: var(--accent-frame-ok); } .frame-text.inexact { color: var(--accent-frame-bad); } .frame-text.neutral { color: var(--control-fg); } @@ -335,93 +474,189 @@ tr.separator { /* —— Bottom options zone —— */ .options-zone { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr); - grid-template-rows: auto auto; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.8fr) minmax(0, 1fr) minmax(10rem, 0.65fr) auto; column-gap: 14.4px; row-gap: 8px; align-items: center; } -.option-cell { +.inline-expression { + grid-column: 1 / -1; display: grid; - grid-template-columns: auto minmax(0, 1fr); - column-gap: 6.4px; + grid-template-columns: auto minmax(8rem, 1fr) auto auto auto; align-items: center; + gap: 0.4rem; min-width: 0; - min-height: 27.2px; + padding-top: 0.2rem; } -.option-format { - grid-column: 1; - grid-row: 1; +.inline-expression textarea { + display: block; + width: 100%; + min-width: 0; + min-height: 2.25rem; + max-height: 8rem; + resize: vertical; + font-family: var(--mono); } -.option-name-mode { - grid-column: 2; - grid-row: 1; +.inline-expression-editor { + position: relative; + min-width: 0; +} + +.inline-expression-editor.has-diagnostics textarea { + border-color: var(--danger, #c0392b); +} + +.inline-expression-diagnostics { + display: none; + position: absolute; + bottom: calc(100% + 4px); + left: 0; + z-index: 20; + width: max-content; + max-width: min(360px, 100%); + max-height: 12rem; + overflow: auto; + padding: 10px 8px; + color: var(--danger, #c0392b); + background: var(--control-bg); + border: 1px solid var(--danger, #c0392b); + border-radius: 4px; + box-shadow: 0 6px 18px #0003; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.inline-expression-editor:hover .inline-expression-diagnostics, +.inline-expression-editor:focus-within .inline-expression-diagnostics { + display: block; +} + +.expression-state-announcement { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.inline-expression-actions { + display: flex; + align-items: stretch; + flex-wrap: wrap; + gap: 0.4rem; + min-width: 0; +} + +.inline-expression-actions.inactive { + visibility: hidden; +} + +.inline-expression-actions > button { + min-height: 43.2px; + white-space: nowrap; +} + +.inline-expression-summary { + color: var(--muted); + font-size: 0.85rem; } -.option-order-shift { - grid-column: 3; +.expression-cell-preview { + display: inline-flex; + justify-content: center; + align-items: baseline; + gap: 0.25rem; + max-width: 100%; + overflow-wrap: anywhere; +} + +.time-expression-preview { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.15rem; + font-family: var(--monospace-font, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} + +.time-expression-preview > span:first-child { + grid-column: 2; grid-row: 1; + white-space: nowrap; } -.option-xml-lang { +.time-expression-preview > span[aria-hidden="true"] { grid-column: 1; grid-row: 2; } -.option-expression { - grid-column: 2 / -1; +.time-expression-preview > strong { + grid-column: 2; grid-row: 2; - grid-template-columns: auto minmax(0, 1fr); + white-space: nowrap; +} + +.time-expression-preview > .expression-delta { + grid-column: 2; + grid-row: 3; } -.name-mode-controls { +.expression-cell-preview strong { + color: var(--accent); +} + +.option-cell { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 6.4px; - min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 6.4px; align-items: center; + min-width: 0; + min-height: 27.2px; } -button.template-button.icon-button { - width: 35.2px; - min-width: 35.2px; - height: 25.6px; - min-height: 25.6px; - padding: 3.2px 6.4px; - display: inline-grid; - place-content: center; +.option-format { + grid-column: 1; + grid-row: 1; } -button.template-button.icon-button svg { - width: 12px; - height: 12px; - fill: none; - stroke: currentColor; - stroke-width: 1.6; - stroke-linecap: round; - stroke-linejoin: round; +.option-name-mode { + grid-column: 2; + grid-row: 1; } -.expression-label-group { - display: inline-flex; - align-items: center; - gap: 6.4px; - min-width: 0; -} +.option-xml-language { grid-column: 3; grid-row: 1; } +.option-order-shift { grid-column: 4; grid-row: 1; } +.option-tools { grid-column: 5; grid-row: 1; display: flex; flex-wrap: wrap; gap: 0.5rem; } +.name-mode-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6.4px; min-width: 0; align-items: center; } +.template-button, .expression-script-button { width: 2rem; height: 2rem; padding: 0.35rem; } +.template-button svg, .expression-script-button svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.option-order-shift input { width: 100%; min-width: 6rem; } -.expression-enable { - margin: 0; +@media (min-width: 761px) and (max-width: 1100px) { + .options-zone { + grid-template-columns: minmax(12rem, 0.9fr) minmax(16rem, 1.4fr) minmax(14rem, 1fr); + } + + .option-format { grid-column: 1; grid-row: 1; } + .option-name-mode { grid-column: 2; grid-row: 1; } + .option-xml-language { grid-column: 3; grid-row: 1; } + .option-order-shift { grid-column: 1; grid-row: 2; } + .option-tools { grid-column: 2 / -1; grid-row: 2; justify-self: start; } } + + + .expression-controls { display: grid; grid-template-columns: minmax(9rem, 12rem) minmax(0, 1fr); gap: 6.4px; min-width: 0; align-items: center; + margin-top: 0.5rem; } .expression-preset { @@ -436,10 +671,6 @@ button.template-button.icon-button svg { font-size: 11.2px; } -.order-shift-input { - font-family: var(--mono); - min-width: 73.6px; -} .preview-modal, .related-modal { @@ -512,7 +743,15 @@ input[type="number"], } .expression-input { - font-family: var(--mono); + box-sizing: border-box; + width: 100%; + min-height: 3.5rem; + max-height: 12rem; + padding: 0.5rem; + resize: vertical; + font: inherit; + font-family: var(--monospace-font, ui-monospace, monospace); + white-space: pre; } select:disabled, @@ -526,7 +765,8 @@ input:disabled { grid-template-columns: minmax(0, 1fr) auto auto; gap: 0.75rem; align-items: center; - height: 27.2px; + flex-shrink: 0; + min-height: 27.2px; padding: 0 8px; background: var(--status-bg); border-top: 1px solid var(--border); @@ -558,7 +798,7 @@ input:disabled { .log-entry { display: grid; - grid-template-columns: 4.5rem 4.2rem minmax(0, 1fr); + grid-template-columns: auto auto minmax(0, 1fr) auto; gap: 0.45rem; padding: 0.12rem 0; white-space: nowrap; @@ -604,21 +844,17 @@ input:disabled { .context-menu button:hover:not(:disabled) { background: var(--hover); } .context-menu button.danger { color: var(--accent-frame-bad); } -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 30; - display: grid; - place-items: center; - padding: 1rem; - background: rgba(0, 0, 0, 0.32); -} .modal-dialog { + padding: 0; + color: var(--control-fg); + max-width: calc(100vw - 2rem); + max-width: calc(100vw - 2 * max(1rem, env(safe-area-inset-left), env(safe-area-inset-right))); display: flex; flex-direction: column; - width: min(46rem, 100%); - max-height: min(38rem, 100%); + width: min(46rem, calc(100vw - 2rem)); + max-height: min(38rem, calc(100vh - 2rem)); + max-height: min(38rem, calc(100dvh - 2 * max(1rem, env(safe-area-inset-top), env(safe-area-inset-bottom)))); border: 1px solid var(--border); background: var(--window-bg); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); @@ -629,8 +865,100 @@ input:disabled { } .settings-modal { - width: min(38rem, 100%); - height: min(40rem, 100%); + width: min(38rem, calc(100vw - 2rem)); + height: min(40rem, calc(100vh - 2rem)); + height: min(40rem, calc(100dvh - 2rem)); +} + +.expression-preview { + min-width: 0; + margin-top: 0; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; + overflow-wrap: anywhere; +} + +.expression-preview h3, +.expression-preview p { + margin: 0 0 0.35rem; + font-size: inherit; +} + +.expression-preview ul { + margin: 0; + padding-left: 1.2rem; +} + +.expression-preview li { + overflow-wrap: anywhere; +} + +.expression-preview-errors { + color: var(--danger, #c0392b); +} + +.expression-body { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.6rem; } +.expression-authoring { display: grid; gap: 0.4rem; flex-shrink: 0; } +.expression-authoring p { margin: 0; } +.expression-preset-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; } +.expression-preset-row select { min-height: 2.25rem; } +.expression-other-summary { color: var(--muted); font-size: 0.85rem; line-height: 1.45; } +.expression-preview .expression-time-summary { font-size: 1rem; font-weight: 600; margin-block: 0.15rem; } +.expression-scope { font-size: 0.85rem; color: var(--muted); } +.expression-separators, .expression-status { color: var(--muted); } +.expression-properties { margin-top: 0.4rem; padding: 0.4rem 0.55rem; background: var(--panel-bg); border: 1px solid var(--grid-line); border-radius: 4px; } +.expression-properties summary, .expression-frame-details summary { cursor: pointer; min-height: 28px; display: list-item; } +.expression-preview-options label { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 32px; } +.expression-preview-options input { accent-color: var(--accent); } +.expression-value, .expression-delta { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.expression-chapter .expression-value:nth-of-type(4) { font-weight: 600; } +.expression-frame-details { color: var(--muted); font-size: 0.8rem; grid-column: 1 / -1; } +.expression-properties[open] { color: var(--control-fg); } +.expression-properties li { margin-block: 0.25rem; } +.expression-preview-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.65rem; + margin-block: 0.15rem; +} +.expression-comparison { border: 1px solid var(--grid-line); border-radius: 4px; } +.expression-comparison-header, +.expression-chapter { + display: grid; + grid-template-columns: minmax(0, 1.3fr) repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + align-items: start; + padding: 0.4rem 0.5rem; +} +.expression-comparison-header { + position: sticky; + top: 0; + background: var(--grid-header); + font-weight: 600; +} +.expression-chapter + .expression-chapter { border-top: 1px solid var(--border); } +.expression-chapter-name, .expression-value, .expression-delta { overflow-wrap: anywhere; } +.expression-value-label { display: none; } +.expression-frame-details dl { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 0.25rem 0.75rem; } +.expression-frame-details dt { font-weight: 600; } +.expression-frame-details dd { margin: 0; overflow-wrap: anywhere; } +.expression-footer { border-top: 1px solid var(--border); border-bottom: 0; justify-content: flex-end; } +.expression-status { flex: 1; min-width: 0; font-size: 0.8rem; } +.expression-footer button { min-width: 7rem; } +.expression-footer button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + font-weight: 600; +} +.expression-footer button.primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 88%, #000); +} +.expression-footer button.primary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .modal-header, @@ -645,11 +973,16 @@ input:disabled { .modal-header h2 { flex: 1; + min-width: 0; margin: 0; font-size: 1rem; + overflow-wrap: anywhere; } .modal-close { + min-width: 44px; + min-height: 44px; + flex-shrink: 0; border: 0; background: transparent; color: var(--control-fg); @@ -664,9 +997,12 @@ input:disabled { .modal-body { flex: 1; + min-width: 0; min-height: 0; padding: 0.75rem; overflow: auto; + overscroll-behavior: contain; + overflow-wrap: anywhere; background: #fff; } @@ -825,6 +1161,7 @@ input:disabled { border-top: 1px solid var(--border); border-bottom: 0; background: var(--window-bg); + flex-wrap: wrap; } .modal-spacer { flex: 1; } @@ -886,9 +1223,49 @@ input:disabled { } @media (max-width: 760px) { + .main { + gap: 0.55rem; + padding: 0.6rem; + } + .top-bar { grid-template-columns: 1fr; align-items: stretch; + gap: 0.55rem; + } + + .top-actions { + width: 100%; + gap: 0.45rem; + } + + .top-actions > button { + flex: 1 1 auto; + min-width: max-content; + } + + .top-actions .load-group { + flex: 0 0 auto; + min-width: max-content; + } + + .top-actions .load-group > button { + flex: 0 0 auto; + width: auto; + } + + .top-actions .load-group > button:first-child { + white-space: nowrap; + } + + .top-actions .load-group > .load-menu-toggle { + min-width: 2.75rem; + min-height: 2.75rem; + } + + .clip-box { + min-width: 0; + max-width: none; } .status-bar { @@ -909,45 +1286,61 @@ input:disabled { .top-tools { align-items: stretch; + min-width: 0; + width: 100%; } .top-tool-actions { justify-content: flex-start; + gap: 0.45rem; } - /* Avalonia narrow AdvancedOptions: 2 cols, Expression full width on row 3 */ - .options-zone { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - grid-template-rows: auto auto auto; + .fps-box { + flex: 1; + min-width: 0; } - .option-format { - grid-column: 1; - grid-row: 1; + .grid-wrap { + min-height: 0; + -webkit-overflow-scrolling: touch; } - .option-name-mode { - grid-column: 2; - grid-row: 1; + /* Stack export controls so translated labels cannot force horizontal overflow. */ + .options-zone { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + gap: 0.5rem; } - .option-xml-lang { + .option-format, + .option-name-mode, + .option-xml-language, + .option-order-shift, + .option-tools { grid-column: 1; - grid-row: 2; + grid-row: auto; + } + + .inline-expression { + grid-template-columns: minmax(0, 1fr) auto; } - .option-order-shift { - grid-column: 2; - grid-row: 2; + .inline-expression > label, + .inline-expression-editor { + grid-column: 1 / -1; } - .option-expression { + .inline-expression-actions { grid-column: 1 / -1; - grid-row: 3; + } + + .option-cell { + grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr); } .expression-controls { grid-template-columns: minmax(0, 1fr); + gap: 0.35rem; } } @@ -959,25 +1352,99 @@ input:disabled { .option-format, .option-name-mode, - .option-xml-lang, + .option-xml-language, .option-order-shift, - .option-expression { + .option-tools { grid-column: 1; grid-row: auto; } .option-cell { - grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr); - } - - .option-expression { - grid-template-columns: auto minmax(0, 1fr); + grid-template-columns: minmax(5rem, auto) minmax(0, 1fr); } .option-label { white-space: normal; } - .option-cell.option-expression .expression-input { - grid-column: 1 / -1; - } +} + +/* Native top layer keeps tool content outside the editing grid. */ +.cell-error { display: block; color: var(--accent-frame-bad); font-size: 0.8rem; overflow-wrap: anywhere; } +.candidate-review article { border-bottom: 1px solid var(--border); padding: 0.5rem 0; overflow-wrap: anywhere; } +.candidate-review dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; } +.candidate-review dd { margin: 0; } +.shortcut-row { display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(5rem, 1fr) auto auto; gap: 0.4rem; margin-bottom: 0.5rem; align-items: center; } +.settings-discard { background: var(--panel-bg); border: 1px solid var(--accent); padding: 0.75rem; } +.log-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding-bottom: 0.5rem; } +.log-surfaces { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; min-height: 0; } +.log-surfaces.inspecting { grid-template-columns: minmax(0, 1fr) minmax(15rem, 40%); } +.log-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.log-entry.selected { background: var(--active); } +.log-inspector { min-width: 0; overflow: auto; border-left: 1px solid var(--border); padding-left: 0.5rem; } +.log-inspector pre { white-space: pre-wrap; overflow-wrap: anywhere; } +.log-export { position: relative; } +.log-export summary { cursor: pointer; padding: 0.5rem; } +.lua-editor { position: relative; min-width: 0; } +.lua-editor .expression-input, .lua-highlight { font: inherit; font-family: var(--mono); line-height: 1.5; padding: 0.5rem; border: 1px solid var(--border); margin: 0; white-space: pre-wrap; overflow-wrap: break-word; tab-size: 4; } +.lua-highlight { position: absolute; inset: 0; pointer-events: none; overflow: hidden; background: var(--control-bg); color: var(--control-fg); border-radius: 4px; } +.lua-editor .expression-input { position: relative; background: transparent; color: transparent; caret-color: var(--control-fg); width: 100%; resize: vertical; } +.lua-token-keyword, .lua-token-function { color: var(--accent); } +.lua-token-string, .lua-token-number { color: var(--accent-frame-ok); } +.lua-token-comment { color: var(--muted); } +.lua-completions { display: flex; flex-direction: column; max-height: 12rem; overflow: auto; border: 1px solid var(--border); } +.lua-completions button { text-align: left; white-space: pre-wrap; } +.lua-completions button[aria-selected="true"] { background: var(--active); } +.lua-diagnostics { margin: 0; padding-left: 1rem; overflow-wrap: anywhere; } +@media (max-width: 700px) { + .shortcut-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .log-surfaces.inspecting { grid-template-columns: minmax(0, 1fr); } + .log-surfaces.inspecting > .log-list { display: none; } + .log-entry { display: flex; flex-wrap: wrap; } + .log-summary { flex: 1 1 100%; } +} +.modal-dialog:not([open]) { display: none; } +.modal-dialog::backdrop { background: rgba(0, 0, 0, 0.32); } +.modal-header, .modal-footer, .settings-tabs { flex-shrink: 0; } +.modal-body input, .modal-body select { min-width: 0; font-size: 1rem; min-height: 36px; } +.modal-body input[type="checkbox"] { min-height: auto; } +.modal-body .settings-check { gap: 0.5rem; } +.modal-footer button { min-height: 44px; } +.history-note { margin: 0 0 0.7rem; color: var(--muted); } +.expression-modal { + width: min(52rem, calc(100vw - 2rem)); + max-height: min(56rem, calc(100dvh - 2 * max(1rem, env(safe-area-inset-top), env(safe-area-inset-bottom)))); +} +.template-setting { min-width: 0; display: grid; gap: 0.5rem; } +button { text-align: center; } +@media (max-width: 760px) { + .option-tools { grid-column: 1; } + .option-tools button { flex: 1; min-height: 44px; } + .modal-body input, .modal-body select, .modal-body button { min-height: 44px; } + .modal-body input[type="checkbox"] { min-height: auto; } + .settings-inline { flex-wrap: wrap; } + .expression-comparison-header { display: none; } + .expression-chapter { grid-template-columns: auto minmax(0, 1fr); gap: 0.35rem 0.75rem; } + .expression-chapter-name { grid-column: 1 / -1; margin-bottom: 0.25rem; } + .expression-value-label { display: block; grid-column: 1; font-weight: 400; color: var(--muted); } + .expression-value, .expression-delta { grid-column: 2; text-align: right; } + .expression-frame-details dl { grid-template-columns: 1fr; gap: 0.1rem; } + .expression-frame-details dd { margin: 0 0 0.35rem; } + .expression-preview-options label { flex-basis: auto; min-height: 44px; } + .expression-preview-options { margin-block: 0.25rem; } + .expression-preview { padding-top: 0.4rem; } + .expression-properties summary, .expression-frame-details summary { min-height: 44px; display: flex; align-items: center; gap: 0.5rem; } + .expression-properties summary::before, .expression-frame-details summary::before { content: "▸"; } + .expression-properties[open] > summary::before, .expression-frame-details[open] > summary::before { content: "▾"; } + .expression-footer { justify-content: stretch; flex-wrap: wrap; } + .expression-status { flex-basis: 100%; } + .expression-body { gap: 0.4rem; } + .expression-footer button { flex: 1; } +} +@media (max-height: 500px) { + .main { grid-template-rows: auto minmax(8rem, 1fr) auto; } + .modal-dialog { max-height: calc(100dvh - 2 * max(0.5rem, env(safe-area-inset-top), env(safe-area-inset-bottom))); } +} + +@media (max-height: 500px) { + .expression-status { display: none; } } diff --git a/src/ChapterTool.Wasm/wwwroot/js/download.js b/src/ChapterTool.Wasm/wwwroot/js/download.js index 822a7dfb..450ce703 100644 --- a/src/ChapterTool.Wasm/wwwroot/js/download.js +++ b/src/ChapterTool.Wasm/wwwroot/js/download.js @@ -1,4 +1,52 @@ +const chapterToolDialogs = new Map(); +let chapterToolDialogOpener; window.chapterToolWasm = { + openDialog: function (element, reference, lightDismiss) { + // WebKit does not focus buttons on a pointer click. Retain the activation target. + const opener = chapterToolDialogOpener?.isConnected ? chapterToolDialogOpener : document.activeElement; + chapterToolDialogOpener = null; + const cancel = (event) => { + event.preventDefault(); + reference.invokeMethodAsync('CancelAsync'); + }; + const dismiss = (event) => { + if (!lightDismiss || event.target !== element) return; + const bounds = element.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || + event.clientY < bounds.top || event.clientY > bounds.bottom) cancel(event); + }; + const containFocus = (event) => { + if (event.key !== 'Tab') return; + const targets = Array.from(element.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')) + .filter(target => !target.disabled && target.tabIndex >= 0 && target.getClientRects().length && + getComputedStyle(target).visibility !== 'hidden' && getComputedStyle(target).opacity !== '0'); + const first = targets[0]; + const last = targets[targets.length - 1]; + if (!first) { event.preventDefault(); return; } + if ((event.shiftKey && document.activeElement === first) || + (!event.shiftKey && document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } + }; + element.addEventListener('cancel', cancel); + element.addEventListener('click', dismiss); + element.addEventListener('keydown', containFocus); + chapterToolDialogs.set(element.id, { element, opener, cancel, dismiss, containFocus }); + element.showModal(); + }, + disposeDialog: function (id) { + const state = chapterToolDialogs.get(id); + if (!state) return; + const element = state.element; + element.removeEventListener('cancel', state.cancel); + element.removeEventListener('click', state.dismiss); + element.removeEventListener('keydown', state.containFocus); + element.close(); + chapterToolDialogs.delete(id); + const target = state.opener?.isConnected ? state.opener : document.getElementById('chaptertool-shell'); + target?.focus({ preventScroll: true }); + }, downloadText: function (fileName, content, encodingId, emitBom) { const bytes = encodeText(content || '', encodingId || 'utf8', emitBom === true); const blob = new Blob([bytes], { type: 'text/plain' }); @@ -15,6 +63,13 @@ window.chapterToolWasm = { el.click(); } }, + getGridScrollTop: function () { + return document.querySelector('.grid-wrap')?.scrollTop || 0; + }, + setGridScrollTop: function (scrollTop) { + const grid = document.querySelector('.grid-wrap'); + if (grid) grid.scrollTop = scrollTop; + }, copyText: async function (text) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text || ''); @@ -24,7 +79,7 @@ window.chapterToolWasm = { area.value = text || ''; area.style.position = 'fixed'; area.style.opacity = '0'; - document.body.appendChild(area); + (document.querySelector('dialog[open] .modal-body') || document.body).appendChild(area); area.focus(); area.select(); document.execCommand('copy'); @@ -38,10 +93,7 @@ window.chapterToolWasm = { } }, setLocalStorage: function (key, value) { - try { - window.localStorage.setItem(key, value); - } catch (error) { - } + window.localStorage.setItem(key, value); }, removeLocalStorage: function (key) { try { @@ -88,12 +140,38 @@ window.chapterToolWasm = { return; } window.__chapterToolShortcutGuard = true; + document.addEventListener('click', (event) => { + if (!document.querySelector('dialog[open]')) { + chapterToolDialogOpener = event.target.closest('button, [role="button"]') || document.activeElement; + } + }, true); document.addEventListener('keydown', (event) => { + if (!document.querySelector('dialog[open]')) chapterToolDialogOpener = document.activeElement; if (shouldPreventBrowserShortcut(event)) { event.preventDefault(); } }, true); + for (const type of ['dragover', 'drop']) { + document.addEventListener(type, (event) => { + if (document.querySelector('dialog[open]')) { + event.preventDefault(); + event.stopPropagation(); + } + }, true); + } + }, + focus: function (id) { document.getElementById(id)?.focus(); }, + focusLoadMenuItem: function (direction) { + const items = Array.from(document.querySelectorAll('#load-menu [role="menuitem"]')) + .filter(item => !item.disabled && item.getClientRects().length); + if (items.length === 0) return; + const current = items.indexOf(document.activeElement); + const next = current < 0 + ? (direction > 0 ? 0 : items.length - 1) + : (current + direction + items.length) % items.length; + items[next].focus(); }, + setShortcutBindings: function (gestures) { window.__chapterToolBindings = new Set(gestures); }, shouldPreventBrowserShortcut: function (event) { return shouldPreventBrowserShortcut(event); }, @@ -112,10 +190,12 @@ window.chapterToolWasm = { el.addEventListener('dragenter', (event) => { event.preventDefault(); + if (document.querySelector('dialog[open]')) return; setOver(true); }); el.addEventListener('dragover', (event) => { event.preventDefault(); + if (document.querySelector('dialog[open]')) return; if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } @@ -128,6 +208,7 @@ window.chapterToolWasm = { }); el.addEventListener('drop', async (event) => { event.preventDefault(); + if (document.querySelector('dialog[open]')) return; setOver(false); const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]; if (!file) { @@ -169,10 +250,11 @@ function shouldPreventBrowserShortcut(event) { if (isEditableShortcutTarget(event.target)) { return false; } - if (ctrl && (key.toLowerCase() === 's' || key.toLowerCase() === 'o' || key.toLowerCase() === 'l')) { - return true; - } - return key === 'F11' || key === 'F9'; + const aliases = { ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', ' ': 'Space' }; + const normalizedKey = aliases[key] || (key.length === 1 ? key.toUpperCase() : key); + const gesture = (event.ctrlKey ? 'Ctrl+' : '') + (event.altKey ? 'Alt+' : '') + + (event.metaKey ? 'Meta+' : '') + (event.shiftKey ? 'Shift+' : '') + normalizedKey; + return window.__chapterToolBindings?.has(gesture) === true; } function encodeText(text, encodingId, emitBom) { diff --git a/src/ChapterTool.Wasm/wwwroot/js/expression-composition.js b/src/ChapterTool.Wasm/wwwroot/js/expression-composition.js new file mode 100644 index 00000000..36518357 --- /dev/null +++ b/src/ChapterTool.Wasm/wwwroot/js/expression-composition.js @@ -0,0 +1,49 @@ +const listeners = new WeakMap(); + +export function attach(element, callback) { + const onStart = () => callback.invokeMethodAsync('CompositionStarted'); + const onEnd = () => callback.invokeMethodAsync('CompositionEnded'); + const onKey = (event) => { + if (event.isComposing) return; + if ((event.ctrlKey || event.metaKey) && event.code === 'Space') { + event.preventDefault(); event.stopPropagation(); + callback.invokeMethodAsync('EditorKey', 'Complete', element.selectionStart); + } else if (element.dataset.completionOpen === 'true' && ['Enter', 'Tab', 'Escape', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); event.stopPropagation(); + callback.invokeMethodAsync('EditorKey', event.key, element.selectionStart); + } + }; + const onScroll = () => { + const highlight = element.parentElement.querySelector('.lua-highlight'); + if (highlight) { highlight.scrollTop = element.scrollTop; highlight.scrollLeft = element.scrollLeft; } + }; + element.addEventListener('compositionstart', onStart); + element.addEventListener('compositionend', onEnd); + element.addEventListener('keydown', onKey); + element.addEventListener('scroll', onScroll); + listeners.set(element, [onStart, onEnd, onKey, onScroll]); + element.dataset.compositionReady = 'true'; +} + +export function detach(element) { + const registered = listeners.get(element); + if (!registered) return; + element.removeEventListener('compositionstart', registered[0]); + element.removeEventListener('compositionend', registered[1]); + element.removeEventListener('keydown', registered[2]); + element.removeEventListener('scroll', registered[3]); + listeners.delete(element); + delete element.dataset.compositionReady; +} + +export function caret(element) { return element.selectionStart; } +export function completionState(element, open) { element.dataset.completionOpen = String(open); } +export function select(element, start, length) { element.focus(); element.setSelectionRange(start, start + length); } +export function replace(element, start, length, text) { + select(element, start, length); + // insertText preserves the browser's native text undo stack. + if (!document.execCommand('insertText', false, text)) { + element.setRangeText(text, start, start + length, 'end'); + element.dispatchEvent(new Event('input', { bubbles: true })); + } +} diff --git a/tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj b/tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj index dfea6f06..81182a0c 100644 --- a/tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj +++ b/tests/ChapterTool.Avalonia.Headless.Tests/ChapterTool.Avalonia.Headless.Tests.csproj @@ -10,15 +10,16 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs b/tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs index 54a79bfe..b3343233 100644 --- a/tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Headless.Tests/Composition/AppCompositionRootIdentityHeadlessTests.cs @@ -30,9 +30,9 @@ public void Desktop_composition_resolves_the_validated_host_graph_and_catalog() Assert.NotNull(hostComposition.Workspace.LoadService); Assert.NotNull(hostComposition.AuxiliaryTools.Host); Assert.NotNull(hostComposition.AuxiliaryTools.Presenter); - Assert.Equal(8, catalog.Descriptors.Count); + Assert.Equal(9, catalog.Descriptors.Count); Assert.All( - [ToolIds.Preview, ToolIds.Log, ToolIds.Settings, ToolIds.Language, ToolIds.Expression, ToolIds.TemplateNames, ToolIds.Zones, ToolIds.ForwardShift], + [ToolIds.Preview, ToolIds.Log, ToolIds.Settings, ToolIds.Language, ToolIds.Expression, ToolIds.TemplateNames, ToolIds.Zones, ToolIds.ForwardShift, ToolIds.History], id => Assert.True(catalog.TryGet(id, out _))); } @@ -162,7 +162,7 @@ private sealed class SentinelAuthoringService : IExpressionAuthoringService public IReadOnlyList Symbols { get; } = [new("sentinel", ExpressionTokenKind.Function, "Sentinel", 0, "sentinel()")]; - public ExpressionAnalysisResult Analyze(string expression, int caretIndex, decimal timeSeconds = 0, decimal framesPerSecond = 24) + public ExpressionAnalysisResult Analyze(string expression, int caretIndex, long startTicks = 0, Core.Models.FrameRateValue frameRate = default) { AnalyzeCalls++; Expressions.Add(expression); diff --git a/tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs b/tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs index e970c269..5d609ddb 100644 --- a/tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs +++ b/tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs @@ -1,6 +1,8 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Headless.XUnit; +using Avalonia.Input; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; @@ -13,6 +15,7 @@ using ChapterTool.Contracts.PlatformPorts; using ChapterTool.Infrastructure.Platform; using Microsoft.Extensions.Logging; +using Optris.Icons.Avalonia; namespace ChapterTool.Avalonia.Headless.Tests.Headless; @@ -20,7 +23,7 @@ namespace ChapterTool.Avalonia.Headless.Tests.Headless; public sealed class AuxiliaryToolHeadlessTests { [AvaloniaFact] - public async Task Log_window_filters_selects_copies_clears_and_receives_new_entries() + public async Task Log_window_is_list_first_and_inspects_entries_on_explicit_request() { var localizer = new AppLocalizationManager("en-US"); using var localizationAdapter = new AvaloniaLocalizationResourceAdapter(localizer); @@ -40,17 +43,43 @@ public async Task Log_window_filters_selects_copies_clears_and_receives_new_entr CaptureIfRequested(window, "log-default.png"); + window.Width = 1200; + await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window); + CaptureIfRequested(window, "log-wide.png"); + window.Width = 760; + await MainWindowHeadlessTestHost.ExecuteLayoutAsync(window); + var list = window.GetVisualDescendants().OfType().Single(); Assert.Equal(2, list.Items.Count); + Assert.Null(viewModel.SelectedEntry); + Assert.False(viewModel.IsDetailsOpen); + Assert.False(viewModel.ShowDetails); + + var filterButton = window.GetVisualDescendants().OfType
@L.T("Grid.Number")
@(row.IsSeparator ? "—" : row.Number.ToString()) + @if (showOptionsCandidate && optionsComparison is { Before: not null, Candidate: not null } numberChange + && numberChange.Before.DisplayNumber != numberChange.Candidate.DisplayNumber) + { @numberChange.Before.DisplayNumber@numberChange.Candidate.DisplayNumber } + else { @(row.IsSeparator ? "—" : row.Number.ToString()) } + @if (row.IsSeparator) { } + else if (showOptionsCandidate && optionsComparison is { Before: not null, Candidate: not null } timeChange + && (timeChange.Changes & ExpressionChapterChangeKind.Time) != 0) + { + @Workspace.FormatExpressionTimeTicks(timeChange.Before.StartTicks)@Workspace.FormatExpressionTimeTicks(timeChange.Candidate.StartTicks)(@FormatExpressionDelta(timeChange.DeltaTicks)) + } else { - + } - + @if (showOptionsCandidate && optionsComparison is { Before: not null, Candidate: not null } nameChange + && nameChange.Before.Name != nameChange.Candidate.Name) + { @nameChange.Before.Name@nameChange.Candidate.Name } + else { } - - @(string.IsNullOrEmpty(row.FramesInfo) ? "—" : row.FramesInfo) - + @if (!row.IsSeparator && Workspace.FramesPerSecond > 0) + { + @if (showOptionsCandidate && optionsComparison is { CandidateFrames: not null } framesChange + && (framesChange.Changes & ExpressionChapterChangeKind.FrameInformation) != 0 + && !string.Equals(framesChange.BeforeFrames?.Text, framesChange.CandidateFrames.Text, StringComparison.Ordinal)) + { + var beforeParts = Workspace.FormatPreviewFramePresentation(framesChange, candidate: false, _showRepeatingFrameDecimals); + var candidateParts = Workspace.FormatPreviewFramePresentation(framesChange, candidate: true, _showRepeatingFrameDecimals); + var candidateFrameAccuracy = Workspace.GetPreviewFrameAccuracy(framesChange.Candidate!.StartTicks); + + + + + + } + else + { + + } + } + else { — }