diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md index db4d8ce..0573792 100644 --- a/.codex/skills/openspec-apply-change/SKILL.md +++ b/.codex/skills/openspec-apply-change/SKILL.md @@ -6,11 +6,13 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.4.1" + generatedBy: "1.5.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. + **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** @@ -50,8 +52,6 @@ Implement tasks from an OpenSpec change. - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation - **Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files. - 4. **Read context files** Read every file path listed under `contextFiles` from the apply instructions output. diff --git a/.codex/skills/openspec-archive-change/SKILL.md b/.codex/skills/openspec-archive-change/SKILL.md index 97c3e5e..bb6a8cf 100644 --- a/.codex/skills/openspec-archive-change/SKILL.md +++ b/.codex/skills/openspec-archive-change/SKILL.md @@ -6,11 +6,13 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.4.1" + generatedBy: "1.5.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. + **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** @@ -33,8 +35,6 @@ Archive a completed change in the experimental workflow. - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) - If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos. - **If any artifacts are not `done`:** - Display warning listing incomplete artifacts - Use **AskUserQuestion tool** to confirm user wants to proceed diff --git a/.codex/skills/openspec-bulk-archive-change/SKILL.md b/.codex/skills/openspec-bulk-archive-change/SKILL.md new file mode 100644 index 0000000..1184181 --- /dev/null +++ b/.codex/skills/openspec-bulk-archive-change/SKILL.md @@ -0,0 +1,248 @@ +--- +name: openspec-bulk-archive-change +description: Archive multiple completed changes at once. Use when archiving several parallel changes. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**Input**: None required (prompts for selection) + +**Steps** + +1. **Get active changes** + + Run `openspec list --json` to get all active changes. + + If no active changes exist, inform user and stop. + +2. **Prompt for change selection** + + Use **AskUserQuestion tool** with multi-select to let user choose changes: + - 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. + +3. **Batch validation - gather status for all selected changes** + + For each selected change, collect: + + a. **Artifact status** - Run `openspec status --change "" --json` + - Parse `schemaName`, `artifacts`, `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext` + - Note which artifacts are `done` vs other states + + b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON + - Count `- [ ]` (incomplete) vs `- [x]` (complete) + - If no tasks file exists, note as "No tasks" + + c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON + - List which capability specs exist + - For each, extract requirement names (lines matching `### Requirement: `) + +4. **Detect spec conflicts** + + Build a map of `capability -> [changes that touch it]`: + + ``` + auth -> [change-a, change-b] <- CONFLICT (2+ changes) + api -> [change-c] <- OK (only 1 change) + ``` + + A conflict exists when 2+ selected changes have delta specs for the same capability. + +5. **Resolve conflicts agentically** + + **For each conflict**, investigate the codebase: + + a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify + + b. **Search the codebase** for implementation evidence: + - Look for code implementing requirements from each delta spec + - Check for related files, functions, or tests + + c. **Determine resolution**: + - If only one change is actually implemented -> sync that one's specs + - If both implemented -> apply in chronological order (older first, newer overwrites) + - 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) + - Rationale (what was found in codebase) + +6. **Show consolidated status table** + + Display a table summarizing all changes: + + ``` + | 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-verify-skill | 1 left | 2/5 | None | None | Warn | + ``` + + For conflicts, show the resolution: + ``` + * Conflict resolution: + - auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order) + ``` + + For incomplete changes, show warnings: + ``` + Warnings: + - add-verify-skill: 1 incomplete artifact, 3 incomplete tasks + ``` + +7. **Confirm batch operation** + + Use **AskUserQuestion tool** with a single confirmation: + + - "Archive N changes?" with options based on status + - Options might include: + - "Archive all N changes" + - "Archive only N ready changes (skip incomplete)" + - "Cancel" + + If there are incomplete changes, make clear they'll be archived with warnings. + +8. **Execute archive for each confirmed change** + + 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 + + b. **Perform the archive**: + ```bash + mkdir -p "/archive" + mv "" "/archive/YYYY-MM-DD-" + ``` + + c. **Track outcome** for each change: + - Success: archived successfully + - Failed: error during archive (record error) + - Skipped: user chose not to archive (if applicable) + +9. **Display summary** + + Show final results: + + ``` + ## Bulk Archive Complete + + Archived 3 changes: + - schema-management-cli -> archive/2026-01-19-schema-management-cli/ + - project-config -> archive/2026-01-19-project-config/ + - add-oauth -> archive/2026-01-19-add-oauth/ + + Skipped 1 change: + - add-verify-skill (user chose not to archive incomplete) + + Spec sync summary: + - 4 delta specs synced to main specs + - 1 conflict resolved (auth: applied both in chronological order) + ``` + + If any failures: + ``` + Failed 1 change: + - some-change: Archive directory already exists + ``` + +**Conflict Resolution Examples** + +Example 1: Only one implemented +``` +Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt] + +Checking add-oauth: +- Delta adds "OAuth Provider Integration" requirement +- Searching codebase... found src/auth/oauth.ts implementing OAuth flow + +Checking add-jwt: +- Delta adds "JWT Token Handling" requirement +- Searching codebase... no JWT implementation found + +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] + +Checking add-rest-api (created 2026-01-10): +- Delta adds "REST Endpoints" requirement +- Searching codebase... found src/api/rest.ts + +Checking add-graphql (created 2026-01-15): +- Delta adds "GraphQL Schema" requirement +- Searching codebase... found src/api/graphql.ts + +Resolution: Both implemented. Will apply add-rest-api specs first, +then add-graphql specs (chronological order, newer takes precedence). +``` + +**Output On Success** + +``` +## Bulk Archive Complete + +Archived N changes: +- -> archive/YYYY-MM-DD-/ +- -> archive/YYYY-MM-DD-/ + +Spec sync summary: +- N delta specs synced to main specs +- No conflicts (or: M conflicts resolved) +``` + +**Output On Partial Success** + +``` +## Bulk Archive Complete (partial) + +Archived N changes: +- -> archive/YYYY-MM-DD-/ + +Skipped M changes: +- (user chose not to archive incomplete) + +Failed K changes: +- : Archive directory already exists +``` + +**Output When No Changes** + +``` +## No Changes to Archive + +No active changes found. Create a new change to get started. +``` + +**Guardrails** +- Allow any number of changes (1+ is fine, 2+ is the typical use case) +- Always prompt for selection, never auto-select +- Detect spec conflicts early and resolve by checking codebase +- When both changes are implemented, apply specs in chronological order +- Skip spec sync only when implementation is missing (warn user) +- Show clear per-change status before confirming +- Use single confirmation for entire batch +- Track and report all outcomes (success/skip/fail) +- Preserve .openspec.yaml when moving to archive +- Archive directory target uses current date: YYYY-MM-DD- +- If archive target exists, fail that change but continue with others diff --git a/.codex/skills/openspec-continue-change/SKILL.md b/.codex/skills/openspec-continue-change/SKILL.md new file mode 100644 index 0000000..76a33b6 --- /dev/null +++ b/.codex/skills/openspec-continue-change/SKILL.md @@ -0,0 +1,121 @@ +--- +name: openspec-continue-change +description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**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** + + 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. + + 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") + - How recently it was modified (from `lastModified` field) + + 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. + +2. **Check current status** + ```bash + openspec status --change "" --json + ``` + 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 + - `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`)**: + - Congratulate the user + - Show final status including the schema used + - Suggest: "All artifacts created! You can now implement this change or archive it." + - STOP + + --- + + **If artifacts are ready to create** (status shows artifacts with `status: "ready"`): + - Pick the FIRST artifact with `status: "ready"` from the status output + - Get its instructions: + ```bash + openspec instructions --change "" --json + ``` + - Parse the JSON. The key fields are: + - `context`: Project background (constraints for you - do NOT include in output) + - `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 + - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `dependencies`: Completed artifacts to read for context + - **Create the artifact file**: + - Read any completed dependency files for context + - 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 + - STOP after creating ONE artifact + + --- + + **If no artifacts are ready (all blocked)**: + - This shouldn't happen with a valid schema + - Show status and suggest checking for issues + +4. **After creating an artifact, show progress** + ```bash + openspec status --change "" + ``` + +**Output** + +After each invocation, show: +- Which artifact was created +- Schema workflow being used +- Current progress (N/M complete) +- What artifacts are now unlocked +- Prompt: "Want to continue? Just ask me to continue or tell me what to do next." + +**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. + +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. + +**Guardrails** +- Create ONE artifact per invocation +- Always read dependency artifacts before creating a new one +- 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 +- Use the schema's artifact sequence, don't assume specific artifact names +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy ``, ``, `` blocks into the artifact + - These guide what you write, but should never appear in the output diff --git a/.codex/skills/openspec-explore/SKILL.md b/.codex/skills/openspec-explore/SKILL.md index 1e97aaa..756b31c 100644 --- a/.codex/skills/openspec-explore/SKILL.md +++ b/.codex/skills/openspec-explore/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.4.1" + generatedBy: "1.5.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -15,6 +15,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **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. + --- ## The Stance diff --git a/.codex/skills/openspec-ff-change/SKILL.md b/.codex/skills/openspec-ff-change/SKILL.md new file mode 100644 index 0000000..a345b30 --- /dev/null +++ b/.codex/skills/openspec-ff-change/SKILL.md @@ -0,0 +1,104 @@ +--- +name: openspec-ff-change +description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**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** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "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** + ```bash + openspec new change "" + ``` + This creates a scaffolded change in the planning home resolved by the CLI. + +3. **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 + - `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** + + Use the **TodoWrite tool** to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is `ready` (dependencies satisfied)**: + - Get instructions: + ```bash + openspec instructions --change "" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in output) + - `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 + - `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` + - 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** + - 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 + + c. **If an artifact requires user input** (unclear context): + - Use **AskUserQuestion tool** to clarify + - Then continue with creation + +5. **Show final status** + ```bash + openspec status --change "" + ``` + +**Output** + +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." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type +- 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 +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy ``, ``, `` blocks into the artifact + - 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 +- 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 new file mode 100644 index 0000000..091c4e2 --- /dev/null +++ b/.codex/skills/openspec-new-change/SKILL.md @@ -0,0 +1,76 @@ +--- +name: openspec-new-change +description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**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** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "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. **Determine the workflow schema** + + Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow. + + **Use a different schema only if the user mentions:** + - A specific schema name → use `--schema ` + - "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose + + **Otherwise**: Omit `--schema` to use the default. + +3. **Create the change directory** + ```bash + openspec new change "" + ``` + Add `--schema ` only if the user requested a specific workflow. + This creates a scaffolded change in the planning home resolved by the CLI. + +4. **Show the artifact status** + ```bash + openspec status --change "" --json + ``` + Use the returned `planningHome`, `changeRoot`, `artifactPaths`, and `nextSteps` instead of assuming repo-local paths. + +5. **Get instructions for the first artifact** + The first artifact depends on the schema (e.g., `proposal` for spec-driven). + Check the status output to find the first artifact with status "ready". + ```bash + openspec instructions --change "" + ``` + This outputs the template and context for creating the first artifact. + +6. **STOP and wait for user direction** + +**Output** + +After completing the steps, summarize: +- Change name and location +- Schema/workflow being used and its artifact sequence +- Current status (0/N artifacts complete) +- The template for the first artifact +- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue." + +**Guardrails** +- Do NOT create any artifacts yet - just show the instructions +- Do NOT advance beyond showing the first artifact template +- If the name is invalid (not kebab-case), ask for a valid name +- If a change with that name already exists, suggest continuing that change instead +- Pass --schema if using a non-default workflow diff --git a/.codex/skills/openspec-onboard/SKILL.md b/.codex/skills/openspec-onboard/SKILL.md new file mode 100644 index 0000000..bc791d5 --- /dev/null +++ b/.codex/skills/openspec-onboard/SKILL.md @@ -0,0 +1,554 @@ +--- +name: openspec-onboard +description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +--- + +## Preflight + +Before starting, check if the OpenSpec CLI is installed: + +```bash +# Unix/macOS +openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" +# Windows (PowerShell) +# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" } +``` + +**If CLI not installed:** +> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`. + +Stop here if not installed. + +--- + +## Phase 1: Welcome + +Display: + +``` +## Welcome to OpenSpec! + +I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it. + +**What we'll do:** +1. Pick a small, real task in your codebase +2. Explore the problem briefly +3. Create a change (the container for our work) +4. Build the artifacts: proposal → specs → design → tasks +5. Implement the tasks +6. Archive the completed change + +**Time:** ~15-20 minutes + +Let's start by finding something to work on. +``` + +--- + +## Phase 2: Task Selection + +### Codebase Analysis + +Scan the codebase for small improvement opportunities. Look for: + +1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files +2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch +3. **Functions without tests** - Cross-reference `src/` with test directories +4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`) +5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code +6. **Missing validation** - User input handlers without validation + +Also check recent git activity: +```bash +# Unix/macOS +git log --oneline -10 2>/dev/null || echo "No git history" +# Windows (PowerShell) +# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" } +``` + +### Present Suggestions + +From your analysis, present 3-4 specific suggestions: + +``` +## Task Suggestions + +Based on scanning your codebase, here are some good starter tasks: + +**1. [Most promising task]** + Location: `src/path/to/file.ts:42` + Scope: ~1-2 files, ~20-30 lines + Why it's good: [brief reason] + +**2. [Second task]** + Location: `src/another/file.ts` + Scope: ~1 file, ~15 lines + Why it's good: [brief reason] + +**3. [Third task]** + Location: [location] + Scope: [estimate] + Why it's good: [brief reason] + +**4. Something else?** + Tell me what you'd like to work on. + +Which task interests you? (Pick a number or describe your own) +``` + +**If nothing found:** Fall back to asking what the user wants to build: +> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix? + +### Scope Guardrail + +If the user picks or describes something too large (major feature, multi-day work): + +``` +That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through. + +For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details. + +**Options:** +1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]? +2. **Pick something else** - One of the other suggestions, or a different small task? +3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer. + +What would you prefer? +``` + +Let the user override if they insist—this is a soft guardrail. + +--- + +## Phase 3: Explore Demo + +Once a task is selected, briefly demonstrate explore mode: + +``` +Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction. +``` + +Spend 1-2 minutes investigating the relevant code: +- Read the file(s) involved +- Draw a quick ASCII diagram if it helps +- Note any considerations + +``` +## Quick Exploration + +[Your brief analysis—what you found, any considerations] + +┌─────────────────────────────────────────┐ +│ [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. + +Now let's create a change to hold our work. +``` + +**PAUSE** - Wait for user acknowledgment before proceeding. + +--- + +## Phase 4: Create the Change + +**EXPLAIN:** +``` +## Creating a Change + +A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives at the `changeRoot` reported by `openspec status --change "" --json` and holds your artifacts—proposal, specs, design, tasks. + +Let me create one for our task. +``` + +**DO:** Create the change with a derived kebab-case name: +```bash +openspec new change "" +``` + +**SHOW:** +``` +Created: + +The folder structure: +``` +/ +├── proposal.md ← Why we're doing this (empty, we'll fill it) +├── design.md ← How we'll build it (empty) +├── specs/ ← Detailed requirements (empty) +└── tasks.md ← Implementation checklist (empty) +``` + +Now let's fill in the first artifact—the proposal. +``` + +--- + +## Phase 5: Proposal + +**EXPLAIN:** +``` +## The Proposal + +The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work. + +I'll draft one based on our task. +``` + +**DO:** Draft the proposal content (don't save yet): + +``` +Here's a draft proposal: + +--- + +## Why + +[1-2 sentences explaining the problem/opportunity] + +## What Changes + +[Bullet points of what will be different] + +## Capabilities + +### New Capabilities +- ``: [brief description] + +### Modified Capabilities + + +## Impact + +- `src/path/to/file.ts`: [what changes] +- [other files if applicable] + +--- + +Does this capture the intent? I can adjust before we save it. +``` + +**PAUSE** - Wait for user approval/feedback. + +After approval, save the proposal: +```bash +openspec instructions proposal --change "" --json +``` +Then write the content to the `resolvedOutputPath` from `openspec instructions proposal --change "" --json`. + +``` +Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. + +Next up: specs. +``` + +--- + +## Phase 6: Specs + +**EXPLAIN:** +``` +## Specs + +Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear. + +For a small task like this, we might only need one spec file. +``` + +**DO:** Resolve where the spec file should be created: +```bash +openspec instructions specs --change "" --json +# Use resolvedOutputPath from the JSON. If it is a glob, choose the concrete file path using the schema instruction and the change's context. +``` + +Draft the spec content: + +``` +Here's the spec: + +--- + +## ADDED Requirements + +### Requirement: + + + +#### Scenario: + +- **WHEN** +- **THEN** +- **AND** + +--- + +This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases. +``` + +Save to the concrete file path chosen from `resolvedOutputPath`. + +--- + +## Phase 7: Design + +**EXPLAIN:** +``` +## Design + +The design captures **how** we'll build it—technical decisions, tradeoffs, approach. + +For small changes, this might be brief. That's fine—not every change needs deep design discussion. +``` + +**DO:** Draft design.md: + +``` +Here's the design: + +--- + +## Context + +[Brief context about the current state] + +## Goals / Non-Goals + +**Goals:** +- [What we're trying to achieve] + +**Non-Goals:** +- [What's explicitly out of scope] + +## Decisions + +### Decision 1: [Key decision] + +[Explanation of approach and rationale] + +--- + +For a small task, this captures the key decisions without over-engineering. +``` + +Save to the `resolvedOutputPath` from `openspec instructions design --change "" --json`. + +--- + +## Phase 8: Tasks + +**EXPLAIN:** +``` +## Tasks + +Finally, we break the work into implementation tasks—checkboxes that drive the apply phase. + +These should be small, clear, and in logical order. +``` + +**DO:** Generate tasks based on specs and design: + +``` +Here are the implementation tasks: + +--- + +## 1. [Category or file] + +- [ ] 1.1 [Specific task] +- [ ] 1.2 [Specific task] + +## 2. Verify + +- [ ] 2.1 [Verification step] + +--- + +Each checkbox becomes a unit of work in the apply phase. Ready to implement? +``` + +**PAUSE** - Wait for user to confirm they're ready to implement. + +Save to the `resolvedOutputPath` from `openspec instructions tasks --change "" --json`. + +--- + +## Phase 9: Apply (Implementation) + +**EXPLAIN:** +``` +## Implementation + +Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach. +``` + +**DO:** For each task: + +1. Announce: "Working on task N: [description]" +2. Implement the change in the codebase +3. Reference specs/design naturally: "The spec says X, so I'm doing Y" +4. Mark complete in tasks.md: `- [ ]` → `- [x]` +5. Brief status: "✓ Task N complete" + +Keep narration light—don't over-explain every line of code. + +After all tasks: + +``` +## Implementation Complete + +All tasks done: +- [x] Task 1 +- [x] Task 2 +- [x] ... + +The change is implemented! One more step—let's archive it. +``` + +--- + +## Phase 10: Archive + +**EXPLAIN:** +``` +## Archiving + +When a change is complete, we archive it. The archive path is derived from `planningHome.changesDir` and the date. + +Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. +``` + +**DO:** +```bash +openspec archive "" +``` + +**SHOW:** +``` +Archived to: `/archive/YYYY-MM-DD-/` + +The change is now part of your project's history. The code is in your codebase, the decision record is preserved. +``` + +--- + +## Phase 11: Recap & Next Steps + +``` +## Congratulations! + +You just completed a full OpenSpec cycle: + +1. **Explore** - Thought through the problem +2. **New** - Created a change container +3. **Proposal** - Captured WHY +4. **Specs** - Defined WHAT in detail +5. **Design** - Decided HOW +6. **Tasks** - Broke it into steps +7. **Apply** - Implemented the work +8. **Archive** - Preserved the record + +This same rhythm works for any size change—a small fix or a major feature. + +--- + +## Command Reference + +**Core workflow:** + + | 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 | + +**Additional commands:** + + | 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 | + +--- + +## What's Next? + +Try `/opsx:propose` on something you actually want to build. You've got the rhythm now! +``` + +--- + +## Graceful Exit Handling + +### User wants to stop mid-way + +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) + +The work won't be lost. Come back whenever you're ready. +``` + +Exit gracefully without pressure. + +### User just wants command reference + +If the user says they just want to see the commands or skip the tutorial: + +``` +## OpenSpec Quick Reference + +**Core workflow:** + + | 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 | + +**Additional commands:** + + | 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 | + +Try `/opsx:propose` to start your first change. +``` + +Exit gracefully. + +--- + +## Guardrails + +- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive) +- **Keep narration light** during implementation—teach without lecturing +- **Don't skip phases** even if the change is small—the goal is teaching the workflow +- **Pause for acknowledgment** at marked points, but don't over-pause +- **Handle exits gracefully**—never pressure the user to continue +- **Use real codebase tasks**—don't simulate or use fake examples +- **Adjust scope gently**—guide toward smaller tasks but respect user choice diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md index 9fc8513..0208e03 100644 --- a/.codex/skills/openspec-propose/SKILL.md +++ b/.codex/skills/openspec-propose/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.4.1" + generatedBy: "1.5.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -20,6 +20,8 @@ When ready to implement, run /opsx:apply --- +**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. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/.codex/skills/openspec-sync-specs/SKILL.md b/.codex/skills/openspec-sync-specs/SKILL.md new file mode 100644 index 0000000..f4ec054 --- /dev/null +++ b/.codex/skills/openspec-sync-specs/SKILL.md @@ -0,0 +1,147 @@ +--- +name: openspec-sync-specs +description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**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** + + Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + Show changes that have delta specs (under `specs/` directory). + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Resolve change context** + + Run: + ```bash + openspec status --change "" --json + ``` + +3. **Find delta specs** + + Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files. + + Each delta spec file contains sections like: + - `## ADDED Requirements` - New requirements to add + - `## MODIFIED Requirements` - Changes to existing requirements + - `## REMOVED Requirements` - Requirements to remove + - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +4. **For each delta spec, apply changes to main specs** + + For each repo-local capability delta spec path returned by the CLI: + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at `openspec/specs//spec.md` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios (don't need to copy existing ones) + - 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 + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + 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) + - Add Requirements section with the ADDED requirements + +5. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + +**Delta Spec Format Reference** + +```markdown +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: `### Requirement: Old Name` +- TO: `### Requirement: New Name` +``` + +**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 +- Use your judgment to merge changes sensibly + +**Output On Success** + +``` +## Specs Synced: + +Updated main specs: + +****: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +****: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +``` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- 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 diff --git a/.codex/skills/openspec-verify-change/SKILL.md b/.codex/skills/openspec-verify-change/SKILL.md new file mode 100644 index 0000000..8d11108 --- /dev/null +++ b/.codex/skills/openspec-verify-change/SKILL.md @@ -0,0 +1,171 @@ +--- +name: openspec-verify-change +description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.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. + +**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** + + Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + 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. + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context + - Which artifacts exist for this change + +3. **Get planning context and load artifacts** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`. + +4. **Initialize verification report structure** + + Create a report structure with three dimensions: + - **Completeness**: Track tasks and spec coverage + - **Correctness**: Track requirement implementation and scenario coverage + - **Coherence**: Track design adherence and pattern consistency + + Each dimension can have CRITICAL, WARNING, or SUGGESTION issues. + +5. **Verify Completeness** + + **Task Completion**: + - If `contextFiles.tasks` exists, read every file path in it + - Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete) + - Count complete vs total tasks + - If incomplete tasks exist: + - Add CRITICAL issue for each incomplete task + - Recommendation: "Complete task: " or "Mark as done if already implemented" + + **Spec Coverage**: + - If delta specs exist in `contextFiles.specs`: + - Extract all requirements (marked with "### Requirement:") + - For each requirement: + - Search codebase for keywords related to the requirement + - Assess if implementation likely exists + - If requirements appear unimplemented: + - Add CRITICAL issue: "Requirement not found: " + - Recommendation: "Implement requirement X: " + +6. **Verify Correctness** + + **Requirement Implementation Mapping**: + - For each requirement from delta specs: + - Search codebase for implementation evidence + - If found, note file paths and line ranges + - Assess if implementation matches requirement intent + - If divergence detected: + - Add WARNING: "Implementation may diverge from spec:
" + - Recommendation: "Review : against requirement X" + + **Scenario Coverage**: + - For each scenario in delta specs (marked with "#### Scenario:"): + - Check if conditions are handled in code + - Check if tests exist covering the scenario + - If scenario appears uncovered: + - Add WARNING: "Scenario not covered: " + - Recommendation: "Add test or implementation for scenario: " + +7. **Verify Coherence** + + **Design Adherence**: + - If `contextFiles.design` exists: + - Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:") + - Verify implementation follows those decisions + - If contradiction detected: + - Add WARNING: "Design decision not followed: " + - Recommendation: "Update implementation or revise design.md to match reality" + - If no design.md: Skip design adherence check, note "No design.md to verify against" + + **Code Pattern Consistency**: + - Review new code for consistency with project patterns + - Check file naming, directory structure, coding style + - If significant deviations found: + - Add SUGGESTION: "Code pattern deviation:
" + - Recommendation: "Consider following project pattern: " + +8. **Generate Verification Report** + + **Summary Scorecard**: + ``` + ## Verification Report: + + ### Summary + | Dimension | Status | + |--------------|------------------| + | Completeness | X/Y tasks, N reqs| + | Correctness | M/N reqs covered | + | Coherence | Followed/Issues | + ``` + + **Issues by Priority**: + + 1. **CRITICAL** (Must fix before archive): + - Incomplete tasks + - Missing requirement implementations + - Each with specific, actionable recommendation + + 2. **WARNING** (Should fix): + - Spec/design divergences + - Missing scenario coverage + - Each with specific recommendation + + 3. **SUGGESTION** (Nice to fix): + - Pattern inconsistencies + - Minor improvements + - Each with specific recommendation + + **Final Assessment**: + - If CRITICAL issues: "X critical issue(s) found. Fix before archiving." + - If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)." + - If all clear: "All checks passed. Ready for archive." + +**Verification Heuristics** + +- **Completeness**: Focus on objective checklist items (checkboxes, requirements list) +- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty +- **Coherence**: Look for glaring inconsistencies, don't nitpick style +- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL +- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable + +**Graceful Degradation** + +- If only tasks.md exists: verify task completion only, skip spec/design checks +- If tasks + specs exist: verify completeness and correctness, skip design +- If full artifacts: verify all three dimensions +- Always note which checks were skipped and why + +**Output Format** + +Use clear markdown with: +- Table for summary scorecard +- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION) +- Code references in format: `file.ts:123` +- Specific, actionable recommendations +- No vague suggestions like "consider reviewing" diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 1d71fb4..b137c6c 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -6,7 +6,7 @@ on: jobs: build-test: - runs-on: windows-2025 + runs-on: ubuntu-24.04 steps: - name: Checkout @@ -18,7 +18,9 @@ jobs: dotnet-version: 10.0.x - name: Install external tools - run: choco install ffmpeg mkvtoolnix --no-progress -y + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg mkvtoolnix - name: Restore run: dotnet restore ChapterTool.Avalonia.slnx @@ -27,18 +29,18 @@ jobs: run: dotnet build ChapterTool.Avalonia.slnx --configuration Release --no-restore - name: Test - run: dotnet test ChapterTool.Avalonia.slnx --configuration Release --no-build + run: dotnet test ChapterTool.Avalonia.slnx --configuration Release --no-build --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full publish: needs: build-test - runs-on: windows-2025 + name: Publish (${{ matrix.runtime }}) + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: runtime: - win-x64 - linux-x64 - - osx-x64 - osx-arm64 steps: @@ -54,15 +56,7 @@ jobs: run: dotnet restore src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj --runtime ${{ matrix.runtime }} - name: Publish - run: > - dotnet publish src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj - --configuration Release - --runtime ${{ matrix.runtime }} - --self-contained false - --no-restore - --output artifacts/publish/framework-dependent/${{ matrix.runtime }} - -p:PublishSingleFile=true - -p:IncludeNativeLibrariesForSelfExtract=true + run: ./scripts/publish.sh -Runtime ${{ matrix.runtime }} -NoRestore -PublishSingleFile - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 7d0c119..9c38039 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,4 @@ artifacts/ .idea/ log*.txt +.omo/ diff --git a/AGENTS.md b/AGENTS.md index 9a62448..5d031fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,8 @@ - Run the focused Avalonia tests after XAML or UI shell changes: - `dotnet test tests\ChapterTool.Avalonia.Tests\ChapterTool.Avalonia.Tests.csproj --no-restore` +- Keep Avalonia Headless tests isolated without serializing the entire Avalonia test assembly. Do not reintroduce assembly-level `CollectionBehavior(DisableTestParallelization = true)` for this project; instead put every class containing `[AvaloniaFact]` or `[AvaloniaTheory]` in `AvaloniaHeadlessTestCollection`. The guard test `HeadlessTestCollectionGuardTests` exists to catch missed classes. +- When a test constructs `SettingsToolViewModel` and then calls `LoadAsync` explicitly, pass `autoLoad: false`. Otherwise the constructor starts a background load and the test performs the same initialization twice, which slows Headless runs and can introduce races. - Do not test source/configuration files by reading them as text and asserting strings. This includes `.cs`, `.axaml`, `.csproj`, scripts, CI YAML, README, and docs. Prefer compiled coverage, behavior tests, runtime verification, structured public APIs, or integration checks. - Run the full solution tests before finalizing broader changes: - `dotnet test ChapterTool.Avalonia.slnx --no-restore` diff --git a/ChangeLog.md b/ChangeLog.md index 6c7f0fe..2eb9546 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -381,3 +381,10 @@ - 更新依赖版本 - 增加对playitem无marks的mpls文件的处理 - 增加github action + +## [23.0.0] - 2026.07.06 +- 迁移到 .NET 10 与跨平台 Avalonia 桌面架构,保留章节载入、编辑、组合、预览、导出等核心工作流。 +- 新增正式命令行入口,支持 `formats`、`inspect`、`convert` 和 `load`。 +- 增强表达式编辑体验,加入语法高亮、补全、诊断信息。 +- 扩展并整理导入导出能力,覆盖文本、XML、WebVTT、CUE、MPLS、BDMV、IFO、XPL、Matroska 与常见媒体容器;导出支持 TXT、XML、QPF、TimeCodes、tsMuxeR meta、CUE、JSON、WebVTT、Celltimes 和 Chapter2QPF。 +- 改进外部工具发现、ffprobe/mkvextract/eac3to 诊断与回退路径,提升无章节、无效文件、多 edition/clip/title 等场景下的提示稳定性。 diff --git a/Directory.Build.props b/Directory.Build.props index ef27217..bf9e7da 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,8 +6,8 @@ enable true latest - 0.1.0 - ChapterTool contributors + 23.0.0 + TautCony ChapterTool https://github.com/tautcony/ChapterTool diff --git a/README.md b/README.md index 079803f..8352393 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ ChapterTool is a cross-platform Avalonia desktop chapter editor for importing, a - Import chapter data from text files, disc playlist formats, BDMV folders, and media containers. - Edit chapter names and timestamps in a cross-platform Avalonia UI. -- Apply time adjustments with infix or Reverse Polish notation expressions. +- Apply time adjustments with an expression editor that supports infix or Reverse Polish notation expressions, diagnostics, completion, and syntax highlighting. - Calculate frame information from chapter times and frame rate settings. - Combine supported multi-segment sources such as MPLS and IFO. - Export chapters as `.txt`, `.xml`, `.qpf`, `.TimeCodes.txt`, `.TsMuxeR_Meta.txt`, `.cue`, `.json`, `.vtt`, and Celltimes output. +- Use the CLI to list supported formats, inspect selectable chapter groups, and convert chapter sources without launching the desktop UI. ## Supported Import Sources @@ -31,6 +32,7 @@ ChapterTool is a cross-platform Avalonia desktop chapter editor for importing, a ## Requirements +- .NET 10 runtime for framework-dependent release builds. - .NET 10 SDK for building from source. - `ffprobe` from FFmpeg for media-container chapter import. - `mkvextract` from MKVToolNix for primary Matroska chapter extraction. @@ -38,6 +40,20 @@ ChapterTool is a cross-platform Avalonia desktop chapter editor for importing, a External tool paths can be configured in the app settings. ChapterTool also searches common configured paths and platform tool locations where supported. +## Command Line + +The Avalonia executable also exposes maintained CLI workflows. Run these from a published artifact or from the project with `dotnet run --project src/ChapterTool.Avalonia --`. + +```powershell +ChapterTool.Avalonia formats +ChapterTool.Avalonia inspect input.mpls +ChapterTool.Avalonia convert input.xml --format txt --output chapters.txt +ChapterTool.Avalonia convert input.xml --format vtt --stdout +ChapterTool.Avalonia load input.xml +``` + +`formats` lists the stable CLI import/export surface. `inspect` reports imported groups, selectable options, and diagnostics. `convert` supports file output, stdout output, explicit group/option selection, XML language, CUE source file name, and frame-rate override. GUI-only expression transforms are intentionally not applied by CLI conversion. + ## Build And Test Restore, build, and test the current solution: @@ -48,20 +64,33 @@ dotnet build ChapterTool.Avalonia.slnx --no-restore dotnet test ChapterTool.Avalonia.slnx --no-restore ``` -The CI workflow is `.github/workflows/dotnet-ci.yml` and runs on Windows with .NET 10, FFmpeg, and MKVToolNix. +The CI workflow is `.github/workflows/dotnet-ci.yml` and runs on Linux with .NET 10, FFmpeg, and MKVToolNix. ## Publish -Use the publish helper for local artifacts: +Use the publish helpers for local artifacts: + +```bash +./scripts/publish.sh -Runtime linux-x64 +./scripts/publish.sh -Runtime osx-arm64 +./scripts/publish.sh -Runtime win-x64 -SelfContained +``` + +`scripts/publish.ps1` is available for Windows publishing only: ```powershell ./scripts/publish.ps1 -Runtime win-x64 ./scripts/publish.ps1 -Runtime win-x64 -SelfContained ``` +```bash +./scripts/publish.sh -Runtime linux-x64 +./scripts/publish.sh -Runtime osx-arm64 -SelfContained +``` + Framework-dependent artifacts are written under `artifacts/publish/framework-dependent/`. Self-contained artifacts are written under `artifacts/publish/self-contained/`. -The GitHub Actions publish job currently builds framework-dependent artifacts for `win-x64`, `linux-x64`, `osx-x64`, and `osx-arm64`. +The GitHub Actions publish job currently builds framework-dependent artifacts for `win-x64`, `linux-x64`, and `osx-arm64`. ## Project Layout diff --git a/docs/code-review-2026-07-06.md b/docs/code-review-2026-07-06.md new file mode 100644 index 0000000..eb785e7 --- /dev/null +++ b/docs/code-review-2026-07-06.md @@ -0,0 +1,280 @@ +# Code Review - 2026-07-06 + +## Scope + +This review inspected the current `src/` and `tests/` code for: + +- fake or incomplete stub behavior exposed as real functionality +- low-value or misleading tests +- unusual compatibility or platform handling +- deviations from the repository guidance and common .NET/Avalonia practices + +Four subagents reviewed Core, Infrastructure, Avalonia/UI, and cross-repository suspicious patterns in parallel. The review was read-only; no tests were run. + +Existing working tree note: `scripts/publish.sh` was already modified before this review and was not touched. + +## Summary + +No critical issues were found. The highest-risk findings are: + +- WebVTT import drops cue end times and calculates duration incorrectly. +- Windows terminal fallback in `ShellService` builds a command string from a path. +- File association support exists as a partial service surface but is not complete enough to be trustworthy. +- Preview UI is available when no chapter data exists and opens an empty window. + +## High Severity + +### WebVTT cue end times are parsed but discarded + +- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:40` +- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:48` +- File: `src/ChapterTool.Core/Importing/Text/WebVttChapterImporter.cs:62` + +The importer validates the cue end time with `TimeSpan.TryParse(parts[1], out _)`, but the parsed value is discarded. `Chapter.End` is never populated, and `ChapterInfo.Duration` is set to the last chapter start time. + +Impact: imported WebVTT chapters lose end times. Any later export or duration-dependent workflow can produce wrong final segment timing. + +Recommendation: keep the parsed end value, pass it as `End` when creating `Chapter`, and calculate duration from the last valid cue end. + +### Windows terminal fallback is command-injection prone + +- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:62` + +The Windows fallback path builds a command string: + +```text +cmd /c start cmd /k "cd /d {directoryPath}" +``` + +Because `directoryPath` is user/path data, characters such as `&` or quotes can change the command. Exceptions around this path are also swallowed. + +Impact: malformed or adversarial paths can execute unintended shell commands; failures are invisible to the user. + +Recommendation: avoid command-string composition. Start `cmd.exe` directly with fixed arguments and set `ProcessStartInfo.WorkingDirectory = directoryPath`, or use a safer platform abstraction that returns a structured failure. + +### File association service is incomplete and not wired to the documented command surface + +- File: `src/ChapterTool.Infrastructure/Platform/WindowsFileAssociationService.cs:27` +- File: `src/ChapterTool.Infrastructure/Platform/WindowsFileAssociationService.cs:60` +- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:520` +- Related spec: `openspec/specs/avalonia-ui-shell/spec.md:60` + +`WindowsFileAssociationService` writes only the ProgID description and extension default value. It does not write `shell\open\command`, icon metadata, ownership markers, or shell change notifications. `UnregisterAsync` deletes the extension key without confirming it is still owned by ChapterTool. + +The spec says the main window ViewModel shall expose a file association command, but the current command list has no such command. The composition root can create the service, but no user-visible workflow appears to consume it. + +Impact: registration can report success without making files open correctly, and unregister can remove another app's association. The implementation also looks like a feature but lacks an end-to-end user path. + +Recommendation: either complete the service and UI workflow, or remove/hide the feature surface until it is ready. A complete implementation should write an open command, track ownership, guard unregistration, refresh shell associations, and have tests through an injectable registry abstraction. + +### Preview opens an empty window with no loaded chapters + +- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:260` +- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:148` +- File: `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1304` + +The preview command is an unconditional `WindowCommand("preview")`. With no current chapter data, `BuildPreview()` returns empty text, so the user can open a blank preview window. + +Impact: this is a visible UI stub: the control appears usable but has no meaningful behavior in the empty state. Keyboard access such as `F11` can trigger the same result. + +Recommendation: make `PreviewCommand.CanExecute` depend on loaded chapter data, and ensure buttons, menus, and shortcuts share that state. Alternatively, show an explicit empty-state message. + +## Medium Severity + +### `CueChapterImporter` has a fake injectable parser + +- File: `src/ChapterTool.Core/Importing/Cue/CueChapterImporter.cs:3` +- File: `src/ChapterTool.Core/Importing/Cue/CueChapterImporter.cs:29` + +The constructor accepts `CueSheetParser? parser` and stores it, but `ImportAsync` calls the static `CueSheetParser.Parse` method. + +Impact: callers and tests may think parser behavior is replaceable, but the injected object is ignored. + +Recommendation: remove the constructor parameter and field, or introduce a real parser interface/instance method and use it. + +### `IfoChapterImporter` is fake-async and ignores request content + +- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:16` +- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:18` +- File: `src/ChapterTool.Core/Importing/Disc/IfoChapterImporter.cs:21` + +`ImportAsync` awaits `Task.CompletedTask`, then reads only from `request.Path`. It ignores `request.Content`. + +Impact: IFO behaves differently from other importers that support in-memory content. The fake await also obscures the synchronous file-only behavior. + +Recommendation: parse from a seekable stream and prefer `request.Content` when present. If file-only sync parsing is intentional for now, remove the fake await and document the limitation. + +### Core tests depend on Infrastructure + +- File: `tests/ChapterTool.Core.Tests/ChapterTool.Core.Tests.csproj:30` +- File: `tests/ChapterTool.Core.Tests/Importing/MediaChapterImporterTests.cs:4` + +`ChapterTool.Core.Tests` references `ChapterTool.Infrastructure` and instantiates `FfprobeMediaChapterReader`. + +Impact: Core tests become mixed integration tests and can fail because of Infrastructure parser behavior rather than Core importer behavior. + +Recommendation: test Core's `MediaChapterImporter` using a fake `IMediaChapterReader` that returns `MediaChapterEntry` values. Keep ffprobe JSON/process parsing tests in `ChapterTool.Infrastructure.Tests`. + +### External tool locator treats any file as an executable + +- File: `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs:37` +- File: `tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs:15` + +Tool resolution uses `File.Exists` only, and tests use empty text files as successful tool candidates. + +Impact: on Linux/macOS, non-executable files can be reported as found. The settings UI can show success, then runtime execution fails later. + +Recommendation: add executable validation. On Unix-like systems, check execute permissions. On Windows, validate expected executable naming, and consider an optional lightweight `--version` probe for configured tools. + +### Corrupt settings silently reset to defaults + +- File: `src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs:43` +- File: `src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs:29` + +Invalid JSON is caught and replaced with default settings without logging or surfacing a diagnostic. + +Impact: user settings can appear to reset, and a later save can overwrite the corrupt file with defaults, making recovery harder. + +Recommendation: log the parse failure, preserve the corrupt file as `.corrupt`, and expose a user-visible warning before defaults are saved back. + +### Shell failures are silently swallowed + +- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:41` +- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:74` +- File: `src/ChapterTool.Infrastructure/Platform/ShellService.cs:105` + +Reveal/open-terminal helper paths catch broad exceptions and ignore them. + +Impact: users see an action that does nothing, with no status or log entry. + +Recommendation: catch expected process/platform exceptions and return or log structured failure information. + +### FFmpeg directory setting actually validates ffprobe + +- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:248` +- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:255` +- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:577` +- File: `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs:593` + +`FfmpegPath` is presented as an FFmpeg directory setting, but validation and discovery check `ffprobe`. + +Impact: users may think they configured FFmpeg, while the app actually validates ffprobe. The setting overlaps semantically with `FfprobePath`. + +Recommendation: rename it to ffprobe directory if that is the intended behavior, or validate/discover `ffmpeg` if it truly represents FFmpeg. + +### Native file picker strings are hard-coded English + +- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:11` +- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:16` +- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:37` +- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:54` +- File: `src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs:71` + +File picker titles and file type labels are English string literals. + +Impact: localized UI still opens English system dialogs. + +Recommendation: inject `IAppLocalizer` into `AvaloniaFilePickerService`, or have callers pass localized titles/filter names. + +### Icon buttons lack accessible names + +- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:260` +- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:267` +- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:275` +- File: `src/ChapterTool.Avalonia/Views/MainWindow.axaml:505` +- File: `src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml:101` + +Several icon buttons have tooltip text and automation IDs, but no localized `AutomationProperties.Name`. + +Impact: screen readers may not announce a useful action name. Automation IDs are not a substitute for accessible names. + +Recommendation: add localized `AutomationProperties.Name` and, where useful, `AutomationProperties.HelpText`. Headless tests should verify the accessible name, not only command binding. + +### BDMV parser moves data through diagnostics and narrow regexes + +- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:160` +- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:344` + +The BDMV importer stores eac3to stdout in a diagnostic message and then parses it later with narrow text regexes. + +Impact: adding another diagnostic can break assumptions, and small eac3to output/version/localization changes can stop detection. + +Recommendation: return an internal structured result that carries stdout separately from diagnostics. Add fixture coverage from real eac3to outputs and handle known format variants. + +## Low Severity + +### Test fakes live in production Infrastructure + +- File: `src/ChapterTool.Infrastructure/Platform/MemoryClipboardService.cs:5` +- File: `src/ChapterTool.Infrastructure/Platform/ScriptedDialogService.cs:5` +- File: `src/ChapterTool.Infrastructure/Platform/RecordingWindowService.cs:5` +- File: `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs:63` + +These are test-double style services in the production Infrastructure assembly. The test name calls them skeletons. + +Impact: they can be accidentally injected into production paths and silently record operations instead of performing real platform behavior. + +Recommendation: move them to test projects, or mark them internal/test-only and avoid exposing them as production services. + +### `UiCommandTests` waits with a fixed delay + +- File: `tests/ChapterTool.Avalonia.Tests/Commands/UiCommandTests.cs:47` + +The test waits for `async void` command completion with `Task.Delay(50)`. + +Impact: slow CI machines or scheduling delays can make the test flaky. + +Recommendation: use a `TaskCompletionSource`, property changed event, or polling with a bounded timeout for `ExecutionError`. + +### Matroska integration setup blocks on async + +- File: `tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs:27` + +`IAsyncLifetime.InitializeAsync` uses `.AsTask().Result`. + +Impact: this is a sync-over-async pattern with deadlock and cancellation risks. + +Recommendation: make `InitializeAsync` an `async ValueTask` and `await LocateAsync(...)`. + +### Screenshot tests are weak regression guards + +- File: `tests/ChapterTool.Avalonia.Tests/Headless/MainWindowHeadlessTests.cs:190` +- File: `tests/ChapterTool.Avalonia.Tests/Headless/LocalizationAndLayoutHeadlessTests.cs:117` + +The tests mainly capture screenshots and assert the files exist/non-empty. + +Impact: a badly misaligned or semantically blank UI can still pass. + +Recommendation: keep screenshots as artifacts, but add behavioral/layout assertions such as key controls visible, no bounds overflow, and meaningful non-background pixel regions. + +### MKVToolNix registry DisplayIcon parsing misses quoted paths + +- File: `src/ChapterTool.Infrastructure/Tools/MkvToolNixInstallProbe.cs:78` + +The install probe trims a trailing comma suffix but does not normalize quoted `DisplayIcon` values. + +Impact: common registry values such as `"C:\Program Files\...\mkvtoolnix-gui.exe",0` may fail to resolve to the install directory. + +Recommendation: strip quotes after trimming the icon suffix, then take the directory. Add a quoted DisplayIcon test. + +### eac3to export can show a console window + +- File: `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs:171` +- File: `tests/ChapterTool.Infrastructure.Tests/BdmvChapterImporterTests.cs:51` + +The eac3to chapter export request explicitly sets `CreateNoWindow: false`, and the test locks in that behavior. + +Impact: GUI import can flash or show a console window. + +Recommendation: default to hidden process windows unless eac3to requires visibility. If visibility is required, document the reason and test that reason rather than the raw flag. + +## Non-Issues / Filtered Results + +The review did not find meaningful hits for: + +- `NotImplementedException` +- `PlatformNotSupportedException` +- `Thread.Sleep` +- global test parallelization disablement in Avalonia tests +- tests reading source/configuration files and asserting incidental source strings + diff --git a/docs/code-review-fix-todo-2026-07-06.md b/docs/code-review-fix-todo-2026-07-06.md new file mode 100644 index 0000000..b1def27 --- /dev/null +++ b/docs/code-review-fix-todo-2026-07-06.md @@ -0,0 +1,40 @@ +# Code Review Fix TODO - 2026-07-06 + +Source report: `docs/code-review-2026-07-06.md` + +## Rules + +- Fix one issue at a time. +- Run focused tests after each fix. +- Commit each completed fix separately. +- Do not include unrelated working tree changes, especially the pre-existing `scripts/publish.sh` modification. + +## High Priority + +- [x] Fix WebVTT import so cue end times and duration are preserved. +- [x] Remove command-string path interpolation from `ShellService.OpenTerminalAsync`. +- [x] Complete Windows file association registry behavior so registration writes an open command and unregistration protects existing associations. +- [ ] Add or explicitly defer a non-primary settings/command surface for file association. +- [x] Prevent preview from opening as an empty stub when no chapters are loaded. + +## Medium Priority + +- [x] Remove the fake injectable parser from `CueChapterImporter`. +- [x] Remove fake async behavior from `IfoChapterImporter` and handle `request.Content` consistently. +- [x] Remove the Infrastructure dependency from Core tests. +- [x] Validate external tools as executables, not just existing files. +- [x] Preserve and surface corrupt settings files instead of silently resetting. +- [x] Return or log shell service failures instead of swallowing them. +- [x] Clarify whether `FfmpegPath` means ffmpeg or ffprobe directory and validate accordingly. +- [x] Localize native file picker titles and file type labels. +- [x] Add accessible names for icon-only buttons. +- [x] Refactor BDMV parsing so stdout is not passed through diagnostics. + +## Low Priority + +- [x] Move production test-double services out of Infrastructure or mark them test-only. +- [x] Replace fixed `Task.Delay` in `UiCommandTests`. +- [x] Remove sync-over-async from Matroska integration setup. +- [x] Strengthen screenshot tests with layout/content assertions. +- [x] Handle quoted MKVToolNix `DisplayIcon` registry values. +- [x] Hide eac3to export process windows unless visibility is required. diff --git a/docs/gui-verification.md b/docs/gui-verification.md index 11c7e2c..9352e70 100644 --- a/docs/gui-verification.md +++ b/docs/gui-verification.md @@ -14,7 +14,7 @@ Stable automation ids currently exposed by `MainWindow.axaml`: - `ClipSelectionPanel`, `ClipSelector`, `CombineButton`, `AppendMplsButton` - `AdvancedOptions`, `XmlLanguage`, `AutoNames`, `ExpressionText`, `ApplyExpression`, `OrderShift`, `TemplateNames`, `RefreshRows` - `ChapterGrid` -- `PreviewWindow`, `LogWindow`, `ColorSettingsWindow`, `LanguageWindow`, `ExpressionWindow`, `TemplateNamesWindow`, `FileAssociationWindow`, `ZonesWindow`, `ForwardShiftWindow` +- `PreviewWindow`, `LogWindow`, `ColorSettingsWindow`, `LanguageWindow`, `ExpressionWindow`, `TemplateNamesWindow`, `ZonesWindow`, `ForwardShiftWindow` Run: diff --git a/docs/i18n-audit-and-fix-plan.md b/docs/i18n-audit-and-fix-plan.md new file mode 100644 index 0000000..92cc0df --- /dev/null +++ b/docs/i18n-audit-and-fix-plan.md @@ -0,0 +1,165 @@ +# 国际化(i18n)排查与修复计划 + +> 生成日期:2026-07-04 +> 范围:`ChapterTool.Avalonia` / `ChapterTool.Core` / `ChapterTool.Infrastructure` +> 现象:选择中文(或日文)后,部分日志、状态、诊断信息仍显示英文。 + +## 执行结果(截至 2026-07-04) + +| 任务 | 状态 | 说明 | +|------|------|------| +| Task 1 — Diagnostic.* 本地化 | ✅ 已完成 | 新增 73 + 18 个 `Diagnostic.*` 键(三语);`ChapterDiagnostic` 增加 `Arguments`;`LocalizeDiagnostic` 支持 `{message}` 自动回填与命名占位符;`LogDiagnostics` 改用本地化消息。 | +| Task 2 — operation/action 本地化 | ✅ 已完成 | 新增 `Action.*`(7)/`EditKind.*`(3)/`Operation.*`(6) 键;`ApplyEdit`、`LogDiagnostics` 全部调用点改用本地化字符串。 | +| Task 3 — ExpressionService 错误本地化 | ✅ 已完成 | 引入 `ExpressionException(code,message,args)`,18 个错误码 `InvalidExpression.*` 三语;测试断言改为 `StartsWith`。 | +| Task 4 — 外部工具/依赖缺失消息 | ✅ 已随 Task 1 完成 | `MissingDependency`/`MatroskaMissingDependency`/`FfprobeMissingDependency` 为静态本地化键(内嵌工具名),显示时覆盖定位器英文消息。 | +| Task 5 — 清理废弃本地化抽象 | ✅ 已完成 | 删除 `ILocalizationService`/`LocalizationService`(Core/Infrastructure);从 `PlatformServiceTests` 移除对应测试块。无悬空引用。 | + +**资源键总数**:每语言由 126 → **233** 键(三语严格对齐,占位符一致性测试守护)。 +**测试**:Core 276 / Infrastructure 88 / Avalonia.Localization 11 全绿;Avalonia 全量(含 headless)在 Task 1-2 阶段已全绿(Task 3/5 仅触及 Core/Infrastructure)。 + +--- + +## 一、现状架构概览 + +| 维度 | 说明 | +|------|------| +| 资源文件 | `src/ChapterTool.Avalonia/Localization/Resources/Strings.{zh-CN,en-US,ja-JP}.resx`(各 126 键,键/占位符一致性有测试守护) | +| 默认/回退文化 | **zh-CN**(`AppLanguage.DefaultCultureName = "zh-CN"`,无中性 `Strings.resx`) | +| 活跃本地化服务 | `IAppLocalizer` / `AppLocalizationManager`(Avalonia 层),`SetCulture` 同时写入 `Application.Current.Resources` 供 `{DynamicResource}` 使用 | +| XAML | 全量使用 `{DynamicResource Key}`,UI 文本无硬编码英文 ✅ | +| 日志通道 | `MainWindowViewModel.Log(key,...)` 注入 `MessageKey`;显示时由 `FormatLogEntry` 经 `Localizer.Format` 重新本地化。**只有传入真实 `Log.*`/`Status.*` 键才会本地化**,传字面量英文则绕过本地化 | + +### 文化切换链路(已正确) +`AppSettings.Language` → 启动 `MainWindowViewModel.LoadSettings` → `Localizer.SetCulture` → `CultureChanged` 事件 → 各 ViewModel 刷新 + XAML `{DynamicResource}` 自动更新 + 持久化。 + +## 二、缺陷清单(按优先级) + +### P0 — 诊断信息(Diagnostic)完全不翻译 ⭐ 最大问题 +- **位置**:`MainWindowViewModel.LocalizeDiagnostic`(`MainWindowViewModel.cs:1365`) +- **机制**:代码已构造 `Diagnostic.{Code}` 键查找,但 **三个 resx 文件中 `Diagnostic.*` 键数为 0**。 +- **后果**:状态栏、日志面板对所有诊断一律回退到 `diagnostic.Message`(硬编码英文)。 +- **涉及诊断码(约 60 个)**:`InvalidFrameRate`、`NoSegments`、`UnsupportedCombineSource`、`InvalidChapterIndex`、`InvalidExpression`、`PartialParse`、`EmptyCueFile`、`MalformedCueSyntax`、`MatroskaProcessFailed`、`FfprobeProcessFailed`、`Mp4ReadFailed`、`MissingDependency`、`Stdout` 等(完整清单见附录 A)。 +- **来源文件**:`src/ChapterTool.Core/{Editing,Transform,Exporting,Importing}/...`、`src/ChapterTool.Infrastructure/{Platform,Importing,Tools}/...` + +### P1 — 日志 operation/action 参数硬编码英文 +- **A. `ApplyEdit(result, action)`**(`MainWindowViewModel.cs:890`) + - `action` 作为本地化模板 `Log.EditChapters` 的 `{action}` 占位符,但调用方传入全英文:`$"Delete rows: indexes=..."`、`$"Insert row: index=..."`、`$"Shift frames forward: ..."`、`$"Edit {kind}: row=..."`、`$"Combine segments: ..."`。 + - → 模板被翻译,但内嵌的 `{action}` 片段恒为英文。 +- **B. `LogDiagnostics(operation, ...)`**(`MainWindowViewModel.cs:1516`) + - 多处调用传入英文字面量:`"Load"`、`"Save"`、`"Create zones"`、`"Append load"`、`"Append edit"`、`"Output projection"` 等(`{operation}` 占位符)。 + - 同时该函数把 `diagnostic.Message`(英文)直接塞入 `Log.Diagnostic` 的 `{message}` 占位符,**未走 `LocalizeDiagnostic`**。 + +### P2 — ExpressionService 异常消息全英文 +- **位置**:`src/ChapterTool.Core/Transform/ExpressionService.cs:140,261,271,283,293,303,320,338,348` +- 抛出的英文消息(`"Misplaced comma."`、`"Unbalanced parentheses."`、`"Expression did not reduce to one value."` 等)经 `InvalidExpression` 诊断原样显示给用户。 + +### P3 — 外部工具/原生依赖缺失消息全英文 +- `src/ChapterTool.Infrastructure/Tools/ExternalToolLocator.cs:77`:`$"External tool '{toolId}' was not found."` +- `src/ChapterTool.Infrastructure/Platform/FileSystemNativeDependencyService.cs:21`:`$"Native dependency '{dependencyId}' was not found."` +- 被各 importer 原样透传为 `ChapterDiagnostic.Message`(code=`MissingDependency`)。 + +### P4 — 重复/废弃的本地化抽象(清理项) +- `src/ChapterTool.Core/Services/ILocalizationService.cs` + `src/ChapterTool.Infrastructure/Platform/LocalizationService.cs` +- 构造时资源字典为空,**未被 `AppCompositionRoot` 接入**,仅出现在测试中。与 `IAppLocalizer` 重复,易致混淆。 + +### 已验证为正常(无需改动) +- 所有 `.axaml` 用户文本均走 `{DynamicResource}`。 +- `SetStatus` / `SetProgressStatus` 一律使用 `Status.*` 键。 +- `SettingsToolViewModel`、`AvaloniaSettingsCloseConfirmationService` 全本地化。 +- 无 `Console/Debug/Trace.WriteLine`。 + +## 三、修复任务(subagent 顺序执行,避免并发改 resx 冲突) + +> ⚠️ 所有任务共享 `Strings.*.resx`,**必须串行**;每个 subagent 完成后由主控做构建/测试验证再启动下一个。 + +### Task 1 — 诊断信息本地化(P0)⭐ 核心 +**目标**:状态栏与日志面板的诊断消息随当前语言切换。 + +**改动**: +1. `ChapterDiagnostic` 增加 `IReadOnlyDictionary? Arguments = null`(可选,向后兼容)。 +2. 三个 resx 新增全部 `Diagnostic.{Code}` 键(含 `{占位符}`,见附录 A 的带参清单)。 +3. `LocalizeDiagnostic` 改为 `Localizer.Format(diagnosticKey, diagnostic.Arguments)`。 +4. `LogDiagnostics` 中 `{message}` 改用 `LocalizeDiagnostic(diagnostic)`,`{code}` 保留原始码(技术标识)。 +5. 对带插值的诊断生产点(`InvalidChapterIndex`、`PartialParse(line)`、`OrderShiftNormalized` 等)改为传 `Arguments`,`Message` 保留英文作技术回退。 +6. `LocalizationTests` 增加 `Diagnostic.*` 三语键/占位符一致性校验。 + +**验收**:`dotnet test ChapterTool.Avalonia.slnx --no-restore` 通过;切换语言后诊断消息随之变化。 + +### Task 2 — 日志 operation/action 参数本地化(P1) +**目标**:消除日志行中内嵌的英文片段。 + +**改动**: +1. 新增 `Operation.*` 键(`Operation.Load/Save/CreateZones/AppendLoad/AppendEdit/OutputProjection/AppendLoadEdit` 等)三语。 +2. 新增 `Log.EditChapters.*` 子键或 `Action.*` 键(`DeleteRows/InsertRow/ShiftFramesForward/EditCell/CombineSegments/EditChapters`)三语,带 `{indexes}`/`{index}`/`{frames}`/`{kind}`/`{row}`/`{value}`/`{count}`/`{sourceType}` 占位符。 +3. `ApplyEdit` 签名改为接收 `LocalizedMessage` 或 `(string key, args)` 而非原始英文字符串;更新全部调用点。 +4. `LogDiagnostics` 的 `operation` 参数改为本地化键路径;更新全部调用点。 + +**验收**:构建通过;日志面板在中文/日文下不再出现 "Delete rows"、"Load" 等英文片段。 + +### Task 3 — ExpressionService 错误消息本地化(P2) +**目标**:表达式解析错误随语言切换。 + +**改动**: +1. 为 `ExpressionService` 中每种解析失败新增 `Expression.*` 键三语(或扩展 `Diagnostic.InvalidExpression.*`)。 +2. 改造抛错路径:以「错误码 + 参数」形式携带,最终在 `InvalidExpression` 诊断处本地化;或令 `ExpressionService` 接收本地化查表回调。 +3. 同步附录 A 的 `InvalidExpression` 处理。 + +**验收**:构造错误表达式,中文/日文下提示为对应语言。 + +### Task 4 — 外部工具/依赖缺失消息本地化(P3) +**目标**:`MissingDependency` 诊断消息本地化。 + +**改动**: +1. `ExternalToolLocator` / `FileSystemNativeDependencyService` 返回结构化位置(`ToolId`/`DependencyId`)而非预格式化英文 `Message`。 +2. 由 importer 在生成 `MissingDependency` 诊断时携带 `Arguments={toolId/dependencyId}`,复用 Task 1 的 `Diagnostic.MissingDependency` 键。 +3. 校验 `BdmvChapterImporter`、`MatroskaChapterImporter`、`FfprobeMediaChapterReader` 的透传链路。 + +**验收**:缺依赖场景下提示为当前语言。 + +### Task 5 — 清理废弃本地化抽象(P4) +**目标**:消除 `ILocalizationService`/`LocalizationService` 死代码与潜在误用。 + +**改动**: +1. 确认仅 `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs` 使用。 +2. 删除接口与实现 + 测试引用,或统一并入 `IAppLocalizer`(视依赖最小化原则择一)。 + +**验收**:构建+全量测试通过,无悬空引用。 + +## 四、执行顺序与验证 +1. Task 1(P0)→ `dotnet build` + `dotnet test ChapterTool.Avalonia.slnx --no-restore` +2. Task 2(P1)→ 同上 +3. Task 3(P2)→ 同上 +4. Task 4(P3)→ 同上 +5. Task 5(P4)→ 同上 +6. 终验:`openspec validate --all`(如涉及 spec)+ 全量测试 + +## 附录 A — 诊断码完整清单(待补 Diagnostic.* 键) + +### 静态消息(无占位符) +``` +InvalidFrameRate, InvalidFrameText, NoRowsSelected, NoSegments, +UnsupportedCombineSource, UnsupportedAppendSource, UnsupportedExportFormat, +NoChapters, NoChaptersFound, EmptyCueFile, MalformedCueSyntax, +InvalidContainerHeader, FlacEmbeddedCueNotFound, EmbeddedCueNotFound, +InvalidMpls, InvalidStructure, InvalidIfo, InvalidXml, InvalidEntryElement, +InvalidChapterPair, EmptyXml, XmlInvalidRoot, XmlNoChapters, XplNoChapters, +XplParseFailed, EmptyChapters, OgmInvalidFirstLine, PremiereMarkerListInvalid, +WebVttInvalidHeader, WebVttMalformedCue, InvalidTimeText, InvalidTimecodeText, +InvalidChapterText, InvalidExpressionTime, UnsupportedPlatform, +DependencyExecutionCancelled, DependencyExecutionFailed, DependencyExecutionTimedOut, +DependencyOutputUnrecognized, DependencyOutputMissing, DependencyOutputEmpty, +MatroskaCannotStart, MatroskaProcessCancelled, MatroskaProcessFailed, +MatroskaProcessTimedOut, FfprobeEmptyOutput, FfprobeParseFailed, +FfprobeProcessCancelled, FfprobeProcessFailed, FfprobeProcessTimedOut, +Mp4FileInaccessible, Mp4FileNotFound, Mp4InvalidPath, Mp4MalformedMetadata, +Mp4ReadFailed, Mp4UnsupportedMetadata, Stdout, MissingDependency +``` + +### 带占位符消息 +| Code | 占位符 | 示例 | +|------|--------|------| +| `InvalidChapterIndex` | `{index}` | Chapter index {index} is out of range. | +| `PartialParse` | `{line}` | Parsing stopped at line: {line} | +| `OrderShiftNormalized` | (待核) | 订单位移已归一化 | +| `MissingDependency` | `{toolId}` / `{dependencyId}` | External tool '{toolId}' was not found. | +| `Stdout` | `{output}` | (原样透传命令输出) | diff --git a/docs/review-2026-07-05/agent-findings.md b/docs/review-2026-07-05/agent-findings.md new file mode 100644 index 0000000..834e39a --- /dev/null +++ b/docs/review-2026-07-05/agent-findings.md @@ -0,0 +1,83 @@ +# Agent Findings — Sub-agent Dispatch Log + +> Record of sub-agent dispatches, candidate findings, adoption/rejection decisions, and verification notes. + +## Dispatch Summary + +| Phase | Task ID | Duration | Candidate Findings | Adopted | Rejected | Downgraded | +|-------|---------|----------|--------------------|---------|----------|-------------| +| 1 Core | bg_b9a2378f | 9m21s | 2 + 1 suspect | 2 (P2,P3) | 1 (P1 FP) | — | +| 2 Infra | bg_5f185fb5 | 9m47s | 2 + 3 suspects | 2 (P2,P2) | — | 1 (P1→P2) | +| 3 VM | bg_cbd33c95 | 7m52s | 4 + 3 suspects | 2 (P2,P3) | 1 (P1 FP) | 1 (P2→P3) | +| 4 Views | bg_1c8af626 | 6m31s | 3 + 3 suspects | 3 (P2,P3,P3) | — | 1 (P1→P2), 1 (P2→P3) | +| 5 Scripts | bg_183f5248 | 4m57s | 3 + 3 suspects | 4 (P2,P2,P3,P3) | — | — | +| 6 L10n | bg_0defc2e9 | 5m47s | 0 | 0 | — | — | + +## Rejected Findings (False Positives) + +### Phase 1 — ~~P1: MissingOperatorBeforeFunction missing resx key~~ + +**Sub-agent claim:** The code `InvalidExpression.MissingOperatorBeforeFunction` thrown at `ExpressionService.cs:262` has no corresponding `Diagnostic.InvalidExpression.MissingOperatorBeforeFunction` key in the resx. + +**Main-agent verification:** +``` +$ grep -n "MissingOperatorBeforeFunction" Strings.en-US.resx +:161: + +$ comm -23 <(all codes thrown) <(all keys present in resx) +(empty) +``` + +**Verdict: REJECTED.** Key exists at line 161 in all three resx files. All 18 `InvalidExpression.*` codes have complete coverage. + +**Root cause of sub-agent error:** The sub-agent checked for key presence by reading a narrow line range (`:158-166`) and didn't find the key in that window, but it was at `:161` — likely a parsing/line-counting discrepancy in the sub-agent's resx read. + +--- + +### Phase 3 — ~~P1: Selector identity mismatch (recomputed list + reference equality)~~ + +**Sub-agent claim:** `XmlLanguageDisplayOptions` returns a new array every getter call; `SelectorDisplayOption` has no value equality → UI selection desync. + +**Main-agent verification:** +- `XmlLanguageDisplayOptions => xmlLanguageDisplayOptions` returns the **same persistent `ObservableCollection`** (`MainWindowViewModel.cs:289`), not a new array. +- `RefreshXmlLanguageDisplayOptions` (`:1463-1487`) preserves identity: + - Count unchanged → `UpdateFrom()` mutates in place. + - Count changed → clear + re-add on same collection instance. +- `SelectedXmlLanguageDisplayOption` setter matches by `MainText` value (`:304`), not reference. +- Getter indexes by `XmlLanguageIndex` integer (`:296`), not reference. + +**Verdict: REJECTED.** The code explicitly handles identity preservation — this is deliberate defensive design, not a bug. + +**Root cause of sub-agent error:** The sub-agent inferred "recomputed array" from the `XmlLanguageDisplay.Options()` factory method without reading the `RefreshXmlLanguageDisplayOptions` caller that bridges the factory output into a persistent collection with identity-preserving mutation. + +## Downgraded Findings (Severity Calibration) + +| Original | Calibrated | Rationale | +|----------|------------|-----------| +| Phase 2 P1 (event race) | **P2** | Real C# event race, but impact bounded to logging path in a desktop app. Worst case: rare NRE in `ILogger.Log`, not data corruption. | +| Phase 3 P1 (culture mutation) | **P2** | Real design anti-pattern, but `Options()` call sites are UI-thread-bound and infrequent (culture change only). Cross-thread impact requires a concurrent background operation reading `CurrentCulture` during the brief `using` window. | +| Phase 4 P1 (CFBundleExecutable) | **P2** | Names match by default (no `AssemblyName` override). The real defect is the silent chmod skip, not the name itself. | +| Phase 3 P2 (live refresh leak) | **P3** | `TextToolView.axaml.cs:40` explicitly calls `DetachLiveRefresh()` on DataContext change — the View handles the lifecycle. | +| Phase 4 P2 (no unsubscribe) | **P3** | Main window owns its ViewModel; subscription graph is self-contained and collectable together. | + +## Verification Commands Run by Main Agent + +1. `grep -n "MissingOperatorBeforeFunction" Strings.*.resx` — key presence in all 3 files. +2. `comm -23 <(thrown codes) <(resx keys)` — zero missing diagnostic codes. +3. `grep -rn "ILocalizationService|LocalizationService" --type cs` repo-wide — zero dangling refs. +4. `dotnet build ChapterTool.Avalonia.slnx` — 0 warnings, 0 errors. +5. Full read of `ApplicationLogPanelProvider.cs`, `Info.plist`, `publish.sh` macOS block, `ToolWindowViewModels.cs`, `RefreshXmlLanguageDisplayOptions`. +6. `grep "IsChapterGridEmpty"` — overlay binding verified. +7. `grep "DetachLiveRefresh"` — View calls detach. +8. Empty-catch / `dynamic` / unsafe-cast scan — clean. + +## Assessment of Sub-agent Quality + +- **Phase 6 (Localization):** Excellent — thorough, programmatic, all claims verified. No false positives. +- **Phase 5 (Scripts):** Strong — accurate shell analysis, correct severity calls. +- **Phase 4 (Views):** Good on Info.plist audit and AGENTS.md compliance; slightly over-conservative on severity calibration. +- **Phase 2 (Infra):** Correct dangling-ref audit; P1 was technically right but over-calibrated for a desktop logging path. +- **Phase 3 (VM):** Correct XmlLanguageDisplay culture analysis; **produced a false positive** on selector identity by not reading the refresh method. +- **Phase 1 (Core):** Correct ExpressionService evaluation spot-checks; **produced a false positive** on the missing key by misreading the resx. + +**False-positive rate:** 2 of 13 candidate findings rejected (15%). Both were P1 claims that would have been the highest-severity items — highlighting the importance of main-agent verification for P0/P1 candidates per the skill protocol. diff --git a/docs/review-2026-07-05/fixes-plan.md b/docs/review-2026-07-05/fixes-plan.md new file mode 100644 index 0000000..0ded5ce --- /dev/null +++ b/docs/review-2026-07-05/fixes-plan.md @@ -0,0 +1,122 @@ +# Fixes Plan + +> Execution-oriented fix plan for findings from the 2026-07-05 code review. +> Ordered by impact × effort. Each fix is independently shippable. + +## Batch 1 — Pre-release Hardening (recommend before next tag) + +### Fix A: Remove ambient culture mutation from XmlLanguageDisplay +- **Addresses:** P2-1 +- **Files:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs` +- **Complexity:** Medium (1 file, ~30 lines rewritten) +- **Change:** + 1. Delete `TemporaryCurrentUiCulture` class entirely. + 2. In `LanguageDisplayName`, call `CultureInfo.GetCultureInfo(language.Code).DisplayName` **without** swapping `CurrentCulture`/`CurrentUICulture`. For .NET, `CultureInfo.DisplayName` returns the display name in the culture's own language regardless of ambient UI culture — so the result is deterministic. + 3. For the `"und"` code, keep the existing `localizer.GetString("XmlLanguage.Undetermined")` lookup. + 4. Cache results per `localizer.CurrentCultureName` to avoid repeated `GetCultureInfo` calls. +- **Verification:** Open Settings → XML language selector; verify display names are correct in all 3 UI languages. Run `dotnet test ChapterTool.Avalonia.slnx --no-restore`. + +### Fix B: publish.sh interrupt resilience + executable validation +- **Addresses:** P2-5, P2-6, P3-4 +- **Files:** `scripts/publish.sh` +- **Complexity:** Low (~15 lines added) +- **Change:** + ```bash + # 1. Arg-value guard (line ~19, ~23): + case "$1" in + -Configuration) [[ $# -ge 2 ]] || { echo "ERROR: -Configuration requires a value" >&2; exit 2; }; Configuration="$2"; shift 2 ;; + -Runtime) [[ $# -ge 2 ]] || { echo "ERROR: -Runtime requires a value" >&2; exit 2; }; Runtime="$2"; shift 2 ;; + ... + esac + + # 2. Trap + stage-then-rename for macOS bundle: + trap 'rm -rf "$app_bundle"' INT TERM + # ... stage into "$app_bundle.tmp", then mv "$app_bundle.tmp" "$app_bundle" at the end + trap - INT TERM + + # 3. Hard error on missing executable: + [[ -f "$exe_path" ]] || { echo "ERROR: executable '$exe_path' not found" >&2; exit 1; } + chmod +x "$exe_path" + + # 4. Glob guard: + shopt -s nullglob + items=("$output"/*) + shopt -u nullglob + (( ${#items[@]} > 0 )) || { echo "ERROR: no publish output to bundle" >&2; exit 1; } + ``` +- **Verification:** `./scripts/publish.sh -Runtime osx-arm64` on macOS; verify `.app` launches. Test `-Runtime` with no value → clean error. + +## Batch 2 — Observability & Correctness Hardening + +### Fix C: ExpressionException innerException + OverflowException catch +- **Addresses:** P2-3, P3-1 +- **Files:** `src/ChapterTool.Core/Transform/ExpressionService.cs` +- **Complexity:** Low (~10 lines) +- **Change:** + 1. Add ctor: `ExpressionException(string code, string message, IReadOnlyDictionary? args = null, Exception? innerException = null) : base(message, innerException)`. + 2. Add `OverflowException` to the catch list at the two evaluator catch sites (`:89`, `:147`), mapping to a diagnostic (e.g., `InvalidExpression.Overflow`). +- **Verification:** `dotnet test tests/ChapterTool.Core.Tests --no-restore`. Add a test: `ExpressionService_EvaluatesOverflowExpression_ReturnsDiagnostic`. + +### Fix D: ApplicationLogPanelProvider event invocation local-copy +- **Addresses:** P2-2 +- **Files:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- **Complexity:** Trivial (2 lines) +- **Change:** + ```csharp + // Line 106, replace: + EntryAdded?.Invoke(this, entry); + // With: + var handler = EntryAdded; + handler?.Invoke(this, entry); + ``` +- **Verification:** `dotnet test tests/ChapterTool.Infrastructure.Tests --no-restore`. + +### Fix E: IApplicationLogService default interface implementation +- **Addresses:** P2-4 +- **Files:** `src/ChapterTool.Core/Services/IApplicationLogService.cs` +- **Complexity:** Low (3 lines) +- **Change:** Add default impl to the event: + ```csharp + event EventHandler? EntryAdded + { + add { } + remove { } + } + ``` + This makes the event optional for implementers. `ApplicationLogPanelProvider` overrides it with a real event. +- **Verification:** Build passes; existing tests pass. + +## Batch 3 — Minor Lifecycle Hardening (can defer) + +### Fix F: MainWindow unsubscribe on close +- **Addresses:** P3-2 +- **Files:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs` +- **Complexity:** Low +- **Change:** Add `UnsubscribeViewModelCommandState()` method mirroring `SubscribeViewModelCommandState()`, call from `OnClosed` override. + +### Fix G: publish.ps1 try/finally cleanup +- **Addresses:** P3-5 +- **Files:** `scripts/publish.ps1` +- **Complexity:** Low + +### Fix H: Diagnostic placeholder backfill +- **Addresses:** P3-3 +- **Files:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` +- **Complexity:** Low +- **Change:** In `LocalizeDiagnostic`, after format, strip any remaining `{token}` patterns with empty string or `[?]`. + +### Fix I: TextToolView detach on DetachedFromVisualTree +- **Addresses:** P3-6 +- **Files:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs` +- **Complexity:** Low + +## Verification Commands (per AGENTS.md) + +After all fixes: +```powershell +dotnet build ChapterTool.Avalonia.slnx --no-restore +dotnet test ChapterTool.Avalonia.slnx --no-restore +openspec validate --all +``` + +For macOS bundle fix: `./scripts/publish.sh -Runtime osx-arm64` on a macOS host, verify `.app` launches. diff --git a/docs/review-2026-07-05/phase-0-baseline.md b/docs/review-2026-07-05/phase-0-baseline.md new file mode 100644 index 0000000..2924b9b --- /dev/null +++ b/docs/review-2026-07-05/phase-0-baseline.md @@ -0,0 +1,86 @@ +# Phase 0 — Baseline + +> Code review of `feat/improve-struct` vs `master`, including uncommitted changes. +> Generated: 2026-07-05 (Asia/Shanghai) + +## Branch & Change Surface + +- **Branch**: `feat/improve-struct` +- **Commits ahead of master**: 5 + - `2e2f18f` feat: Add macOS app bundle support and update application icon + - `c8522ba` test: speed up Avalonia unit test execution + - `25e4752` fix: Correct case of 'Full' in blame-hang-dump-type for CI + - `260c053` feat: Enhance ChapterDiagnostic with Arguments and improve error handling in expression evaluation + - `eb42d91` feat: Update store selection process and enhance log service integration +- **Uncommitted**: 12 modified + 3 untracked files (i18n follow-ups + new `XmlLanguageDisplay` ViewModel + new `show-chapter-empty-state` OpenSpec change). +- **Combined diff vs master (committed + uncommitted)**: 73 files changed, +4,161 / −810. + +## Tech Stack & Module Boundaries + +- .NET 10 / Avalonia 11 desktop app for chapter editing. +- Solution: `ChapterTool.Avalonia.slnx` + - `src/ChapterTool.Core` — domain: models, transformations (ExpressionService), importers (text/XML/OGM/cue/etc.), exporters, diagnostics, editing. + - `src/ChapterTool.Infrastructure` — external tools, process exec, settings, infra-backed importers (BDMV/Matroska/MP4 via ffprobe). + - `src/ChapterTool.Avalonia` — UI: ViewModels, Views (.axaml + code-behind), Services, Localization resx. + - `tests/` — Core.Tests, Infrastructure.Tests, Avalonia.Tests (incl. headless). +- Build verified at baseline: **`dotnet build ChapterTool.Avalonia.slnx` → 0 warnings, 0 errors.** + +## Intent of This Branch (per `docs/i18n-audit-and-fix-plan.md` & OpenSpec change) + +1. **i18n refactor** (largest portion of the diff): + - P0: Add ~91 new `Diagnostic.*` keys (3 languages), wire `ChapterDiagnostic.Arguments`, route `LocalizeDiagnostic` through `Localizer.Format`. + - P1: Add `Action.*`, `EditKind.*`, `Operation.*` keys; refactor `ApplyEdit` / `LogDiagnostics` to take localized keys instead of raw English strings. + - P2: New `ExpressionException(code, message, args)` in `ExpressionService`; 18 `InvalidExpression.*` codes. + - P3: `MissingDependency` / external-tool messages localized at the importer boundary. + - P4: **Delete** `ILocalizationService` + `LocalizationService` (Core/Infrastructure) — was unused dead code. +2. **macOS app bundle support**: new `Assets/MacOS/Info.plist`, app-icon assets (`icns`/`ico`/updated `svg`), `csproj` bundle config, new `scripts/publish.{ps1,sh}` helpers. +3. **Empty-grid UI feature** (uncommitted, OpenSpec change `show-chapter-empty-state`): render `chapter-empty.svg` overlay when grid is empty; new headless tests. +4. **New `XmlLanguageDisplay` ViewModel** (untracked): provides display-name localization for the XML chapter language `` selector. + +## Phase Plan (parallel sub-agents) + +| Phase | Layer | Scope | +|---|---|---| +| 1 | Core domain | `src/ChapterTool.Core/**` | +| 2 | Infrastructure | `src/ChapterTool.Infrastructure/**` (incl. deleted-abstraction dangling-ref audit) | +| 3 | Avalonia ViewModels | `ViewModels/**` (incl. new `XmlLanguageDisplay`) | +| 4 | Avalonia Views + bundle config | `Views/**`, `.axaml.cs`, `csproj`, `Info.plist`, `Assets/`, `Services/` | +| 5 | Build / publish | `scripts/**`, `.github/workflows/**`, `.gitignore` | +| 6 | Localization | 3× `resx`, `LocalizationTests.cs` | +| 7 (me) | Cross-cutting differential disproof | Horizontal sweep across all phases | +| 8 (me) | Aggregation | `summary.md`, `fixes-plan.md` | + +## High-Risk Surface Map (preliminary — to be confirmed/denied by phases) + +The following are the most likely defect clusters, listed before reading the diffs so sub-agent findings can be checked against this expectation: + +1. **`XmlLanguageDisplay` global culture mutation** (Phase 3): `TemporaryCurrentUiCulture : IDisposable` mutates `CultureInfo.CurrentCulture`/`CurrentUICulture` process-wide inside a `using`. Race-prone, exception-unsafe if `GetCultureInfo` throws after assignment, and impacts any concurrent culture-sensitive code. **Pre-rated P1 pending Phase 3 evidence.** +2. **`ILocalizationService` deletion dangling references** (Phase 2): If DI registration or any test still references the deleted type → runtime ActivatorException or compile failure. (Build passed → compile is clean; DI/runtime resolution still needs evidence.) +3. **Localization key/placeholder parity** (Phase 6): 91 new keys × 3 languages = lots of room for missing keys or mismatched `{name}` placeholders → runtime `FormatException` or English fallback. +4. **macOS `Info.plist` correctness** (Phase 4): Missing `CFBundleIdentifier`/`CFBundleExecutable`/version keys → bundle won't launch on macOS. +5. **`publish.sh` shell quoting** (Phase 5): Unquoted `$VAR` expansions on paths with spaces → silent partial artifacts. +6. **`ExpressionService` evaluation regression** (Phase 1): The refactor touches the core expression evaluator; verify precedence, div-by-zero, NaN, and that all throw sites now use `ExpressionException` (no dropped error cases). + +## Anti-Pattern Coverage Plan + +The following high-leakage patterns will be checked by every phase AND in the final cross-cutting pass: + +- Default branch on unknown input / unknown diagnostic code / unknown lang code. +- Async failure propagation (cancellation, IO failure → status bar / log panel). +- Half-committed state across persistence + cache + observable collections. +- Deferred-execution context invalidation (culture swap, captured closures, event handlers firing after window close). +- Single-point utility assumptions: encoding (resx UTF-8), time (frame-rate math), collision (lang codes), empty (null `Arguments`). +- Re-entrant UI (rapid button clicks, window close mid-async). + +## Out of Scope (explicitly excluded) + +- `.codex/skills/**` markdown files (tooling docs, no runtime behavior). +- `openspec/changes/**` proposal/design/tasks markdown (planning artifacts). +- Binary icon assets (`icns`/`ico`) — only their wiring (csproj `BuildAction`) is reviewed. +- Subjective translation quality — only structural/behavioral defects (missing keys, placeholder mismatches, UTF-8 mojibake). +- Style/formatting nits. + +## Pre-existing Review Context + +- `docs/review-2026-06-10/` exists but `summary.md` is empty; not a useful baseline. +- `docs/i18n-audit-and-fix-plan.md` documents the i18n refactor as "completed" with green tests; Phase 6 will verify that claim structurally rather than trust it. diff --git a/docs/review-2026-07-05/phase-1-core.md b/docs/review-2026-07-05/phase-1-core.md new file mode 100644 index 0000000..736b6f2 --- /dev/null +++ b/docs/review-2026-07-05/phase-1-core.md @@ -0,0 +1,50 @@ +# Phase 1 — Core Domain Review + +> Scope: `src/ChapterTool.Core/**` changes vs `master` (committed + uncommitted). +> Reviewer: sub-agent bg_b9a2378f + main-agent verification. + +## Phase Summary + +The ExpressionService refactor to structured `ExpressionException(code, message, args)` is consistent across all 18 throw sites. **All 18 `InvalidExpression.*` codes have corresponding `Diagnostic.InvalidExpression.*` keys in all three resx files** — verified programmatically by the main agent (`comm -23` between thrown codes and resx keys returned empty). Importer and diagnostic-arguments changes are structurally safe. One observability gap (no innerException) and one pre-existing overflow edge case noted. + +## Files Reviewed + +- `src/ChapterTool.Core/Diagnostics/ChapterDiagnostic.cs` +- `src/ChapterTool.Core/Editing/ChapterEditingService.cs` +- `src/ChapterTool.Core/Exporting/ChapterOutputProjectionService.cs` +- `src/ChapterTool.Core/Importing/Text/OgmChapterImporter.cs` +- `src/ChapterTool.Core/Importing/Text/XmlChapterImporter.cs` +- `src/ChapterTool.Core/Transform/ExpressionService.cs` +- `src/ChapterTool.Core/Services/IApplicationLogService.cs` +- `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` (boundary) + +## Sub-agent Finding Rejected (FALSE POSITIVE) + +### ~~[P1] MissingOperatorBeforeFunction code missing localization key~~ — **REJECTED** + +The sub-agent claimed `Diagnostic.InvalidExpression.MissingOperatorBeforeFunction` was absent from the resx. **Main-agent verification disproved this**: the key exists at line 161 in all three resx files (`en-US`, `zh-CN`, `ja-JP`). Furthermore, a programmatic `comm -23` between all 18 codes thrown by `ExpressionService.cs` and all `Diagnostic.InvalidExpression.*` keys in `Strings.en-US.resx` returned **zero missing codes**. The i18n refactor's diagnostic-code-to-resx-key coverage is complete. + +## Findings + +### [P2] ExpressionException lacks innerException channel +- **Location:** `src/ChapterTool.Core/Transform/ExpressionService.cs:166-171` +- **Trigger:** Any future throw site that wraps a lower-level exception (parser/format helper) into `ExpressionException` cannot carry the root cause. +- **Impact:** Reduced debuggability. Current throw sites are all direct validation errors (no wrapping needed today), so immediate functional impact is low — but the refactor established a new exception type without the standard `.innerException` ctor, violating the "no swallow of inner exceptions" contract for future maintainers. +- **Fix:** Add `ExpressionException(string code, string message, IReadOnlyDictionary? args = null, Exception? innerException = null)` passing to `base(message, innerException)`. +- **Confidence:** High + +### [P3] Pre-existing overflow risk in double→decimal cast (not introduced by this diff) +- **Location:** `src/ChapterTool.Core/Transform/ExpressionService.cs:~505,508` +- **Trigger:** Extreme-domain inputs (very large exponents, NaN-producing trig/log) cast `double` results to `decimal`, which can throw `OverflowException` — not caught by the existing `InvalidOperationException|FormatException|KeyNotFoundException|DivideByZeroException` handlers. +- **Impact:** Unhandled exception propagates to caller; user sees a crash instead of a diagnostic. +- **Fix:** Add `OverflowException` to the catch list, or clamp NaN/Infinity before cast. +- **Confidence:** Medium (pre-existing, not a regression) + +## 漏检复盘 (Missed-pattern Retrospective) + +- **Unknown input default branch**: Checked `Tokenize`, `ToPostfix`, `EvaluatePostfix` — all unsupported tokens map to explicit diagnostics. Clean. +- **Downstream failure propagation**: Importers still return `ChapterImportResult.Failed(...)` on error. No silent success-on-error introduced. Clean. +- **Half-committed state**: Chapter lists only published on success paths. Clean. +- **Deferred-execution context invalidation**: Expression evaluation is eager over token stream; no captured mutable context. Clean. +- **Single-point utility assumptions**: Placeholder substitution null-safe; diagnostic argument transport null-guarded at call sites. Clean. +- **Evaluation spot-checks**: `1 + 2 * 3` → 7 ✅; `0 ? 2 : 3 + 1` → 4 ✅; `1/0` → DivideByZeroException caught ✅. diff --git a/docs/review-2026-07-05/phase-2-infrastructure.md b/docs/review-2026-07-05/phase-2-infrastructure.md new file mode 100644 index 0000000..6b02245 --- /dev/null +++ b/docs/review-2026-07-05/phase-2-infrastructure.md @@ -0,0 +1,52 @@ +# Phase 2 — Infrastructure Review + +> Scope: `src/ChapterTool.Infrastructure/**` changes vs `master`. +> Reviewer: sub-agent bg_5f185fb5 + main-agent verification. + +## Phase Summary + +`ILocalizationService` / `LocalizationService` deletion leaves **zero C# dangling references** repo-wide (confirmed via `git grep`). One event-invocation race found in `ApplicationLogPanelProvider` (calibrated P2 — real but low-impact in a logging path). The new `IApplicationLogService.EntryAdded` event is a breaking interface change for out-of-tree implementers (P2). + +## Files Reviewed + +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` (full read + verified) +- `src/ChapterTool.Infrastructure/Services/IApplicationLogService.cs` +- `src/ChapterTool.Infrastructure/Importing/Bdmv/BdmvChapterImporter.cs` + +## Dangling-Reference Audit + +``` +$ git grep -n -E "ILocalizationService|LocalizationService" -- "*.cs" +(no output) +``` + +**Verdict: CLEAN.** Zero C# references to the deleted types remain in production or test code. Mentions in docs/OpenSpec markdown are historical narrative, not runtime risk. + +## Findings + +### [P2] Event invocation race in ApplicationLogPanelProvider +- **Location:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs:106` +- **Trigger:** Thread A calls `Capture()` → reaches `EntryAdded?.Invoke(this, entry)`. Thread B unsubscribes (`EntryAdded -= handler`) between the null-check and the delegate invocation. +- **Impact:** Intermittent `NullReferenceException` from the logging path. In practice, low probability (desktop app, logging is near-sequential), but it's the textbook C# event race. An unhandled NRE from `ILogger.Log` could propagate depending on the logging pipeline's exception guards. +- **Fix:** Copy delegate to local before invoking: + ```csharp + var handler = EntryAdded; + handler?.Invoke(this, entry); + ``` + Note: invocation is correctly outside the lock (avoids reentrancy deadlock) — keep it there. +- **Confidence:** High (the race is real; severity calibrated from sub-agent's P1 to P2 given low-impact logging context) + +### [P2] IApplicationLogService.EntryAdded is a breaking interface change +- **Location:** `src/ChapterTool.Core/Services/IApplicationLogService.cs:7` +- **Trigger:** Any external implementer of `IApplicationLogService` compiled against the old interface. +- **Impact:** Compile-time break for out-of-tree consumers. In-repo, the single implementer (`ApplicationLogPanelProvider`) is updated. +- **Fix:** If backward-compat matters, add a default interface implementation: `event EventHandler? EntryAdded { add { } remove { } }` or document the contract change. +- **Confidence:** Medium (repo-local correctness is fine) + +## 漏检复盘 (Missed-pattern Retrospective) + +- **悬空引用**: 全仓 C# 搜索确认删除类型零残留。Clean. +- **接口新增成员**: 仓内实现已覆盖;对外破坏已报 P2。 +- **异步阻塞**: 未引入 `.Result` / `.Wait()`。Clean. +- **类型安全**: 无 `dynamic`、危险强转、空 `catch`。Clean. +- **事件并发反路径**: 命中 `EntryAdded?.Invoke` 竞态(P2)。 diff --git a/docs/review-2026-07-05/phase-3-viewmodels.md b/docs/review-2026-07-05/phase-3-viewmodels.md new file mode 100644 index 0000000..a608994 --- /dev/null +++ b/docs/review-2026-07-05/phase-3-viewmodels.md @@ -0,0 +1,55 @@ +# Phase 3 — Avalonia ViewModels Review + +> Scope: `src/ChapterTool.Avalonia/ViewModels/**` changes vs `master`. +> Reviewer: sub-agent bg_cbd33c95 + main-agent verification. + +## Phase Summary + +The `MainWindowViewModel` i18n refactor is coherent — `LocalizeDiagnostic` uses replace-based formatting (no `FormatException` vector), and all `ApplyEdit`/`LogDiagnostics` call sites within the file now pass localized keys. The new `XmlLanguageDisplay` has a legitimate ambient-culture-mutation design concern (P2). The sub-agent's "selector identity mismatch" finding was **rejected as a false positive** after code inspection. + +## Files Reviewed + +- `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs` (full) +- `src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs` (full) +- `src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs` (full) +- `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs` (full, untracked new file) + +## Sub-agent Finding Rejected (FALSE POSITIVE) + +### ~~[P1] XML language selected-item identity mismatch~~ — **REJECTED** + +The sub-agent claimed `XmlLanguageDisplayOptions` "returns a new array every getter call." **Main-agent verification disproved this:** + +- `XmlLanguageDisplayOptions => xmlLanguageDisplayOptions` returns the **same persistent `ObservableCollection`** every call (`MainWindowViewModel.cs:289`). +- `RefreshXmlLanguageDisplayOptions` preserves identity by design (`MainWindowViewModel.cs:1463-1487`): + - If count unchanged: mutates items in-place via `UpdateFrom()` — item identity preserved. + - If count changed: clears + re-adds — collection identity preserved. +- The `SelectedXmlLanguageDisplayOption` setter matches by **value** (`option.MainText`, `StringComparison.OrdinalIgnoreCase`), not by reference (`:304-305`). +- The getter indexes by `XmlLanguageIndex` integer (`:296-298`), not by reference. + +This is deliberate defensive code that avoids the exact problem the sub-agent flagged. + +## Findings + +### [P2] Ambient culture mutation in XmlLanguageDisplay static helper +- **Location:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs:27, 63-82` +- **Trigger:** `XmlLanguageDisplay.Options(localizer)` temporarily sets `CultureInfo.CurrentCulture` / `CurrentUICulture` (process-global) inside a `using var _ = new TemporaryCurrentUiCulture(...)`. +- **Impact:** During the `using` window, ALL threads in the process see the swapped culture. If a background operation (chapter save, expression eval, formatting) runs concurrently and reads `CurrentCulture`, it gets the wrong culture transiently. The `Options()` call itself is safe on the UI thread (exception-safe: `GetCultureInfo` runs before assignment in the ctor), but the side-effect scope is process-wide. In practice, severity is bounded because `Options()` is called only during culture-change refresh and tool-window construction — but it's an anti-pattern in a static helper. +- **Fix:** Remove `TemporaryCurrentUiCulture` entirely. Compute display labels via explicit resource lookup (`localizer.GetString("XmlLanguage." + code)`) with `CultureInfo.GetCultureInfo(code).DisplayName` called WITHOUT mutating ambient state — `DisplayName` respects the culture's own display name regardless of `CurrentUICulture` for neutral cultures. Cache results per UI culture. +- **Confidence:** High (the design issue is real; severity calibrated from sub-agent's P1 to P2) + +### [P3] Localized diagnostic template can leave unresolved placeholders +- **Location:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1378-1393` +- **Trigger:** Localization template expects placeholders absent from `diagnostic.Arguments` (non-null dictionary missing keys). +- **Impact:** User-visible unresolved `{token}` in status/log text. No crash (replace-based formatter). +- **Fix:** Backfill missing keys from a known-defaults map, or strip unresolved tokens. +- **Confidence:** Medium + +## 漏检复盘 (Missed-pattern Retrospective) + +- **默认分支/未知输入**: `LocalizeDiagnostic` key-miss fallback handled; XmlLanguage index bounds checked. Clean. +- **异步失败路径**: `TextToolViewModel.OnEntryAdded` marshals to UI thread via `Dispatcher.UIThread.Post`. Clean. +- **半完成状态窗口**: `TemporaryCurrentUiCulture` constructor reads culture before assignment — no partial swap on ctor failure. But ambient-mutation design itself is the systemic risk (P2). +- **延迟执行上下文失效**: `Options()` static call lacks call-context guard — flagged as P2. +- **隐式协议/兼容前提**: `SelectorDisplayOption` reference-equality concern investigated and **rejected** — code uses `UpdateFrom` + value-matching. +- **ApplyEdit/LogDiagnostics call sites**: All updated to localized keys; no remaining raw-English callers in-file. diff --git a/docs/review-2026-07-05/phase-4-views.md b/docs/review-2026-07-05/phase-4-views.md new file mode 100644 index 0000000..50404a4 --- /dev/null +++ b/docs/review-2026-07-05/phase-4-views.md @@ -0,0 +1,80 @@ +# Phase 4 — Avalonia Views, Code-Behind & Bundle Config Review + +> Scope: `src/ChapterTool.Avalonia/Views/**`, `csproj`, `Assets/`, `Services/`. +> Reviewer: sub-agent bg_1c8af626 + main-agent verification. + +## Phase Summary + +No P0 found. The macOS `Info.plist` has all required keys with valid values; `CFBundleExecutable=ChapterTool.Avalonia` matches the default assembly name. The real risk is the **silent chmod skip** in `publish.sh` (calibrated P2). Empty-grid overlay binds correctly to `IsChapterGridEmpty` and respects AGENTS.md layout rules. Code-behind async handlers have try/catch. + +## Files Reviewed + +- `src/ChapterTool.Avalonia/Views/MainWindow.axaml` + `.axaml.cs` +- `src/ChapterTool.Avalonia/Views/Tools/SettingsToolView.axaml` +- `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml` + `.axaml.cs` +- `src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj` +- `src/ChapterTool.Avalonia/Assets/MacOS/Info.plist` +- `src/ChapterTool.Avalonia/Services/{AvaloniaWindowService,RuntimeChapterLoadService,RuntimeChapterSaveService}.cs` + +## Info.plist & Bundle Config Audit + +All required keys present and valid: + +| Key | Value | Status | +|-----|-------|--------| +| `CFBundleName` | `ChapterTool` | ✅ | +| `CFBundleDisplayName` | `ChapterTool` | ✅ | +| `CFBundleIdentifier` | `com.tautcony.chaptertool` | ✅ reverse-DNS | +| `CFBundleVersion` | `1.0.0` | ✅ numeric | +| `CFBundleShortVersionString` | `1.0.0` | ✅ numeric | +| `CFBundlePackageType` | `APPL` | ✅ | +| `CFBundleExecutable` | `ChapterTool.Avalonia` | ⚠️ matches default assembly name, but no validation in publish script | +| `CFBundleIconFile` | `app-icon.icns` | ✅ | +| `LSMinimumSystemVersion` | `10.15` | ✅ | +| `NSHighResolutionCapable` | `true` | ✅ | +| `LSApplicationCategoryType` | `public.app-category.developer-tools` | ✅ | + +**csproj**: No explicit `AssemblyName` → defaults to `ChapterTool.Avalonia` (matches `CFBundleExecutable`). No `PublishSingleFile` (no rename risk). `ApplicationIcon` → `Assets\Icons\app-icon.ico` ✅. + +## AGENTS.md UI Compliance + +- No `Canvas`/absolute positioning in reviewed XAML ✅ +- DataGrid columns have `MinWidth` (56, 105.6, 144, 76.8) ✅ +- Empty-grid overlay: `HorizontalAlignment/VerticalAlignment=Center`, `IsHitTestVisible=False`, has automation id ✅ +- Overlay binds `IsVisible="{Binding IsChapterGridEmpty}"` → `Rows.Count == 0` with `OnPropertyChanged` at row mutation ✅ +- No visible Canvas regression ✅ + +## Findings + +### [P2] CFBundleExecutable name consistency + silent chmod skip in publish.sh +- **Location:** `src/ChapterTool.Avalonia/Assets/MacOS/Info.plist:19-20` + `scripts/publish.sh:94-95` +- **Trigger:** `publish.sh` does `[[ -f "$exe_path" ]] && chmod +x "$exe_path"` — if the executable doesn't exist with the expected name `ChapterTool.Avalonia`, the `chmod` is **silently skipped** and the bundle ships without a valid executable. LaunchServices will refuse to launch the `.app`. +- **Impact:** macOS bundle launch failure with no diagnostic at publish time. The names match by default today (no `AssemblyName` override), but there's no assertion/validation to catch drift if assembly naming changes. +- **Fix:** Make the executable check a hard error: + ```bash + [[ -f "$exe_path" ]] || { echo "ERROR: expected executable '$exe_path' not found" >&2; exit 1; } + chmod +x "$exe_path" + ``` +- **Confidence:** High (the silent-skip is real; the name-match risk is conditional on future changes) + +### [P3] MainWindow subscribes commands/Rows without unsubscribe on close +- **Location:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs:498-506` +- **Trigger:** `SubscribeViewModelCommandState()` attaches `CanExecuteChanged` to 18 commands + `Rows.CollectionChanged`. No `OnClosed` / `Detached` unsubscribe in the file. +- **Impact:** For the **main window** (which owns its ViewModel), the subscription graph is self-contained — when the window closes, both subscriber and source are collectable together. Risk only materializes if a ViewModel outlives the window (multi-window scenario). Low practical impact for single-window desktop app. +- **Fix:** Override `OnClosed` and detach, or use weak-event pattern. Defensive but not urgent. +- **Confidence:** High (the missing unsubscribe is real; severity calibrated from sub-agent's P2 to P3) + +### [P3] TextToolView control-level event handler permanently attached +- **Location:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs:18` +- **Trigger:** `DataContextChanged += OnDataContextChanged` attached without corresponding detach. +- **Impact:** Low in Avalonia control lifecycle. The view does call `subscribedViewModel.DetachLiveRefresh()` on DataContext change (`:40`), which handles the subscription leak the sub-agent flagged. Residual reference risk only in complex host scenarios. +- **Fix:** Add detach in `DetachedFromVisualTree` if control can outlive host transitions. +- **Confidence:** Medium + +## 漏检复盘 (Missed-pattern Retrospective) + +- **默认分支/非法输入**: `RuntimeChapterLoadService` has explicit failure diagnostics for invalid paths/unsupported extensions. Clean. +- **异步失败路径**: `OnDrop` has try/catch with status writeback (`:230-255`). Load/Save services propagate IO exceptions. Clean. +- **半提交状态窗口**: `RuntimeChapterSaveService` only appends "Saved" diagnostic after successful file write. Clean. +- **延迟执行上下文失效**: `ScheduleWindowCommandRefresh` uses `Dispatcher.UIThread.Post` — but no close-time unsubscribe (P3). +- **UI 布局规则**: Compliant with AGENTS.md (no Canvas, MinWidth present, overlay responsive). diff --git a/docs/review-2026-07-05/phase-5-scripts.md b/docs/review-2026-07-05/phase-5-scripts.md new file mode 100644 index 0000000..a8ab244 --- /dev/null +++ b/docs/review-2026-07-05/phase-5-scripts.md @@ -0,0 +1,86 @@ +# Phase 5 — Build / Publish Scripts Review + +> Scope: `scripts/**`, `.github/workflows/dotnet-ci.yml`, `.gitignore`. +> Reviewer: sub-agent bg_183f5248 + main-agent verification. + +## Phase Summary + +No P0/P1 found. `publish.sh` has `set -euo pipefail`, no `eval`/backticks, mostly correct quoting. Main risks are argument-parse robustness under `set -u` and interrupt resilience during macOS bundle restructuring. `publish.ps1` is solid PowerShell. CI workflow change (blame-hang flags) is benign. + +## Files Reviewed + +- `scripts/publish.sh` (new, 103 lines) +- `scripts/publish.ps1` (modified, 63 lines) +- `.github/workflows/dotnet-ci.yml` (1 line changed) +- `.gitignore` (1 line: `.omo/`) + +## Shell-script Audit (publish.sh) + +| Line | Pattern | Verdict | +|------|---------|---------| +| `:8` | `set -euo pipefail` | ✅ | +| throughout | quoting | ✅ mostly correct (`"$repo_root"`, `"$output"`) | +| `:8` | no `eval`/backticks | ✅ | +| `:76` | `mkdir -p` | ✅ idempotent | +| `:38-39` | `BASH_SOURCE[0]` for CWD-independence | ✅ | +| `:50-55` | `dotnet publish` args | ✅ valid | +| `:19,23` | arg value guard | ⚠️ missing `$#` check before consuming `$2` | +| `:79` | `for item in "$output"/*` | ⚠️ no `nullglob` → literal `*` on empty | +| `:82` | `mv "$item"` loop | ⚠️ non-atomic, no trap | +| `:94-95` | `[[ -f ... ]] && chmod +x` | ⚠️ silent skip if exe missing (see Phase 4) | + +No secrets/credentials found. RID list consistent with CI matrix (`win-x64`, `linux-x64`, `osx-x64`, `osx-arm64`). + +## PowerShell Audit (publish.ps1) + +- `param()` + `$ErrorActionPreference = "Stop"` ✅ +- CWD-independent via `$PSScriptRoot` ✅ +- `Test-Path` guard before `Remove-Item` ✅ +- `Copy-Item -Force`, `New-Item -ItemType Directory -Force` ✅ +- Missing `[CmdletBinding()]` and `#Requires -Version` (minor) +- No rollback on interrupt during move (`:36-38`) — P3 + +## Findings + +### [P2] Missing value guard in bash arg parsing +- **Location:** `scripts/publish.sh:19, 23` +- **Trigger:** `./scripts/publish.sh -Runtime` (trailing key without value). Under `set -u`, `$2` is unbound → abrupt exit with cryptic shell error. +- **Impact:** Poor operator UX; brittle automation. +- **Fix:** + ```bash + [[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 2; } + ``` +- **Confidence:** High + +### [P2] No trap/cleanup on interrupt during macOS bundle restructuring +- **Location:** `scripts/publish.sh:75-83` +- **Trigger:** SIGINT/termination or command failure mid-loop while moving files into `.app/Contents/MacOS/`. +- **Impact:** Partial bundle state (some files in root, some in `.app`). Re-runs fail or require manual cleanup. +- **Fix:** Add `trap` for `INT TERM` with cleanup of partially built `.app`, or stage into temp dir then atomic `mv` rename. +- **Confidence:** Medium-High + +### [P3] Glob-empty edge case on literal pattern +- **Location:** `scripts/publish.sh:79-83` +- **Trigger:** Empty output dir (interrupted prior step). +- **Impact:** `mv` tries literal `*` → noisy failure. +- **Fix:** `shopt -s nullglob` in local scope or guard with array-length check. +- **Confidence:** High + +### [P3] publish.ps1 no rollback on interrupt during move +- **Location:** `scripts/publish.ps1:36-38` +- **Trigger:** Interrupt during `Copy-Item`/`Remove-Item` sequence. +- **Impact:** Partial artifact state. +- **Fix:** `try/finally` with cleanup, or stage-then-rename pattern. +- **Confidence:** Medium + +## CI Workflow Diff + +`.github/workflows/dotnet-ci.yml:30`: `dotnet test` gains `--blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full`. Improves hang diagnostics. Does not alter matrix, runtime list, or publish wiring. **Benign.** + +## 漏检复盘 (Missed-pattern Retrospective) + +- **默认分支/未知输入**: 参数解析覆盖;发现 bash 缺 `$2` 存在性保护 (P2)。 +- **异步/中断路径**: 串行命令链无竞态;中断导致半成品状态风险存在 (P2/P3)。 +- **半提交状态窗口**: macOS bundle 重组存在"先删后搬"窗口 (P2)。 +- **协议/隐式约定**: README `-Runtime`/`-SelfContained` 与脚本一致;CI matrix 未破坏。 +- **安全边界**: 无 `eval`/反引号/未引用命令替换/密钥泄露。 diff --git a/docs/review-2026-07-05/phase-6-localization.md b/docs/review-2026-07-05/phase-6-localization.md new file mode 100644 index 0000000..fbae1c5 --- /dev/null +++ b/docs/review-2026-07-05/phase-6-localization.md @@ -0,0 +1,77 @@ +# Phase 6 — Localization Consistency Review + +> Scope: 3× `Strings.*.resx` + `LocalizationTests.cs`. +> Reviewer: sub-agent bg_0defc2e9 + main-agent verification. + +## Phase Summary + +**No structural i18n defects found.** All three resx files have exactly 234 keys each, with zero missing/extra keys across languages. All 62 keys containing placeholders have identical placeholder name-sets across all three languages. No duplicate keys, no XML malformation, no UTF-8 mojibake detected. The i18n refactor's structural integrity is solid. + +## Files Reviewed + +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx` (719 lines) +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx` (719 lines) +- `src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx` (719 lines) +- `tests/ChapterTool.Avalonia.Tests/Localization/LocalizationTests.cs` (163 lines) + +## Key Parity Table + +| Culture | Key Count | Missing vs en-US | Extra vs en-US | Duplicates | +|---------|-----------|-------------------|-----------------|------------| +| en-US | 234 | — | — | 0 | +| ja-JP | 234 | 0 | 0 | 0 | +| zh-CN | 234 | 0 | 0 | 0 | + +**Verdict: PERFECT PARITY.** + +## Placeholder Parity + +- Total keys with placeholders: **62** +- Placeholder mismatches across languages: **0** +- Positional `{0}` placeholders: **0** (all use named `{name}` convention) +- Named-placeholder consistency: **100%** + +Spot-checked high-risk keys: `Log.Diagnostic` (6 placeholders: code/details/location/message/operation/severity) — identical in all 3 languages. `Log.ImportOption` (9 placeholders) — identical in all 3 languages. + +## Main-Agent Cross-Verification + +The main agent independently verified ExpressionService diagnostic-code coverage: + +``` +$ comm -23 <(grep -oE 'InvalidExpression\.[A-Za-z]+' ExpressionService.cs | sort -u) \ + <(grep -oE 'name="Diagnostic\.InvalidExpression\.[A-Za-z]+"' en-US.resx | ... | sort -u) +(empty — zero missing codes) +``` + +All 18 `InvalidExpression.*` codes thrown by `ExpressionService.cs` have corresponding `Diagnostic.InvalidExpression.*` keys in the resx. **Coverage complete.** + +## XML / UTF-8 Health + +- All three files: well-formed XML ✅ +- CJK spot-check (byte-level): `日本語`, `簡体字中国語`, `简体中文` — all valid UTF-8, no mojibake signatures (`Ã`, `Â`, U+FFFD) ✅ +- No empty `` where English counterpart is non-empty ✅ + +## Test Coverage + +`LocalizationTests.cs` enforces: +- Key parity: `SupportedCulturesHaveMatchingResourceKeys()` ✅ +- Placeholder parity: `LocalizedFormatStringsUseCompatiblePlaceholders()` ✅ +- Encoding artifacts: `NonEnglishResourcesDoNotContainEncodingArtifacts()` ✅ +- Runtime formatting: `LocalizerFallsBackAndFormatsMessages()` ✅ +- New diagnostic smoke test: `DiagnosticKeysLocalizeAcrossCultures()` ✅ + +**No coverage gap requiring a defect ticket.** + +## Findings + +**None.** This phase is clean. + +## 漏检复盘 (Missed-pattern Retrospective) + +- 键集合不一致: 三语 234 键一致,差集为空。Clean. +- 占位符不一致: 62 键逐一比对,完全一致。Clean. +- 命名/位置占位符混用: 未发现 `{0}` 位置参数。Clean. +- 重复 key: 三文件均无重复。Clean. +- XML 结构损坏: 三文件解析通过。Clean. +- UTF-8 损坏: CJK 字节级 spot-check 正常。Clean. +- 空翻译值: 无非英文空值。Clean. diff --git a/docs/review-2026-07-05/phase-7-crosscutting.md b/docs/review-2026-07-05/phase-7-crosscutting.md new file mode 100644 index 0000000..521446d --- /dev/null +++ b/docs/review-2026-07-05/phase-7-crosscutting.md @@ -0,0 +1,74 @@ +# Phase 7 — Cross-cutting Differential Disproof Pass + +> Horizontal sweep across all phases, independent of phase ordering. Goal: catch systemic patterns that individual phase reviews might miss. + +## Methodology + +Instead of reviewing by layer, this pass sweeps by **anti-pattern category** across the entire diff, looking for instances that might have fallen between phase boundaries. + +## Sweep Results + +### 1. All dispatch/command/protocol entries: default branch, param validation, failure回传 + +| Entry point | Default branch | Param validation | Failure propagation | Verdict | +|-------------|----------------|-------------------|---------------------|---------| +| `ExpressionService.Tokenize/ToPostfix/EvaluatePostfix` | ✅ all token types → explicit diagnostic | ✅ | ✅ `ExpressionException` | Clean | +| Importers (OGM/XML/BDMV) | ✅ unknown format → `ChapterImportResult.Failed` | ✅ extension check | ✅ diagnostics returned | Clean | +| `LocalizeDiagnostic` | ✅ key-miss → fallback to `diagnostic.Message` | ✅ null args guard | ✅ replace-based (no FormatException) | Clean | +| `XmlLanguageDisplay.LanguageDisplayName` | ✅ unknown code → `EnglishDisplayName` fallback | ✅ CultureNotFoundException catch | ✅ | Clean | +| `publish.sh` arg parser | ⚠️ missing `$2` guard | ❌ | `set -u` abort (P2) | Flagged | + +### 2. All async chains: failure, cancellation, timeout, idempotency, context invalidation + +| Async path | Failure handling | Cancellation | Reentrancy | Verdict | +|------------|-----------------|--------------|------------|---------| +| `MainWindow.OnDrop` (async void) | ✅ try/catch → status | N/A (no token) | ✅ command CanExecute | Clean | +| `MainWindow.OnOpened` (async void) | ✅ per Phase 4 | — | — | Clean | +| `MainWindow.OnKeyDown` (async void) | ✅ per Phase 4 | — | command-level | Clean | +| `TextToolViewModel.OnEntryAdded` | ✅ UI-thread marshalled | N/A | ✅ refresh is idempotent | Clean | +| `ColorSettingsViewModel.LoadAsync` | ✅ store-null guard | `CancellationToken.None` | ✅ | Clean | + +**No new `.Result` / `.Wait()` introduced anywhere in the diff.** + +### 3. All state-write chains: "half-committed state from ordering errors" + +| State write | Atomicity | Rollback | Verdict | +|-------------|-----------|----------|---------| +| `RuntimeChapterSaveService` | ✅ "Saved" diagnostic only after successful write | N/A (file write) | Clean | +| `RefreshXmlLanguageDisplayOptions` | ✅ count-check → either clear+add or in-place UpdateFrom | N/A | Clean | +| `ApplicationLogPanelProvider.Capture` | ✅ entry built before lock; added under lock; capacity trim under same lock | N/A | Clean | +| `publish.sh` macOS bundle move | ❌ non-atomic multi-file `mv` loop, no trap | ❌ no rollback (P2) | Flagged | + +### 4. All rebuild/batch/cleanup/migration chains: "remove-then-rebuild data window" + +| Rebuild path | Window risk | Verdict | +|--------------|-------------|---------| +| `RefreshXmlLanguageDisplayOptions` clear+add (count change) | Brief empty collection during clear+add — but UI binding reads on `PropertyChanged` which fires AFTER rebuild completes | Clean | +| `LanguageToolViewModel.ReplaceLanguages` clear+add | Same pattern — `OnPropertyChanged` after rebuild | Clean | +| `publish.sh` `rm -rf "$app_bundle"` then rebuild | Window exists but acceptable for publish artifact (not runtime data) | Acceptable | + +### 5. All content-rendering / rich-text / export chains: security boundary, scale + +| Render point | Input source | Scale boundary | Verdict | +|--------------|-------------|----------------|---------| +| `TextToolViewModel.Format` (JSON/XML pretty-print) | User chapter export text | ✅ catches `JsonException`/`XmlException` → returns raw text | Clean | +| `HighlightJson`/`HighlightXml` | Line-level text | O(n) per line, no regex backtracking | Clean | +| `LocalizeDiagnostic` | Diagnostic args (bounded set) | Replace-based, no composite format injection | Clean | + +### 6. All high-leverage utility functions: encoding, time, collision, naming, compatibility + +| Utility | Risk | Verdict | +|---------|------|---------| +| `XmlLanguageDisplay` culture swap | Process-global mutation (P2) | Flagged | +| `ExpressionService` double→decimal cast | OverflowException uncaught (P3, pre-existing) | Flagged | +| `Uri.IsHexDigit` usage in color parsing | ✅ correct hex validation | Clean | +| `AppLanguage.Normalize` | ✅ culture-name normalization | Clean | +| resx placeholder formatting | ✅ named-only, verified parity | Clean | + +## New Findings from This Pass + +**None.** All patterns flagged in this sweep were already captured by the phase reviews. No cross-cutting issue fell between phase boundaries. + +## Verdict + +The phase decomposition + this horizontal sweep provide **complete coverage** of the diff's high-risk surface. The two highest-risk categories (async/concurrency and state-write atomicity) are clean except for the already-flagged `publish.sh` interrupt window and `XmlLanguageDisplay` ambient mutation. diff --git a/docs/review-2026-07-05/summary.md b/docs/review-2026-07-05/summary.md new file mode 100644 index 0000000..a5b6ea6 --- /dev/null +++ b/docs/review-2026-07-05/summary.md @@ -0,0 +1,107 @@ +# Code Review Summary — `feat/improve-struct` vs `master` + +> Review date: 2026-07-05 +> Scope: all changes (committed + uncommitted) on `feat/improve-struct` vs `master` +> 73 files changed, +4,161 / −810 lines + +## Verdict + +**No P0 (critical/blocker) defects found.** The branch is structurally sound and the build passes (0 warnings / 0 errors). The i18n refactor — the largest portion of the diff — is **structurally complete**: all 234 keys × 3 languages are in parity, all 62 placeholder keys match across languages, and all 18 `InvalidExpression.*` diagnostic codes have resx coverage. The `ILocalizationService` deletion leaves zero dangling references. + +The 12 confirmed findings are all P2/P3 and cluster around three themes: (1) a culture-mutation anti-pattern in a new helper, (2) interrupt-resilience gaps in publish scripts, and (3) minor lifecycle/observability hardening. + +## Finding Count by Severity + +| Severity | Count | Theme | +|----------|-------|-------| +| **P0** | 0 | — | +| **P1** | 0 | — | +| **P2** | 6 | Culture mutation, event race, interface break, arg guard, interrupt cleanup, bundle validation | +| **P3** | 6 | Overflow edge case, unsub lifecycle, unresolved placeholders, glob edge case, ps1 rollback, view detach | +| **Rejected FPs** | 2 | Sub-agent false positives caught by main-agent verification | + +## Findings (P2 → P3) + +### P2-1: Ambient culture mutation in `XmlLanguageDisplay` +- **File:** `src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs:27, 63-82` +- `TemporaryCurrentUiCulture` mutates `CultureInfo.CurrentCulture`/`CurrentUICulture` process-wide inside a `using`. Concurrent background operations reading `CurrentCulture` during this window get the wrong culture. +- **Fix:** Remove ambient mutation; use explicit resource lookup + `CultureInfo.DisplayName` without swapping ambient state. + +### P2-2: Event invocation race in `ApplicationLogPanelProvider` +- **File:** `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs:106` +- `EntryAdded?.Invoke(this, entry)` without local-copy pattern — standard C# event race on concurrent unsubscribe. +- **Fix:** `var handler = EntryAdded; handler?.Invoke(this, entry);` + +### P2-3: `ExpressionException` lacks `innerException` channel +- **File:** `src/ChapterTool.Core/Transform/ExpressionService.cs:166-171` +- New exception type has no `innerException` constructor — future wrapping sites can't carry root cause. +- **Fix:** Add `Exception? innerException = null` ctor overload. + +### P2-4: Breaking interface change on `IApplicationLogService` +- **File:** `src/ChapterTool.Core/Services/IApplicationLogService.cs:7` +- New `EntryAdded` event breaks out-of-tree implementers. In-repo implementer updated. +- **Fix:** Default interface implementation or version the contract if external consumers exist. + +### P2-5: `publish.sh` missing arg-value guard +- **File:** `scripts/publish.sh:19, 23` +- `-Runtime` without value → `set -u` abort with cryptic error. +- **Fix:** `[[ $# -ge 2 ]] || { echo "ERROR: $1 requires a value" >&2; exit 2; }` + +### P2-6: No interrupt cleanup during macOS bundle restructuring + silent chmod skip +- **File:** `scripts/publish.sh:75-83, 94-95` +- Non-atomic multi-file `mv` loop with no `trap`; `chmod +x` silently skipped if executable missing. +- **Fix:** Add `trap` cleanup or stage-then-rename; make exe-existence a hard error. + +### P3-1: Pre-existing `OverflowException` uncaught in ExpressionService +- **File:** `src/ChapterTool.Core/Transform/ExpressionService.cs:~505` +- `double`→`decimal` cast on extreme-domain inputs; not in catch list. Pre-existing, not a regression. + +### P3-2: MainWindow command subscriptions not detached on close +- **File:** `src/ChapterTool.Avalonia/Views/MainWindow.axaml.cs:498-506` +- Self-contained for single-window app; risk only if ViewModel outlives window. + +### P3-3: Localized diagnostic template can leave unresolved placeholders +- **File:** `src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs:1378-1393` +- Non-null but incomplete `Arguments` dict → unresolved `{token}` in UI text. No crash. + +### P3-4: `publish.sh` glob-empty literal pattern +- **File:** `scripts/publish.sh:79-83` +- Empty output dir → `mv` tries literal `*`. + +### P3-5: `publish.ps1` no rollback on interrupt +- **File:** `scripts/publish.ps1:36-38` + +### P3-6: TextToolView permanent DataContextChanged handler +- **File:** `src/ChapterTool.Avalonia/Views/Tools/TextToolView.axaml.cs:18` + +## Rejected False Positives + +Two sub-agent P1 candidates were **rejected after main-agent verification**: + +1. ~~"MissingOperatorBeforeFunction resx key missing"~~ — key exists at line 161 in all 3 resx files; all 18 `InvalidExpression.*` codes have complete coverage. +2. ~~"Selector identity mismatch from rebuilt list"~~ — code deliberately preserves identity via `UpdateFrom()` in-place mutation + value-based selection matching. + +See `agent-findings.md` for full disproof evidence. + +## Cross-module Systemic Issues + +None identified. The branch's changes are well-contained within their layers. The i18n refactor touches Core (diagnostics), Infrastructure (deleted abstraction), and Avalonia (ViewModels + resx) coherently — the boundary contracts (diagnostic codes → resx keys → UI formatting) are intact end-to-end. + +## Patterns Checked and Confirmed Clean (Cross-cutting Pass) + +- All dispatch entries have default branches and param validation (except `publish.sh` arg guard). +- No new `.Result` / `.Wait()` / `async void` outside event handlers. +- No empty catches, no `dynamic`, no unsafe casts. +- No half-committed state in runtime data paths (chapter save, log capture, option refresh). +- resx key/placeholder parity verified programmatically (234 keys × 3 languages, 0 mismatches). +- Zero dangling references to deleted `ILocalizationService`/`LocalizationService` (repo-wide). +- Build: 0 warnings, 0 errors. + +## Coverage + +- **Fully reviewed:** Core (ExpressionService, diagnostics, importers, editing), Infrastructure (log provider, deleted abstraction), Avalonia ViewModels (all 4), Views (MainWindow + tool views), Info.plist + csproj, publish scripts, CI workflow, 3× resx, localization tests. +- **Out of scope:** `.codex/skills/**` markdown, `openspec/changes/**` planning docs, binary icon assets (only wiring reviewed). + +## Recommended Merge Posture + +**Mergeable as-is for functional correctness.** The P2 items are quality/hardening improvements that don't block the branch's stated goals (i18n completion + macOS bundle support). Recommend addressing P2-1 (culture mutation) and P2-6 (publish interrupt resilience) before the next release tag, as they affect runtime correctness and release-artifact integrity respectively. All other items can be follow-up. diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/.openspec.yaml b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/design.md b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/design.md new file mode 100644 index 0000000..649a203 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/design.md @@ -0,0 +1,84 @@ +## Context + +`docs/todo-list.md` is an architectural review checklist derived from legacy WinForms patterns. The current Avalonia/Core/Infrastructure solution already follows several of its recommendations: + +- `MainWindowViewModel` is constructed with `IFilePickerService`, `IWindowService`, `IApplicationLogService`, `ILogger`, settings, shell, editing, import, export, and transform services; it does not expose a `SetMainWindow(Window)` pattern. +- `RuntimeChapterLoadService` resolves importers through `IChapterImporterRegistry` and fallback logic instead of putting extension switches in the ViewModel. +- `DialogRequest`, `IDialogService`, `IWindowService`, and platform service interfaces keep Core and ViewModels away from static UI notification callbacks. +- `ApplicationLogPanelProvider` is bounded, structured, and backed by `Microsoft.Extensions.Logging`. + +The remaining hardening work is mostly about making these boundaries observable and regression-resistant. `ApplicationLogPanelProvider` currently exposes snapshots and formatting, but no event that lets the log window subscribe to new entries. `MainWindow.axaml.cs` still performs some routine state synchronization through `Refresh()`, direct selector assignments, and manual command-state raises. Loading is async and awaited, but the contract that importer work must not mutate observable UI collections should be explicit and tested. + +## Goals / Non-Goals + +**Goals:** + +- Add a bounded, evented UI log surface that supports both history replay and incremental updates. +- Keep structured `ILogger` as the source of diagnostic events while giving the log window a simple event-driven subscription model. +- Reduce main-window code-behind responsibility for routine UI state by moving state, command availability, and option synchronization into observable ViewModel properties and commands. +- Make async load/update threading expectations explicit: importers may perform background IO, but `ObservableCollection` updates happen in ViewModel-controlled UI flow. +- Preserve importer registry/fallback extensibility and platform-service boundaries. +- Add focused tests that prevent regressions without static source-string assertions over implementation files. + +**Non-Goals:** + +- No parser, exporter, chapter model, or file format behavior changes. +- No replacement of the existing OpenSpec capabilities or broad UI redesign. +- No new external logging framework beyond the existing standard logging/Serilog setup. +- No attempt to remove every view-specific code-behind event adapter; file pickers, drag/drop, keyboard adaptation, and layout adaptation can remain view responsibilities. + +## Decisions + +1. Keep `IApplicationLogService` as the UI log abstraction and extend it with evented behavior. + + The log window needs three things: a snapshot for history, an event for new entries, and a clear operation. Extending the existing service keeps the surface small and aligns with `ApplicationLogPanelProvider` already implementing both `IApplicationLogService` and `ILoggerProvider`. + + Alternative considered: introduce a separate `IApplicationLogObservable`. That would make read/subscribe responsibilities explicit, but it adds a second abstraction for the same UI sink and more composition wiring. A separate interface can be introduced later if non-UI sinks need different lifecycle semantics. + +2. Emit events after entries are stored and trimmed. + + Subscribers should see the same entries that `Entries` would expose after capacity enforcement. The event payload should include the newly accepted `ApplicationLogEntry`; subscribers that need full state after a clear can ask for `Entries`. + + Alternative considered: emit before adding the entry. That makes the event lower latency but creates race-prone behavior where a log window receiving the event cannot yet see the entry in the snapshot. + +3. Treat localization as display-time formatting, not event-time rendering. + + Existing `ApplicationLogEntry` supports message keys, arguments, technical detail, category, severity, and exception text. The event should carry the structured entry, and the log view/model should render with the active localizer when displayed. + + Alternative considered: store only rendered text. That is simpler for append-only UI but conflicts with existing language-switch requirements and loses structured details. + +4. Move routine refresh state into ViewModel-observable properties and command state. + + The current code-behind `Refresh()` updates clip selector state, combine menu checks, and command availability. These should be bound to `SelectedClipIndex`, `IsClipCombineChecked`, capability flags, and commands that raise availability changes when relevant state changes. + + Alternative considered: keep `Refresh()` and add more tests around it. That preserves current behavior but keeps state synchronization split across ViewModel and view, which is the same failure mode this change is trying to prevent. + +5. Keep code-behind as an adapter for platform UI events. + + Browse/save dialogs, drag/drop file extraction, DataGrid edit event adaptation, and responsive layout rearrangement are view concerns. The boundary issue is not the presence of code-behind; it is code-behind owning business or routine state synchronization. + +6. Preserve registry-based import resolution and fallback as the only runtime importer routing path. + + `RuntimeChapterImporterRegistry` can keep internal extension switches because it is the registry implementation. ViewModels and windows must call load services and must not branch on file extensions to new parser instances. + +## Risks / Trade-offs + +- [Risk] Log events raised on background threads can update UI-bound collections from the wrong dispatcher. -> Mitigation: the log view/model subscribes through an adapter that marshals collection mutation to the UI dispatcher; tests cover event delivery without requiring a desktop session. +- [Risk] Removing manual `Refresh()` can expose missing property notifications or command availability raises. -> Mitigation: convert in small steps and add ViewModel/headless tests for clip switching, combine toggles, save availability, and frame/advanced option changes. +- [Risk] Extending `IApplicationLogService` affects test fakes and composition. -> Mitigation: update fakes in one pass and keep the new member minimal. +- [Risk] Background importers may report progress from worker threads. -> Mitigation: ViewModel progress and collection updates remain the only observable UI mutations; progress callbacks either update scalar state safely through the command path or are marshaled before touching UI-bound properties. +- [Risk] Static source assertions would be tempting for boundary checks. -> Mitigation: use compiled references, public service seams, ViewModel construction tests, headless UI behavior, and dependency-level tests instead of reading `.cs` or `.axaml` as text. + +## Migration Plan + +1. Extend the log-service contract and update `ApplicationLogPanelProvider`, tests, and log window/view-model subscription behavior. +2. Add tests that prove history replay plus incremental log events work and that clear removes history without disabling future events. +3. Move main-window option state and command availability refreshes into ViewModel properties/commands where they are currently mirrored by code-behind. +4. Reduce `MainWindow.Refresh()` to view-only adaptation or remove it once bindings and command notifications cover the state. +5. Add async load/threading regression tests using deterministic fake importers that complete asynchronously and verify `Rows`, `ClipOptions`, progress, and status update through ViewModel command flow. +6. Validate focused Avalonia and Infrastructure tests, then run the full solution test command before archiving. + +## Open Questions + +- Should `IApplicationLogService` expose a single `EntryAdded` event, or a richer change event that also reports `Cleared`? The lean initial path is `EntryAdded` plus existing `Clear()`, with a richer event only if the log window needs clear notifications for live views. +- Should command-state notification be centralized inside `UiCommand` with observed dependencies, or manually raised from ViewModel setters? Manual raises match the current codebase and are lower risk; a dependency-aware command helper can be considered later if duplication grows. diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/proposal.md b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/proposal.md new file mode 100644 index 0000000..ffaf644 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/proposal.md @@ -0,0 +1,29 @@ +## Why + +`docs/todo-list.md` captures the architectural guardrails learned from the legacy implementation: UI logging must be observable and bounded, ViewModels must stay independent of `Window`, chapter loading must not update observable UI collections from background work, import routing must remain registry-based, and UI prompts must use explicit services. The current Avalonia rewrite largely follows these directions, but the contracts are not fully explicit and a few seams still depend on manual UI refreshes or snapshot-only services, which makes regressions easy as more legacy workflows are restored. + +## What Changes + +- Add an evented application-log contract so the log window can display history and receive newly captured log lines without polling or reconstructing the panel from snapshots. +- Keep the log buffer bounded and structured, including diagnostics from import, export, and external-tool fallback paths. +- Tighten the main-window boundary so `MainWindowViewModel` remains free of Avalonia `Window`/`StorageProvider` dependencies and routine UI state is driven by bindings and observable command state rather than manual code-behind refresh. +- Make the load pipeline contract explicit: background import work may perform IO/parsing, but observable UI collections are updated only by the ViewModel command flow on the UI thread. +- Preserve registry-based importer resolution and fallback, and document that adding formats must register importers instead of expanding ViewModel extension switches. +- Preserve explicit dialog/window/platform services for prompts and notifications; do not reintroduce static notification events or Core UI callbacks. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `supporting-ui-platform-services`: strengthen logging, dialog, window, and platform-service requirements around bounded evented logs and explicit UI notification services. +- `avalonia-ui-shell`: strengthen main-window requirements around ViewModel/window decoupling, binding-driven refresh, command state observability, and UI-thread collection updates after async loads. + +## Impact + +- Affected projects: `src/ChapterTool.Core`, `src/ChapterTool.Infrastructure`, `src/ChapterTool.Avalonia`. +- Affected tests: `tests/ChapterTool.Infrastructure.Tests` for log service behavior and platform services; `tests/ChapterTool.Avalonia.Tests` for ViewModel/window decoupling, command state, headless UI binding behavior, and async load/update behavior. +- No chapter file format, parser, exporter, or external-tool command-line compatibility changes are expected. diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/avalonia-ui-shell/spec.md new file mode 100644 index 0000000..e450755 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Async load updates observable UI state safely +The Avalonia shell SHALL keep file IO and parsing separate from observable UI state mutation during asynchronous loads. + +#### Scenario: Import work completes asynchronously +- **WHEN** a load service or importer completes asynchronously after performing file IO or parsing +- **THEN** the main-window ViewModel SHALL update `Rows`, `ClipOptions`, selected clip state, status, and progress through its command flow +- **AND** background import code SHALL NOT mutate UI-bound `ObservableCollection` instances directly + +#### Scenario: Load progress does not bypass ViewModel state +- **WHEN** an importer reports intermediate progress during a load +- **THEN** progress updates SHALL be surfaced through ViewModel state or command-owned progress handling +- **AND** the view SHALL NOT rely on importer callbacks to update controls directly + +### Requirement: Main window ViewModel stays independent of Avalonia windows +The main-window ViewModel SHALL remain independent of Avalonia `Window`, storage-provider, and control instances. + +#### Scenario: File picking remains view or service responsibility +- **WHEN** a user browses for a source file, MPLS file, chapter-name template, or save directory +- **THEN** the selection SHALL be performed by a file-picker service or view adapter +- **AND** the ViewModel SHALL receive the selected path or cancellation result without holding an Avalonia `Window` + +#### Scenario: Routine state does not require manual window refresh +- **WHEN** clip selection, combine state, save availability, selected rows, frame options, naming options, expression options, or current chapter data changes +- **THEN** the ViewModel SHALL raise observable property or command-availability notifications sufficient for bound controls to update +- **AND** the window SHALL NOT need to run a routine manual refresh method to synchronize that state + +### Requirement: Import formats are routed through the importer registry +The Avalonia shell SHALL route source loading through the load service and importer registry rather than constructing importers from ViewModel or window extension switches. + +#### Scenario: New source format is added +- **WHEN** support for a new chapter source format is introduced +- **THEN** runtime selection SHALL be added through an `IChapterImporterRegistry` implementation or registered importer path +- **AND** `MainWindowViewModel` SHALL continue to call the load service without branching on the file extension + +#### Scenario: Import fallback diagnostics are logged structurally +- **WHEN** the primary importer cannot be invoked and a fallback importer is used +- **THEN** the load pipeline SHALL return or log a structured diagnostic identifying the primary importer, fallback importer, source path context, and reason for fallback diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/supporting-ui-platform-services/spec.md b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/supporting-ui-platform-services/spec.md new file mode 100644 index 0000000..b885497 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/specs/supporting-ui-platform-services/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Application log service is evented and bounded +The application log service SHALL expose recent log history and notify subscribers when new user-visible log entries are captured, while retaining only a bounded in-memory set. + +#### Scenario: Log window receives existing history then new entries +- **WHEN** a log window or log view model is opened after log entries already exist +- **THEN** it SHALL be able to read the existing bounded log history +- **AND** when a subsequent log event passes the UI sink filter it SHALL receive a notification containing the new structured log entry + +#### Scenario: Log buffer remains bounded while events continue +- **WHEN** more log entries are captured than the configured UI log capacity +- **THEN** the log service SHALL retain only the most recent bounded entries +- **AND** it SHALL continue notifying subscribers for newly accepted entries + +#### Scenario: Clearing history does not disable live logging +- **WHEN** the user clears the log window history +- **THEN** the in-memory log history SHALL be empty +- **AND** subsequent captured log entries SHALL still be retained and delivered to subscribers + +### Requirement: UI notifications use explicit services +The application SHALL route user-facing prompts, tool windows, clipboard operations, shell operations, and platform integrations through explicit injectable services rather than static notification events or Core-to-UI callbacks. + +#### Scenario: ViewModels request UI interactions through services +- **WHEN** a ViewModel needs to show a prompt, open an auxiliary window, copy text, open related media, select files, or request a platform-gated action +- **THEN** it SHALL use the corresponding service abstraction or command parameter flow +- **AND** it SHALL NOT require a static notification event or a direct Avalonia `Window` reference + +#### Scenario: Core remains UI notification independent +- **WHEN** Core services import, edit, transform, convert, or export chapters +- **THEN** they SHALL return structured results or diagnostics to callers +- **AND** they SHALL NOT display UI prompts or publish static UI notification events diff --git a/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/tasks.md b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/tasks.md new file mode 100644 index 0000000..5c8396b --- /dev/null +++ b/openspec/changes/archive/2026-07-03-harden-ui-service-boundaries/tasks.md @@ -0,0 +1,36 @@ +## 1. Evented Application Log Service + +- [x] 1.1 Extend the UI log service contract with a structured new-entry notification while preserving snapshot history, formatting, and clear behavior. +- [x] 1.2 Update `ApplicationLogPanelProvider` to raise notifications only for entries accepted by the configured level filter and retained by the bounded buffer. +- [x] 1.3 Add or update Infrastructure tests for history replay, new-entry delivery, capacity trimming, severity filtering, structured state retention, and clear-then-continue behavior. +- [x] 1.4 Update log-window or tool-view behavior so live logs append from service notifications and existing logs still render when the window opens. +- [x] 1.5 Verify log entries continue to format through active localization resources and preserve technical details after language changes. + +## 2. Main-Window ViewModel and Service Boundaries + +- [x] 2.1 Audit `MainWindowViewModel` constructor and command paths to confirm file picking, windows, shell, clipboard, prompts, settings, and platform actions remain behind services or command parameters. +- [x] 2.2 Move routine state currently synchronized by `MainWindow.Refresh()` into observable ViewModel properties and command availability notifications. +- [x] 2.3 Bind combine checked state, clip selection, save availability, related-media availability, and auxiliary command enabled state directly to ViewModel state or commands. +- [x] 2.4 Keep code-behind only for view adaptation: file picker invocation, drag/drop path extraction, DataGrid edit event mapping, shortcut adaptation, and responsive layout adjustments. +- [x] 2.5 Add or update ViewModel and headless UI tests proving state changes update visible controls without calling a routine manual refresh method. + +## 3. Async Load and Import Routing + +- [x] 3.1 Add a deterministic async load test that completes after an await boundary and verifies `Rows`, `ClipOptions`, selected clip state, status text, and progress update through `MainWindowViewModel`. +- [x] 3.2 Ensure importer progress callbacks update ViewModel-owned progress/status state without direct control mutation. +- [x] 3.3 Add or update tests covering importer registry resolution for existing extensions and the fallback cases called out in the current specs. +- [x] 3.4 Verify `MainWindowViewModel` and `MainWindow` continue to call load services without constructing importers or branching on source extensions. +- [x] 3.5 Ensure fallback diagnostics for ffprobe, mkvextract, FLAC embedded CUE, and unsupported sources are returned or logged structurally. + +## 4. Prompt and Notification Boundaries + +- [x] 4.1 Verify Core services return structured results or diagnostics instead of displaying UI prompts or publishing static notification events. +- [x] 4.2 Verify ViewModels route user-facing prompts, tool windows, clipboard operations, related-media launch, file association, and unsupported-platform feedback through explicit services. +- [x] 4.3 Add focused tests for prompt/window service substitution where boundary behavior is not already covered. + +## 5. Validation + +- [x] 5.1 Run `openspec validate "harden-ui-service-boundaries" --strict`. +- [x] 5.2 Run `dotnet test tests/ChapterTool.Infrastructure.Tests/ChapterTool.Infrastructure.Tests.csproj --no-restore`. +- [x] 5.3 Run `dotnet test tests/ChapterTool.Avalonia.Tests/ChapterTool.Avalonia.Tests.csproj --no-restore`. +- [x] 5.4 Run `dotnet test ChapterTool.Avalonia.slnx --no-restore`. diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/.openspec.yaml b/openspec/changes/archive/2026-07-05-enhance-expression-editor/.openspec.yaml new file mode 100644 index 0000000..e089cfa --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-05 diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/design.md b/openspec/changes/archive/2026-07-05-enhance-expression-editor/design.md new file mode 100644 index 0000000..1e8bed1 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/design.md @@ -0,0 +1,42 @@ +## Context + +The current Avalonia shell binds expression input to plain `TextBox` controls in the main window and expression tool. Core already centralizes expression evaluation in `ExpressionService` and emits structured `InvalidExpression.*` diagnostics, but the UI has no editing model for token knowledge, highlighting, completions, or inline correction advice. + +## Goals / Non-Goals + +**Goals:** +- Provide a complete expression editor experience in one implementation pass. +- Keep expression language knowledge in Core so UI and tests do not duplicate token lists. +- Use a real editor foundation for caret, selection, syntax spans, completion, and keyboard behavior. +- Present specific syntax problems and correction suggestions before save/export. +- Reuse one Avalonia editor control in all expression entry points. + +**Non-Goals:** +- Change expression evaluation semantics or add a new expression language. +- Add multi-line scripting or user-defined functions. +- Rebuild a general code editor framework inside ChapterTool. + +## Decisions + +1. Use AvaloniaEdit as the editor foundation. + + AvaloniaEdit is maintained by the Avalonia project and has a 12.x package matching this app's Avalonia 12 stack. It provides text rendering, caret behavior, highlighting transformers, and completion windows. Building equivalent behavior on `TextBox` would require fragile event and overlay code and still miss editor fundamentals. + +2. Add Core expression authoring APIs beside `ExpressionService`. + + `ExpressionAuthoringService` will expose metadata, lexical/classification spans, completions, and validation diagnostics. It will reuse `ExpressionService` evaluation for final grammar checks, keeping runtime behavior aligned with existing transformations. The UI will consume this service rather than parsing source files or hard-coding token lists. + +3. Treat syntax feedback as semantic diagnostics with suggestions. + + Diagnostics will include the existing diagnostic code, localized message text from the shell, and a stable suggestion key/text. The UI can show a compact status line and tooltip, while tests assert behavior through public APIs and rendered controls. + +4. One reusable Avalonia control for all expression inputs. + + `ExpressionEditor` will wrap AvaloniaEdit, bind `Text`, `DiagnosticText`, `SuggestionText`, and completion items, and own Tab acceptance behavior. Main window and expression tool use the same control to avoid drift. + +## Risks / Trade-offs + +- [Risk] AvaloniaEdit adds a new UI dependency. → Mitigation: pin the 12.0.0 package and keep usage isolated under `Views/Controls`. +- [Risk] Analyzer and evaluator grammar can diverge. → Mitigation: Core authoring service uses evaluator validation for final correctness and shares a public metadata catalog for known tokens. +- [Risk] Completion popups can be hard to test headlessly. → Mitigation: test Core completion logic directly and cover the Avalonia control's Tab behavior and diagnostic rendering with headless tests. +- [Risk] Revalidating on every keystroke could refresh rows too aggressively. → Mitigation: validation stays in the editor VM/control path; existing `Expression` binding behavior remains, but diagnostics do not apply transformations independently. diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/proposal.md b/openspec/changes/archive/2026-07-05-enhance-expression-editor/proposal.md new file mode 100644 index 0000000..1a0d0b9 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/proposal.md @@ -0,0 +1,26 @@ +## Why + +Expression input is currently a plain text field, so users must know the token set, function signatures, and grammar rules from memory. Syntax failures surface only after evaluation and do not provide focused correction guidance in the editing surface. + +## What Changes + +- Add first-class expression language metadata in Core for variables, constants, functions, operators, examples, and correction hints. +- Add a reusable expression analyzer that returns syntax classification spans, completion candidates, and diagnostics without applying chapter transformations. +- Replace expression text inputs in the main window and expression tool with a shared Avalonia editor based on AvaloniaEdit. +- Provide syntax highlighting, context-aware completion, Tab acceptance, and localized validation messages with actionable suggestions. +- Keep expression evaluation semantics compatible with the existing `ExpressionService`. + +## Capabilities + +### New Capabilities +- `expression-authoring`: Expression authoring metadata, validation, highlighting classification, completion, and correction suggestions. + +### Modified Capabilities +- `avalonia-ui-shell`: Main and tool expression inputs become full expression editors with highlighting, completion, and validation feedback. + +## Impact + +- `src/ChapterTool.Core`: new expression authoring analyzer APIs layered beside existing evaluator behavior. +- `src/ChapterTool.Avalonia`: add AvaloniaEdit dependency and a reusable expression editor view/control. +- `tests/ChapterTool.Core.Tests`: analyzer and completion coverage. +- `tests/ChapterTool.Avalonia.Tests`: headless coverage for editor rendering, completion, and diagnostics. diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/avalonia-ui-shell/spec.md new file mode 100644 index 0000000..70dd1a6 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Expression editor authoring experience +The Avalonia shell SHALL use a dedicated expression editor for all expression inputs instead of a plain text box. + +#### Scenario: Main expression input uses the expression editor +- **WHEN** the main window is rendered +- **THEN** the expression input SHALL provide syntax highlighting for expression tokens +- **AND** it SHALL expose context-aware completion candidates based on the caret token + +#### Scenario: Expression tool uses the same editor +- **WHEN** the expression tool window is opened +- **THEN** its expression input SHALL use the same editor behavior as the main window + +#### Scenario: Tab accepts completion +- **WHEN** a completion popup is open for an expression prefix +- **THEN** pressing `Tab` SHALL insert the selected completion +- **AND** focus SHALL remain in the expression editor + +#### Scenario: Syntax errors show correction guidance +- **WHEN** the user enters an invalid expression +- **THEN** the editor SHALL display the specific syntax problem +- **AND** it SHALL display a correction suggestion derived from the diagnostic + +#### Scenario: Valid expression clears errors +- **WHEN** the user corrects an invalid expression to a valid expression +- **THEN** the error and suggestion feedback SHALL be cleared diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/chapter-core-transform-export/spec.md b/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/chapter-core-transform-export/spec.md new file mode 100644 index 0000000..64fc5b1 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/specs/chapter-core-transform-export/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Expression authoring metadata +The Core expression subsystem SHALL expose supported variables, constants, functions, and operators as structured metadata for UI authoring features. + +#### Scenario: Metadata exposes supported tokens +- **WHEN** expression authoring metadata is requested +- **THEN** the result SHALL include variables `t` and `fps`, the supported mathematical constants, supported functions with arity, and supported operators with display text + +### Requirement: Expression authoring analysis +The Core expression subsystem SHALL analyze expression text for editor classification, completion, and validation without mutating chapter data. + +#### Scenario: Analyze valid expression +- **WHEN** the expression `t + floor(fps / 2)` is analyzed +- **THEN** the analysis SHALL classify variable, operator, function, punctuation, and number spans +- **AND** it SHALL report no diagnostics + +#### Scenario: Analyze invalid expression +- **WHEN** the expression `t +` is analyzed +- **THEN** the analysis SHALL include an `InvalidExpression.*` diagnostic +- **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user + +#### Scenario: Completion uses caret context +- **WHEN** completions are requested after the prefix `flo` +- **THEN** the result SHALL include `floor` +- **AND** accepting the completion SHALL identify the replacement span for only the prefix token diff --git a/openspec/changes/archive/2026-07-05-enhance-expression-editor/tasks.md b/openspec/changes/archive/2026-07-05-enhance-expression-editor/tasks.md new file mode 100644 index 0000000..bb4799e --- /dev/null +++ b/openspec/changes/archive/2026-07-05-enhance-expression-editor/tasks.md @@ -0,0 +1,17 @@ +## 1. Core Authoring Model + +- [x] 1.1 Add expression metadata, classification, completion, and analysis result models. +- [x] 1.2 Implement `ExpressionAuthoringService` using the evaluator for final validation. +- [x] 1.3 Add Core tests for metadata, highlighting spans, completions, diagnostics, and suggestions. + +## 2. Avalonia Editor + +- [x] 2.1 Add AvaloniaEdit dependency and a reusable expression editor control. +- [x] 2.2 Implement syntax highlighting transformer, completion popup, Tab acceptance, and diagnostic display. +- [x] 2.3 Replace main-window and expression-tool `TextBox` inputs with the shared editor. + +## 3. Localization And Tests + +- [x] 3.1 Add localized editor labels, diagnostic summaries, and correction suggestion strings. +- [x] 3.2 Add Avalonia headless tests for rendering, diagnostics, and Tab completion behavior. +- [x] 3.3 Run OpenSpec validation plus focused Core and Avalonia tests. diff --git a/openspec/changes/archive/2026-07-05-show-chapter-empty-state/.openspec.yaml b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-05-show-chapter-empty-state/design.md b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/design.md new file mode 100644 index 0000000..3f25499 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/design.md @@ -0,0 +1,34 @@ +## Context + +The Avalonia main window already uses a ViewModel-backed `DataGrid` named `ChapterGrid` for chapter rows. The project packages `Assets/**` as content, and `Assets/Images/chapter-empty.svg` is already present but unused. Existing UI guidance requires responsive layout, no WinForms-style absolute positioning, and behavior-level or rendered UI validation for layout changes. + +## Goals / Non-Goals + +**Goals:** +- Show the existing SVG asset centered in the chapter grid area before chapters are loaded. +- Keep the grid surface available for sizing and context-menu behavior. +- Make visibility driven by observable ViewModel state. +- Validate through headless Avalonia runtime tests. + +**Non-Goals:** +- Redesign the chapter grid, columns, or row templates. +- Add new empty-state text or localization strings. +- Change import, export, editing, or command semantics. +- Add a new image-loading dependency. + +## Decisions + +- Add `MainWindowViewModel.IsChapterGridEmpty` as a read-only binding target derived from `Rows.Count`. + This keeps XAML simple and avoids converter or expression-binding assumptions around collection count. + +- Place the empty-state image in a grid overlay above the DataGrid with `IsHitTestVisible="False"`. + The DataGrid remains the owned interaction surface for context menus and keyboard behavior, while the image can be visually centered. + +- Load the SVG using an Avalonia asset URI in XAML. + The existing project already copies assets and Avalonia supports `Image` source loading from packaged assets, so no new service or dependency is needed. + +## Risks / Trade-offs + +- SVG loading behavior can differ by Avalonia version -> Mitigate with a focused app build and headless test that resolves the rendered `Image` control. +- Overlay can accidentally intercept grid input -> Mitigate by setting the overlay non-hit-testable and retaining existing context-menu tests. +- Empty-state visibility can go stale after load/clear -> Mitigate by raising property changed from `Rows.CollectionChanged`. diff --git a/openspec/changes/archive/2026-07-05-show-chapter-empty-state/proposal.md b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/proposal.md new file mode 100644 index 0000000..8c5b092 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/proposal.md @@ -0,0 +1,23 @@ +## Why + +The main chapter grid currently renders as an empty table before a source is loaded, even though `chapter-empty.svg` already exists as an intended empty-state asset. Showing the asset in the grid center makes the unloaded state clearer without changing the load workflow. + +## What Changes + +- Render `Assets/Images/chapter-empty.svg` centered over the chapter grid when no chapter rows are loaded. +- Hide the empty-state visual as soon as chapter rows are available. +- Keep the DataGrid, context menu, columns, and command behavior unchanged. +- Cover the behavior with compiled Avalonia/headless UI tests instead of static source assertions. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `avalonia-ui-shell`: Add a visual empty state for the main chapter grid when no chapters are loaded. + +## Impact + +- Affects `src/ChapterTool.Avalonia` main-window UI and ViewModel state exposed to bindings. +- Affects `tests/ChapterTool.Avalonia.Tests` headless UI coverage. +- No Core, Infrastructure, file format, or dependency changes are intended. diff --git a/openspec/changes/archive/2026-07-05-show-chapter-empty-state/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/specs/avalonia-ui-shell/spec.md new file mode 100644 index 0000000..b087498 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Chapter grid empty-state visual +The Avalonia main window SHALL render the existing chapter empty SVG as a centered visual state when the chapter table has no rows. + +#### Scenario: Empty chapter grid shows SVG +- **WHEN** the main window is rendered before any chapter source has been loaded +- **THEN** the chapter grid area SHALL contain a visible centered image loaded from `Assets/Images/chapter-empty.svg` +- **AND** the chapter grid SHALL remain present as the command and layout surface + +#### Scenario: Loaded chapters hide SVG +- **WHEN** a chapter source is loaded and chapter rows are displayed +- **THEN** the empty-state SVG SHALL be hidden +- **AND** the chapter rows SHALL remain displayed in the grid diff --git a/openspec/changes/archive/2026-07-05-show-chapter-empty-state/tasks.md b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/tasks.md new file mode 100644 index 0000000..88e13d1 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-show-chapter-empty-state/tasks.md @@ -0,0 +1,6 @@ +## 1. Implementation + +- [x] 1.1 Expose observable empty-grid state from the main-window ViewModel. +- [x] 1.2 Render `chapter-empty.svg` centered over the chapter grid when the grid is empty. +- [x] 1.3 Add headless Avalonia coverage for empty and loaded grid visibility. +- [x] 1.4 Validate the OpenSpec change and run focused Avalonia tests. diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/.openspec.yaml b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/.openspec.yaml new file mode 100644 index 0000000..dd9a1d9 --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/design.md b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/design.md new file mode 100644 index 0000000..c66c9ab --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/design.md @@ -0,0 +1,101 @@ +## Context + +当前桌面程序和命令行入口复用同一个 `src/ChapterTool.Avalonia` 可执行文件,但入口逻辑只做了最简陋的参数判断。与此同时,`RuntimeChapterImporterRegistry`、`ChapterExportService` 和 `ChapterConversionService` 已经具备绝大多数基础转换能力,缺的是一个稳定的命令树、可测试的 CLI 应用服务,以及把多 source/edition 导入结果映射到终端语义的策略。 + +这个改动是横跨入口、组合根、Core 导入导出复用和测试覆盖的交叉变更,并且会引入新的外部依赖 `DotMake.CommandLine`,因此需要单独设计。 + +## Goals / Non-Goals + +**Goals:** +- 提供正式的根命令与子命令结构,替代手写参数解析。 +- 允许用户通过 CLI 完成基础章节文件转换,不启动 GUI。 +- 支持把转换结果写入文件或输出到 stdout,便于 shell 管道和脚本调用。 +- 允许用户检查输入文件的 source/edition 结构和诊断信息。 +- 复用现有 Core/Infrastructure 能力,避免复制转换逻辑。 + +**Non-Goals:** +- 不实现 expression、自定义公式、复杂批量模板或交互式编辑。 +- 不把 GUI 的全部保存选项逐一暴露给 CLI。 +- 不新增独立控制台项目;本阶段继续复用 Avalonia 可执行文件,在命中 CLI 子命令时短路退出。 + +## Decisions + +### 1. 在现有 Avalonia 可执行文件中嵌入正式 CLI 命令树 + +采用 `DotMake.CommandLine` 的 class-based 模型,在 `src/ChapterTool.Avalonia` 中定义根命令和 `convert` / `inspect` / `formats` 子命令。`Program.Main` 先判断是否命中了 CLI 命令,如果命中则运行 CLI 并返回退出码;否则保持现有 GUI 启动路径。 + +这样做的原因: +- 现有发布物已经是单一桌面可执行文件,额外拆 console 项目会增加发布和 spec 成本。 +- CLI 只需要复用现有服务,不需要独立 UI 或宿主。 +- `DotMake.CommandLine` 提供帮助、版本、子命令和退出码,能直接取代现有“太儿戏”的解析。 + +备选方案: +- 新建独立 `ChapterTool.Cli` 项目。优点是宿主更纯粹;缺点是发布与测试面扩大,本阶段收益不足。 +- 直接用 `System.CommandLine` 手写。缺点是样板过多,与用户要求不符。 + +### 2. 把 CLI 业务收敛到可测试的应用服务,而不是把逻辑堆在命令类里 + +新增一个 CLI 运行时服务,负责: +- 解析输入路径并选择 importer。 +- 在导入结果中选择 group 和 option。 +- 构造基础 `ChapterExportOptions`。 +- 决定输出到 stdout 还是文件。 +- 渲染 inspect / diagnostics / formats 的文本输出。 + +命令类只负责参数绑定和调用服务。这样测试可以直接覆盖服务逻辑,而不必通过真实进程或 Avalonia 生命周期驱动。 + +备选方案: +- 在命令类中直接拼 importer/exporter 调用。缺点是难测、难复用、入口继续臃肿。 + +### 3. CLI 只暴露“基础转换”选项,并显式禁用高阶能力 + +`convert` 只支持基础导入导出和少量稳定选项: +- 输入文件 +- 输出格式 +- 输出路径或 stdout +- source/edition 选择 +- XML language +- CUE source file name + +内部会把 `ApplyExpression` 固定为 `false`,避免把尚未准备好的高级表达式能力暴露到 CLI。 + +备选方案: +- 把 GUI 的所有导出开关一次性搬到 CLI。缺点是参数面膨胀,且与“暂时不需要 expression 等高阶功能”的范围冲突。 + +### 4. 多 source/edition 选择采用显式索引或 option id + +导入结果可能包含多个 `ChapterInfoGroup` 或 `ChapterSourceOption`。CLI 采用两层显式选择: +- `--group-index` +- `--option-id` 或 `--option-index` + +默认行为是当且仅当结果可唯一确定时自动选择,否则返回非零退出码并输出可选项列表。这样能避免脚本环境里出现隐式猜测。 + +### 5. 退出码与诊断分级分离 + +CLI 服务统一返回: +- `0` 成功 +- `1` 用户输入错误、选择歧义、导入/导出失败 +- `2` 未处理异常 + +同时把 Core/Infrastructure 的 `ChapterDiagnostic` 原样降级渲染到控制台,保留外部工具缺失、解析失败等结构化信息。 + +## Risks / Trade-offs + +- [同一可执行文件同时承担 GUI 和 CLI] → 通过“先识别 CLI,再决定是否进入 Avalonia 生命周期”隔离启动路径,避免无谓创建桌面宿主。 +- [多个入口共享组合逻辑,后续容易漂移] → 抽出 CLI 专用应用服务与 importer/exporter 组合方法,避免在 `Program.cs` 里散落依赖拼装。 +- [导入结果可能多组且含外部工具依赖] → 对歧义和依赖失败使用结构化错误与可选项列表,不做隐式 fallback 之外的猜测。 +- [测试如果走真实进程会很脆弱] → 优先测试 CLI 服务、命令解析与帮助输出,避免依赖完整桌面生命周期。 + +## Migration Plan + +1. 引入 `DotMake.CommandLine` 并新增 CLI 命令定义与运行时服务。 +2. 调整 `Program.Main`:命中 CLI 子命令时执行 CLI,否则继续 GUI。 +3. 为 CLI 服务与入口增加测试。 +4. 运行 solution 测试,确认桌面路径未回归。 + +回滚方式: +- 若 CLI 集成产生桌面启动回归,可移除 `Program.Main` 的 CLI 分支并保留底层服务代码,恢复原有 GUI 启动路径。 + +## Open Questions + +- 暂无必须阻塞实现的开放问题。本阶段默认不支持批量多文件转换;后续如有需要,再在同一命令树下增加新子命令。 diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/proposal.md b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/proposal.md new file mode 100644 index 0000000..23852a8 --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/proposal.md @@ -0,0 +1,26 @@ +## Why + +当前 Avalonia 可执行文件的命令行参数只支持临时拼接的 `--help` / `--version` 和一个启动文件路径,既不稳定也无法承载真正的批处理工作流。仓库已经有成熟的 Core/Infrastructure 导入导出能力,但缺少一个正式的 CLI 外壳把这些能力暴露给脚本、终端和 CI。 + +## What Changes + +- 以 `DotMake.CommandLine` 替换 `Program.cs` 中手写的参数分支,建立可维护的根命令和子命令结构。 +- 新增 `convert` 命令,用于读取章节源文件并导出为目标格式,支持写入文件或直接输出到 stdout。 +- 新增 `inspect` 命令,用于在终端输出导入结果、可选 source/edition、章节概要和诊断信息。 +- 新增 `formats` 命令,用于列出 CLI 支持的输入/输出格式与基础选项。 +- 为 CLI 行为补充规范与自动化测试,明确本阶段不实现 expression 等高阶转换能力。 + +## Capabilities + +### New Capabilities +- `command-line-conversion-workflows`: 定义 ChapterTool 的正式命令行工作流,包括格式发现、基础文件转换、终端输出和结构化退出行为。 + +### Modified Capabilities +- `tests-build-distribution-assets`: 为 CLI 命令解析、基础转换和控制台输出增加自动化测试与构建约束。 + +## Impact + +- 影响 `src/ChapterTool.Avalonia` 的程序入口、命令定义和运行时组合。 +- 复用 `src/ChapterTool.Core` 与 `src/ChapterTool.Infrastructure` 的导入导出、外部工具定位和诊断能力。 +- 引入 `DotMake.CommandLine` NuGet 依赖。 +- 需要为 `tests/ChapterTool.Avalonia.Tests` 增加 CLI 服务与命令层覆盖。 diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/command-line-conversion-workflows/spec.md b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/command-line-conversion-workflows/spec.md new file mode 100644 index 0000000..043af46 --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/command-line-conversion-workflows/spec.md @@ -0,0 +1,92 @@ +## ADDED Requirements + +### Requirement: CLI command tree +The system SHALL expose a maintained command-line interface for ChapterTool through structured commands instead of ad-hoc argument branching. + +#### Scenario: Root command shows help +- **WHEN** the user runs ChapterTool with `--help`, `-h`, or `-?` +- **THEN** the CLI SHALL show generated usage/help output for the root command + +#### Scenario: Version is available from CLI +- **WHEN** the user runs ChapterTool with the CLI version option +- **THEN** the process SHALL print the application version to stdout and exit without launching the GUI + +#### Scenario: Existing startup paths stay on the GUI path +- **WHEN** the user launches ChapterTool with a single existing file-system path and no CLI subcommand or switch +- **THEN** the application SHALL treat that argument as GUI startup input instead of forcing CLI parsing + +### Requirement: Supported formats are discoverable +The system SHALL provide a CLI command that lists supported input and output formats for basic conversion workflows. + +#### Scenario: Formats command lists stable conversion surface +- **WHEN** the user runs the `formats` command +- **THEN** stdout SHALL list the basic input families supported by the runtime importer registry +- **AND** stdout SHALL list the output formats supported by CLI conversion +- **AND** the output SHALL not advertise expression or other high-order transforms as implemented CLI features + +### Requirement: CLI inspection reports import structure +The system SHALL provide a CLI command that inspects an input source and reports selectable chapter sets and diagnostics in terminal-friendly text. + +#### Scenario: Inspect reports available chapter options +- **WHEN** the user runs `inspect ` +- **THEN** stdout SHALL include each imported group and selectable option with stable group/index identifiers +- **AND** the output SHALL identify the default option for each group when one exists + +#### Scenario: Inspect reports import diagnostics +- **WHEN** an importer returns warnings, partial results, or informational diagnostics during `inspect` +- **THEN** the CLI SHALL print those diagnostics with severity and code +- **AND** the process SHALL still succeed when at least one selectable chapter option was produced + +#### Scenario: Inspect fails structurally when import fails +- **WHEN** the input path is unsupported, missing, or the importer reports import failure +- **THEN** the CLI SHALL print a failure summary and diagnostics to stderr +- **AND** the process SHALL exit with a non-zero code + +### Requirement: CLI convert performs basic file conversion +The system SHALL provide a CLI command that imports a chapter source, selects one chapter option, and exports it in a supported output format without launching the GUI. + +#### Scenario: Convert writes a target file +- **WHEN** the user runs `convert --format ` with a file output target or default output path +- **THEN** the CLI SHALL import the requested chapter option +- **AND** it SHALL export using the existing Core/Infrastructure conversion services +- **AND** it SHALL write the output content to the resolved file path + +#### Scenario: Convert writes to stdout +- **WHEN** the user runs `convert --format --stdout` +- **THEN** the CLI SHALL write only the exported chapter content to stdout +- **AND** it SHALL not require an output file path + +#### Scenario: Convert supports basic stable export options +- **WHEN** the user runs `convert` with stable options such as XML language, CUE source file name, or explicit group/option selection +- **THEN** the CLI SHALL map those values into export/import selection behavior without requiring GUI-only state + +#### Scenario: Convert does not apply advanced expressions +- **WHEN** the user runs `convert` for any supported format +- **THEN** the CLI SHALL leave expression-based transformations disabled +- **AND** it SHALL not require or expose expression text parameters in this change + +#### Scenario: Convert fails on ambiguous selection +- **WHEN** the importer returns multiple selectable chapter options and the user did not provide enough selection information +- **THEN** the CLI SHALL exit with a non-zero code +- **AND** stderr SHALL list the available group and option identifiers needed to rerun the command deterministically + +#### Scenario: Convert fails on unsupported output format +- **WHEN** the user requests an output format that the CLI does not support +- **THEN** the CLI SHALL print a validation error to stderr +- **AND** the process SHALL exit with a non-zero code without launching the GUI + +### Requirement: CLI diagnostics and exit codes +The system SHALL provide deterministic console diagnostics and exit codes for CLI workflows. + +#### Scenario: Successful command exits zero +- **WHEN** `formats`, `inspect`, or `convert` completes successfully +- **THEN** the process SHALL exit with code `0` + +#### Scenario: User-facing CLI failure exits non-zero +- **WHEN** CLI argument validation fails, import/export fails, or selection is ambiguous +- **THEN** the process SHALL exit with code `1` + +#### Scenario: Unhandled runtime failure exits with exception code +- **WHEN** a CLI command throws an unhandled exception +- **THEN** the process SHALL print the exception summary to stderr +- **AND** it SHALL exit with code `2` diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/tests-build-distribution-assets/spec.md b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/tests-build-distribution-assets/spec.md new file mode 100644 index 0000000..046a5b8 --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/specs/tests-build-distribution-assets/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: xUnit test migration +The rewrite SHALL migrate and strengthen existing MSTest coverage into .NET 10 tests that run on xUnit v3. + +#### Scenario: Test projects reference xUnit v3 +- **WHEN** test project package references are inspected +- **THEN** Core, Infrastructure, and Avalonia test projects SHALL use xUnit v3 framework packages and a compatible xUnit v3 Visual Studio runner +- **AND** they SHALL NOT retain xUnit v2 framework package references + +#### Scenario: Existing parser tests are preserved +- **WHEN** tests are migrated +- **THEN** equivalent assertions SHALL exist for Expression, ToolKits, OGM, WebVTT, CUE, MPLS, IFO, media chapter import, and SharpDvdInfo behavior + +#### Scenario: FFprobe media reader tests cover adapter behavior +- **WHEN** media import tests run +- **THEN** they SHALL cover successful ffprobe JSON output, missing ffprobe diagnostics, cannot-start diagnostics, process failure diagnostics, timeout/cancellation handling, malformed JSON, unsupported or chapterless media, Unicode chapter names, timestamp fallback from `time_base`, cannot-invoke fallback routing to legacy importers, multi-edition grouping by EDITION_UID, single-edition fallback without EDITION_UID, mixed EDITION_UID presence, and empty chapter output without requiring a real installed command-line tool + +#### Scenario: Fallback routing tests verify automatic degradation +- **WHEN** importer registry/load service tests run +- **THEN** they SHALL cover ffprobe-cannot-invoke-to-ATL fallback for `.mp4`/`.m4a`/`.m4v`, mkvextract-cannot-invoke-to-ffprobe fallback for Matroska-family files, fallback informational diagnostics, invoked-ffprobe-failure-without-ATL-fallback, invoked-mkvextract-failure-without-ffprobe-fallback, and no-fallback diagnostic for extensions without legacy importer support + +#### Scenario: External tool discovery tests avoid machine installation dependencies +- **WHEN** ffprobe discovery tests run +- **THEN** they SHALL cover configured executable path, configured FFmpeg directory path, PATH/search-directory discovery, platform executable naming, and missing-tool diagnostics using fake filesystem/platform probes rather than requiring a real FFmpeg installation + +#### Scenario: External process encoding tests preserve non-ASCII output +- **WHEN** process runner tests exercise redirected stdout and stderr +- **THEN** they SHALL include non-ASCII text and verify it is decoded without platform terminal mojibake + +#### Scenario: Core remains free of platform implementation details +- **WHEN** Core tests verify project and type dependencies +- **THEN** Core SHALL NOT reference registry access, filesystem discovery, FFmpeg probing, MKVToolNix app-bundle probing, or process encoding implementation types + +#### Scenario: Tests are split by responsibility +- **WHEN** tests are organized +- **THEN** Core behavior SHALL live in Core tests, process/native/filesystem behavior SHALL live in Infrastructure tests, and ViewModel commands SHALL live in Avalonia or ViewModel tests + +#### Scenario: CLI workflows are covered without launching desktop UI +- **WHEN** CLI tests run +- **THEN** they SHALL cover CLI token detection, startup-path routing, formats output, inspect output, file conversion, stdout conversion, and representative CLI validation failures +- **AND** they SHALL invoke CLI services or command definitions without starting the Avalonia desktop lifetime + +#### Scenario: xUnit v3 test suite runs through dotnet test +- **WHEN** `dotnet test ChapterTool.Avalonia.slnx --no-restore` runs after restore +- **THEN** Core, Infrastructure, and Avalonia test assemblies SHALL execute under xUnit v3 without framework discovery failures diff --git a/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/tasks.md b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/tasks.md new file mode 100644 index 0000000..9555aae --- /dev/null +++ b/openspec/changes/archive/2026-07-06-add-cli-conversion-workflows/tasks.md @@ -0,0 +1,14 @@ +## 1. CLI contract and composition + +- [x] 1.1 Add `DotMake.CommandLine` and define the root CLI command plus `convert`, `inspect`, and `formats` subcommands. +- [x] 1.2 Update the Avalonia program entrypoint to run CLI commands without launching the desktop lifetime and keep the existing GUI startup path for non-CLI usage. + +## 2. Conversion and output implementation + +- [x] 2.1 Implement a CLI application service that resolves importers, selects chapter groups/options, renders diagnostics, and maps stable CLI options into `ChapterExportOptions`. +- [x] 2.2 Implement file output and stdout output for basic conversions while explicitly leaving expression-based transforms disabled. + +## 3. Verification + +- [x] 3.1 Add automated tests for CLI parsing and service behavior, including formats output, inspect output, deterministic selection, file conversion, stdout conversion, and non-zero failure exits. +- [x] 3.2 Validate the OpenSpec change and run the relevant .NET test commands for the modified solution. diff --git a/openspec/specs/avalonia-ui-shell/spec.md b/openspec/specs/avalonia-ui-shell/spec.md index ae89309..f8e3b9f 100644 --- a/openspec/specs/avalonia-ui-shell/spec.md +++ b/openspec/specs/avalonia-ui-shell/spec.md @@ -99,6 +99,19 @@ The chapter table SHALL use observable row models and commands instead of UI row - **WHEN** selected rows are deleted - **THEN** the ViewModel SHALL update the underlying chapter list through Core operations and refresh numbering, time, and frame values +### Requirement: Chapter grid empty-state visual +The Avalonia main window SHALL render the existing chapter empty SVG as a centered visual state when the chapter table has no rows. + +#### Scenario: Empty chapter grid shows SVG +- **WHEN** the main window is rendered before any chapter source has been loaded +- **THEN** the chapter grid area SHALL contain a visible centered image loaded from `Assets/Images/chapter-empty.svg` +- **AND** the chapter grid SHALL remain present as the command and layout surface + +#### Scenario: Loaded chapters hide SVG +- **WHEN** a chapter source is loaded and chapter rows are displayed +- **THEN** the empty-state SVG SHALL be hidden +- **AND** the chapter rows SHALL remain displayed in the grid + ### Requirement: No WinForms coupling The Avalonia project and ViewModels MUST NOT require WinForms for main-window behavior. @@ -233,6 +246,32 @@ The Avalonia language tool SHALL allow users to choose Simplified Chinese, Engli - **WHEN** the user applies a language selection - **THEN** the selected culture tag SHALL be persisted to settings and applied to the current application localization manager +### Requirement: Expression editor authoring experience +The Avalonia shell SHALL use a dedicated expression editor for all expression inputs instead of a plain text box. + +#### Scenario: Main expression input uses the expression editor +- **WHEN** the main window is rendered +- **THEN** the expression input SHALL provide syntax highlighting for expression tokens +- **AND** it SHALL expose context-aware completion candidates based on the caret token + +#### Scenario: Expression tool uses the same editor +- **WHEN** the expression tool window is opened +- **THEN** its expression input SHALL use the same editor behavior as the main window + +#### Scenario: Tab accepts completion +- **WHEN** a completion popup is open for an expression prefix +- **THEN** pressing `Tab` SHALL insert the selected completion +- **AND** focus SHALL remain in the expression editor + +#### Scenario: Syntax errors show correction guidance +- **WHEN** the user enters an invalid expression +- **THEN** the editor SHALL display the specific syntax problem +- **AND** it SHALL display a correction suggestion derived from the diagnostic + +#### Scenario: Valid expression clears errors +- **WHEN** the user corrects an invalid expression to a valid expression +- **THEN** the error and suggestion feedback SHALL be cleared + ### Requirement: Unified settings command The Avalonia shell SHALL expose a unified Settings command that opens a settings panel for durable application, external tool, appearance, and platform preferences. @@ -259,6 +298,44 @@ The settings panel SHALL organize the durable configurable features discovered f - **WHEN** the settings panel is opened - **THEN** it SHALL expose MKVToolNix/mkvextract path, eac3to path, ffprobe path, and ffmpeg directory fallback controls with browse, clear, and validation status behavior +### Requirement: Async load updates observable UI state safely +The Avalonia shell SHALL keep file IO and parsing separate from observable UI state mutation during asynchronous loads. + +#### Scenario: Import work completes asynchronously +- **WHEN** a load service or importer completes asynchronously after performing file IO or parsing +- **THEN** the main-window ViewModel SHALL update `Rows`, `ClipOptions`, selected clip state, status, and progress through its command flow +- **AND** background import code SHALL NOT mutate UI-bound `ObservableCollection` instances directly + +#### Scenario: Load progress does not bypass ViewModel state +- **WHEN** an importer reports intermediate progress during a load +- **THEN** progress updates SHALL be surfaced through ViewModel state or command-owned progress handling +- **AND** the view SHALL NOT rely on importer callbacks to update controls directly + +### Requirement: Main window ViewModel stays independent of Avalonia windows +The main-window ViewModel SHALL remain independent of Avalonia `Window`, storage-provider, and control instances. + +#### Scenario: File picking remains view or service responsibility +- **WHEN** a user browses for a source file, MPLS file, chapter-name template, or save directory +- **THEN** the selection SHALL be performed by a file-picker service or view adapter +- **AND** the ViewModel SHALL receive the selected path or cancellation result without holding an Avalonia `Window` + +#### Scenario: Routine state does not require manual window refresh +- **WHEN** clip selection, combine state, save availability, selected rows, frame options, naming options, expression options, or current chapter data changes +- **THEN** the ViewModel SHALL raise observable property or command-availability notifications sufficient for bound controls to update +- **AND** the window SHALL NOT need to run a routine manual refresh method to synchronize that state + +### Requirement: Import formats are routed through the importer registry +The Avalonia shell SHALL route source loading through the load service and importer registry rather than constructing importers from ViewModel or window extension switches. + +#### Scenario: New source format is added +- **WHEN** support for a new chapter source format is introduced +- **THEN** runtime selection SHALL be added through an `IChapterImporterRegistry` implementation or registered importer path +- **AND** `MainWindowViewModel` SHALL continue to call the load service without branching on the file extension + +#### Scenario: Import fallback diagnostics are logged structurally +- **WHEN** the primary importer cannot be invoked and a fallback importer is used +- **THEN** the load pipeline SHALL return or log a structured diagnostic identifying the primary importer, fallback importer, source path context, and reason for fallback + #### Scenario: Output defaults are editable - **WHEN** the settings panel is opened - **THEN** it SHALL expose default save format and default XML chapter language rather than the current working values being edited on the main screen diff --git a/openspec/specs/chapter-core-transform-export/spec.md b/openspec/specs/chapter-core-transform-export/spec.md index e126065..4c804c6 100644 --- a/openspec/specs/chapter-core-transform-export/spec.md +++ b/openspec/specs/chapter-core-transform-export/spec.md @@ -155,3 +155,28 @@ The system SHALL export Matroska chapter XML in a legacy-compatible structured f #### Scenario: XML export preserves selected language - **WHEN** XML export runs with a selected ISO chapter language code - **THEN** each `ChapterLanguage` SHALL contain that code exactly when the code is valid + +### Requirement: Expression authoring metadata +The Core expression subsystem SHALL expose supported variables, constants, functions, and operators as structured metadata for UI authoring features. + +#### Scenario: Metadata exposes supported tokens +- **WHEN** expression authoring metadata is requested +- **THEN** the result SHALL include variables `t` and `fps`, the supported mathematical constants, supported functions with arity, and supported operators with display text + +### Requirement: Expression authoring analysis +The Core expression subsystem SHALL analyze expression text for editor classification, completion, and validation without mutating chapter data. + +#### Scenario: Analyze valid expression +- **WHEN** the expression `t + floor(fps / 2)` is analyzed +- **THEN** the analysis SHALL classify variable, operator, function, punctuation, and number spans +- **AND** it SHALL report no diagnostics + +#### Scenario: Analyze invalid expression +- **WHEN** the expression `t +` is analyzed +- **THEN** the analysis SHALL include an `InvalidExpression.*` diagnostic +- **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user + +#### Scenario: Completion uses caret context +- **WHEN** completions are requested after the prefix `flo` +- **THEN** the result SHALL include `floor` +- **AND** accepting the completion SHALL identify the replacement span for only the prefix token diff --git a/openspec/specs/command-line-conversion-workflows/spec.md b/openspec/specs/command-line-conversion-workflows/spec.md new file mode 100644 index 0000000..e69b41c --- /dev/null +++ b/openspec/specs/command-line-conversion-workflows/spec.md @@ -0,0 +1,97 @@ +# command-line-conversion-workflows Specification + +## Purpose +Define the maintained ChapterTool CLI surface for inspecting supported formats and converting chapter sources without launching the desktop UI. + +## Requirements + +### Requirement: CLI command tree +The system SHALL expose a maintained command-line interface for ChapterTool through structured commands instead of ad-hoc argument branching. + +#### Scenario: Root command shows help +- **WHEN** the user runs ChapterTool with `--help`, `-h`, or `-?` +- **THEN** the CLI SHALL show generated usage/help output for the root command + +#### Scenario: Version is available from CLI +- **WHEN** the user runs ChapterTool with the CLI version option +- **THEN** the process SHALL print the application version to stdout and exit without launching the GUI + +#### Scenario: Existing startup paths stay on the GUI path +- **WHEN** the user launches ChapterTool with a single existing file-system path and no CLI subcommand or switch +- **THEN** the application SHALL treat that argument as GUI startup input instead of forcing CLI parsing + +### Requirement: Supported formats are discoverable +The system SHALL provide a CLI command that lists supported input and output formats for basic conversion workflows. + +#### Scenario: Formats command lists stable conversion surface +- **WHEN** the user runs the `formats` command +- **THEN** stdout SHALL list the basic input families supported by the runtime importer registry +- **AND** stdout SHALL list the output formats supported by CLI conversion +- **AND** the output SHALL not advertise expression or other high-order transforms as implemented CLI features + +### Requirement: CLI inspection reports import structure +The system SHALL provide a CLI command that inspects an input source and reports selectable chapter sets and diagnostics in terminal-friendly text. + +#### Scenario: Inspect reports available chapter options +- **WHEN** the user runs `inspect ` +- **THEN** stdout SHALL include each imported group and selectable option with stable group/index identifiers +- **AND** the output SHALL identify the default option for each group when one exists + +#### Scenario: Inspect reports import diagnostics +- **WHEN** an importer returns warnings, partial results, or informational diagnostics during `inspect` +- **THEN** the CLI SHALL print those diagnostics with severity and code +- **AND** the process SHALL still succeed when at least one selectable chapter option was produced + +#### Scenario: Inspect fails structurally when import fails +- **WHEN** the input path is unsupported, missing, or the importer reports import failure +- **THEN** the CLI SHALL print a failure summary and diagnostics to stderr +- **AND** the process SHALL exit with a non-zero code + +### Requirement: CLI convert performs basic file conversion +The system SHALL provide a CLI command that imports a chapter source, selects one chapter option, and exports it in a supported output format without launching the GUI. + +#### Scenario: Convert writes a target file +- **WHEN** the user runs `convert --format ` with a file output target or default output path +- **THEN** the CLI SHALL import the requested chapter option +- **AND** it SHALL export using the existing Core/Infrastructure conversion services +- **AND** it SHALL write the output content to the resolved file path + +#### Scenario: Convert writes to stdout +- **WHEN** the user runs `convert --format --stdout` +- **THEN** the CLI SHALL write only the exported chapter content to stdout +- **AND** it SHALL not require an output file path + +#### Scenario: Convert supports basic stable export options +- **WHEN** the user runs `convert` with stable options such as XML language, CUE source file name, or explicit group/option selection +- **THEN** the CLI SHALL map those values into export/import selection behavior without requiring GUI-only state + +#### Scenario: Convert does not apply advanced expressions +- **WHEN** the user runs `convert` for any supported format +- **THEN** the CLI SHALL leave expression-based transformations disabled +- **AND** it SHALL not require or expose expression text parameters in this change + +#### Scenario: Convert fails on ambiguous selection +- **WHEN** the importer returns multiple selectable chapter options and the user did not provide enough selection information +- **THEN** the CLI SHALL exit with a non-zero code +- **AND** stderr SHALL list the available group and option identifiers needed to rerun the command deterministically + +#### Scenario: Convert fails on unsupported output format +- **WHEN** the user requests an output format that the CLI does not support +- **THEN** the CLI SHALL print a validation error to stderr +- **AND** the process SHALL exit with a non-zero code without launching the GUI + +### Requirement: CLI diagnostics and exit codes +The system SHALL provide deterministic console diagnostics and exit codes for CLI workflows. + +#### Scenario: Successful command exits zero +- **WHEN** `formats`, `inspect`, or `convert` completes successfully +- **THEN** the process SHALL exit with code `0` + +#### Scenario: User-facing CLI failure exits non-zero +- **WHEN** CLI argument validation fails, import/export fails, or selection is ambiguous +- **THEN** the process SHALL exit with code `1` + +#### Scenario: Unhandled runtime failure exits with exception code +- **WHEN** a CLI command throws an unhandled exception +- **THEN** the process SHALL print the exception summary to stderr +- **AND** it SHALL exit with code `2` diff --git a/openspec/specs/supporting-ui-platform-services/spec.md b/openspec/specs/supporting-ui-platform-services/spec.md index ccba334..73059cb 100644 --- a/openspec/specs/supporting-ui-platform-services/spec.md +++ b/openspec/specs/supporting-ui-platform-services/spec.md @@ -259,6 +259,37 @@ Settings-related UI behavior SHALL use injectable services for settings stores, - **WHEN** settings validation, save, reset, or unsupported-platform feedback is displayed - **THEN** visible messages SHALL be formatted through the active localization resources +### Requirement: Application log service is evented and bounded +The application log service SHALL expose recent log history and notify subscribers when new user-visible log entries are captured, while retaining only a bounded in-memory set. + +#### Scenario: Log window receives existing history then new entries +- **WHEN** a log window or log view model is opened after log entries already exist +- **THEN** it SHALL be able to read the existing bounded log history +- **AND** when a subsequent log event passes the UI sink filter it SHALL receive a notification containing the new structured log entry + +#### Scenario: Log buffer remains bounded while events continue +- **WHEN** more log entries are captured than the configured UI log capacity +- **THEN** the log service SHALL retain only the most recent bounded entries +- **AND** it SHALL continue notifying subscribers for newly accepted entries + +#### Scenario: Clearing history does not disable live logging +- **WHEN** the user clears the log window history +- **THEN** the in-memory log history SHALL be empty +- **AND** subsequent captured log entries SHALL still be retained and delivered to subscribers + +### Requirement: UI notifications use explicit services +The application SHALL route user-facing prompts, tool windows, clipboard operations, shell operations, and platform integrations through explicit injectable services rather than static notification events or Core-to-UI callbacks. + +#### Scenario: ViewModels request UI interactions through services +- **WHEN** a ViewModel needs to show a prompt, open an auxiliary window, copy text, open related media, select files, or request a platform-gated action +- **THEN** it SHALL use the corresponding service abstraction or command parameter flow +- **AND** it SHALL NOT require a static notification event or a direct Avalonia `Window` reference + +#### Scenario: Core remains UI notification independent +- **WHEN** Core services import, edit, transform, convert, or export chapters +- **THEN** they SHALL return structured results or diagnostics to callers +- **AND** they SHALL NOT display UI prompts or publish static UI notification events + ### Requirement: Application diagnostics use standard logging middleware The application SHALL route diagnostic logging through standard .NET logging abstractions backed by a structured logging provider instead of using a hand-written in-memory list as the primary logging mechanism. diff --git a/openspec/specs/tests-build-distribution-assets/spec.md b/openspec/specs/tests-build-distribution-assets/spec.md index d24575e..5bb20ea 100644 --- a/openspec/specs/tests-build-distribution-assets/spec.md +++ b/openspec/specs/tests-build-distribution-assets/spec.md @@ -50,6 +50,11 @@ The rewrite SHALL migrate and strengthen existing MSTest coverage into .NET 10 t - **WHEN** tests are organized - **THEN** Core behavior SHALL live in Core tests, process/native/filesystem behavior SHALL live in Infrastructure tests, and ViewModel commands SHALL live in Avalonia or ViewModel tests +#### Scenario: CLI workflows are covered without launching desktop UI +- **WHEN** CLI tests run +- **THEN** they SHALL cover CLI token detection, startup-path routing, formats output, inspect output, file conversion, stdout conversion, and representative CLI validation failures +- **AND** they SHALL invoke CLI services or command definitions without starting the Avalonia desktop lifetime + #### Scenario: xUnit v3 test suite runs through dotnet test - **WHEN** `dotnet test ChapterTool.Avalonia.slnx --no-restore` runs after restore - **THEN** Core, Infrastructure, and Avalonia test assemblies SHALL execute under xUnit v3 without framework discovery failures diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 index bdf2eee..471ea2c 100644 --- a/scripts/publish.ps1 +++ b/scripts/publish.ps1 @@ -1,21 +1,50 @@ param( [string]$Configuration = "Release", [string]$Runtime = "win-x64", - [switch]$SelfContained + [switch]$SelfContained, + [switch]$NoRestore, + [switch]$PublishSingleFile ) $ErrorActionPreference = "Stop" +if (-not $Runtime.StartsWith("win-", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "publish.ps1 only supports Windows runtime identifiers. Use scripts/publish.sh for '$Runtime'." +} + $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" -dotnet restore (Join-Path $repoRoot "ChapterTool.Avalonia.slnx") -dotnet publish (Join-Path $repoRoot "src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj") ` - --configuration $Configuration ` - --runtime $Runtime ` - --self-contained:$SelfContained ` - --output $output ` - -p:PublishSingleFile=false +if (Test-Path $output) { + Remove-Item -Recurse -Force $output +} +New-Item -ItemType Directory -Force -Path $output | Out-Null + +if (-not $NoRestore) { + dotnet restore $project --runtime $Runtime +} + +$selfContainedValue = $SelfContained.IsPresent.ToString().ToLowerInvariant() +$publishSingleFileValue = $PublishSingleFile.IsPresent.ToString().ToLowerInvariant() +$publishArgs = @( + "publish", $project, + "--configuration", $Configuration, + "--runtime", $Runtime, + "--self-contained:$selfContainedValue", + "--output", $output, + "-p:PublishSingleFile=$publishSingleFileValue" +) + +if ($PublishSingleFile) { + $publishArgs += "-p:IncludeNativeLibrariesForSelfExtract=true" +} + +if ($NoRestore) { + $publishArgs += "--no-restore" +} + +dotnet @publishArgs Write-Host "Published ChapterTool Avalonia to $output" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..db3c1ca --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# 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 +set -euo pipefail + +# ---- defaults ---- +Configuration="Release" +Runtime="win-x64" +SelfContained="false" +NoRestore="false" +PublishSingleFile="false" + +usage() { + sed -n '2,8p' "$0" +} + +normalize_bool() { + case "$1" in + true|True|TRUE|1|yes|Yes|YES|on|On|ON) printf 'true' ;; + false|False|FALSE|0|no|No|NO|off|Off|OFF) printf 'false' ;; + *) echo "ERROR: expected boolean value but got '$1'" >&2; exit 2 ;; + esac +} + +require_value() { + local option="$1" + local value="${2-}" + if [[ -z "$value" || "$value" == -* ]]; then + echo "ERROR: $option requires a value" >&2 + exit 2 + fi +} + +# ---- arg parse ---- +while [[ $# -gt 0 ]]; do + case "$1" in + -Configuration) + require_value "$1" "${2-}"; Configuration="$2"; shift 2 ;; + -Configuration=*) + Configuration="${1#*=}"; shift ;; + -Runtime) + require_value "$1" "${2-}"; Runtime="$2"; shift 2 ;; + -Runtime=*) + Runtime="${1#*=}"; shift ;; + -SelfContained) + SelfContained="true"; shift ;; + -SelfContained=*) + SelfContained="$(normalize_bool "${1#*=}")"; shift ;; + -NoRestore) + NoRestore="true"; shift ;; + -NoRestore=*) + NoRestore="$(normalize_bool "${1#*=}")"; shift ;; + -PublishSingleFile) + PublishSingleFile="true"; shift ;; + -PublishSingleFile=*) + PublishSingleFile="$(normalize_bool "${1#*=}")"; shift ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +# ---- paths ---- +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +project="$repo_root/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj" + +if [[ "$SelfContained" == "true" ]]; then + publish_kind="self-contained" +else + publish_kind="framework-dependent" +fi +output="$repo_root/artifacts/publish/$publish_kind/$Runtime" +dotnet_output="$output" +should_bundle_macos="false" + +case "$Runtime" in + osx-*) + if [[ "$(uname -s)" == "Darwin" ]]; then + should_bundle_macos="true" + dotnet_output="$output/.publish-raw" + fi + ;; +esac + +# ---- publish ---- +rm -rf "$output" +mkdir -p "$dotnet_output" + +if [[ "$NoRestore" != "true" ]]; then + dotnet restore "$project" --runtime "$Runtime" +fi + +publish_args=( + publish "$project" + --configuration "$Configuration" + --runtime "$Runtime" + --self-contained:"$SelfContained" + --output "$dotnet_output" + -p:PublishSingleFile="$PublishSingleFile" +) + +if [[ "$PublishSingleFile" == "true" ]]; then + publish_args+=(-p:IncludeNativeLibrariesForSelfExtract=true) +fi + +if [[ "$NoRestore" == "true" ]]; then + publish_args+=(--no-restore) +fi + +dotnet "${publish_args[@]}" + +# ---- macOS .app bundle ---- +# Build a proper .app bundle on macOS so the Dock/Finder show the real icon. +# Runs only for osx RIDs and only when invoked on a macOS host. +case "$Runtime" in + osx-*) + if [[ "$should_bundle_macos" != "true" ]]; then + echo "warning: Runtime is $Runtime but host is not macOS; skipped .app bundling. Run on macOS to produce the .app bundle." >&2 + echo "Published ChapterTool Avalonia to $output" + exit 0 + fi + + app_name="ChapterTool.Avalonia" + app_bundle="$output/$app_name.app" + app_bundle_tmp="$output/.$app_name.app.tmp" + contents_dir="$app_bundle_tmp/Contents" + macos_dir="$contents_dir/MacOS" + resources_dir="$contents_dir/Resources" + + rm -rf "$app_bundle" "$app_bundle_tmp" + mkdir -p "$macos_dir" "$resources_dir" + + trap 'rm -rf "$app_bundle_tmp"' EXIT INT TERM + + # Move everything dotnet emitted into Contents/MacOS/. + shopt -s nullglob + items=("$dotnet_output"/*) + shopt -u nullglob + if (( ${#items[@]} == 0 )); then + echo "ERROR: no publish output found in '$dotnet_output'" >&2 + exit 1 + fi + for item in "${items[@]}"; do + mv "$item" "$macos_dir/" + done + + # Stage bundle metadata. + info_plist_src="$repo_root/src/ChapterTool.Avalonia/Assets/MacOS/Info.plist" + icns_src="$repo_root/src/ChapterTool.Avalonia/Assets/Icons/app-icon.icns" + cp "$info_plist_src" "$contents_dir/Info.plist" + cp "$icns_src" "$resources_dir/app-icon.icns" + + # PkgInfo: 8 bytes, "APPL????" — required by LaunchServices. + printf 'APPL????' > "$contents_dir/PkgInfo" + + # Mark the executable so the bundle is double-clickable. + exe_path="$macos_dir/$app_name" + if [[ ! -f "$exe_path" ]]; then + echo "ERROR: expected executable '$exe_path' not found" >&2 + exit 1 + fi + chmod +x "$exe_path" + + trap - INT TERM + + rm -rf "$dotnet_output" + mv "$app_bundle_tmp" "$app_bundle" + trap - EXIT INT TERM + + # Refresh icon cache so Dock picks up the new art immediately during dev. + touch "$app_bundle" + + echo "Created macOS app bundle at $app_bundle" + ;; + *) + echo "Published ChapterTool Avalonia to $output" + ;; +esac diff --git a/src/ChapterTool.Avalonia/App.axaml b/src/ChapterTool.Avalonia/App.axaml index 4da9edb..5efa59b 100644 --- a/src/ChapterTool.Avalonia/App.axaml +++ b/src/ChapterTool.Avalonia/App.axaml @@ -12,6 +12,7 @@ + diff --git a/src/ChapterTool.Avalonia/App.axaml.cs b/src/ChapterTool.Avalonia/App.axaml.cs index a0a0adb..11c1875 100644 --- a/src/ChapterTool.Avalonia/App.axaml.cs +++ b/src/ChapterTool.Avalonia/App.axaml.cs @@ -18,8 +18,7 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var startupPath = Program.StartupArgs.FirstOrDefault(static arg => !arg.StartsWith("--", StringComparison.Ordinal)); - composition = new AppCompositionRoot(startupPath); + composition = new AppCompositionRoot(Program.GuiStartupPath); desktop.Exit += (_, _) => composition.Dispose(); desktop.MainWindow = composition.CreateMainWindow(); } diff --git a/src/ChapterTool.Avalonia/Assets/Icons/app-icon.icns b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.icns new file mode 100644 index 0000000..b55c003 Binary files /dev/null and b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.icns differ diff --git a/src/ChapterTool.Avalonia/Assets/Icons/app-icon.ico b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.ico new file mode 100644 index 0000000..016f3e4 Binary files /dev/null and b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.ico differ diff --git a/src/ChapterTool.Avalonia/Assets/Icons/app-icon.svg b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.svg index c33f8b8..876e3ce 100644 --- a/src/ChapterTool.Avalonia/Assets/Icons/app-icon.svg +++ b/src/ChapterTool.Avalonia/Assets/Icons/app-icon.svg @@ -1,5 +1,7 @@ - - - - + + + + + + diff --git a/src/ChapterTool.Avalonia/Assets/MacOS/Info.plist b/src/ChapterTool.Avalonia/Assets/MacOS/Info.plist new file mode 100644 index 0000000..c989d55 --- /dev/null +++ b/src/ChapterTool.Avalonia/Assets/MacOS/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleName + ChapterTool + CFBundleDisplayName + ChapterTool + CFBundleIdentifier + com.tautcony.chaptertool + CFBundleVersion + 1.0.0 + CFBundleShortVersionString + 1.0.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleExecutable + ChapterTool.Avalonia + CFBundleIconFile + app-icon.icns + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.developer-tools + + diff --git a/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj b/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj index 338f268..6c822e7 100644 --- a/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj +++ b/src/ChapterTool.Avalonia/ChapterTool.Avalonia.csproj @@ -7,19 +7,24 @@ + + - - - - + + + + + + - - + + + @@ -27,6 +32,7 @@ net10.0 enable enable + Assets\Icons\app-icon.ico diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs new file mode 100644 index 0000000..f8a4f62 --- /dev/null +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliApplication.cs @@ -0,0 +1,470 @@ +using System.Globalization; +using System.Text; +using ChapterTool.Avalonia.Composition; +using ChapterTool.Avalonia.Services; +using ChapterTool.Core.Diagnostics; +using ChapterTool.Core.Exporting; +using ChapterTool.Core.Importing; +using ChapterTool.Core.Models; + +namespace ChapterTool.Avalonia.Cli; + +public sealed class ChapterToolCliApplication( + ICliConsole? console = null, + RuntimeChapterImporterRegistry? importerRegistry = null, + ChapterExportService? exporter = null) +{ + private readonly ICliConsole console = console ?? new SystemCliConsole(); + private readonly RuntimeChapterImporterRegistry importerRegistry = importerRegistry ?? CreateImporterRegistry(); + private readonly ChapterExportService exporter = exporter ?? new ChapterExportService(new Core.Transform.ChapterTimeFormatter(), new Core.Transform.ExpressionService()); + + public int ShowFormats() + { + console.WriteLine("Input formats"); + foreach (var line in SupportedInputFormats()) + { + console.WriteLine($" {line}"); + } + + console.WriteLine(); + console.WriteLine("Output formats"); + foreach (var format in ChapterToolCliSupport.OutputFormats) + { + console.WriteLine($" {format.Name,-12} {format.FileExtension,-18} {format.Description}"); + } + + console.WriteLine(); + console.WriteLine("Scope"); + console.WriteLine(" Basic import/export and terminal output are supported."); + console.WriteLine(" Expression and other advanced transforms are intentionally disabled in CLI."); + return 0; + } + + public async Task InspectAsync(CliInspectRequest request, CancellationToken cancellationToken) + { + var import = await ImportAsync(request.InputPath, cancellationToken); + if (!import.Success) + { + RenderFailure("Import failed.", import.Result.Diagnostics); + return 1; + } + + console.WriteLine($"Source: {Path.GetFullPath(request.InputPath)}"); + console.WriteLine($"Importer: {import.Importer.Id}"); + console.WriteLine($"Groups: {import.Result.Groups.Count}"); + + for (var groupIndex = 0; groupIndex < import.Result.Groups.Count; groupIndex++) + { + var group = import.Result.Groups[groupIndex]; + console.WriteLine(); + console.WriteLine($"[{groupIndex}] {Path.GetFileName(group.SourcePath)}"); + foreach (var optionLine in DescribeGroup(group)) + { + console.WriteLine(optionLine); + } + } + + if (import.Result.Diagnostics.Count > 0) + { + console.WriteLine(); + console.WriteLine("Diagnostics"); + foreach (var line in FormatDiagnostics(import.Result.Diagnostics)) + { + console.WriteLine($" {line}"); + } + } + + return 0; + } + + public async Task ConvertAsync(CliConvertRequest request, CancellationToken cancellationToken) + { + if (!TryValidateRequest(request, out var format, out var errorCode)) + { + return errorCode; + } + + var import = await ImportAsync(request.InputPath, cancellationToken); + if (!import.Success) + { + RenderFailure("Import failed.", import.Result.Diagnostics); + return 1; + } + + var selection = SelectOption(import.Result.Groups, request); + if (selection is not { IsSuccess: true }) + { + RenderFailure(selection?.Message ?? "Selection failed.", selection?.Diagnostics ?? Array.Empty()); + return 1; + } + + var info = selection.Option!.ChapterInfo; + var export = exporter.Export( + info with + { + FramesPerSecond = request.FrameRate ?? info.FramesPerSecond + }, + new ChapterExportOptions( + format.Format, + XmlLanguage: request.XmlLanguage, + SourceFileName: request.SourceFileName, + ApplyExpression: false, + ProjectOutput: true)); + + if (!export.Success) + { + RenderFailure("Export failed.", export.Diagnostics); + return 1; + } + + return await WriteExportOutputAsync(request, format, info, export, cancellationToken); + } + + private bool TryValidateRequest(CliConvertRequest request, out CliOutputFormatDefinition format, out int errorCode) + { + if (request.Stdout && !string.IsNullOrWhiteSpace(request.OutputPath)) + { + console.WriteErrorLine("Options --stdout and --output cannot be used together."); + format = null!; + errorCode = 1; + return false; + } + + if (request.FrameRate is <= 0) + { + console.WriteErrorLine("Frame rate must be greater than zero when --frame-rate is specified."); + format = null!; + errorCode = 1; + return false; + } + + if (!ChapterToolCliSupport.TryParseFormat(request.Format, out format)) + { + console.WriteErrorLine($"Unsupported output format '{request.Format}'."); + console.WriteErrorLine("Run `formats` to see the supported CLI conversion targets."); + errorCode = 1; + return false; + } + + errorCode = 0; + return true; + } + + private async Task WriteExportOutputAsync( + CliConvertRequest request, + CliOutputFormatDefinition format, + ChapterInfo info, + ChapterExportResult export, + CancellationToken cancellationToken) + { + if (request.Stdout) + { + console.Write(export.Content); + return 0; + } + + var targetPath = ResolveOutputPath(request, format, info); + var directory = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(targetPath, export.Content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); + console.WriteLine(targetPath); + + if (export.Diagnostics.Count > 0) + { + foreach (var line in FormatDiagnostics(export.Diagnostics)) + { + console.WriteLine($" {line}"); + } + } + + return 0; + } + + private async Task ImportAsync(string inputPath, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(inputPath)) + { + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "MissingInput", "Input path is required.")); + } + + if (!File.Exists(inputPath) && !Directory.Exists(inputPath)) + { + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "InputNotFound", $"Input path '{inputPath}' was not found.")); + } + + var importer = importerRegistry.Resolve(inputPath); + if (importer is null) + { + return CliImportExecution.Failure(new ChapterDiagnostic(DiagnosticSeverity.Error, "UnsupportedInput", $"No importer is available for '{inputPath}'.")); + } + + var result = await importer.ImportAsync(new ChapterImportRequest(inputPath), cancellationToken); + if (!result.Success) + { + var fallback = importerRegistry.ResolveFallback(inputPath, importer, result); + if (fallback is not null) + { + result = await fallback.ImportAsync(new ChapterImportRequest(inputPath), cancellationToken); + if (result.Success) + { + var diagnostics = result.Diagnostics.Concat([ + new ChapterDiagnostic( + DiagnosticSeverity.Info, + "FallbackImporterUsed", + $"Primary importer '{importer.Id}' could not be invoked; fallback importer '{fallback.Id}' was used.") + ]).ToList(); + + return new CliImportExecution(true, fallback, result with { Diagnostics = diagnostics }); + } + } + } + + return new CliImportExecution(result.Success, importer, result); + } + + private CliSelectionResult? SelectOption(IReadOnlyList groups, CliConvertRequest request) + { + if (groups.Count == 0) + { + return CliSelectionResult.Failure("No chapter groups were imported.", []); + } + + if (!TryResolveGroupIndex(groups, request, out var group, out var failure)) + { + return failure; + } + + if (group is null || group.Options.Count == 0) + { + return CliSelectionResult.Failure($"Group {request.GroupIndex ?? 0} contains no selectable chapter options.", []); + } + + return ResolveOptionFromGroup(group, request); + } + + private bool TryResolveGroupIndex( + IReadOnlyList groups, + CliConvertRequest request, + out ChapterInfoGroup? group, + out CliSelectionResult? failure) + { + var groupIndex = request.GroupIndex ?? (groups.Count == 1 ? 0 : null); + if (groupIndex is null || groupIndex < 0 || groupIndex >= groups.Count) + { + group = null; + failure = CliSelectionResult.Failure( + "Multiple groups are available. Specify --group-index to select one.", + AmbiguousSelectionDiagnostics(groups)); + return false; + } + + group = groups[groupIndex.Value]; + failure = null; + return true; + } + + private CliSelectionResult ResolveOptionFromGroup(ChapterInfoGroup group, CliConvertRequest request) + { + var groupIndex = request.GroupIndex ?? 0; + + if (!string.IsNullOrWhiteSpace(request.OptionId)) + { + return ResolveOptionById(group, request.OptionId, groupIndex); + } + + if (request.OptionIndex is not null) + { + return ResolveOptionByIndex(group, request.OptionIndex.Value, groupIndex); + } + + if (group.Options.Count == 1) + { + return CliSelectionResult.Success(group.Options[0]); + } + + return CliSelectionResult.Failure( + $"Group {groupIndex} has multiple options. Specify --option-id or --option-index.", + AmbiguousSelectionDiagnostics([group], groupIndex)); + } + + private CliSelectionResult ResolveOptionById(ChapterInfoGroup group, string optionId, int groupIndex) + { + var option = group.Options.FirstOrDefault(candidate => + string.Equals(candidate.Id, optionId, StringComparison.OrdinalIgnoreCase)); + if (option is null) + { + return CliSelectionResult.Failure( + $"Option id '{optionId}' was not found in group {groupIndex}.", + AmbiguousSelectionDiagnostics([group], groupIndex)); + } + + return CliSelectionResult.Success(option); + } + + private CliSelectionResult ResolveOptionByIndex(ChapterInfoGroup group, int optionIndex, int groupIndex) + { + if (optionIndex < 0 || optionIndex >= group.Options.Count) + { + return CliSelectionResult.Failure( + $"Option index {optionIndex} is out of range for group {groupIndex}.", + AmbiguousSelectionDiagnostics([group], groupIndex)); + } + + return CliSelectionResult.Success(group.Options[optionIndex]); + } + + private static string ResolveOutputPath(CliConvertRequest request, CliOutputFormatDefinition format, ChapterInfo info) + { + if (!string.IsNullOrWhiteSpace(request.OutputPath)) + { + return Path.GetFullPath(request.OutputPath); + } + + var inputFullPath = Path.GetFullPath(request.InputPath); + var directory = Path.GetDirectoryName(inputFullPath) ?? Environment.CurrentDirectory; + var baseName = Path.GetFileNameWithoutExtension(inputFullPath); + return Path.Combine(directory, baseName + format.FileExtension); + } + + private static IEnumerable DescribeGroup(ChapterInfoGroup group) + { + for (var optionIndex = 0; optionIndex < group.Options.Count; optionIndex++) + { + var option = group.Options[optionIndex]; + var defaultMarker = optionIndex == group.DefaultOptionIndex ? " default" : string.Empty; + yield return string.Create( + CultureInfo.InvariantCulture, + $" ({optionIndex}) id={option.Id} name=\"{option.DisplayName}\" chapters={option.ChapterInfo.Chapters.Count(static chapter => !chapter.IsSeparator)} fps={option.ChapterInfo.FramesPerSecond:0.###}{defaultMarker}"); + } + } + + private IEnumerable SupportedInputFormats() + { + var importers = new[] + { + importerRegistry.Resolve("chapters.txt"), + importerRegistry.Resolve("chapters.csv"), + importerRegistry.Resolve("chapters.xml"), + importerRegistry.Resolve("chapters.vtt"), + importerRegistry.Resolve("chapters.cue"), + importerRegistry.Resolve("chapters.flac"), + importerRegistry.Resolve("chapters.tak"), + importerRegistry.Resolve("chapters.mpls"), + importerRegistry.Resolve("chapters.ifo"), + importerRegistry.Resolve("chapters.xpl"), + importerRegistry.Resolve("chapters.mkv"), + importerRegistry.Resolve("chapters.mp4") + } + .OfType() + .DistinctBy(static importer => importer.Id) + .OrderBy(static importer => importer.Id, StringComparer.Ordinal); + + foreach (var importer in importers) + { + var extensions = string.Join(", ", importer.SupportedExtensions.OrderBy(static extension => extension, StringComparer.OrdinalIgnoreCase)); + yield return $"{importer.Id,-20} {extensions}"; + } + + yield return "bdmv-directory BDMV/PLAYLIST directory"; + } + + private IReadOnlyList AmbiguousSelectionDiagnostics(IReadOnlyList groups, int groupOffset = 0) + { + var diagnostics = new List(); + for (var localGroupIndex = 0; localGroupIndex < groups.Count; localGroupIndex++) + { + var group = groups[localGroupIndex]; + var groupIndex = localGroupIndex + groupOffset; + diagnostics.Add(new ChapterDiagnostic( + DiagnosticSeverity.Info, + "AvailableGroup", + $"group={groupIndex} default-option-index={group.DefaultOptionIndex} source={group.SourcePath}")); + for (var optionIndex = 0; optionIndex < group.Options.Count; optionIndex++) + { + var option = group.Options[optionIndex]; + diagnostics.Add(new ChapterDiagnostic( + DiagnosticSeverity.Info, + "AvailableOption", + $"group={groupIndex} option-index={optionIndex} option-id={option.Id} name={option.DisplayName}")); + } + } + + return diagnostics; + } + + private IEnumerable FormatDiagnostics(IEnumerable diagnostics) => + diagnostics.Select(static diagnostic => $"{diagnostic.Severity.ToString().ToUpperInvariant()} {diagnostic.Code}: {diagnostic.Message}"); + + private void RenderFailure(string message, IReadOnlyList diagnostics) + { + console.WriteErrorLine(message); + foreach (var line in FormatDiagnostics(diagnostics)) + { + console.WriteErrorLine($" {line}"); + } + } + + private static RuntimeChapterImporterRegistry CreateImporterRegistry() + { + var formatter = new Core.Transform.ChapterTimeFormatter(); + var settingsDirectory = Path.Combine(Path.GetTempPath(), "ChapterTool.Cli"); + Directory.CreateDirectory(settingsDirectory); + var appSettingsStore = new Infrastructure.Configuration.AppSettingsStore(settingsDirectory); + var toolLocator = new Infrastructure.Tools.ExternalToolLocator(appSettingsStore, AppCompositionRoot.PathSearchDirectoriesForTests().ToList()); + return new RuntimeChapterImporterRegistry( + formatter, + toolLocator, + AppCompositionRoot.CreateProcessRunner(), + new Infrastructure.Importing.Media.FfprobeMediaChapterReader(toolLocator, AppCompositionRoot.CreateProcessRunner()), + AppCompositionRoot.CreateMp4ChapterReader()); + } + + private sealed record CliImportExecution(bool Success, IChapterImporter Importer, ChapterImportResult Result) + { + public static CliImportExecution Failure(params ChapterDiagnostic[] diagnostics) => + new(false, new NullImporter(), new ChapterImportResult(false, [], diagnostics)); + } + + private sealed class NullImporter : IChapterImporter + { + public string Id => "none"; + + public IReadOnlySet SupportedExtensions { get; } = new HashSet(); + + public ValueTask ImportAsync(ChapterImportRequest request, CancellationToken cancellationToken) => + ValueTask.FromResult(ChapterImportResult.Failed(new ChapterDiagnostic(DiagnosticSeverity.Error, "Unavailable", "Importer is unavailable."))); + } +} + +public sealed record CliInspectRequest(string InputPath); + +public sealed record CliConvertRequest( + string InputPath, + string Format, + string? OutputPath, + bool Stdout, + int? GroupIndex, + int? OptionIndex, + string? OptionId, + string? XmlLanguage, + string? SourceFileName, + double? FrameRate); + +public sealed record CliSelectionResult(bool IsSuccess, ChapterSourceOption? Option, string Message, IReadOnlyList Diagnostics) +{ + public static CliSelectionResult Success(ChapterSourceOption option) => new(true, option, string.Empty, []); + + public static CliSelectionResult Failure(string message, IReadOnlyList diagnostics) => new(false, null, message, diagnostics); +} + +public static class CliInputResolver +{ + public static string? Resolve(string? argumentInput, string? sourceOption) => + !string.IsNullOrWhiteSpace(sourceOption) + ? sourceOption + : string.IsNullOrWhiteSpace(argumentInput) ? null : argumentInput; +} diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs new file mode 100644 index 0000000..868d9be --- /dev/null +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliCommands.cs @@ -0,0 +1,115 @@ +using DotMake.CommandLine; + +namespace ChapterTool.Avalonia.Cli; + +[CliCommand( + Description = "ChapterTool command-line workflows", + Children = [typeof(LoadCliCommand), typeof(ConvertCliCommand), typeof(InspectCliCommand), typeof(FormatsCliCommand)])] +public sealed class ChapterToolRootCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} + +[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "Launch the GUI and load a source path")] +public sealed class LoadCliCommand +{ + [CliArgument(Description = "Input file or supported source path", Required = false)] + public string Input { get; set; } = string.Empty; + + [CliOption(Alias = "-i", Description = "Input file or supported source path.", Required = false)] + public string? Source { get; set; } + + public int Run() + { + return 0; + } +} + +[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "Convert a chapter source into another format")] +public sealed class ConvertCliCommand +{ + [CliArgument(Description = "Input file or supported source path", Required = false)] + public string Input { get; set; } = string.Empty; + + [CliOption(Alias = "-i", Description = "Input file or supported source path.", Required = false)] + public string? Source { get; set; } + + [CliOption( + Description = "Output format. Run `formats` to see the supported values.", + Required = false, + AllowedValues = ["txt", "xml", "qpf", "timecodes", "tsmuxer", "cue", "json", "vtt", "celltimes", "chapter2qpf"])] + public string Format { get; set; } = "txt"; + + [CliOption(Description = "Output file path. If omitted, ChapterTool writes next to the input file.", Required = false)] + public string? Output { get; set; } + + [CliOption(Alias = "-s", Description = "Write converted content to stdout instead of a file.", Required = false)] + public bool Stdout { get; set; } + + [CliOption(Description = "Imported group index to use when the source exposes multiple groups.", Required = false)] + public int? GroupIndex { get; set; } + + [CliOption(Description = "Imported option index to use inside the selected group.", Required = false)] + public int? OptionIndex { get; set; } + + [CliOption(Description = "Imported option id to use inside the selected group.", Required = false)] + public string? OptionId { get; set; } + + [CliOption(Description = "Chapter language code for XML export.", Required = false)] + public string? XmlLanguage { get; set; } + + [CliOption(Description = "Source file name to embed in CUE export.", Required = false)] + public string? SourceFileName { get; set; } + + [CliOption(Description = "Override frame rate for frame-based exports.", Required = false)] + public double? FrameRate { get; set; } + + public async Task RunAsync() + { + var app = new ChapterToolCliApplication(); + return await app.ConvertAsync( + new CliConvertRequest( + CliInputResolver.Resolve(Input, Source) ?? string.Empty, + Format, + Output, + Stdout, + GroupIndex, + OptionIndex, + OptionId, + XmlLanguage, + SourceFileName, + FrameRate), + CancellationToken.None); + } +} + +[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "Inspect available chapter groups, options, and diagnostics")] +public sealed class InspectCliCommand +{ + [CliArgument(Description = "Input file or supported source path", Required = false)] + public string Input { get; set; } = string.Empty; + + [CliOption(Alias = "-i", Description = "Input file or supported source path.", Required = false)] + public string? Source { get; set; } + + public async Task RunAsync() + { + var app = new ChapterToolCliApplication(); + return await app.InspectAsync( + new CliInspectRequest(CliInputResolver.Resolve(Input, Source) ?? string.Empty), + CancellationToken.None); + } +} + +[CliCommand(Parent = typeof(ChapterToolRootCliCommand), Description = "List CLI-supported input and output formats")] +public sealed class FormatsCliCommand +{ + public int Run() + { + var app = new ChapterToolCliApplication(); + return app.ShowFormats(); + } +} diff --git a/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs b/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs new file mode 100644 index 0000000..e27910a --- /dev/null +++ b/src/ChapterTool.Avalonia/Cli/ChapterToolCliSupport.cs @@ -0,0 +1,78 @@ +using ChapterTool.Core.Exporting; +using DotMake.CommandLine; + +namespace ChapterTool.Avalonia.Cli; + +internal static class ChapterToolCliSupport +{ + private static readonly CliSettings ParseSettings = new() + { + EnableDefaultExceptionHandler = false + }; + + public static CliLaunchPlan AnalyzeLaunch(IReadOnlyList args) + { + if (args.Count == 0) + { + return CliLaunchPlan.None; + } + + var parsed = DotMake.CommandLine.Cli.Parse([.. args], ParseSettings); + if (parsed.IsCalled()) + { + var startupPath = parsed.BindCalled() is LoadCliCommand command + ? CliInputResolver.Resolve(command.Input, command.Source) + : null; + return CliLaunchPlan.Gui(startupPath); + } + + var shouldRunCli = parsed.IsCalled() + || parsed.IsCalled() + || parsed.IsCalled() + || parsed.HasTokens; + return shouldRunCli ? CliLaunchPlan.Cli(parsed) : CliLaunchPlan.None; + } + + public static IReadOnlyList OutputFormats { get; } = + [ + new("txt", ChapterExportFormat.Txt, ".txt", "OGM chapter pairs"), + new("xml", ChapterExportFormat.Xml, ".xml", "Matroska chapter XML"), + new("qpf", ChapterExportFormat.Qpfile, ".qpf", "QPFile keyframe list"), + new("timecodes", ChapterExportFormat.TimeCodes, ".TimeCodes.txt", "Chapter start times only"), + new("tsmuxer", ChapterExportFormat.TsMuxerMeta, ".TsMuxeR_Meta.txt", "tsMuxeR meta chapter list"), + new("cue", ChapterExportFormat.Cue, ".cue", "CUE sheet"), + new("json", ChapterExportFormat.Json, ".json", "Structured JSON chapter payload"), + new("vtt", ChapterExportFormat.WebVtt, ".vtt", "WebVTT cue list"), + new("celltimes", ChapterExportFormat.Celltimes, ".txt", "Celltimes frame list"), + new("chapter2qpf", ChapterExportFormat.Chapter2Qpfile, ".qpf", "OGM-to-QPFile conversion") + ]; + + public static bool TryParseFormat(string value, out CliOutputFormatDefinition definition) + { + var match = OutputFormats.FirstOrDefault(format => + string.Equals(format.Name, value, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + definition = OutputFormats[0]; + return false; + } + + definition = match; + return true; + } +} + +internal sealed record CliLaunchPlan(bool LaunchGui, string? GuiStartupPath, CliRunnableResult? CliResult) +{ + public static CliLaunchPlan None { get; } = new(false, null, null); + + public static CliLaunchPlan Gui(string? startupPath) => new(true, startupPath, null); + + public static CliLaunchPlan Cli(CliRunnableResult result) => new(false, null, result); +} + +public sealed record CliOutputFormatDefinition( + string Name, + ChapterExportFormat Format, + string FileExtension, + string Description); diff --git a/src/ChapterTool.Avalonia/Cli/CliConsole.cs b/src/ChapterTool.Avalonia/Cli/CliConsole.cs new file mode 100644 index 0000000..86e59cf --- /dev/null +++ b/src/ChapterTool.Avalonia/Cli/CliConsole.cs @@ -0,0 +1,23 @@ +namespace ChapterTool.Avalonia.Cli; + +public interface ICliConsole +{ + void Write(string text); + + void WriteLine(string text = ""); + + void WriteError(string text); + + void WriteErrorLine(string text = ""); +} + +public sealed class SystemCliConsole : ICliConsole +{ + public void Write(string text) => Console.Out.Write(text); + + public void WriteLine(string text = "") => Console.Out.WriteLine(text); + + public void WriteError(string text) => Console.Error.Write(text); + + public void WriteErrorLine(string text = "") => Console.Error.WriteLine(text); +} diff --git a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs index 31c4dd6..4876bbd 100644 --- a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs +++ b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs @@ -16,7 +16,6 @@ using Serilog; using Serilog.Core; using Serilog.Events; -using ILogger = Serilog.ILogger; namespace ChapterTool.Avalonia.Composition; @@ -107,20 +106,21 @@ public IWindowService CreateWindowService() => themeSettingsStore, themeApplicationService, localizationManager, - owner => new AvaloniaSettingsPickerService(owner), + owner => new AvaloniaSettingsPickerService(owner, localizationManager), CreateExternalToolLocator(), - new AvaloniaSettingsCloseConfirmationService(localizationManager)); + new AvaloniaSettingsCloseConfirmationService(localizationManager), + shellService: CreateShellService()); public IAppLocalizer CreateLocalizer() => localizationManager; public static IShellService CreateShellService() => new ShellService(); - public static IFilePickerService CreateFilePickerService(Window owner) => new AvaloniaFilePickerService(owner); + public IFilePickerService CreateFilePickerService(Window owner) => new AvaloniaFilePickerService(owner, localizationManager); public IExternalToolLocator CreateExternalToolLocator() => new ExternalToolLocator(appSettingsStore, PathSearchDirectories().ToList()); - public IProcessRunner CreateProcessRunner() => new ProcessRunner(); + public static IProcessRunner CreateProcessRunner() => new ProcessRunner(); public static INativeDependencyService CreateNativeDependencyService() => new FileSystemNativeDependencyService(PathSearchDirectories().Prepend(AppContext.BaseDirectory).ToList()); @@ -186,4 +186,6 @@ private static IEnumerable PathSearchDirectories() yield return part; } } + + internal static IEnumerable PathSearchDirectoriesForTests() => PathSearchDirectories(); } diff --git a/src/ChapterTool.Avalonia/Localization/AppLocalizationResources.cs b/src/ChapterTool.Avalonia/Localization/AppLocalizationResources.cs index 7054138..ea688a2 100644 --- a/src/ChapterTool.Avalonia/Localization/AppLocalizationResources.cs +++ b/src/ChapterTool.Avalonia/Localization/AppLocalizationResources.cs @@ -27,7 +27,7 @@ private static IReadOnlyDictionary LoadCulture(string cultureNam var values = new Dictionary(StringComparer.Ordinal); foreach (DictionaryEntry entry in resourceSet) { - if (entry.Key is string key && entry.Value is string value) + if (entry is { Key: string key, Value: string value }) { values[key] = value; } diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx index 6d93d29..a374b31 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx @@ -12,382 +12,795 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + + + Combine segments: options={options}, sourceType={sourceType} + + + Delete rows: indexes={indexes} + + + Edit {kind}: row={row}, value='{value}' + + + Edit chapters + + + Insert row: index={index} + + + Shift frames forward: frames={frames} + + + Split combined segments: options={options}, sourceType={sourceType} + + [VCB-Studio] ChapterTool - + Apply - + Cancel - + Clear - + Copy - + Refresh - + Save - + This platform-gated feature is not enabled in this build. - + Use - + + The operation was cancelled. + + + The operation failed: {message} + + + The operation timed out. + + + The chapter export file was empty. + + + The chapter export file was not created. + + + The playlist output was not recognized. + + + No embedded cuesheet was found. + + + No chapters were parsed. + + + CUE text is empty or no chapters were parsed. + + + The XML document has no root element. + + + ffprobe could not be started: {message} + + + ffprobe did not return chapter JSON. + + + ffprobe was not found. + + + ffprobe output could not be parsed: {message} + + + ffprobe was cancelled. + + + ffprobe exited with a non-zero code. + + + ffprobe timed out. + + + No Vorbis cuesheet comment was found. + + + The primary importer was unavailable; a fallback importer was used. + + + Chapter index {index} is out of range. + + + A chapter line could not be parsed: {line} + + + Chapter text is empty or has no valid entries. + + + A media chapter had no valid non-negative start timestamp. + + + The file is not a supported container. + + + Expected EditionEntry, got {name}. + + + The expression is invalid: {message} + + + The expression did not reduce to one value. + + + Token '{token}' requires more operands. + + + Invalid character '{character}'. + + + Misplaced comma. + + + Missing operand before ')'. + + + Missing operator before '{token}'. + + + Missing operator before function '{token}'. + + + Missing operator before '('. + + + Operator '{token}' requires a left operand. + + + Operator '?' requires a condition. + + + Operator ':' requires a true expression. + + + Operator ':' requires a matching '?'. + + + Operator '?' requires a matching ':'. + + + Unbalanced parentheses. + + + Unknown token '{token}'. + + + Unsupported function '{function}'. + + + Unsupported operator '{token}'. + + + Unsupported token '{token}'. + + + The expression time is invalid: {message} + + + Frame rate must be greater than zero. + + + Frame text did not contain a frame number or fps was invalid. + + + The IFO file is invalid: {message} + + + The MPLS file is invalid: {message} + + + The Blu-ray BDMV/PLAYLIST directory was not found. + + + Time text is empty or does not match the expected format. + + + No valid timecode entries were found. + + + The XML could not be parsed: {message} + + + CUE index syntax is unsupported or malformed. + + + mkvextract could not be started: {message} + + + mkvextract was not found. + + + mkvextract did not return chapter XML. + + + mkvextract was cancelled: {message} + + + mkvextract failed: {message} + + + mkvextract timed out: {message} + + + Media chapters could not be read: {message} + + + eac3to was not found. + + + The MP4 file could not be accessed: {message} + + + The MP4 file was not found: {message} + + + The MP4 path is empty. + + + The MP4 chapter metadata is malformed. + + + The MP4 file could not be read: {message} + + + The MP4 chapter metadata is unsupported. + + + A native dependency was not found: {message} + + + No chapters are available for export. + + + No chapters were found. + + + Select one or more chapter rows first. + + + No chapter segments are available. + + + The first OGM chapter line is missing or invalid. + + + Chapter number shift {shift} would produce non-positive chapter numbers and was normalized to 0. + + + Parsing stopped before all input was consumed. + + + Unable to parse the Adobe Premiere Pro chapter marker list. + + + Saved: {path} + + + Command output: {output} + + + Only MPLS chapter groups can be appended. + + + Only MPLS and DVD chapter groups can be combined. + + + Unsupported export format. + + + Unsupported source extension: {extension} + + + The WebVTT header is missing. + + + A WebVTT cue could not be parsed. + + + The WebVTT timing settings are unsupported. + + + Expected Chapters root, got {name}. + + + No Matroska XML chapters were parsed. + + + No HD-DVD chapters were parsed. + + + The XPL playlist could not be parsed: {message} + + + frame + + + name + + + time + + + Append MPLS + + + Executable files + + + MPLS playlist + + + Open Chapter Name Template + + + Open Source + + + Save Chapters To + + + Chapter and media files + + + Text files + + Simplified Chinese - + English - + Japanese - + + Undetermined + + Appending MPLS: path='{path}' - + Auto frame-rate detection: option={option}, confidence={confidence}, accurate={accurate}/{evaluated}, deviation={deviation} - - Change FPS: {sourceFps} -> {targetFps}, chapters {before} -> {after} + + 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} group {groupIndex}: sourcePath='{sourcePath}', defaultOptionIndex={defaultOptionIndex}, options={options} - + {operation} option {optionIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} - + {operation} result: success={success}, partial={partial}, groups={groups}, options={options}, chapters={chapters}, diagnostics={diagnostics} - + 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} - + {status} from {path} - + Append MPLS - + Auto - - Chapter name + + Convert to current FPS - - Change FPS + + Chapter name - + Frames - + Name - + # - + Time - + Combine - + Create Zones - + Delete - + Expression - + + Add a left operand before this operator. + + + Add the missing operand before applying this token. + + + Add an operand before the closing parenthesis. + + + Add an operator between adjacent values. + + + Add an operator before the function call. + + + Add an operator before the opening parenthesis. + + + Add a condition before '?'. + + + Add the true expression between '?' and ':'. + + + Add or remove parentheses so every '(' has a matching ')'. + + + Check the expression syntax. + + + Complete the expression so it produces one value. + + + Use commas only between function arguments. + + + Add a matching ':' and false expression. + + + Add a matching '?' before this ':'. + + + Remove the invalid character or replace it with a supported token. + + + Use a supported variable, constant, function, or operator. + + + Replace the function with a supported expression function. + + + Replace the operator with a supported expression operator. + + + Replace the token with a supported expression token. + + Forward Translation - + Insert - + Keep original - + Load - + Log - + Open Related Media - + Order shift - + Preview - + Preview - + Reload - + Round frames - + Save format - + Choose Save Directory - + Settings - + Standard template - + Template file - + XML language - - File association registration is platform-gated and not enabled in this build. + + Append edit + + + Append load + + + Create zones + + + Load + + + Output projection + + + Save - + Appearance - + + About + + + ChapterTool is an Avalonia-based cross-platform chapter editor for importing, adjusting, combining, and exporting chapter lists from text, disc playlist, and media container sources. + + + License: GPLv3+. See LICENSE in the repository. + + + Open project repository + + + https://github.com/tautcony/ChapterTool + + Choose Directory - + Choose Executable - + Default save format - + Default XML language - + eac3to - + Blank uses the source directory - + External Tools - - FFmpeg directory + + FFmpeg bin directory (contains ffprobe) - + ffprobe - + Frame tolerance - + General - + MKVToolNix / mkvextract - + Output Defaults - - Platform - - + Reset Defaults - + Default save directory - + Settings loaded - + Defaults restored - + Settings saved - + Not configured; PATH or platform discovery will be used - + Found: {path} - + Path does not exist - + {name} was not found in this directory - + + Path must be a directory + + Unsupported on this platform - + UI language - + Discard - + Settings changes are already applied to this session but have not been saved. Save them before closing? - + Unsaved Settings - + Check Tools - - Appended {count} MPLS segment(s) - - + Append failed - + + Appended {count} MPLS segment(s) + + Detected {displayName} (confidence: {confidence}) - + + Load failed + + Loaded {count} chapters - + Loading source... - - Validating source... - - + Locating eac3to... - + Exporting chapter text... - + Parsing chapter text... - - Load failed + + Validating source... - + No current MPLS group is loaded - + No source selected - + Opened {fileName} - + Ready - + Related media file was not found - - Saved - - + Save failed - + + Saved + + Shell service is unavailable - + Not selected - + Updated - + Zones generated - + Color Settings - + Legacy color slots - + Apply expression - + Expression, e.g. t + 1 - + Expression - - File Association - - + Forward Shift - + Language - + Log - + Preview - + Settings - + Template Names - + Use standard template names - + Zones diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx index 4b4ed57..945ab26 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx @@ -12,382 +12,795 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + + + セグメントを結合: オプション数={options}, ソース種別={sourceType} + + + 行を削除: インデックス={indexes} + + + {kind}を編集: 行={row}, 値='{value}' + + + 章節を編集 + + + 行を挿入: インデックス={index} + + + フレーム前方シフト: フレーム数={frames} + + + 結合済みセグメントを分割: オプション数={options}, ソース種別={sourceType} + + [VCB-Studio] ChapterTool - + 適用 - + キャンセル - + クリア - + コピー - + 更新 - + 保存 - + このビルドでは、このプラットフォーム依存機能は有効化されていません。 - + 使用 - + + 操作はキャンセルされました。 + + + 操作が失敗しました: {message} + + + 操作がタイムアウトしました。 + + + 章節エクスポートファイルが空です。 + + + 章節エクスポートファイルが作成されませんでした。 + + + プレイリスト出力を認識できませんでした。 + + + 埋め込み cuesheet が見つかりませんでした。 + + + 章節が解析されませんでした。 + + + CUE テキストが空であるか、章節が解析されませんでした。 + + + XML ドキュメントにルート要素がありません。 + + + ffprobe を起動できませんでした: {message} + + + ffprobe が章節 JSON を返しませんでした。 + + + ffprobe が見つかりませんでした。 + + + ffprobe の出力を解析できませんでした: {message} + + + ffprobe はキャンセルされました。 + + + ffprobe は 0 以外の終了コードで終了しました。 + + + ffprobe がタイムアウトしました。 + + + Vorbis cuesheet コメントが見つかりませんでした。 + + + プライマリ インポーターが利用できないため、フォールバック インポーターを使用しました。 + + + 章節インデックス {index} が範囲外です。 + + + 章節行を解析できませんでした: {line} + + + 章節テキストが空であるか、有効なエントリがありません。 + + + メディア章節に有効な負でない開始タイムスタンプがありません。 + + + このファイルはサポート対象のコンテナではありません。 + + + EditionEntry を期待しましたが、{name} でした。 + + + 式が無効です: {message} + + + 式が単一の値に還元されませんでした。 + + + トークン '{token}' にはより多くのオペランドが必要です。 + + + 無効な文字 '{character}' です。 + + + カンマの位置が不正です。 + + + ')' の前にオペランドがありません。 + + + '{token}' の前に演算子がありません。 + + + 関数 '{token}' の前に演算子がありません。 + + + '(' の前に演算子がありません。 + + + 演算子 '{token}' には左オペランドが必要です。 + + + 演算子 '?' には条件が必要です。 + + + 演算子 ':' には真の式が必要です。 + + + 演算子 ':' に対応する '?' がありません。 + + + 演算子 '?' に対応する ':' がありません。 + + + 括弧が一致していません。 + + + 不明なトークン '{token}' です。 + + + サポート対象外の関数 '{function}' です。 + + + サポート対象外の演算子 '{token}' です。 + + + サポート対象外のトークン '{token}' です。 + + + 式の時間が無効です: {message} + + + フレームレートは 0 より大きい必要があります。 + + + フレームテキストにフレーム番号が含まれないか、fps が無効です。 + + + IFO ファイルが無効です: {message} + + + MPLS ファイルが無効です: {message} + + + Blu-ray の BDMV/PLAYLIST ディレクトリが見つかりませんでした。 + + + 時間テキストが空であるか、想定形式と一致しません。 + + + 有効なタイムコード エントリが見つかりませんでした。 + + + XML を解析できませんでした: {message} + + + CUE インデックス構文はサポート対象外または不正です。 + + + mkvextract を起動できませんでした: {message} + + + mkvextract が見つかりませんでした。 + + + mkvextract が章節 XML を返しませんでした。 + + + mkvextract はキャンセルされました: {message} + + + mkvextract が失敗しました: {message} + + + mkvextract がタイムアウトしました: {message} + + + メディア章節を読み取れませんでした: {message} + + + eac3to が見つかりませんでした。 + + + MP4 ファイルにアクセスできませんでした: {message} + + + MP4 ファイルが見つかりませんでした: {message} + + + MP4 パスが空です。 + + + MP4 章節メタデータが不正です。 + + + MP4 ファイルを読み取れませんでした: {message} + + + MP4 章節メタデータはサポート対象外です。 + + + ネイティブ依存関係が見つかりませんでした: {message} + + + エクスポート可能な章節がありません。 + + + 章節が見つかりませんでした。 + + + 1 つ以上の章節行を選択してください。 + + + 使用可能な章節セグメントがありません。 + + + 先頭の OGM 章節行が存在しないか無効です。 + + + 章節番号シフト {shift} は負の章節番号になるため、0 に正規化されました。 + + + すべての入力を消費する前に解析が停止しました。 + + + Adobe Premiere Pro の章節マーカーリストを解析できませんでした。 + + + 保存しました: {path} + + + コマンド出力: {output} + + + 追加できるのは MPLS 章節グループのみです。 + + + 結合できるのは MPLS および DVD 章節グループのみです。 + + + サポート対象外のエクスポート形式です。 + + + サポート対象外のソース拡張子です: {extension} + + + WebVTT ヘッダーがありません。 + + + WebVTT キューを解析できませんでした。 + + + サポート対象外の WebVTT タイミング設定です。 + + + Chapters ルートを期待しましたが、{name} でした。 + + + Matroska XML 章節が解析されませんでした。 + + + HD-DVD 章節が解析されませんでした。 + + + XPL プレイリストを解析できませんでした: {message} + + + フレーム + + + 名前 + + + 時間 + + + MPLS を追加 + + + 実行ファイル + + + MPLS プレイリスト + + + チャプター名テンプレートを開く + + + ソースを開く + + + チャプターの保存先 + + + チャプターとメディアファイル + + + テキストファイル + + 簡体字中国語 - + 英語 - + 日本語 - + + 未定義 + + MPLS を追加中: path='{path}' - + 自動フレームレート検出: option={option}, confidence={confidence}, accurate={accurate}/{evaluated}, deviation={deviation} - - FPS を変換: {sourceFps} -> {targetFps}, chapters {before} -> {after} + + 現在の FPS に変換: source={sourceFps}, target={targetFps}, chapters {before} -> {after} - + Zones 生成: selectedRows={selectedRows}, chapters={chapters} - + {operation} diagnostic: severity={severity}, code={code}{location} message='{message}'{details} - + {action}: chapters {before} -> {after} - + フレーム情報を更新: option={option}, fps={fps}, round={round}, chapters={chapters} - + {operation} group {groupIndex}: sourcePath='{sourcePath}', defaultOptionIndex={defaultOptionIndex}, options={options} - + {operation} option {optionIndex}: id='{id}', label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, duration={duration}, fps={fps} - + {operation} result: success={success}, partial={partial}, groups={groups}, options={options}, chapters={chapters}, diagnostics={diagnostics} - + 言語を {language} に設定しました - + ソースを読み込み中: path='{path}' - + {status}: path='{path}' - + {status}: reference='{reference}', resolved='{resolved}' - + チャプターを保存中: format={format}, directory='{directory}', source='{source}', chapters={chapters}, applyExpression={applyExpression}, expression='{expression}' - + ソースオプションを選択: index={index}, label='{label}', source='{source}', sourceType={sourceType}, chapters={chapters}, fps={fps} - + 設定を読み込みました: savingPath='{savingPath}', language='{language}' - + {status} - + {status}: {path} - + MPLS を追加結合 - + 自動 - - チャプター名 + + 現在の FPS に変換 - - FPS を変換 + + チャプター名 - + フレーム - + チャプター名 - + # - + 時刻 - + 結合 - + Zones を生成 - + 削除 - + - + + この演算子の前に左辺の値を追加してください。 + + + このトークンを適用する前に不足している値を追加してください。 + + + 閉じ括弧の前に値を追加してください。 + + + 隣接する値の間に演算子を追加してください。 + + + 関数呼び出しの前に演算子を追加してください。 + + + 開き括弧の前に演算子を追加してください。 + + + '?' の前に条件式を追加してください。 + + + '?' と ':' の間に真の場合の式を追加してください。 + + + すべての '(' に対応する ')' があるように括弧を調整してください。 + + + 式の構文を確認してください。 + + + 1つの値になるように式を補完してください。 + + + カンマは関数引数の区切りにのみ使用してください。 + + + 対応する ':' と偽の場合の式を追加してください。 + + + この ':' の前に対応する '?' を追加してください。 + + + 無効な文字を削除するか、対応しているトークンに置き換えてください。 + + + 対応している変数、定数、関数、または演算子を使用してください。 + + + 対応している式関数に置き換えてください。 + + + 対応している式演算子に置き換えてください。 + + + 対応している式トークンに置き換えてください。 + + 前方シフト - + 挿入 - + 元の名前を保持 - + 読み込み - + ログ - + 関連メディアを開く - + 番号シフト - + プレビュー - + プレビュー - + 再読み込み - + フレームを丸める - + 保存形式 - + 保存先を選択 - + 設定 - + 標準テンプレート - + テンプレートファイル - + XML 言語 - - このビルドでは、ファイル関連付け登録は有効化されていません。 + + 追加編集 + + + 追加読み込み + + + Zone を作成 + + + 読み込み + + + 出力プロジェクション + + + 保存 - + 外観 - + + このアプリについて + + + ChapterTool は Avalonia ベースのクロスプラットフォーム チャプターエディターで、テキスト、ディスクプレイリスト、メディアコンテナ由来のチャプター一覧の読み込み、調整、結合、書き出しに対応します。 + + + ライセンス: GPLv3+。詳細はリポジトリの LICENSE を参照してください。 + + + プロジェクトリポジトリを開く + + + https://github.com/tautcony/ChapterTool + + ディレクトリを選択 - + 実行ファイルを選択 - + 既定の保存形式 - + 既定の XML 言語 - + eac3to - + 空の場合はソースの場所を使用 - + 外部ツール - - FFmpeg ディレクトリ + + FFmpeg bin ディレクトリ(ffprobe を含む) - + ffprobe - + フレーム許容誤差 - + 一般 - + MKVToolNix / mkvextract - + 出力の既定値 - - プラットフォーム - - + 既定値に戻す - + 既定の保存先 - + 設定を読み込みました - + 既定値を復元しました - + 設定を保存しました - + 未設定です。PATH またはプラットフォーム検出を使用します - + 検出済み: {path} - + パスが存在しません - + このディレクトリに {name} が見つかりません - + + パスはディレクトリである必要があります + + このプラットフォームでは未対応です - + UI 言語 - + 破棄 - + 設定の変更は現在のセッションに適用済みですが、まだ保存されていません。閉じる前に保存しますか? - + 未保存の設定 - + ツールを確認 - - {count} 個の MPLS セグメントを追加しました - - + 追加に失敗しました - + + {count} 個の MPLS セグメントを追加しました + + {displayName} を検出しました(信頼度: {confidence}) - + + 読み込みに失敗しました + + {count} 個のチャプターを読み込みました - + 読み込み中... - - ソースを検証中... - - + eac3to を検索中... - + チャプターを出力中... - + チャプターを解析中... - - 読み込みに失敗しました + + ソースを検証中... - + 現在の MPLS グループが読み込まれていません - + ソースが選択されていません - + {fileName} を開きました - + 準備完了 - + 関連メディアファイルが見つかりません - - 保存しました - - + 保存に失敗しました - + + 保存しました + + Shell サービスを利用できません - + 未選択 - + 更新しました - + Zones を生成しました - + 色設定 - + 旧形式の色スロット - + 式を適用 - + 式、例: t + 1 - + - - ファイル関連付け - - + 前方シフト - + 言語 - + ログ - + プレビュー - + 設定 - + テンプレート名 - + 標準テンプレート名を使用 - + Zones diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx index 77e6864..3c5d82b 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx @@ -12,382 +12,795 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + + + 合并段落:选项数={options},源类型={sourceType} + + + 删除行:索引={indexes} + + + 编辑{kind}:行={row},值='{value}' + + + 编辑章节 + + + 插入行:索引={index} + + + 帧数前移:帧数={frames} + + + 拆分已合并段落:选项数={options},源类型={sourceType} + + [VCB-Studio] ChapterTool - + 应用 - + 取消 - + 清空 - + 复制 - + 刷新 - + 保存 - + 此构建未启用该平台相关功能。 - + 使用 - + + 操作已取消。 + + + 操作失败:{message} + + + 操作超时。 + + + 章节导出文件为空。 + + + 未生成章节导出文件。 + + + 无法识别播放列表输出。 + + + 未找到内嵌的 cuesheet。 + + + 未解析到任何章节。 + + + CUE 文本为空,或未解析到章节。 + + + XML 文档没有根元素。 + + + 无法启动 ffprobe:{message} + + + ffprobe 未返回章节 JSON。 + + + 未找到 ffprobe。 + + + 无法解析 ffprobe 输出:{message} + + + ffprobe 已取消。 + + + ffprobe 以非零代码退出。 + + + ffprobe 超时。 + + + 未找到 Vorbis cuesheet 注释。 + + + 主导入器不可用,已使用备用导入器。 + + + 章节索引 {index} 超出范围。 + + + 无法解析章节行:{line} + + + 章节文本为空,或没有有效条目。 + + + 媒体章节没有有效的非负起始时间戳。 + + + 该文件不是受支持的容器。 + + + 应为 EditionEntry,实际为 {name}。 + + + 表达式无效:{message} + + + 表达式未能归约为单个值。 + + + 标记 '{token}' 需要更多操作数。 + + + 无效字符 '{character}'。 + + + 逗号位置错误。 + + + ')' 前缺少操作数。 + + + '{token}' 前缺少运算符。 + + + 函数 '{token}' 前缺少运算符。 + + + '(' 前缺少运算符。 + + + 运算符 '{token}' 需要左操作数。 + + + 运算符 '?' 需要条件。 + + + 运算符 ':' 需要真值表达式。 + + + 运算符 ':' 需要匹配的 '?'。 + + + 运算符 '?' 需要匹配的 ':'。 + + + 括号不匹配。 + + + 未知标记 '{token}'。 + + + 不受支持的函数 '{function}'。 + + + 不受支持的运算符 '{token}'。 + + + 不受支持的标记 '{token}'。 + + + 表达式时间无效:{message} + + + 帧率必须大于零。 + + + 帧文本未包含帧编号,或帧率无效。 + + + IFO 文件无效:{message} + + + MPLS 文件无效:{message} + + + 未找到 Blu-ray BDMV/PLAYLIST 目录。 + + + 时间文本为空,或不符合预期格式。 + + + 未找到有效的时间码条目。 + + + 无法解析 XML:{message} + + + CUE 索引语法不受支持或格式错误。 + + + 无法启动 mkvextract:{message} + + + 未找到 mkvextract。 + + + mkvextract 未返回章节 XML。 + + + mkvextract 已取消:{message} + + + mkvextract 执行失败:{message} + + + mkvextract 超时:{message} + + + 无法读取媒体章节:{message} + + + 未找到 eac3to。 + + + 无法访问 MP4 文件:{message} + + + 未找到 MP4 文件:{message} + + + MP4 路径为空。 + + + MP4 章节元数据格式错误。 + + + 无法读取 MP4 文件:{message} + + + MP4 章节元数据不受支持。 + + + 未找到本地依赖:{message} + + + 没有可导出的章节。 + + + 未找到章节。 + + + 请先选择一个或多个章节行。 + + + 没有可用的章节段落。 + + + 缺少或无效的首行 OGM 章节行。 + + + 章节编号位移 {shift} 会产生非正的章节编号,已归一化为 0。 + + + 解析在消耗全部输入前停止。 + + + 无法解析 Adobe Premiere Pro 章节标记列表。 + + + 已保存:{path} + + + 命令输出:{output} + + + 仅支持追加 MPLS 章节组。 + + + 仅支持合并 MPLS 与 DVD 章节组。 + + + 不受支持的导出格式。 + + + 不受支持的来源扩展名:{extension} + + + 缺少 WebVTT 头。 + + + 无法解析 WebVTT 提示。 + + + 不受支持的 WebVTT 时间设置。 + + + 应为 Chapters 根节点,实际为 {name}。 + + + 未解析到 Matroska XML 章节。 + + + 未解析到 HD-DVD 章节。 + + + 无法解析 XPL 播放列表:{message} + + + + + + 名称 + + + 时间 + + + 追加 MPLS + + + 可执行文件 + + + MPLS 播放列表 + + + 打开章节名称模板 + + + 打开源文件 + + + 保存章节到 + + + 章节和媒体文件 + + + 文本文件 + + 简体中文 - + 英语 - + 日语 - + + 未确定 + + 正在追加 MPLS:path='{path}' - + 自动帧率检测:option={option},confidence={confidence},accurate={accurate}/{evaluated},deviation={deviation} - - 转换帧率:{sourceFps} -> {targetFps},章节数 {before} -> {after} + + 转换至当前帧率:源配置={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} 分组 {groupIndex}:sourcePath='{sourcePath}',defaultOptionIndex={defaultOptionIndex},options={options} - + {operation} 选项 {optionIndex}:id='{id}',label='{label}',source='{source}',sourceType={sourceType},chapters={chapters},duration={duration},fps={fps} - + {operation} 结果:success={success},partial={partial},groups={groups},options={options},chapters={chapters},diagnostics={diagnostics} - + 语言已设置为 {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} - + 追加合并 MPLS - + 自动 - - 章节名 + + 转换至当前帧率 - - 转换帧率 + + 章节名 - + 帧数 - + 章节名 - + # - + 时间点 - + 合并 - + 生成 Zones - + 删除 - + 表达式 - + + 在此运算符前添加左侧操作数。 + + + 在应用此记号前补充缺失的操作数。 + + + 在右括号前添加操作数。 + + + 在相邻的值之间添加运算符。 + + + 在函数调用前添加运算符。 + + + 在左括号前添加运算符。 + + + 在 '?' 前添加条件表达式。 + + + 在 '?' 与 ':' 之间添加真值表达式。 + + + 添加或移除括号,使每个 '(' 都有匹配的 ')'。 + + + 检查表达式语法。 + + + 补全表达式,使其能生成一个值。 + + + 逗号只能用于分隔函数参数。 + + + 添加匹配的 ':' 和假值表达式。 + + + 在此 ':' 前添加匹配的 '?'。 + + + 移除无效字符,或替换为支持的记号。 + + + 使用受支持的变量、常量、函数或运算符。 + + + 替换为受支持的表达式函数。 + + + 替换为受支持的表达式运算符。 + + + 替换为受支持的表达式记号。 + + 前移帧 - + 插入 - + 保留原名 - + 载入 - + 日志 - + 打开相关媒体 - + 平移章节号 - + 预览 - + 预览 - + 重新载入 - + 帧数取整 - + 保存格式 - + 选择保存目录 - + 设置 - + 统一模板 - + 模板文件 - + XML语言 - - 此构建未启用文件关联注册。 + + 追加编辑 + + + 追加加载 + + + 创建 Zone + + + 加载 + + + 输出投影 - + + 保存 + + 外观 - + + 关于 + + + ChapterTool 是一个基于 Avalonia 的跨平台章节编辑器,用于从文本、光盘播放列表和媒体容器来源导入、调整、合并并导出章节列表。 + + + 许可证:GPLv3+。详见仓库中的 LICENSE。 + + + 打开项目仓库 + + + https://github.com/tautcony/ChapterTool + + 选择目录 - + 选择可执行文件 - + 默认保存格式 - + 默认 XML 语言 - + eac3to - + 留空则使用源文件目录 - + 外部工具 - - FFmpeg 目录 + + FFmpeg bin 目录(包含 ffprobe) - + ffprobe - + 帧误差阈值 - + 常规 - + MKVToolNix / mkvextract - + 输出默认值 - - 平台集成 - - + 恢复默认 - + 默认保存目录 - + 设置已载入 - + 已恢复默认值 - + 设置已保存 - + 未配置,将使用 PATH 或平台发现 - + 已找到:{path} - + 路径不存在 - + 目录中未找到 {name} - + + 路径必须是目录 + + 当前平台不支持 - + 界面语言 - + 放弃 - + 设置修改已实时应用到当前会话,但尚未保存。关闭前要保存吗? - + 设置尚未保存 - + 检查工具 - - 已追加 {count} 个 MPLS 片段 - - + 追加失败 - + + 已追加 {count} 个 MPLS 片段 + + 已检测到 {displayName}(置信度:{confidence}) - + + 载入失败 + + 已载入 {count} 个章节 - + 正在加载... - - 正在校验源结构... - - + 正在定位 eac3to... - + 正在导出章节文本... - + 正在解析章节文本... - - 载入失败 + + 正在校验源结构... - + 未载入当前 MPLS 分组 - + 未选择源文件 - + 已打开 {fileName} - + 就绪 - + 未找到相关媒体文件 - - 已保存 - - + 保存失败 - + + 已保存 + + Shell 服务不可用 - + 未选择 - + 已更新 - + Zones 已生成 - + 颜色设置 - + 旧版颜色槽 - + 应用表达式 - + 表达式,例如 t + 1 - + 表达式 - - 文件关联 - - + 前移帧 - + 语言 - + 日志 - + 预览 - + 设置 - + 模板名称 - + 使用标准模板名称 - + Zones diff --git a/src/ChapterTool.Avalonia/Program.cs b/src/ChapterTool.Avalonia/Program.cs index d0e4064..2d69e51 100644 --- a/src/ChapterTool.Avalonia/Program.cs +++ b/src/ChapterTool.Avalonia/Program.cs @@ -1,4 +1,5 @@ using Avalonia; +using ChapterTool.Avalonia.Cli; using Optris.Icons.Avalonia; using Optris.Icons.Avalonia.FontAwesome; @@ -6,16 +7,39 @@ namespace ChapterTool.Avalonia; internal static class Program { - internal static IReadOnlyList StartupArgs { get; private set; } = []; + internal static string? GuiStartupPath { get; private set; } [STAThread] public static void Main(string[] args) { - StartupArgs = args; + var launchPlan = ChapterToolCliSupport.AnalyzeLaunch(args); + GuiStartupPath = launchPlan.GuiStartupPath; + if (launchPlan.LaunchGui) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + return; + } + + if (launchPlan.CliResult is not null) + { + try + { + var exitCode = launchPlan.CliResult.Run(); + Environment.ExitCode = exitCode; + return; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Unhandled CLI exception: {exception.Message}"); + Environment.ExitCode = 2; + return; + } + } + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } - public static AppBuilder BuildAvaloniaApp() + private static AppBuilder BuildAvaloniaApp() { RegisterIconProviders(); @@ -26,7 +50,7 @@ public static AppBuilder BuildAvaloniaApp() .LogToTrace(); } - public static void RegisterIconProviders() + private static void RegisterIconProviders() { IconProvider.Current.Register(); } diff --git a/src/ChapterTool.Avalonia/Properties/AssemblyInfo.cs b/src/ChapterTool.Avalonia/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e04c537 --- /dev/null +++ b/src/ChapterTool.Avalonia/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ChapterTool.Avalonia.Tests")] diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs index 2539342..235f54f 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs @@ -1,25 +1,14 @@ using Avalonia.Controls; using Avalonia.Platform.Storage; +using ChapterTool.Avalonia.Localization; namespace ChapterTool.Avalonia.Services; -public sealed class AvaloniaFilePickerService(Window owner) : IFilePickerService +public sealed class AvaloniaFilePickerService(Window owner, IAppLocalizer localizer) : IFilePickerService { public async ValueTask PickSourceAsync(CancellationToken cancellationToken) { - var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "Open Source", - AllowMultiple = false, - FileTypeFilter = - [ - new FilePickerFileType("Chapter and media files") - { - Patterns = ["*.txt", "*.xml", "*.vtt", "*.cue", "*.flac", "*.tak", "*.mpls", "*.ifo", "*.xpl", "*.mkv", "*.mka", "*.mp4", "*.m4a", "*.m4v"] - }, - FilePickerFileTypes.All - ] - }); + var files = await owner.StorageProvider.OpenFilePickerAsync(CreateSourceOptions(localizer)); if (files.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); @@ -32,16 +21,7 @@ public sealed class AvaloniaFilePickerService(Window owner) : IFilePickerService public async ValueTask PickMplsAsync(CancellationToken cancellationToken) { - var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "Append MPLS", - AllowMultiple = false, - FileTypeFilter = - [ - new FilePickerFileType("MPLS playlist") { Patterns = ["*.mpls"] }, - FilePickerFileTypes.All - ] - }); + var files = await owner.StorageProvider.OpenFilePickerAsync(CreateMplsOptions(localizer)); cancellationToken.ThrowIfCancellationRequested(); return files.Count > 0 ? files[0].Path.LocalPath : null; @@ -49,16 +29,7 @@ public sealed class AvaloniaFilePickerService(Window owner) : IFilePickerService public async ValueTask PickChapterNameTemplateAsync(CancellationToken cancellationToken) { - var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "Open Chapter Name Template", - AllowMultiple = false, - FileTypeFilter = - [ - new FilePickerFileType("Text files") { Patterns = ["*.txt"] }, - FilePickerFileTypes.All - ] - }); + var files = await owner.StorageProvider.OpenFilePickerAsync(CreateChapterNameTemplateOptions(localizer)); cancellationToken.ThrowIfCancellationRequested(); return files.Count > 0 ? files[0].Path.LocalPath : null; @@ -66,13 +37,55 @@ public sealed class AvaloniaFilePickerService(Window owner) : IFilePickerService public async ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken) { - var folders = await owner.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = "Save Chapters To", - AllowMultiple = false - }); + var folders = await owner.StorageProvider.OpenFolderPickerAsync(CreateSaveDirectoryOptions(localizer)); cancellationToken.ThrowIfCancellationRequested(); return folders.Count > 0 ? folders[0].Path.LocalPath : null; } + + internal static FilePickerOpenOptions CreateSourceOptions(IAppLocalizer localizer) => + new() + { + Title = localizer.GetString("FilePicker.OpenSource.Title"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(localizer.GetString("FilePicker.SourceFiles")) + { + Patterns = ["*.txt", "*.xml", "*.vtt", "*.cue", "*.flac", "*.tak", "*.mpls", "*.ifo", "*.xpl", "*.mkv", "*.mka", "*.mp4", "*.m4a", "*.m4v"] + }, + FilePickerFileTypes.All + ] + }; + + internal static FilePickerOpenOptions CreateMplsOptions(IAppLocalizer localizer) => + new() + { + Title = localizer.GetString("FilePicker.AppendMpls.Title"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(localizer.GetString("FilePicker.MplsPlaylist")) { Patterns = ["*.mpls"] }, + FilePickerFileTypes.All + ] + }; + + internal static FilePickerOpenOptions CreateChapterNameTemplateOptions(IAppLocalizer localizer) => + new() + { + Title = localizer.GetString("FilePicker.OpenChapterNameTemplate.Title"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(localizer.GetString("FilePicker.TextFiles")) { Patterns = ["*.txt"] }, + FilePickerFileTypes.All + ] + }; + + internal static FolderPickerOpenOptions CreateSaveDirectoryOptions(IAppLocalizer localizer) => + new() + { + Title = localizer.GetString("FilePicker.SaveChaptersTo.Title"), + AllowMultiple = false + }; } diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaSettingsCloseConfirmationService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaSettingsCloseConfirmationService.cs index 326e008..e6d27a4 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaSettingsCloseConfirmationService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaSettingsCloseConfirmationService.cs @@ -75,7 +75,7 @@ public async ValueTask ConfirmCloseAsync(Window owner, Canc } }; Grid.SetRow(message, 0); - Grid.SetRow((Control)((Grid)dialog.Content).Children[1], 1); + Grid.SetRow(((Grid)dialog.Content).Children[1], 1); var result = await dialog.ShowDialog(owner); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaSettingsPickerService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaSettingsPickerService.cs index 1e34be6..2cad8b3 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaSettingsPickerService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaSettingsPickerService.cs @@ -1,9 +1,10 @@ using Avalonia.Controls; using Avalonia.Platform.Storage; +using ChapterTool.Avalonia.Localization; namespace ChapterTool.Avalonia.Services; -public sealed class AvaloniaSettingsPickerService(Window owner) : ISettingsPickerService +public sealed class AvaloniaSettingsPickerService(Window owner, IAppLocalizer localizer) : ISettingsPickerService { public async ValueTask PickDirectoryAsync(string title, CancellationToken cancellationToken) { @@ -19,21 +20,24 @@ public sealed class AvaloniaSettingsPickerService(Window owner) : ISettingsPicke public async ValueTask PickExecutableAsync(string title, CancellationToken cancellationToken) { - var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var files = await owner.StorageProvider.OpenFilePickerAsync(CreateExecutableOptions(title, localizer)); + + cancellationToken.ThrowIfCancellationRequested(); + return files.Count > 0 ? files[0].Path.LocalPath : null; + } + + internal static FilePickerOpenOptions CreateExecutableOptions(string title, IAppLocalizer localizer) => + new() { Title = title, AllowMultiple = false, FileTypeFilter = [ - new FilePickerFileType("Executable files") + new FilePickerFileType(localizer.GetString("FilePicker.ExecutableFiles")) { Patterns = OperatingSystem.IsWindows() ? ["*.exe"] : ["*"] }, FilePickerFileTypes.All ] - }); - - cancellationToken.ThrowIfCancellationRequested(); - return files.Count > 0 ? files[0].Path.LocalPath : null; - } + }; } diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs index 427364f..2b6c1fc 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs @@ -17,6 +17,7 @@ public sealed class AvaloniaWindowService : IWindowService private readonly ISettingsCloseConfirmationService settingsCloseConfirmationService; private readonly Func? settingsPickerFactory; private readonly IExternalToolLocator? externalToolLocator; + private readonly IShellService? shellService; private readonly Dictionary windows = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary parameters = new(StringComparer.OrdinalIgnoreCase); private readonly IAppLocalizer localizer; @@ -28,7 +29,8 @@ public AvaloniaWindowService( IAppLocalizer? localizer = null, Func? settingsPickerFactory = null, IExternalToolLocator? externalToolLocator = null, - ISettingsCloseConfirmationService? settingsCloseConfirmationService = null) + ISettingsCloseConfirmationService? settingsCloseConfirmationService = null, + IShellService? shellService = null) { this.appSettingsStore = appSettingsStore; this.themeSettingsStore = themeSettingsStore; @@ -38,6 +40,7 @@ public AvaloniaWindowService( ?? new AvaloniaSettingsCloseConfirmationService(this.localizer); this.settingsPickerFactory = settingsPickerFactory; this.externalToolLocator = externalToolLocator; + this.shellService = shellService; this.localizer.CultureChanged += (_, _) => { foreach (var (id, window) in windows) @@ -146,7 +149,11 @@ private Control CreateContent(Window window, string id, MainWindowViewModel view { DataContext = new TextToolViewModel( viewModel.LogText, - new TextToolOptions { ClearAction = viewModel.ClearLog }) + new TextToolOptions + { + ClearAction = viewModel.ClearLog, + LiveRefreshService = viewModel.LogService + }) }, "settings" => new SettingsToolView { @@ -157,13 +164,13 @@ private Control CreateContent(Window window, string id, MainWindowViewModel view localizer, settingsPickerFactory?.Invoke(window), externalToolLocator, - themeApplicationService) + themeApplicationService, + shellService) }, "color-settings" => new ColorSettingsView { DataContext = new ColorSettingsViewModel(themeSettingsStore, themeApplicationService) }, "language" => new LanguageToolView { DataContext = new LanguageToolViewModel(viewModel) }, "expression" => new ExpressionToolView { DataContext = new ExpressionToolViewModel(viewModel) }, "template-names" => new TemplateNamesToolView { DataContext = new TemplateNamesToolViewModel(viewModel) }, - "file-association" => Placeholder(PlaceholderText(id)), "zones" => new TextToolView { DataContext = new TextToolViewModel(viewModel.CreateZonesText) }, "forward-shift" => new ForwardShiftToolView { DataContext = new ForwardShiftToolViewModel(viewModel) }, _ => Placeholder(PlaceholderText(id)) @@ -187,16 +194,11 @@ private static TextBlock Placeholder(string text) => "language" => localizer.GetString("Tool.Language.Title"), "expression" => localizer.GetString("Tool.Expression.Title"), "template-names" => localizer.GetString("Tool.TemplateNames.Title"), - "file-association" => localizer.GetString("Tool.FileAssociation.Title"), "zones" => localizer.GetString("Tool.Zones.Title"), "forward-shift" => localizer.GetString("Tool.ForwardShift.Title"), _ => id }; - private string PlaceholderText(string id) => id switch - { - "file-association" => localizer.GetString("Prompt.FileAssociationUnsupported"), - _ => Title(id) - }; + private string PlaceholderText(string id) => Title(id); } diff --git a/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs b/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs index 74d95d1..e663be2 100644 --- a/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs +++ b/src/ChapterTool.Avalonia/Services/RuntimeChapterLoadService.cs @@ -21,7 +21,8 @@ public ValueTask LoadAsync(string path, IProgress(StringComparer.Ordinal) { ["extension"] = extension }))) : LoadWithFallbackAsync(path, importer, progress, cancellationToken); } @@ -44,10 +45,13 @@ private async ValueTask LoadWithFallbackAsync( } var fallbackResult = await fallback.ImportAsync(new ChapterImportRequest(path, Progress: progress), cancellationToken); + var fallbackReason = primaryResult.Diagnostics.FirstOrDefault(); var fallbackDiagnostic = new ChapterDiagnostic( DiagnosticSeverity.Info, "ImporterFallbackUsed", - $"Primary importer '{importer.Id}' could not be invoked; used fallback importer '{fallback.Id}'."); + $"Primary importer '{importer.Id}' could not be invoked; used fallback importer '{fallback.Id}'.", + path, + $"primary={importer.Id}; fallback={fallback.Id}; reason={fallbackReason?.Code ?? "Unknown"}"); var diagnostics = primaryResult.Diagnostics.Concat([fallbackDiagnostic]).Concat(fallbackResult.Diagnostics).ToList(); return fallbackResult with { Diagnostics = diagnostics }; } diff --git a/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs b/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs index cf7ea45..f948bb5 100644 --- a/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs +++ b/src/ChapterTool.Avalonia/Services/RuntimeChapterSaveService.cs @@ -21,7 +21,8 @@ public async ValueTask SaveAsync(ChapterInfo info, ChapterE await File.WriteAllTextAsync(path, result.Content, cancellationToken); return result with { - Diagnostics = [.. result.Diagnostics, new ChapterDiagnostic(DiagnosticSeverity.Info, "Saved", path)] + Diagnostics = [.. result.Diagnostics, new ChapterDiagnostic(DiagnosticSeverity.Info, "Saved", path, + Arguments: new Dictionary(StringComparer.Ordinal) { ["path"] = path })] }; } } diff --git a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs index e6b88fd..585dc14 100644 --- a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Text.RegularExpressions; using ChapterTool.Avalonia.Localization; using ChapterTool.Avalonia.Services; using ChapterTool.Core.Diagnostics; @@ -14,7 +15,7 @@ namespace ChapterTool.Avalonia.ViewModels; -public sealed class MainWindowViewModel : ObservableViewModel +public sealed partial class MainWindowViewModel : ObservableViewModel { private readonly IChapterLoadService loadService; private readonly IChapterSaveService saveService; @@ -32,11 +33,11 @@ public sealed class MainWindowViewModel : ObservableViewModel private ChapterInfoGroup? currentGroup; private ChapterInfo? currentInfo; private FrameRateOption selectedFrameRateOption; + private decimal? configuredFrameRate; private bool currentInfoBelongsToSelectedClip; private ChapterInfoGroup? splitClipGroup; private ChapterSourceOption? combinedClipOption; private bool isRefreshingChapterNameModeOptions; - private bool isClipCombineChecked; private bool autoGenerateNames; private bool useTemplateNames; private string chapterNameTemplateText = string.Empty; @@ -44,7 +45,7 @@ public sealed class MainWindowViewModel : ObservableViewModel private string statusText; private LocalizedMessage? currentStatusMessage; private LocalizedMessage? currentProgressMessage; - private decimal frameAccuracyTolerance = 0.15m; + private readonly ObservableCollection xmlLanguageDisplayOptions = []; public MainWindowViewModel( IChapterLoadService loadService, @@ -76,12 +77,25 @@ public MainWindowViewModel( this.appSettingsStore = appSettingsStore; chapterNameTemplateStatus = this.Localizer.GetString("Status.TemplateNotSelected"); statusText = this.Localizer.GetString("Status.Ready"); + RefreshXmlLanguageDisplayOptions(notify: false); RefreshChapterNameModeOptions(); this.Localizer.CultureChanged += (_, _) => RefreshLocalizedState(); selectedFrameRateOption = this.frameRateService.Options[0]; ClipOptions.CollectionChanged += OnClipOptionsChanged; Rows.CollectionChanged += OnRowsChanged; + InitializeCommands(); + } + + private void InitializeCommands() + { + InitializeFileCommands(); + InitializeEditCommands(); + InitializeWindowCommands(); + } + + private void InitializeFileCommands() + { LoadCommand = new UiCommand(async (parameter, token) => { if (parameter is string path) @@ -100,6 +114,10 @@ public MainWindowViewModel( DropPathLoadCommand = new UiCommand(async (parameter, token) => await LoadPathAsync(parameter?.ToString() ?? string.Empty, token)); SaveCommand = new UiCommand(async (_, token) => await SaveAsync(null, token), _ => currentInfo is not null); SaveDirectoryCommand = new UiCommand(async (parameter, token) => await SaveAsync(parameter?.ToString() ?? SaveDirectory, token), _ => currentInfo is not null); + } + + private void InitializeEditCommands() + { RefreshCommand = new UiCommand((_, _) => { ApplyFrameInfo(); @@ -127,7 +145,7 @@ public MainWindowViewModel( { if (currentInfo is not null && parameter is IReadOnlySet indexes) { - ApplyEdit(editingService.Delete(currentInfo, indexes), $"Delete rows: indexes={string.Join(",", indexes.Order())}"); + ApplyEdit(editingService.Delete(currentInfo, indexes), Localizer.Format(LocalizedMessage.Create("Action.DeleteRows", ("indexes", string.Join(",", indexes.Order()))))); } return ValueTask.CompletedTask; @@ -137,20 +155,22 @@ public MainWindowViewModel( if (currentInfo is not null) { var index = parameter is int value ? value : Rows.Count; - ApplyEdit(editingService.InsertBefore(currentInfo, index), $"Insert row: index={index}"); + ApplyEdit(editingService.InsertBefore(currentInfo, index), Localizer.Format(LocalizedMessage.Create("Action.InsertRow", ("index", index)))); } return ValueTask.CompletedTask; }, _ => currentInfo is not null); + } - PreviewCommand = WindowCommand("preview"); + private void InitializeWindowCommands() + { + PreviewCommand = WindowCommand("preview", () => currentInfo is not null); LogCommand = WindowCommand("log"); SettingsCommand = WindowCommand("settings"); ColorSettingsCommand = WindowCommand("color-settings"); LanguageCommand = WindowCommand("language"); ExpressionCommand = WindowCommand("expression"); TemplateNamesCommand = WindowCommand("template-names"); - FileAssociationCommand = WindowCommand("file-association"); ZonesCommand = WindowCommand("zones"); ForwardShiftCommand = WindowCommand("forward-shift"); OpenRelatedMediaCommand = new UiCommand(async (parameter, token) => await OpenRelatedMediaAsync(parameter, token), _ => RelatedMediaReferences.Count > 0); @@ -170,6 +190,8 @@ public string DisplayPath public ObservableCollection Rows { get; } = []; + public bool IsChapterGridEmpty => Rows.Count == 0; + public ObservableCollection ClipOptions { get; } = []; public ObservableCollection ClipDisplayOptions { get; } = []; @@ -184,6 +206,23 @@ public int SelectedClipIndex if (SetProperty(ref field, value)) { OnPropertyChanged(nameof(RelatedMediaReferences)); + OnPropertyChanged(nameof(SelectedClipDisplayOption)); + } + } + } + + public SelectorDisplayOption? SelectedClipDisplayOption + { + get => SelectedClipIndex < 0 || SelectedClipIndex >= ClipDisplayOptions.Count + ? null + : ClipDisplayOptions[SelectedClipIndex]; + set + { + var index = value is null ? -1 : ClipDisplayOptions.IndexOf(value); + if (index >= 0 && index != SelectedClipIndex) + { + SelectClip(index); + NotifyStateChanged(); } } } @@ -202,16 +241,16 @@ public bool RoundFrames public decimal FrameAccuracyTolerance { - get => frameAccuracyTolerance; + get; set { var normalized = NormalizeFrameAccuracyTolerance(value); - if (SetProperty(ref frameAccuracyTolerance, normalized)) + if (SetProperty(ref field, normalized)) { RefreshRows(); } } - } + } = 0.15m; public int SelectedFrameRateIndex { @@ -223,10 +262,10 @@ public int SelectedFrameRateIndex public bool IsClipCombineChecked { - get => isClipCombineChecked; + get; private set { - if (SetProperty(ref isClipCombineChecked, value)) + if (SetProperty(ref field, value)) { OnPropertyChanged(nameof(IsClipSelectionVisible)); OnPropertyChanged(nameof(CanCombine)); @@ -264,13 +303,29 @@ public int SaveFormatIndex private IReadOnlyDictionary? xmlLanguageIndexes; - public IReadOnlyList XmlLanguageDisplayOptions { get; } = - XmlChapterLanguageCatalog.Languages - .Select(static language => new SelectorDisplayOption( - language.Code, - LanguageDisplayName(language), - $"{language.Code}({LanguageDisplayName(language)})")) - .ToArray(); + public IReadOnlyList XmlLanguageDisplayOptions => xmlLanguageDisplayOptions; + + public SelectorDisplayOption? SelectedXmlLanguageDisplayOption + { + get + { + var options = XmlLanguageDisplayOptions; + return XmlLanguageIndex < 0 || XmlLanguageIndex >= options.Count + ? null + : options[XmlLanguageIndex]; + } + set + { + var index = value is null + ? -1 + : XmlLanguageDisplayOptions.ToList().FindIndex(option => + string.Equals(option.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + { + XmlLanguageIndex = index; + } + } + } public string XmlLanguage { @@ -281,6 +336,7 @@ public string XmlLanguage if (SetProperty(ref field, normalized)) { OnPropertyChanged(nameof(XmlLanguageIndex)); + OnPropertyChanged(nameof(SelectedXmlLanguageDisplayOption)); } } } = "und"; @@ -480,32 +536,31 @@ public double Progress public bool CanOpenRelatedMedia => RelatedMediaReferences.Count > 0; - public UiCommand LoadCommand { get; } - public UiCommand ReloadCommand { get; } - public UiCommand AppendMplsCommand { get; } - public UiCommand DropPathLoadCommand { get; } - public UiCommand SaveCommand { get; } - public UiCommand SaveDirectoryCommand { get; } - public UiCommand RefreshCommand { get; } - public UiCommand ChangeFpsCommand { get; } - public UiCommand SelectClipCommand { get; } - public UiCommand CombineCommand { get; } - public UiCommand EditTimeCommand { get; } - public UiCommand EditNameCommand { get; } - public UiCommand EditFrameCommand { get; } - public UiCommand DeleteCommand { get; } - public UiCommand InsertCommand { get; } - public UiCommand PreviewCommand { get; } - public UiCommand LogCommand { get; } - public UiCommand SettingsCommand { get; } - public UiCommand ColorSettingsCommand { get; } - public UiCommand LanguageCommand { get; } - public UiCommand ExpressionCommand { get; } - public UiCommand TemplateNamesCommand { get; } - public UiCommand FileAssociationCommand { get; } - public UiCommand ZonesCommand { get; } - public UiCommand ForwardShiftCommand { get; } - public UiCommand OpenRelatedMediaCommand { get; } + public UiCommand LoadCommand { get; private set; } = null!; + public UiCommand ReloadCommand { get; private set; } = null!; + public UiCommand AppendMplsCommand { get; private set; } = null!; + public UiCommand DropPathLoadCommand { get; private set; } = null!; + public UiCommand SaveCommand { get; private set; } = null!; + public UiCommand SaveDirectoryCommand { get; private set; } = null!; + public UiCommand RefreshCommand { get; private set; } = null!; + public UiCommand ChangeFpsCommand { get; private set; } = null!; + public UiCommand SelectClipCommand { get; private set; } = null!; + public UiCommand CombineCommand { get; private set; } = null!; + public UiCommand EditTimeCommand { get; private set; } = null!; + public UiCommand EditNameCommand { get; private set; } = null!; + public UiCommand EditFrameCommand { get; private set; } = null!; + public UiCommand DeleteCommand { get; private set; } = null!; + public UiCommand InsertCommand { get; private set; } = null!; + public UiCommand PreviewCommand { get; private set; } = null!; + public UiCommand LogCommand { get; private set; } = null!; + public UiCommand SettingsCommand { get; private set; } = null!; + public UiCommand ColorSettingsCommand { get; private set; } = null!; + public UiCommand LanguageCommand { get; private set; } = null!; + public UiCommand ExpressionCommand { get; private set; } = null!; + public UiCommand TemplateNamesCommand { get; private set; } = null!; + public UiCommand ZonesCommand { get; private set; } = null!; + public UiCommand ForwardShiftCommand { get; private set; } = null!; + public UiCommand OpenRelatedMediaCommand { get; private set; } = null!; public void SetFrameOptions(int frameRateIndex, bool roundFrames) { @@ -591,6 +646,8 @@ public string BuildPreview() public string LogText() => logService.Format(FormatLogEntry); + public IApplicationLogService LogService => logService; + public void ClearLog() => logService.Clear(); public void UpdateSelectedRows(IReadOnlySet indexes) @@ -612,7 +669,7 @@ public string CreateZonesText() var result = editingService.CreateZones(currentInfo, indexes, (decimal)currentInfo.FramesPerSecond); SetStatus(result.Diagnostics.Count == 0 ? "Status.ZonesGenerated" : null, diagnostic: result.Diagnostics.FirstOrDefault()); Log("Log.CreateZones", ("selectedRows", indexes.Count), ("chapters", currentInfo.Chapters.Count)); - LogDiagnostics("Create zones", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.CreateZones"), result.Diagnostics); LogStatus(); NotifyStateChanged(); return result.Zones; @@ -625,7 +682,7 @@ public void ShiftFramesForward(int frames) return; } - ApplyEdit(editingService.ShiftFramesForward(currentInfo, frames, (decimal)currentInfo.FramesPerSecond), $"Shift frames forward: frames={frames}"); + ApplyEdit(editingService.ShiftFramesForward(currentInfo, frames, (decimal)currentInfo.FramesPerSecond), Localizer.Format(LocalizedMessage.Create("Action.ShiftFramesForward", ("frames", frames)))); } private ChapterExportOptions CurrentExportOptions() => @@ -666,7 +723,7 @@ private async ValueTask LoadPathAsync(string path, CancellationToken cancellatio currentProgressMessage = null; Progress = 0; LogStatus(); - LogDiagnostics("Load", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.Load"), result.Diagnostics); NotifyStateChanged(); return; } @@ -690,7 +747,7 @@ private async ValueTask LoadPathAsync(string path, CancellationToken cancellatio currentProgressMessage = null; Progress = 1; Log("Log.StatusFromPath", ("status", StatusText), ("path", path)); - LogDiagnostics("Load", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.Load"), result.Diagnostics); NotifyStateChanged(); } @@ -710,7 +767,7 @@ private async ValueTask SaveAsync(string? directory, CancellationToken cancellat ("chapters", projection.Info.Chapters.Count), ("applyExpression", ApplyExpression), ("expression", Expression)); - LogDiagnostics("Output projection", projection.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.OutputProjection"), projection.Diagnostics); var result = await saveService.SaveAsync(projection.Info, options, directory, cancellationToken); if (!string.IsNullOrWhiteSpace(directory)) { @@ -724,7 +781,7 @@ private async ValueTask SaveAsync(string? directory, CancellationToken cancellat SetStatus(result.Success ? "Status.Saved" : "Status.SaveFailed", diagnostic: result.Diagnostics.FirstOrDefault()); LogStatus(); - LogDiagnostics("Save", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.Save"), result.Diagnostics); NotifyStateChanged(); } @@ -737,6 +794,7 @@ private void SelectClip(int index) SelectedClipIndex = index; currentInfo = ClipOptions[index].ChapterInfo; + configuredFrameRate = (decimal)currentInfo.FramesPerSecond; currentInfoBelongsToSelectedClip = !IsClipCombineChecked; Log("Log.SelectedSourceOption", ("index", index), @@ -764,7 +822,7 @@ private ValueTask EditCell(object? parameter, EditKind kind) EditKind.Frame => editingService.EditFrame(currentInfo, edit.Index, edit.Value, (decimal)currentInfo.FramesPerSecond), _ => new ChapterEditResult(currentInfo, []) }; - ApplyEdit(result, $"Edit {kind}: row={edit.Index}, value='{edit.Value}'"); + ApplyEdit(result, Localizer.Format(LocalizedMessage.Create("Action.EditCell", ("kind", Localizer.GetString($"EditKind.{kind}")), ("row", edit.Index), ("value", edit.Value)))); return ValueTask.CompletedTask; } @@ -785,7 +843,7 @@ private void CombineSegments() var result = ChapterSegmentService.Combine(groupToCombine); if (result.Diagnostics.Count > 0) { - ApplyEdit(result, $"Combine segments: options={groupToCombine.Options.Count}, sourceType={groupToCombine.Options[0].ChapterInfo.SourceType}"); + ApplyEdit(result, Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("options", groupToCombine.Options.Count), ("sourceType", groupToCombine.Options[0].ChapterInfo.SourceType)))); return; } @@ -801,7 +859,7 @@ private void CombineSegments() SelectClip(0); SetStatus("Status.Updated"); Log("Log.EditChapters", - ("action", $"Combine segments: options={groupToCombine.Options.Count}, sourceType={groupToCombine.Options[0].ChapterInfo.SourceType}"), + ("action", Localizer.Format(LocalizedMessage.Create("Action.CombineSegments", ("options", groupToCombine.Options.Count), ("sourceType", groupToCombine.Options[0].ChapterInfo.SourceType)))), ("before", groupToCombine.Options.Sum(static option => option.ChapterInfo.Chapters.Count)), ("after", currentInfo?.Chapters.Count ?? 0)); LogStatus(); @@ -825,18 +883,18 @@ private async ValueTask AppendMplsAsync(string path, CancellationToken cancellat { SetStatus("Status.AppendFailed", diagnostic: result.Diagnostics.FirstOrDefault()); LogStatus(); - LogDiagnostics("Append load", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.AppendLoad"), result.Diagnostics); NotifyStateChanged(); return; } var baseGroup = splitClipGroup ?? currentGroup; - var edit = segmentService.Append(baseGroup, result.Groups[0]); + var edit = ChapterSegmentService.Append(baseGroup, result.Groups[0]); if (edit.Diagnostics.Count > 0) { SetStatus(null, diagnostic: edit.Diagnostics[0]); LogStatus(); - LogDiagnostics("Append edit", edit.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.AppendEdit"), edit.Diagnostics); NotifyStateChanged(); return; } @@ -862,18 +920,19 @@ private async ValueTask AppendMplsAsync(string path, CancellationToken cancellat SelectClip(0); SetStatus("Status.AppendedMplsSegments", ("count", result.Groups[0].Options.Count)); LogStatus(); - LogDiagnostics("Append load", result.Diagnostics); + LogDiagnostics(Localizer.GetString("Operation.AppendLoad"), result.Diagnostics); NotifyStateChanged(); } - private void ApplyEdit(ChapterEditResult result, string action = "Edit chapters") + private void ApplyEdit(ChapterEditResult result, string? action = null) { + var effectiveAction = action ?? Localizer.GetString("Action.EditChapters"); var before = currentInfo?.Chapters.Count ?? 0; currentInfo = result.ChapterInfo; ApplyFrameInfo(); SetStatus(result.Diagnostics.Count == 0 ? "Status.Updated" : null, diagnostic: result.Diagnostics.FirstOrDefault()); - Log("Log.EditChapters", ("action", action), ("before", before), ("after", currentInfo.Chapters.Count)); - LogDiagnostics(action, result.Diagnostics); + Log("Log.EditChapters", ("action", effectiveAction), ("before", before), ("after", currentInfo.Chapters.Count)); + LogDiagnostics(effectiveAction, result.Diagnostics); LogStatus(); NotifyStateChanged(); } @@ -901,6 +960,9 @@ private void ApplyFrameInfo() var result = frameRateService.UpdateFrames(currentInfo, appliedOption, RoundFrames, FrameAccuracyTolerance); currentInfo = result.Info; + var storedInfo = configuredFrameRate is null + ? currentInfo + : currentInfo with { FramesPerSecond = (double)configuredFrameRate.Value }; if (detection is not null) { @@ -926,11 +988,11 @@ private void ApplyFrameInfo() ("chapters", currentInfo.Chapters.Count)); if (currentInfoBelongsToSelectedClip) { - UpdateCurrentClipOption(currentInfo); + UpdateCurrentClipOption(storedInfo); } else if (IsClipCombineChecked) { - UpdateCombinedClipOption(currentInfo); + UpdateCombinedClipOption(storedInfo); } RefreshRows(); NotifyStateChanged(); @@ -943,8 +1005,10 @@ private void ChangeFpsToSelectedOption() return; } - var sourceFps = (decimal)currentInfo.FramesPerSecond; - var result = ChapterFpsTransformService.ChangeFps(currentInfo, sourceFps, selectedFrameRateOption.Value); + var sourceFps = configuredFrameRate ?? (decimal)currentInfo.FramesPerSecond; + var targetOption = selectedFrameRateOption; + var targetFps = targetOption.Value; + var result = ChapterFpsTransformService.ChangeFps(currentInfo, sourceFps, targetFps); if (!result.Success) { SetStatus(null, diagnostic: result.Diagnostics.FirstOrDefault()); @@ -955,10 +1019,12 @@ private void ChangeFpsToSelectedOption() var beforeCount = currentInfo.Chapters.Count; currentInfo = result.Info; + configuredFrameRate = targetFps; ApplyFrameInfo(); SetStatus("Status.Updated"); - Log("Log.EditChapters", - ("action", $"Change FPS: {sourceFps:0.###} -> {selectedFrameRateOption.Value:0.###}"), + Log("Log.ChangeFps", + ("sourceFps", $"{sourceFps:0.###}"), + ("targetFps", $"{targetFps:0.###}"), ("before", beforeCount), ("after", result.Info.Chapters.Count)); LogStatus(); @@ -1038,7 +1104,7 @@ private void RestoreSplitClips() SelectClip(Math.Clamp(currentGroup.DefaultOptionIndex, 0, ClipOptions.Count - 1)); SetStatus("Status.Updated"); Log("Log.EditChapters", - ("action", $"Split combined segments: options={currentGroup.Options.Count}, sourceType={currentGroup.Options[0].ChapterInfo.SourceType}"), + ("action", Localizer.Format(LocalizedMessage.Create("Action.SplitCombinedSegments", ("options", currentGroup.Options.Count), ("sourceType", currentGroup.Options[0].ChapterInfo.SourceType)))), ("before", combinedChapterCount), ("after", currentInfo?.Chapters.Count ?? 0)); LogStatus(); @@ -1097,6 +1163,8 @@ private void OnClipOptionsChanged(object? sender, NotifyCollectionChangedEventAr SyncClipDisplayOptions(args); OnPropertyChanged(nameof(IsClipSelectionVisible)); OnPropertyChanged(nameof(RelatedMediaReferences)); + OnPropertyChanged(nameof(SelectedClipIndex)); + OnPropertyChanged(nameof(SelectedClipDisplayOption)); NotifyCommandStates(); } @@ -1137,7 +1205,7 @@ private void SyncClipDisplayOptions(NotifyCollectionChangedEventArgs args) break; case NotifyCollectionChangedAction.Move: - if (args.OldStartingIndex >= 0 && args.NewStartingIndex >= 0) + if (args is { OldStartingIndex: >= 0, NewStartingIndex: >= 0 }) { ClipDisplayOptions.Move(args.OldStartingIndex, args.NewStartingIndex); } @@ -1178,17 +1246,9 @@ private static SelectorDisplayOption ToClipDisplayOption(ChapterSourceOption opt return new SelectorDisplayOption(mainText, remarkText, displayText); } - private static string LanguageDisplayName(XmlChapterLanguage language) - { - const string separator = " - "; - var separatorIndex = language.DisplayName.IndexOf(separator, StringComparison.Ordinal); - return separatorIndex >= 0 - ? language.DisplayName[(separatorIndex + separator.Length)..] - : language.DisplayName; - } - private void OnRowsChanged(object? sender, NotifyCollectionChangedEventArgs args) { + OnPropertyChanged(nameof(IsChapterGridEmpty)); NotifyCommandStates(); } @@ -1225,7 +1285,6 @@ private void NotifyCommandStates() LanguageCommand.RaiseCanExecuteChanged(); ExpressionCommand.RaiseCanExecuteChanged(); TemplateNamesCommand.RaiseCanExecuteChanged(); - FileAssociationCommand.RaiseCanExecuteChanged(); ZonesCommand.RaiseCanExecuteChanged(); ForwardShiftCommand.RaiseCanExecuteChanged(); } @@ -1261,8 +1320,8 @@ private static int ComboIndexFor(FrameRateOption option) return frameRateService.Options.FirstOrDefault(option => option.LegacyMplsCode == legacyCode); } - private UiCommand WindowCommand(string id) => - new(async (_, token) => await windowService.ShowAsync(id, this, token)); + private UiCommand WindowCommand(string id, Func? canExecute = null) => + new(async (_, token) => await windowService.ShowAsync(id, this, token), _ => canExecute?.Invoke() ?? true); private async ValueTask OpenRelatedMediaAsync(object? parameter, CancellationToken cancellationToken) { @@ -1335,11 +1394,26 @@ private void SetProgressStatus(string? messageKey, params (string Name, object? private string LocalizeDiagnostic(ChapterDiagnostic diagnostic) { var diagnosticKey = $"Diagnostic.{diagnostic.Code}"; - return Localizer.TryGetString(diagnosticKey, out _) - ? Localizer.Format(diagnosticKey) - : diagnostic.Message; + if (!Localizer.TryGetString(diagnosticKey, out var template)) + { + return diagnostic.Message; + } + + var arguments = diagnostic.Arguments; + if (arguments is null && template.Contains("{message}", StringComparison.Ordinal)) + { + arguments = new Dictionary(StringComparer.Ordinal) { ["message"] = diagnostic.Message }; + } + + var formatted = Localizer.Format(diagnosticKey, arguments); + // Strip any unresolved {token} placeholders that may remain when + // the Arguments dictionary is missing keys expected by the template. + return UnresolvedPlaceholderPattern().Replace(formatted, "[?]"); } + [GeneratedRegex(@"\{[^}]+\}")] + private static partial Regex UnresolvedPlaceholderPattern(); + private void LogStatus(LogLevel level = LogLevel.Information) => Log(level, "Log.Status", ("status", StatusText)); private void Log(string key, params (string Name, object? Value)[] arguments) => @@ -1388,6 +1462,7 @@ private string FormatLogEntry(ApplicationLogEntry entry) private void RefreshLocalizedState() { RefreshChapterNameModeOptions(); + RefreshXmlLanguageDisplayOptions(notify: true); if (string.IsNullOrEmpty(chapterNameTemplateText)) { @@ -1406,6 +1481,32 @@ private void RefreshLocalizedState() } } + private void RefreshXmlLanguageDisplayOptions(bool notify) + { + var options = XmlLanguageDisplay.Options(Localizer); + if (xmlLanguageDisplayOptions.Count != options.Count) + { + xmlLanguageDisplayOptions.Clear(); + foreach (var option in options) + { + xmlLanguageDisplayOptions.Add(option); + } + } + else + { + for (var index = 0; index < options.Count; index++) + { + xmlLanguageDisplayOptions[index].UpdateFrom(options[index]); + } + } + + if (notify) + { + OnPropertyChanged(nameof(XmlLanguageDisplayOptions)); + OnPropertyChanged(nameof(SelectedXmlLanguageDisplayOption)); + } + } + private void RefreshChapterNameModeOptions() { var options = new[] @@ -1500,7 +1601,7 @@ private void LogDiagnostics(string operation, IReadOnlyList d ("severity", diagnostic.Severity), ("code", diagnostic.Code), ("location", location), - ("message", diagnostic.Message), + ("message", LocalizeDiagnostic(diagnostic)), ("details", details)); } } diff --git a/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs index 3568415..2e9362f 100644 --- a/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/SettingsToolViewModel.cs @@ -1,5 +1,8 @@ using System.Collections.ObjectModel; using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using Avalonia; using ChapterTool.Avalonia.Localization; using ChapterTool.Avalonia.Services; using ChapterTool.Core.Exporting; @@ -33,6 +36,7 @@ public sealed class SettingsToolViewModel : ObservableViewModel private readonly ISettingsPickerService? picker; private readonly IExternalToolLocator? externalToolLocator; private readonly IThemeApplicationService? themeApplicationService; + private readonly IShellService? shellService; private AppSettings savedAppSettings = new(); private ThemeColorSettings savedThemeSettings = ThemeColorSettings.Default; private string selectedLanguage; @@ -43,6 +47,7 @@ public sealed class SettingsToolViewModel : ObservableViewModel private bool liveApplyEnabled; private bool isApplyingSnapshot; private bool isRefreshingLanguages; + private readonly ObservableCollection xmlLanguageDisplayOptions = []; public SettingsToolViewModel( MainWindowViewModel owner, @@ -51,7 +56,9 @@ public SettingsToolViewModel( IAppLocalizer? localizer = null, ISettingsPickerService? picker = null, IExternalToolLocator? externalToolLocator = null, - IThemeApplicationService? themeApplicationService = null) + IThemeApplicationService? themeApplicationService = null, + IShellService? shellService = null, + bool autoLoad = true) { this.owner = owner; this.appSettingsStore = appSettingsStore; @@ -60,12 +67,14 @@ public SettingsToolViewModel( this.picker = picker; this.externalToolLocator = externalToolLocator; this.themeApplicationService = themeApplicationService; + this.shellService = shellService; selectedLanguage = AppLanguage.Normalize(owner.UiLanguage); defaultSaveFormatIndex = Math.Clamp(owner.SaveFormatIndex, 0, SaveFormats.Length - 1); defaultXmlLanguageIndex = XmlLanguageIndex(owner.XmlLanguage); frameAccuracyTolerance = MainWindowViewModel.NormalizeFrameAccuracyTolerance(owner.FrameAccuracyTolerance); frameAccuracyToleranceSliderValue = (double)frameAccuracyTolerance; ReplaceLanguages(BuildLanguageOptions()); + RefreshXmlLanguageDisplayOptions(notify: false); ColorSlots = new ObservableCollection( ThemeColorSettings.Default.OrderedSlots.Select(static slot => new ColorSlotViewModel(slot.Name, slot.Value))); foreach (var slot in ColorSlots) @@ -97,16 +106,21 @@ public SettingsToolViewModel( ClearEac3toCommand = ClearCommand(() => Eac3toPath = null); ClearFfprobeCommand = ClearCommand(() => FfprobePath = null); ClearFfmpegCommand = ClearCommand(() => FfmpegPath = null); + OpenRepositoryCommand = new UiCommand(async (_, token) => await OpenRepositoryAsync(token), _ => shellService is not null); this.localizer.CultureChanged += (_, _) => { RefreshLanguages(); + RefreshXmlLanguageDisplayOptions(notify: true); RefreshToolStatuses(); if (!string.IsNullOrWhiteSpace(StatusText)) { StatusText = this.localizer.GetString("Settings.Status.Ready"); } }; - _ = InitializeAsync(); + if (autoLoad) + { + _ = InitializeAsync(); + } } public IReadOnlyList Languages => languages; @@ -116,6 +130,34 @@ public SettingsToolViewModel( public IReadOnlyList XmlLanguageOptions { get; } = XmlChapterLanguageCatalog.Languages.Select(static language => language.Code).ToList(); + public IReadOnlyList XmlLanguageDisplayOptions => xmlLanguageDisplayOptions; + + public string AvaloniaRuntimeDisplay { get; } = $"Avalonia v{InformationalVersion(typeof(Application))}"; + + public string DotNetRuntimeDisplay { get; } = $"{RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture}"; + + public SelectorDisplayOption? SelectedDefaultXmlLanguageDisplayOption + { + get + { + var options = XmlLanguageDisplayOptions; + return DefaultXmlLanguageIndex < 0 || DefaultXmlLanguageIndex >= options.Count + ? null + : options[DefaultXmlLanguageIndex]; + } + set + { + var index = value is null + ? -1 + : XmlLanguageDisplayOptions.ToList().FindIndex(option => + string.Equals(option.MainText, value.MainText, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + { + DefaultXmlLanguageIndex = index; + } + } + } + public ObservableCollection ColorSlots { get; } public string SelectedLanguage @@ -210,7 +252,7 @@ public string? FfmpegPath { if (SetProperty(ref field, CleanPath(value))) { - FfmpegStatus = FormatToolStatus(ValidateTool(value, "ffprobe")); + FfmpegStatus = FormatToolStatus(ValidateToolDirectory(value, "ffprobe")); NotifyUnsavedChanges(); } } @@ -235,6 +277,7 @@ public int DefaultXmlLanguageIndex { if (SetProperty(ref defaultXmlLanguageIndex, Math.Clamp(value, 0, XmlLanguageOptions.Count - 1))) { + OnPropertyChanged(nameof(SelectedDefaultXmlLanguageDisplayOption)); ApplyLiveSettings(); } } @@ -320,6 +363,8 @@ public string FfmpegStatus public UiCommand ClearFfmpegCommand { get; } + public UiCommand OpenRepositoryCommand { get; } + public async ValueTask LoadAsync(CancellationToken cancellationToken) { liveApplyEnabled = false; @@ -458,6 +503,14 @@ private static UiCommand ClearCommand(Action clear) => return ValueTask.CompletedTask; }); + private async ValueTask OpenRepositoryAsync(CancellationToken cancellationToken) + { + if (shellService is not null) + { + await shellService.OpenAsync("https://github.com/tautcony/ChapterTool", cancellationToken); + } + } + private void RefreshLanguages() { isRefreshingLanguages = true; @@ -474,6 +527,32 @@ private void RefreshLanguages() OnPropertyChanged(nameof(SelectedLanguageIndex)); } + private void RefreshXmlLanguageDisplayOptions(bool notify) + { + var options = XmlLanguageDisplay.Options(localizer); + if (xmlLanguageDisplayOptions.Count != options.Count) + { + xmlLanguageDisplayOptions.Clear(); + foreach (var option in options) + { + xmlLanguageDisplayOptions.Add(option); + } + } + else + { + for (var index = 0; index < options.Count; index++) + { + xmlLanguageDisplayOptions[index].UpdateFrom(options[index]); + } + } + + if (notify) + { + OnPropertyChanged(nameof(XmlLanguageDisplayOptions)); + OnPropertyChanged(nameof(SelectedDefaultXmlLanguageDisplayOption)); + } + } + private List BuildLanguageOptions() => localizer.SupportedLanguages .Select(language => new LanguageOptionViewModel( @@ -495,7 +574,7 @@ private void RefreshToolStatuses() MkvToolnixStatus = FormatToolStatus(ValidateTool(MkvToolnixPath, "mkvextract")); Eac3toStatus = FormatToolStatus(ValidateTool(Eac3toPath, "eac3to")); FfprobeStatus = FormatToolStatus(ValidateTool(FfprobePath, "ffprobe")); - FfmpegStatus = FormatToolStatus(ValidateTool(FfmpegPath, "ffprobe")); + FfmpegStatus = FormatToolStatus(ValidateToolDirectory(FfmpegPath, "ffprobe")); } private async ValueTask DiscoverAndFillToolPathsAsync(CancellationToken cancellationToken) @@ -511,7 +590,7 @@ private async ValueTask DiscoverAndFillToolPathsAsync(CancellationToken cancella var ffprobe = await DiscoverExecutableAsync("ffprobe", FfprobePath, cancellationToken); FfprobePath = ffprobe; - if (string.IsNullOrWhiteSpace(FfmpegPath) || ValidateTool(FfmpegPath, "ffprobe").Kind != SettingsToolStatusKind.Found) + if (string.IsNullOrWhiteSpace(FfmpegPath) || ValidateToolDirectory(FfmpegPath, "ffprobe").Kind != SettingsToolStatusKind.Found) { var ffprobeDirectory = string.IsNullOrWhiteSpace(ffprobe) ? null : Path.GetDirectoryName(ffprobe); FfmpegPath = string.IsNullOrWhiteSpace(ffprobeDirectory) ? FfmpegPath : ffprobeDirectory; @@ -539,6 +618,7 @@ private string FormatToolStatus(SettingsToolStatus status) => SettingsToolStatusKind.Found => localizer.Format("Settings.ToolStatus.Found", new Dictionary { ["path"] = status.ResolvedPath }), SettingsToolStatusKind.Missing => localizer.Format("Settings.ToolStatus.Missing", new Dictionary { ["name"] = status.ExpectedExecutable }), SettingsToolStatusKind.InvalidPath => localizer.GetString("Settings.ToolStatus.InvalidPath"), + SettingsToolStatusKind.NotDirectory => localizer.GetString("Settings.ToolStatus.NotDirectory"), _ => localizer.GetString("Settings.ToolStatus.Unsupported") }; @@ -567,6 +647,31 @@ private static SettingsToolStatus ValidateTool(string? configuredPath, string to : new SettingsToolStatus(SettingsToolStatusKind.InvalidPath, text, executableName); } + private static SettingsToolStatus ValidateToolDirectory(string? configuredPath, string toolId) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + return new SettingsToolStatus( + SettingsToolStatusKind.Discovery, + null, + ExternalToolPathResolver.ExecutableName(toolId)); + } + + var text = configuredPath.Trim(); + var executableName = ExternalToolPathResolver.ExecutableName(toolId); + if (!Directory.Exists(text)) + { + return File.Exists(text) + ? new SettingsToolStatus(SettingsToolStatusKind.NotDirectory, text, executableName) + : new SettingsToolStatus(SettingsToolStatusKind.InvalidPath, text, executableName); + } + + var candidate = Path.Combine(text, executableName); + return File.Exists(candidate) + ? new SettingsToolStatus(SettingsToolStatusKind.Found, candidate, executableName) + : new SettingsToolStatus(SettingsToolStatusKind.Missing, candidate, executableName); + } + private ThemeColorSettings CurrentThemeSettings() { var defaults = ThemeColorSettings.Default.OrderedSlots.ToList(); @@ -657,6 +762,17 @@ private static string NormalizeColor(string? value, string fallback) private static string? CleanPath(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string InformationalVersion(Type type) + { + var version = type.Assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(version)) + { + return version.Split('+', 2)[0]; + } + + return type.Assembly.GetName().Version?.ToString(3) ?? "unknown"; + } + private void SetFrameAccuracyTolerance(decimal value, bool updateSlider) { var normalized = MainWindowViewModel.NormalizeFrameAccuracyTolerance(value); @@ -702,5 +818,6 @@ public enum SettingsToolStatusKind Found, Missing, InvalidPath, + NotDirectory, Unsupported } diff --git a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs index 4847ed3..0e6bd7d 100644 --- a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs +++ b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Xml.Linq; using Avalonia.Media; +using Avalonia.Threading; using ChapterTool.Avalonia.Services; using ChapterTool.Avalonia.Localization; using ChapterTool.Core.Exporting; @@ -16,6 +17,7 @@ public sealed class TextToolViewModel : ObservableViewModel private readonly Func refreshText; private readonly TextToolOptions options; + private readonly IApplicationLogService? liveRefreshService; private string text; private TextToolKind kind; private IReadOnlyList lines; @@ -24,6 +26,7 @@ public TextToolViewModel(Func refreshText, TextToolOptions? options = nu { this.refreshText = refreshText; this.options = options ?? TextToolOptions.Default; + liveRefreshService = this.options.LiveRefreshService; kind = this.options.FormatSelector?.Kind ?? TextToolKind.Plain; text = Format(refreshText(), kind); lines = BuildLines(text, kind); @@ -38,6 +41,19 @@ public TextToolViewModel(Func refreshText, TextToolOptions? options = nu Text = string.Empty; return ValueTask.CompletedTask; }, _ => this.options.ClearAction is not null); + + if (liveRefreshService is { } service) + { + service.EntryAdded += OnEntryAdded; + } + } + + public void DetachLiveRefresh() + { + if (liveRefreshService is { } service) + { + service.EntryAdded -= OnEntryAdded; + } } public string Text @@ -99,6 +115,22 @@ public IReadOnlyList Lines public UiCommand ClearCommand { get; } + private void OnEntryAdded(object? sender, ApplicationLogEntry entry) + { + if (Dispatcher.UIThread.CheckAccess()) + { + RefreshText(); + return; + } + + Dispatcher.UIThread.Post(RefreshText); + } + + private void RefreshText() + { + Text = Format(refreshText(), Kind); + } + private static string Format(string text, TextToolKind kind) { if (string.IsNullOrWhiteSpace(text)) @@ -283,6 +315,8 @@ public sealed class TextToolOptions public Action? ClearAction { get; init; } public TextToolFormatSelector? FormatSelector { get; init; } + + public IApplicationLogService? LiveRefreshService { get; init; } } public sealed class TextToolFormatSelector(MainWindowViewModel owner) @@ -301,17 +335,15 @@ public sealed class TextToolFormatSelector(MainWindowViewModel owner) ChapterExportFormat.Chapter2Qpfile ]; - private int selectedIndex = Math.Clamp(owner.SaveFormatIndex, 0, Formats.Length - 1); - private MainWindowViewModel Owner { get; } = owner; public IReadOnlyList Labels { get; } = Formats.Select(ChapterExportFormatDisplay.LabelFor).ToArray(); public int SelectedIndex { - get => selectedIndex; - set => selectedIndex = Math.Clamp(value, 0, Formats.Length - 1); - } + get; + set => field = Math.Clamp(value, 0, Formats.Length - 1); + } = Math.Clamp(owner.SaveFormatIndex, 0, Formats.Length - 1); public TextToolKind Kind => KindFor(Formats[SelectedIndex]); @@ -333,7 +365,16 @@ private static TextToolKind KindFor(ChapterExportFormat format) => internal static class ChapterExportFormatDisplay { public static string LabelFor(ChapterExportFormat format) => - format == ChapterExportFormat.Qpfile ? "QPFile" : format.ToString(); + format switch + { + ChapterExportFormat.Txt => "TXT", + ChapterExportFormat.Xml => "XML", + ChapterExportFormat.Qpfile => "QPFile", + ChapterExportFormat.TsMuxerMeta => "TsmuxerMeta", + ChapterExportFormat.Cue => "CUE", + ChapterExportFormat.Json => "JSON", + _ => format.ToString() + }; } public sealed class ColorSettingsViewModel : ObservableViewModel @@ -489,7 +530,7 @@ private static bool TryParseColor(string? value, out Color color) if (!string.IsNullOrWhiteSpace(value)) { var text = value.Trim(); - if (text.Length == 7 && text[0] == '#' && text.Skip(1).All(Uri.IsHexDigit)) + if (text is ['#', _, _, _, _, _, _] && text.Skip(1).All(Uri.IsHexDigit)) { color = Color.Parse(text); return true; @@ -603,6 +644,8 @@ public sealed record LanguageOptionViewModel(string CultureName, string DisplayN public sealed class ExpressionToolViewModel(MainWindowViewModel owner) : ObservableViewModel { + public IAppLocalizer Localizer => owner.Localizer; + public string Expression { get; diff --git a/src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs b/src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs new file mode 100644 index 0000000..c6b63cf --- /dev/null +++ b/src/ChapterTool.Avalonia/ViewModels/XmlLanguageDisplay.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; +using System.Globalization; +using ChapterTool.Avalonia.Localization; +using ChapterTool.Core.Exporting; + +namespace ChapterTool.Avalonia.ViewModels; + +internal static class XmlLanguageDisplay +{ + private static readonly ConcurrentDictionary<(string CultureName, string LanguageCode), string> DisplayNameCache = new(); + + public static IReadOnlyList Options(IAppLocalizer localizer) => + XmlChapterLanguageCatalog.Languages + .Select(language => + { + var displayName = LanguageDisplayName(language, localizer); + return new SelectorDisplayOption(language.Code, displayName, $"{language.Code}({displayName})"); + }) + .ToArray(); + + private static string LanguageDisplayName(XmlChapterLanguage language, IAppLocalizer localizer) + { + if (language.Code.Equals("und", StringComparison.OrdinalIgnoreCase)) + { + return localizer.GetString("XmlLanguage.Undetermined"); + } + + try + { + var culture = CultureForCode(language.Code); + if (culture is null) + { + return EnglishDisplayName(language); + } + + var cacheKey = (localizer.CurrentCultureName, language.Code); + return DisplayNameCache.GetOrAdd(cacheKey, _ => culture.DisplayName); + } + catch (CultureNotFoundException) + { + return EnglishDisplayName(language); + } + } + + private static CultureInfo? CultureForCode(string code) + { + try + { + return CultureInfo.GetCultureInfo(code); + } + catch (CultureNotFoundException) + { + return CultureInfo.GetCultures(CultureTypes.NeutralCultures) + .FirstOrDefault(culture => + string.Equals(culture.ThreeLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase) + || string.Equals(culture.TwoLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase)); + } + } + + private static string EnglishDisplayName(XmlChapterLanguage language) + { + const string separator = " - "; + var separatorIndex = language.DisplayName.IndexOf(separator, StringComparison.Ordinal); + return separatorIndex >= 0 + ? language.DisplayName[(separatorIndex + separator.Length)..] + : language.DisplayName; + } +} diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml new file mode 100644 index 0000000..e3157de --- /dev/null +++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs new file mode 100644 index 0000000..9dd0cbb --- /dev/null +++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs @@ -0,0 +1,596 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using ChapterTool.Avalonia.Localization; +using ChapterTool.Core.Transform; + +namespace ChapterTool.Avalonia.Views.Controls; + +public sealed partial class ExpressionEditor : UserControl +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), "t", defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty LocalizerProperty = + AvaloniaProperty.Register(nameof(Localizer)); + + private readonly IExpressionAuthoringService authoringService = new ExpressionAuthoringService(); + private readonly TextEditor editor; + private readonly ExpressionColorizer colorizer; + private readonly DiagnosticUnderlineRenderer diagnosticRenderer; + private readonly DispatcherTimer diagnosticHideTimer; + private bool updatingText; + private bool shouldShowCompletion; + private bool isPointerOverDiagnosticPopup; + private string diagnosticTooltipText = string.Empty; + private ExpressionAuthoringDiagnostic? primaryDiagnostic; + + public ExpressionEditor() + { + InitializeComponent(); + + colorizer = new ExpressionColorizer([]); + diagnosticRenderer = new DiagnosticUnderlineRenderer(); + diagnosticHideTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(150) + }; + diagnosticHideTimer.Tick += OnDiagnosticHideTimerTick; + editor = new TextEditor + { + ShowLineNumbers = false, + WordWrap = false, + FontFamily = FontFamily.Parse("Menlo, Consolas, monospace"), + FontSize = 13, + Background = Brushes.Transparent, + Foreground = Brush("#24292f"), + Padding = new Thickness(6, 3), + MinHeight = 25.6, + Height = 25.6, + HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + Document = new TextDocument(Text) + }; + editor.TextArea.TextView.LineTransformers.Add(colorizer); + editor.TextArea.TextView.BackgroundRenderers.Add(diagnosticRenderer); + editor.TextChanged += OnEditorTextChanged; + editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; + editor.TextArea.AddHandler(KeyDownEvent, OnEditorPreviewKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true); + editor.TextArea.KeyDown += OnEditorKeyDown; + editor.TextArea.TextInput += OnEditorTextInput; + editor.TextArea.TextView.PointerMoved += OnTextViewPointerMoved; + editor.TextArea.TextView.PointerExited += OnTextViewPointerExited; + editor.GotFocus += OnEditorFocusChanged; + editor.LostFocus += OnEditorFocusChanged; + editor.TextArea.GotFocus += OnEditorFocusChanged; + editor.TextArea.LostFocus += OnEditorFocusChanged; + editor.TextArea.Focusable = true; + EditorHost.Content = editor; + AnalyzeAndRender(); + } + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IAppLocalizer? Localizer + { + get => GetValue(LocalizerProperty); + set => SetValue(LocalizerProperty, value); + } + + public IReadOnlyList CurrentCompletions { get; private set; } = []; + + public string EditorText => editor.Text; + + public string DiagnosticTooltipText => diagnosticTooltipText; + + public bool HasDiagnostic => !string.IsNullOrWhiteSpace(diagnosticTooltipText); + + public bool IsCompletionOpen => CompletionPopup.IsOpen; + + public bool IsDiagnosticOpen => DiagnosticPopup.IsOpen; + + public void MoveCaretToEnd() + { + editor.CaretOffset = editor.Document.TextLength; + FocusInnerEditor(); + AnalyzeAndRender(); + } + + public void InsertTextForTesting(string text) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + FocusInnerEditor(); + shouldShowCompletion = true; + var offset = editor.CaretOffset; + editor.Document.Insert(offset, text); + editor.CaretOffset = Math.Clamp(offset + text.Length, 0, editor.Document.TextLength); + AnalyzeAndRender(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TextProperty && !updatingText) + { + var value = change.NewValue as string ?? string.Empty; + if (editor.Text != value) + { + editor.Text = value; + } + + AnalyzeAndRender(); + } + else if (change.Property == LocalizerProperty) + { + AnalyzeAndRender(); + } + } + + public void AcceptFirstCompletion() + { + if (CurrentCompletions.Count == 0) + { + return; + } + + InsertCompletion(CurrentCompletions[0]); + } + + private void OnEditorTextChanged(object? sender, EventArgs args) + { + updatingText = true; + SetCurrentValue(TextProperty, editor.Text); + updatingText = false; + shouldShowCompletion = true; + AnalyzeAndRender(); + } + + private void OnCaretPositionChanged(object? sender, EventArgs args) + { + AnalyzeAndRender(); + } + + private void OnEditorKeyDown(object? sender, KeyEventArgs args) + { + HandleCompletionKeys(args); + } + + private void OnEditorPreviewKeyDown(object? sender, KeyEventArgs args) + { + HandleCompletionKeys(args); + } + + private void HandleCompletionKeys(KeyEventArgs args) + { + if (args.Handled) + { + return; + } + + if (args.Key == Key.Down && CompletionPopup.IsOpen && CurrentCompletions.Count > 0) + { + MoveCompletionSelection(1); + args.Handled = true; + return; + } + + if (args.Key == Key.Up && CompletionPopup.IsOpen && CurrentCompletions.Count > 0) + { + MoveCompletionSelection(-1); + args.Handled = true; + return; + } + + if ((args.Key == Key.Tab || args.Key == Key.Enter) && CompletionPopup.IsOpen && CurrentCompletions.Count > 0) + { + AcceptSelectedCompletion(); + args.Handled = true; + return; + } + + if (args.Key is Key.Escape) + { + CloseCompletionPopup(); + } + } + + private void OnEditorTextInput(object? sender, TextInputEventArgs args) + { + shouldShowCompletion = true; + } + + private void OnEditorFocusChanged(object? sender, RoutedEventArgs args) + { + if (!editor.IsKeyboardFocusWithin) + { + CloseDiagnosticPopup(); + } + + RenderCompletionPopup(); + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs args) + { + Dispatcher.UIThread.Post(FocusInnerEditor); + } + + private void OnWrapperGotFocus(object? sender, RoutedEventArgs args) + { + FocusInnerEditor(); + } + + private void FocusInnerEditor() + { + editor.Focus(); + editor.TextArea.Focus(); + editor.CaretOffset = Math.Clamp(editor.CaretOffset, 0, editor.Document.TextLength); + } + + private void AnalyzeAndRender() + { + var result = authoringService.Analyze(editor.Text, editor.CaretOffset); + CurrentCompletions = result.Completions; + colorizer.Update(result.Spans); + diagnosticRenderer.Update(result.Diagnostics); + editor.TextArea.TextView.Redraw(); + RenderDiagnostics(result); + RenderCompletionPopup(); + } + + private void RenderDiagnostics(ExpressionAnalysisResult result) + { + primaryDiagnostic = result.Diagnostics.FirstOrDefault(static diagnostic => diagnostic.Length > 0) ?? result.Diagnostics.FirstOrDefault(); + var hasDiagnostic = primaryDiagnostic is not null; + diagnosticTooltipText = hasDiagnostic + ? LocalizeDiagnostic(primaryDiagnostic!.Diagnostic.Code, primaryDiagnostic.Diagnostic.Message, primaryDiagnostic.Diagnostic.Arguments) + Environment.NewLine + LocalizeSuggestion(primaryDiagnostic.Suggestion) + : string.Empty; + DiagnosticTextBlock.Text = diagnosticTooltipText; + if (!hasDiagnostic) + { + CloseDiagnosticPopup(); + } + } + + private void RenderCompletionPopup() + { + if (!shouldShowCompletion || !editor.IsKeyboardFocusWithin || CurrentCompletions.Count == 0) + { + CloseCompletionPopup(); + return; + } + + CompletionList.ItemsSource = CurrentCompletions; + if (CompletionList.SelectedIndex < 0) + { + CompletionList.SelectedIndex = 0; + } + + var completionAnchor = GetCompletionAnchorOffset(); + CompletionPopup.PlacementTarget = RootGrid; + CompletionPopup.HorizontalOffset = completionAnchor.X + 12; + CompletionPopup.VerticalOffset = RootGrid.Bounds.Height + 4; + CompletionPopup.IsOpen = true; + CloseDiagnosticPopup(); + } + + private void InsertCompletion(ExpressionCompletion completion) + { + editor.Document.Replace(completion.ReplacementStart, completion.ReplacementLength, completion.InsertText); + var caret = completion.ReplacementStart + completion.InsertText.Length; + if (completion.InsertText.EndsWith("()", StringComparison.Ordinal)) + { + caret--; + } + else if (completion.InsertText.Contains("(, )", StringComparison.Ordinal)) + { + caret = completion.ReplacementStart + completion.InsertText.IndexOf("(, )", StringComparison.Ordinal) + 1; + } + + editor.CaretOffset = Math.Clamp(caret, 0, editor.Document.TextLength); + FocusInnerEditor(); + CloseCompletionPopup(); + shouldShowCompletion = false; + AnalyzeAndRender(); + } + + private void CloseCompletionPopup() + { + CompletionPopup.IsOpen = false; + CompletionList.ItemsSource = null; + CompletionList.SelectedIndex = -1; + shouldShowCompletion = false; + } + + private void AcceptSelectedCompletion() + { + if (CurrentCompletions.Count == 0) + { + return; + } + var selected = CompletionList.SelectedItem as ExpressionCompletion ?? CurrentCompletions[0]; + + InsertCompletion(selected); + } + + private void MoveCompletionSelection(int delta) + { + if (CurrentCompletions.Count == 0) + { + return; + } + + var next = CompletionList.SelectedIndex < 0 + ? 0 + : Math.Clamp(CompletionList.SelectedIndex + delta, 0, CurrentCompletions.Count - 1); + CompletionList.SelectedIndex = next; + if (CompletionList.SelectedItem is not null) + { + CompletionList.ScrollIntoView(CompletionList.SelectedItem); + } + } + + private Point GetCaretAnchorOffset() + { + var textView = editor.TextArea.TextView; + textView.EnsureVisualLines(); + + var rectangle = editor.TextArea.Caret.CalculateCaretRectangle(); + var origin = editor.TranslatePoint(default, RootGrid) ?? default; + return new Point( + origin.X + rectangle.X, + origin.Y + rectangle.Bottom); + } + + private Point GetCompletionAnchorOffset() + { + if (CurrentCompletions.Count == 0) + { + return new Point(GetCaretAnchorOffset().X, RootGrid.Bounds.Height); + } + + var completion = CompletionList.SelectedItem as ExpressionCompletion ?? CurrentCompletions[0]; + var anchor = GetDocumentOffsetAnchorOffset(completion.ReplacementStart + completion.ReplacementLength); + return new Point(anchor.X, RootGrid.Bounds.Height); + } + + private Point GetDiagnosticAnchorOffset(int offset) + { + return GetDocumentOffsetAnchorOffset(offset); + } + + private Point GetDocumentOffsetAnchorOffset(int offset) + { + var textView = editor.TextArea.TextView; + textView.EnsureVisualLines(); + var location = editor.Document.GetLocation(Math.Clamp(offset, 0, editor.Document.TextLength)); + var visualPoint = textView.GetVisualPosition(new TextViewPosition(location), VisualYPosition.LineBottom); + var origin = textView.TranslatePoint(default, RootGrid) ?? default; + return new Point(origin.X + visualPoint.X, origin.Y + visualPoint.Y); + } + + private void OnTextViewPointerMoved(object? sender, PointerEventArgs args) + { + if (primaryDiagnostic is null || primaryDiagnostic.Length <= 0 || CompletionPopup.IsOpen) + { + CloseDiagnosticPopup(); + return; + } + + var textView = editor.TextArea.TextView; + textView.EnsureVisualLines(); + var position = textView.GetPosition(args.GetPosition(textView)); + if (position is null) + { + ScheduleDiagnosticPopupHide(); + return; + } + + var offset = editor.Document.GetOffset(position.Value.Line, position.Value.Column); + var endOffset = primaryDiagnostic.Start + primaryDiagnostic.Length; + if (offset < primaryDiagnostic.Start || offset > endOffset) + { + ScheduleDiagnosticPopupHide(); + return; + } + + diagnosticHideTimer.Stop(); + DiagnosticPopup.PlacementTarget = EditorBorder; + DiagnosticPopup.HorizontalOffset = 8; + DiagnosticPopup.VerticalOffset = 4; + DiagnosticPopup.IsOpen = true; + } + + private void OnTextViewPointerExited(object? sender, PointerEventArgs args) + { + ScheduleDiagnosticPopupHide(); + } + + private void OnDiagnosticPanelPointerEntered(object? sender, PointerEventArgs args) + { + isPointerOverDiagnosticPopup = true; + diagnosticHideTimer.Stop(); + } + + private void OnDiagnosticPanelPointerExited(object? sender, PointerEventArgs args) + { + isPointerOverDiagnosticPopup = false; + ScheduleDiagnosticPopupHide(); + } + + private void OnDiagnosticHideTimerTick(object? sender, EventArgs args) + { + diagnosticHideTimer.Stop(); + if (!isPointerOverDiagnosticPopup) + { + DiagnosticPopup.IsOpen = false; + } + } + + private void ScheduleDiagnosticPopupHide() + { + if (isPointerOverDiagnosticPopup) + { + return; + } + + diagnosticHideTimer.Stop(); + diagnosticHideTimer.Start(); + } + + private void CloseDiagnosticPopup() + { + diagnosticHideTimer.Stop(); + isPointerOverDiagnosticPopup = false; + DiagnosticPopup.IsOpen = false; + } + + private void OnCompletionSelectionChanged(object? sender, SelectionChangedEventArgs args) + { + shouldShowCompletion = CurrentCompletions.Count > 0; + } + + private void OnCompletionListDoubleTapped(object? sender, RoutedEventArgs args) + { + AcceptSelectedCompletion(); + } + + private string LocalizeDiagnostic(string code, string fallback, IReadOnlyDictionary? arguments) + { + var localizer = Localizer; + if (localizer is null) + { + return fallback; + } + + var message = localizer.Format($"Diagnostic.{code}", arguments ?? new Dictionary()); + return string.Equals(message, $"Diagnostic.{code}", StringComparison.Ordinal) ? fallback : message; + } + + private string LocalizeSuggestion(ExpressionDiagnosticSuggestion suggestion) + { + var localizer = Localizer; + if (localizer is null) + { + return suggestion.Message; + } + + var message = localizer.GetString(suggestion.Code); + return string.Equals(message, suggestion.Code, StringComparison.Ordinal) ? suggestion.Message : message; + } + + private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)); + + private sealed class ExpressionColorizer(IReadOnlyList spans) : DocumentColorizingTransformer + { + private IReadOnlyList spans = spans; + + public void Update(IReadOnlyList value) => spans = value; + + protected override void ColorizeLine(DocumentLine line) + { + var lineStart = line.Offset; + var lineEnd = line.EndOffset; + foreach (var span in spans) + { + var start = span.Start; + var end = span.Start + span.Length; + if (end <= lineStart || start >= lineEnd) + { + continue; + } + + ChangeLinePart( + Math.Max(start, lineStart), + Math.Min(end, lineEnd), + element => element.TextRunProperties.SetForegroundBrush(ForegroundFor(span.Kind))); + } + } + + private static IBrush ForegroundFor(ExpressionTokenKind kind) => + kind switch + { + ExpressionTokenKind.Variable => Brush("#0550ae"), + ExpressionTokenKind.Constant => Brush("#8250df"), + ExpressionTokenKind.Function => Brush("#953800"), + ExpressionTokenKind.Operator => Brush("#cf222e"), + ExpressionTokenKind.Punctuation => Brush("#57606a"), + ExpressionTokenKind.Number => Brush("#116329"), + ExpressionTokenKind.Comment => Brush("#6e7781"), + ExpressionTokenKind.Unknown => Brush("#b42318"), + _ => Brush("#24292f") + }; + } + + private sealed class DiagnosticUnderlineRenderer : IBackgroundRenderer + { + private IReadOnlyList diagnostics = []; + + public KnownLayer Layer => KnownLayer.Selection; + + public void Update(IReadOnlyList value) => diagnostics = value; + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (diagnostics.Count == 0) + { + return; + } + + textView.EnsureVisualLines(); + foreach (var diagnostic in diagnostics.Where(static diagnostic => diagnostic.Length > 0)) + { + var rects = BackgroundGeometryBuilder.GetRectsForSegment( + textView, + new Segment(diagnostic.Start, diagnostic.Length), + false); + + foreach (var rect in rects) + { + DrawUnderline(drawingContext, rect); + } + } + } + + private static void DrawUnderline(DrawingContext drawingContext, Rect rect) + { + var pen = new Pen(Brush("#cf222e"), 1); + var geometry = new StreamGeometry(); + using var context = geometry.Open(); + var startX = rect.X; + var y = rect.Bottom - 1; + var step = 4d; + var up = true; + + context.BeginFigure(new Point(startX, y), false); + for (var x = startX + step; x <= rect.Right; x += step) + { + context.LineTo(new Point(x, y + (up ? -2 : 0))); + up = !up; + } + + drawingContext.DrawGeometry(null, pen, geometry); + } + + private sealed record Segment(int Offset, int Length) : ISegment + { + public int EndOffset => Offset + Length; + } + } +} diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml b/src/ChapterTool.Avalonia/Views/MainWindow.axaml index 9bfca1b..3e57a26 100644 --- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml +++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml @@ -1,11 +1,12 @@ + + + + + + +