From 83cd029137313eec81b393340621d83cdede1145 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Tue, 14 Jul 2026 10:07:29 +0900 Subject: [PATCH 1/3] fix: harden data integrity and privacy safeguards --- .github/workflows/ci.yml | 8 + Makefile | 15 +- apps/desktop/eslint.config.js | 24 + apps/desktop/package.json | 6 +- apps/desktop/pnpm-lock.yaml | 807 +++++++++++++++++- apps/desktop/src-tauri/Cargo.lock | 15 +- apps/desktop/src-tauri/Cargo.toml | 3 +- apps/desktop/src-tauri/src/ffi.rs | 80 +- apps/desktop/src-tauri/src/folder_sync.rs | 131 ++- apps/desktop/src-tauri/src/lib.rs | 473 +++++++++- apps/desktop/src/App.tsx | 8 +- .../src/components/EngineGate.test.tsx | 29 + apps/desktop/src/components/EngineGate.tsx | 48 +- apps/desktop/src/components/FactBookPanel.tsx | 10 +- .../src/components/RelationshipPicker.tsx | 6 +- .../desktop/src/components/ShortcutsModal.tsx | 4 +- apps/desktop/src/components/StatsSection.tsx | 2 +- .../src/components/ai/AIContextChecklist.tsx | 4 +- .../src/components/ai/AISetupStart.tsx | 4 +- .../components/companion/CompanionPanel.tsx | 27 +- .../contextual/ContextChangeWizard.tsx | 2 +- .../src/components/editor/MentionExtension.ts | 11 +- .../components/editor/NoteMarkerExtension.ts | 13 +- .../src/hooks/useDebouncedCallback.test.ts | 41 + .../desktop/src/hooks/useDebouncedCallback.ts | 75 +- .../desktop/src/hooks/useThrottledCallback.ts | 12 +- apps/desktop/src/lib/appEvents.test.ts | 18 + apps/desktop/src/lib/appEvents.ts | 30 + apps/desktop/src/lib/i18n.catalog.test.ts | 24 + apps/desktop/src/lib/i18n.tsx | 463 +++------- apps/desktop/src/lib/outlineCreate.ts | 3 +- apps/desktop/src/lib/outlineRepair.ts | 9 +- apps/desktop/src/lib/rpc.test.ts | 34 + apps/desktop/src/lib/rpc.ts | 63 +- apps/desktop/src/lib/sceneSaveQueue.test.ts | 40 + apps/desktop/src/lib/sceneSaveQueue.ts | 44 + apps/desktop/src/lib/types.ts | 16 +- apps/desktop/src/routes/Library.test.tsx | 30 +- apps/desktop/src/routes/Library.tsx | 16 +- apps/desktop/src/routes/Settings.test.tsx | 20 + apps/desktop/src/routes/Settings.tsx | 70 +- apps/desktop/src/routes/Workspace.tsx | 114 ++- docs/privacy-policy.md | 56 +- engine/go.mod | 2 +- engine/internal/ai/client.go | 21 +- engine/internal/ai/client_test.go | 24 +- engine/internal/ai/context_test.go | 3 +- engine/internal/atomicfile/atomicfile.go | 41 + engine/internal/atomicfile/atomicfile_test.go | 34 + engine/internal/backup/backup.go | 199 ++++- engine/internal/backup/backup_test.go | 158 +++- engine/internal/backup/scheduler.go | 40 +- engine/internal/backup/verify_test.go | 19 + engine/internal/companion/tools.go | 47 +- .../internal/contextualedit/contextualedit.go | 9 +- engine/internal/engineapp/engineapp.go | 20 +- engine/internal/entity/entity.go | 14 +- engine/internal/entity/repo.go | 20 +- engine/internal/entity/repo_test.go | 39 +- engine/internal/export/project.go | 6 + engine/internal/export/project_test.go | 11 + engine/internal/export/sync_manifest.go | 42 + engine/internal/foldersync/foldersync.go | 28 +- engine/internal/foldersync/foldersync_test.go | 47 +- engine/internal/gitsync/gitsync.go | 27 +- engine/internal/gitsync/gitsync_test.go | 38 +- engine/internal/importmd/builder.go | 15 +- engine/internal/manuscript/indexer.go | 74 +- engine/internal/manuscript/manuscript_test.go | 27 + engine/internal/mention/repo.go | 39 + engine/internal/mention/repo_test.go | 27 +- engine/internal/node/repo.go | 39 +- engine/internal/node/repo_test.go | 44 + engine/internal/ptrutil/ptrutil.go | 5 + engine/internal/rpc/handlers/backup.go | 23 + engine/internal/rpc/handlers/beats.go | 5 +- engine/internal/rpc/handlers/imports.go | 17 +- engine/internal/rpc/handlers/imports_test.go | 28 + engine/internal/rpc/handlers/nodes.go | 16 +- engine/internal/rpc/handlers/nodes_test.go | 14 + engine/internal/rpc/handlers/threads.go | 10 +- engine/internal/rpc/server.go | 11 +- engine/internal/settings/settings.go | 157 ++-- engine/internal/settings/settings_test.go | 48 ++ engine/internal/store/migrations.go | 140 ++- engine/internal/store/migrations_test.go | 41 + engine/internal/store/store.go | 13 + engine/internal/thread/repo.go | 15 +- engine/internal/thread/repo_test.go | 39 +- engine/internal/thread/thread.go | 11 +- 90 files changed, 3870 insertions(+), 785 deletions(-) create mode 100644 apps/desktop/eslint.config.js create mode 100644 apps/desktop/src/hooks/useDebouncedCallback.test.ts create mode 100644 apps/desktop/src/lib/appEvents.test.ts create mode 100644 apps/desktop/src/lib/appEvents.ts create mode 100644 apps/desktop/src/lib/i18n.catalog.test.ts create mode 100644 apps/desktop/src/lib/rpc.test.ts create mode 100644 apps/desktop/src/lib/sceneSaveQueue.test.ts create mode 100644 apps/desktop/src/lib/sceneSaveQueue.ts create mode 100644 engine/internal/atomicfile/atomicfile.go create mode 100644 engine/internal/atomicfile/atomicfile_test.go create mode 100644 engine/internal/backup/verify_test.go create mode 100644 engine/internal/export/sync_manifest.go create mode 100644 engine/internal/ptrutil/ptrutil.go create mode 100644 engine/internal/rpc/handlers/backup.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e91cd..d59e41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,5 +52,13 @@ jobs: with: workspaces: apps/desktop/src-tauri + - name: Install security scanners + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + cargo install cargo-audit --locked + - name: Verify run: make ci + + - name: Audit dependencies + run: make audit diff --git a/Makefile b/Makefile index 125bb24..34a07cc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev test test-go test-desktop test-tauri test-mobile-engine validate-actions-runtime validate-distribution build-engine build-mobile-engine-ios build-mobile-engine-android mobile-ios-init mobile-android-init build-mobile-ios-sim smoke-mobile-ios-sim dev-mobile-ios build-mobile-android-debug build-mobile-android-release-smoke patch-mobile-android-signing build-desktop release-macos-local build-mas-local release-mas-local bump-version ci +.PHONY: help dev test test-go test-desktop test-tauri test-mobile-engine audit audit-go audit-desktop audit-rust validate-actions-runtime validate-distribution build-engine build-mobile-engine-ios build-mobile-engine-android mobile-ios-init mobile-android-init build-mobile-ios-sim smoke-mobile-ios-sim dev-mobile-ios build-mobile-android-debug build-mobile-android-release-smoke patch-mobile-android-signing build-desktop release-macos-local build-mas-local release-mas-local bump-version ci help: ## Show available targets @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -10,11 +10,22 @@ test: test-go test-desktop test-tauri ## Run all local verification ci: validate-actions-runtime test ## Run the CI verification contract +audit: audit-go audit-desktop audit-rust ## Check reachable and locked dependency vulnerabilities + +audit-go: ## Check reachable Go vulnerabilities (requires govulncheck) + cd engine && govulncheck ./... + +audit-desktop: ## Check production frontend dependencies + cd apps/desktop && pnpm audit --prod + +audit-rust: ## Check RustSec advisories (requires cargo-audit) + cd apps/desktop/src-tauri && cargo audit + test-go: ## Run Go engine tests cd engine && go test ./... test-desktop: ## Run desktop frontend tests and production build - cd apps/desktop && pnpm test && pnpm build + cd apps/desktop && pnpm lint && pnpm test && pnpm build test-tauri: ## Type-check the Tauri shell cd apps/desktop/src-tauri && cargo check diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js new file mode 100644 index 0000000..307a7fb --- /dev/null +++ b/apps/desktop/eslint.config.js @@ -0,0 +1,24 @@ +import reactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; + +export default tseslint.config({ + ignores: ["dist/**", "src-tauri/**"], + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "@typescript-eslint": tseslint.plugin, + }, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-explicit-any": "warn", + }, +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6855474..0eb6519 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "lint": "eslint src --max-warnings 28", "test": "vitest run", "preview": "vite preview", "tauri": "tauri" @@ -26,7 +27,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "remark-gfm": "^4.0.1" }, "devDependencies": { @@ -38,8 +39,11 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "eslint": "10.7.0", + "eslint-plugin-react-hooks": "7.1.1", "jsdom": "^29.1.1", "typescript": "^5.5.4", + "typescript-eslint": "8.63.0", "vite": "^5.4.0", "vitest": "^2.1.9" } diff --git a/apps/desktop/pnpm-lock.yaml b/apps/desktop/pnpm-lock.yaml index 2584be1..39cb091 100644 --- a/apps/desktop/pnpm-lock.yaml +++ b/apps/desktop/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.29)(react@18.3.1) react-router-dom: - specifier: ^6.30.3 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^6.30.4 + version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -81,12 +81,21 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.7.0(vite@5.4.21(@types/node@25.9.1)) + eslint: + specifier: 10.7.0 + version: 10.7.0 + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1(eslint@10.7.0) jsdom: specifier: ^29.1.1 version: 29.1.1 typescript: specifier: ^5.5.4 version: 5.9.3 + typescript-eslint: + specifier: 8.63.0 + version: 8.63.0(eslint@10.7.0)(typescript@5.9.3) vite: specifier: ^5.4.0 version: 5.4.21(@types/node@25.9.1) @@ -379,6 +388,36 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -403,6 +442,26 @@ packages: '@fontsource/newsreader@5.2.10': resolution: {integrity: sha512-TFaYzoFhDqarUyV2yYjgZZEwT4bpaj6sGBnXSnFknQ/QB8/9LzfY6IO9+inHOX4zzPp87Z7/KuG1OI5gr91Q3A==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -419,8 +478,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + '@remix-run/router@1.23.3': + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} engines: {node: '>=14.0.0'} '@rolldown/pluginutils@1.0.0-beta.27': @@ -858,6 +917,9 @@ packages: '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -867,6 +929,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -896,6 +961,65 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -934,6 +1058,19 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -956,6 +1093,10 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.32: resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} engines: {node: '>=6.0.0'} @@ -964,6 +1105,10 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1005,6 +1150,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1038,6 +1187,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1070,16 +1222,68 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1087,10 +1291,43 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1100,12 +1337,22 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1113,6 +1360,18 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1129,6 +1388,14 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -1139,6 +1406,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1156,14 +1426,34 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1332,6 +1622,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1340,19 +1634,42 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1363,10 +1680,18 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1432,15 +1757,15 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + react-router-dom@6.30.4: + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + react-router@6.30.4: + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' @@ -1488,6 +1813,19 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1526,6 +1864,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1559,6 +1901,23 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1595,6 +1954,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -1686,11 +2048,20 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1701,6 +2072,19 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1939,6 +2323,36 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)': + dependencies: + eslint: 10.7.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@exodus/bytes@1.15.1': {} '@floating-ui/core@1.7.5': @@ -1959,6 +2373,22 @@ snapshots: '@fontsource/newsreader@5.2.10': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1978,7 +2408,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@remix-run/router@1.23.2': {} + '@remix-run/router@1.23.3': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2357,6 +2787,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -2367,6 +2799,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -2394,6 +2828,97 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 10.7.0 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.7.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@10.7.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.1))': @@ -2448,6 +2973,19 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -2462,12 +3000,18 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.32: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.32 @@ -2504,6 +3048,12 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -2532,6 +3082,8 @@ snapshots: deep-eql@5.0.2: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} devlop@1.1.0: @@ -2576,25 +3128,132 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@7.1.1(eslint@10.7.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + eslint: 10.7.0 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.7.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -2619,6 +3278,12 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -2627,6 +3292,12 @@ snapshots: html-url-attributes@3.0.1: {} + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inline-style-parser@0.2.7: {} @@ -2640,12 +3311,20 @@ snapshots: is-decimal@2.0.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} + js-tokens@4.0.0: {} jsdom@29.1.1: @@ -2676,10 +3355,29 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + linkifyjs@4.3.3: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -3054,14 +3752,37 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + ms@2.1.3: {} nanoid@3.3.12: {} + natural-compare@1.4.0: {} + node-releases@2.0.46: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + orderedmap@2.1.1: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -3076,18 +3797,26 @@ snapshots: dependencies: entities: 8.0.0 + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@1.1.2: {} pathval@2.0.1: {} picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -3195,16 +3924,16 @@ snapshots: react-refresh@0.17.0: {} - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) + react-router: 6.30.4(react@18.3.1) - react-router@6.30.3(react@18.3.1): + react-router@6.30.4(react@18.3.1): dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.3 react: 18.3.1 react@18.3.1: @@ -3295,6 +4024,14 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -3328,6 +4065,11 @@ snapshots: tinyexec@0.3.2: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@1.1.1: {} tinyrainbow@1.2.0: {} @@ -3352,6 +4094,25 @@ snapshots: trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.63.0(eslint@10.7.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.7.0)(typescript@5.9.3) + eslint: 10.7.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.24.6: {} @@ -3397,6 +4158,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 @@ -3492,15 +4257,29 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} yallist@3.1.1: {} + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 05793c6..340cc38 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-broadcast" @@ -1940,7 +1940,6 @@ dependencies = [ name = "linetta-desktop" version = "0.9.1" dependencies = [ - "anyhow", "cc", "libloading", "once_cell", @@ -2507,9 +2506,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -2653,9 +2652,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ed2a3d7..91389de 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "linetta-desktop" version = "0.9.1" edition = "2021" -rust-version = "1.78" +rust-version = "1.88" license = "AGPL-3.0-only" [lib] @@ -21,7 +21,6 @@ tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "io-util", "process", "sync", "macros", "time"] } -anyhow = "1" once_cell = "1" tauri-plugin-opener = "2.5.4" diff --git a/apps/desktop/src-tauri/src/ffi.rs b/apps/desktop/src-tauri/src/ffi.rs index 41ac24f..34fcdf7 100644 --- a/apps/desktop/src-tauri/src/ffi.rs +++ b/apps/desktop/src-tauri/src/ffi.rs @@ -194,12 +194,20 @@ extern "C" fn notify_trampoline(method: *const c_char, params: *const c_char) { .to_string_lossy() .into_owned() }; - let payload: Value = serde_json::from_str(¶ms_str).unwrap_or(Value::Null); + let payload: Value = match serde_json::from_str(¶ms_str) { + Ok(payload) => payload, + Err(error) => { + eprintln!("[linetta] ignored invalid {method} notification payload: {error}"); + return; + } + }; let Some(event) = notification_event(&method) else { return; }; if let Some(app) = NOTIFY_APP.get() { - let _ = app.emit(event, payload); + if let Err(error) = app.emit(event, payload) { + eprintln!("[linetta] failed to emit {method} notification: {error}"); + } } }); } @@ -238,6 +246,36 @@ pub struct Engine { next_id: AtomicI64, } +#[derive(Debug, Clone, Serialize)] +pub struct EngineCallError { + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +impl EngineCallError { + fn transport(message: impl Into, request_id: Option) -> Self { + Self { + code: None, + message: message.into(), + data: None, + request_id, + } + } +} + +impl std::fmt::Display for EngineCallError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for EngineCallError {} + impl Engine { pub fn start(app: &AppHandle, home: Option<&str>) -> Result { let _ = NOTIFY_APP.set(app.clone()); @@ -263,7 +301,7 @@ impl Engine { }) } - pub fn call(&self, method: &str, params: Option) -> Result { + pub fn call(&self, method: &str, params: Option) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let request = Request { jsonrpc: "2.0", @@ -271,23 +309,32 @@ impl Engine { method, params: params.as_ref(), }; - let body = serde_json::to_string(&request).map_err(|e| e.to_string())?; - let c_req = CString::new(body).map_err(|e| e.to_string())?; - let ptr = engine_abi::call(c_req.as_ptr())?; + let body = serde_json::to_string(&request) + .map_err(|e| EngineCallError::transport(e.to_string(), Some(id)))?; + let c_req = + CString::new(body).map_err(|e| EngineCallError::transport(e.to_string(), Some(id)))?; + let ptr = engine_abi::call(c_req.as_ptr()) + .map_err(|e| EngineCallError::transport(e, Some(id)))?; if ptr.is_null() { - return Err("engine returned null".to_string()); + return Err(EngineCallError::transport("engine returned null", Some(id))); } let resp_str = unsafe { CStr::from_ptr(ptr) } .to_string_lossy() .into_owned(); - engine_abi::free_cstring(ptr)?; - let resp: Value = serde_json::from_str(&resp_str).map_err(|e| e.to_string())?; + engine_abi::free_cstring(ptr).map_err(|e| EngineCallError::transport(e, Some(id)))?; + let resp: Value = serde_json::from_str(&resp_str) + .map_err(|e| EngineCallError::transport(e.to_string(), Some(id)))?; if let Some(err) = resp.get("error") { let msg = err .get("message") .and_then(|m| m.as_str()) .unwrap_or("engine error"); - return Err(msg.to_string()); + return Err(EngineCallError { + code: err.get("code").and_then(Value::as_i64), + message: msg.to_string(), + data: err.get("data").cloned(), + request_id: resp.get("id").and_then(Value::as_i64).or(Some(id)), + }); } Ok(resp.get("result").cloned().unwrap_or(Value::Null)) } @@ -334,6 +381,19 @@ mod tests { ); } + #[test] + fn jsonrpc_error_preserves_code_data_and_request_id() { + let _guard = test_lock(); + let tmp = std::env::temp_dir().join(format!("linetta-ffi-error-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let eng = Engine::start_raw(tmp.to_str().unwrap()).unwrap(); + let error = eng.call("method.that.does.not.exist", None).unwrap_err(); + + assert_eq!(error.code, Some(-32601)); + assert!(error.message.contains("method not found")); + assert!(error.request_id.is_some()); + } + #[cfg(target_os = "windows")] #[test] fn windows_engine_dll_is_discoverable() { diff --git a/apps/desktop/src-tauri/src/folder_sync.rs b/apps/desktop/src-tauri/src/folder_sync.rs index 497496e..4b57524 100644 --- a/apps/desktop/src-tauri/src/folder_sync.rs +++ b/apps/desktop/src-tauri/src/folder_sync.rs @@ -1,21 +1,69 @@ use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::EngineState; +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +struct CopyError { + #[allow(dead_code)] + copied: usize, + message: String, +} + +impl std::fmt::Display for CopyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + /// Copy each named file from `staging` into `target`. Returns the count copied. /// Used by the MAS orchestration path and by tests; unused in plain non-MAS builds. #[allow(dead_code)] -pub(crate) fn copy_files(staging: &Path, target: &Path, files: &[String]) -> Result { +fn copy_files(staging: &Path, target: &Path, files: &[String]) -> Result { let mut n = 0usize; for f in files { let src = staging.join(f); let dst = target.join(f); - std::fs::copy(&src, &dst).map_err(|e| format!("copy {f}: {e}"))?; + if Path::new(f).file_name().and_then(|name| name.to_str()) != Some(f.as_str()) { + return Err(CopyError { + copied: n, + message: format!("invalid staged filename: {f}"), + }); + } + copy_file_atomic(&src, &dst).map_err(|e| CopyError { + copied: n, + message: format!("copy {f}: {e}"), + })?; n += 1; } Ok(n) } +fn copy_file_atomic(src: &Path, dst: &Path) -> Result<(), String> { + let name = dst + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "destination filename is not valid UTF-8".to_string())?; + let tmp = dst.with_file_name(format!( + ".{name}.linetta-tmp-{}-{}", + std::process::id(), + TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| { + std::fs::copy(src, &tmp).map_err(|e| e.to_string())?; + std::fs::File::open(&tmp) + .and_then(|file| file.sync_all()) + .map_err(|e| e.to_string())?; + std::fs::rename(&tmp, dst).map_err(|e| e.to_string()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + #[cfg(all(target_os = "macos", feature = "mas"))] fn now_millis() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -34,13 +82,6 @@ pub(crate) async fn set_folder_sync_dir( path: String, ) -> Result<(), String> { let engine = crate::engine_handle(state.inner())?; - crate::call_engine( - engine, - "settings.set".to_string(), - Some(serde_json::json!({ "folder_sync_dir": path })), - ) - .await?; - #[cfg(all(target_os = "macos", feature = "mas"))] { let bookmark = crate::macos_bookmarks::create_bookmark(std::path::Path::new(&path))?; @@ -48,15 +89,59 @@ pub(crate) async fn set_folder_sync_dir( if let Some(parent) = store.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - std::fs::write(&store, &bookmark).map_err(|e| e.to_string())?; + let previous = std::fs::read(&store).ok(); + write_bytes_atomic(&store, &bookmark)?; + if let Err(error) = crate::call_engine( + engine, + "settings.set".to_string(), + Some(serde_json::json!({ "folder_sync_dir": path })), + ) + .await + { + match previous { + Some(bytes) => { + let _ = write_bytes_atomic(&store, &bytes); + } + None => { + let _ = std::fs::remove_file(&store); + } + } + return Err(error); + } } #[cfg(not(all(target_os = "macos", feature = "mas")))] { let _ = &app; + crate::call_engine( + engine, + "settings.set".to_string(), + Some(serde_json::json!({ "folder_sync_dir": path })), + ) + .await?; } Ok(()) } +#[cfg(all(target_os = "macos", feature = "mas"))] +fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| { + std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?; + std::fs::File::open(&tmp) + .and_then(|file| file.sync_all()) + .map_err(|e| e.to_string())?; + std::fs::rename(&tmp, path).map_err(|e| e.to_string()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + /// Run a folder sync now. Non-MAS forwards to the engine; MAS orchestrates the /// staged copy via the security-scoped bookmark. #[tauri::command] @@ -122,12 +207,16 @@ pub(crate) async fn run_folder_sync_mas( .map_err(|_| "폴더 접근 권한을 잃었습니다. 다시 선택하세요".to_string())?; let staging_pb = std::path::PathBuf::from(&staging); + let partial_copied = std::cell::Cell::new(0usize); let outcome = crate::macos_bookmarks::with_scoped_access(&bookmark, |target| { - copy_files(&staging_pb, target, &files) + copy_files(&staging_pb, target, &files).map_err(|e| { + partial_copied.set(e.copied); + e.to_string() + }) }); let (ok, copied, errmsg) = match outcome { Ok(Ok(n)) => (true, n, String::new()), - Ok(Err(e)) => (false, 0usize, e), + Ok(Err(e)) => (false, partial_copied.get(), e), Err(e) => (false, 0usize, e), }; @@ -172,6 +261,24 @@ mod tests { ); } + #[test] + fn reports_the_number_copied_before_a_failure() { + let staging = tempdir(); + let target = tempdir(); + std::fs::write(staging.join("a.md"), b"hello").unwrap(); + std::fs::write(staging.join("b.md"), b"world").unwrap(); + std::fs::create_dir(target.join("b.md")).unwrap(); + let files = vec!["a.md".to_string(), "b.md".to_string()]; + + let err = copy_files(&staging, &target, &files).unwrap_err(); + + assert_eq!(err.copied, 1); + assert_eq!( + std::fs::read_to_string(target.join("a.md")).unwrap(), + "hello" + ); + } + fn tempdir() -> std::path::PathBuf { let mut p = std::env::temp_dir(); use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4362097..bfd22ad 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4,12 +4,129 @@ mod folder_sync; mod macos_bookmarks; use serde_json::Value; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tauri::Manager; const ENGINE_STATUS_TIMEOUT: Duration = Duration::from_secs(2); +// Keep renderer access explicit. New engine methods must be reviewed before the +// Tauri webview can call them. +const RENDERER_ENGINE_METHODS: &[&str] = &[ + "ai.cancel", + "ai.preview_context", + "ai.run", + "backup.create_recovery", + "beats.create", + "beats.delete", + "beats.list_by_node", + "beats.list_by_thread", + "beats.reorder", + "beats.update", + "companion.apply_ops", + "companion.cancel", + "companion.clear", + "companion.compact", + "companion.history", + "companion.preview_context", + "companion.references.create", + "companion.references.delete", + "companion.references.list", + "companion.references.update", + "companion.remember", + "companion.send", + "contextual.apply_change", + "contextual.check_consistency", + "contextual.plan_change", + "contextual.resolve_target", + "diagnostics.get", + "entities.create", + "entities.get", + "entities.list", + "entities.scenes", + "entities.search", + "entities.update", + "export.node", + "export.nodeText", + "export.project", + "facts.create", + "facts.create_from_url", + "facts.delete", + "facts.list", + "facts.update", + "git_sync.init", + "git_sync.run", + "imports.markdown", + "imports.preview", + "manuscript.replace_apply", + "manuscript.replace_preview", + "manuscript.search", + "mentions.list_for_node", + "nodes.convert_to_container", + "nodes.create_child", + "nodes.create_sibling", + "nodes.delete", + "nodes.get", + "nodes.list_tree", + "nodes.move_down", + "nodes.move_to", + "nodes.move_to_parent", + "nodes.move_to_root", + "nodes.move_up", + "nodes.rename", + "nodes.restore_outline", + "nodes.set_last_opened", + "nodes.set_status", + "nodes.update_content", + "notes.create", + "notes.delete", + "notes.get", + "notes.list_for_node", + "notes.update", + "openrouter.key_info", + "openrouter.oauth_finish", + "openrouter.oauth_start", + "ops_status.clear_error", + "ops_status.get", + "plot.spine_panel", + "projects.archive", + "projects.clear_synopsis", + "projects.create", + "projects.delete", + "projects.get", + "projects.list", + "projects.restore", + "projects.rewrite_synopsis", + "projects.update", + "providers.detect_cli", + "providers.list_models", + "providers.test", + "relationships.create_one", + "relationships.create_pair", + "relationships.delete", + "relationships.list_by_entity", + "relationships.update", + "search.query", + "settings.get", + "settings.set", + "snapshots.compare", + "snapshots.create_auto", + "snapshots.create_manual", + "snapshots.list_for_node", + "snapshots.restore", + "stats.range", + "stats.summary", + "stats.today", + "threads.close", + "threads.create", + "threads.get", + "threads.list", + "threads.reopen", + "threads.update", + "web_search.test", +]; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -19,18 +136,21 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .setup(|app| { let handle = app.handle().clone(); + let recovery_home = resolve_recovery_home(&handle).ok(); let state = match mobile_engine_home(&handle) .and_then(|home| ffi::Engine::start(&handle, home.as_deref())) { Ok(engine) => EngineState { engine: Some(Arc::new(engine)), startup_error: None, + recovery_home, }, Err(e) => { eprintln!("[linetta] failed to start engine: {e}"); EngineState { engine: None, startup_error: Some(e), + recovery_home, } } }; @@ -56,6 +176,8 @@ pub fn run() { engine_ping, engine_call, engine_status, + open_recovery_folder, + restore_latest_backup, open_path, folder_sync::set_folder_sync_dir, folder_sync::folder_sync_now @@ -67,6 +189,7 @@ pub fn run() { pub(crate) struct EngineState { pub(crate) engine: Option>, pub(crate) startup_error: Option, + recovery_home: Option, } #[derive(serde::Serialize)] @@ -95,14 +218,35 @@ async fn engine_call( state: tauri::State<'_, EngineState>, method: String, params: Option, -) -> Result { - let engine = engine_handle(state.inner())?; - call_engine(engine, method, params).await +) -> Result { + if !is_renderer_engine_method(&method) { + return Err(ffi::EngineCallError { + code: Some(-32601), + message: format!("renderer RPC method is not allowed: {method}"), + data: None, + request_id: None, + }); + } + let engine = engine_handle(state.inner()).map_err(|message| ffi::EngineCallError { + code: None, + message, + data: None, + request_id: None, + })?; + call_engine_typed(engine, method, params).await } #[tauri::command] async fn engine_status(state: tauri::State<'_, EngineState>) -> Result { let Some(engine) = state.engine.clone() else { + let home = state + .recovery_home + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + let db_path = state + .recovery_home + .as_ref() + .map(|path| path.join("library.db").to_string_lossy().into_owned()); return Ok(EngineStatus { ok: false, error: Some( @@ -112,8 +256,8 @@ async fn engine_status(state: tauri::State<'_, EngineState>) -> Result) -> Result Result<(), String> { +async fn open_recovery_folder( + app: tauri::AppHandle, + state: tauri::State<'_, EngineState>, +) -> Result<(), String> { use tauri_plugin_opener::OpenerExt; - let path = path.trim(); - if path.is_empty() { - return Err("path required".to_string()); + let home = state + .recovery_home + .as_ref() + .ok_or_else(|| "recovery folder is unavailable".to_string())?; + let backups = home.join("backups"); + let target = if backups.is_dir() { &backups } else { home }; + app.opener() + .open_path(target.to_string_lossy(), None::<&str>) + .map_err(|e| e.to_string()) +} + +#[derive(serde::Serialize)] +struct RestoreBackupResult { + backup_path: String, + quarantined_path: Option, +} + +#[tauri::command] +async fn restore_latest_backup( + state: tauri::State<'_, EngineState>, +) -> Result { + if state.engine.is_some() { + return Err("recovery is only available when the engine failed to start".to_string()); } + let home = state + .recovery_home + .as_ref() + .ok_or_else(|| "recovery folder is unavailable".to_string())?; + let backup = latest_completed_backup(home)?; + let quarantined = restore_backup_database(home, &backup)?; + Ok(RestoreBackupResult { + backup_path: backup.to_string_lossy().into_owned(), + quarantined_path: quarantined.map(|path| path.to_string_lossy().into_owned()), + }) +} + +#[tauri::command] +async fn open_path( + app: tauri::AppHandle, + state: tauri::State<'_, EngineState>, + path: String, +) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let home = state + .recovery_home + .as_ref() + .ok_or_else(|| "app data folder is unavailable".to_string())?; + let path = validate_open_path(Path::new(path.trim()), home)?; app.opener() - .open_path(path, None::<&str>) + .open_path(path.to_string_lossy(), None::<&str>) .map_err(|e| e.to_string()) } +fn is_renderer_engine_method(method: &str) -> bool { + RENDERER_ENGINE_METHODS.binary_search(&method).is_ok() +} + +fn validate_open_path(requested: &Path, home: &Path) -> Result { + if requested.as_os_str().is_empty() { + return Err("path required".to_string()); + } + let requested = requested + .canonicalize() + .map_err(|e| format!("resolve requested path: {e}"))?; + let home = home + .canonicalize() + .map_err(|e| format!("resolve app data folder: {e}"))?; + let backups = home.join("backups"); + let backups = backups.canonicalize().ok(); + if requested == home || backups.as_ref() == Some(&requested) { + return Ok(requested); + } + Err("opening this path is not allowed".to_string()) +} + pub(crate) fn engine_handle(state: &EngineState) -> Result, String> { state.engine.clone().ok_or_else(|| { state @@ -192,9 +405,24 @@ pub(crate) async fn call_engine( method: String, params: Option, ) -> Result { + call_engine_typed(engine, method, params) + .await + .map_err(|e| e.to_string()) +} + +async fn call_engine_typed( + engine: Arc, + method: String, + params: Option, +) -> Result { tauri::async_runtime::spawn_blocking(move || engine.call(&method, params)) .await - .map_err(|e| e.to_string())? + .map_err(|e| ffi::EngineCallError { + code: None, + message: e.to_string(), + data: None, + request_id: None, + })? } async fn call_engine_with_timeout( @@ -230,3 +458,226 @@ fn mobile_engine_home(app: &tauri::AppHandle) -> Result, String> Ok(None) } } + +fn resolve_recovery_home(app: &tauri::AppHandle) -> Result { + if let Ok(value) = std::env::var("LINETTA_HOME") { + if !value.trim().is_empty() { + return Ok(PathBuf::from(value)); + } + } + app.path().app_data_dir().map_err(|e| e.to_string()) +} + +fn latest_completed_backup(home: &Path) -> Result { + let root = home.join("backups"); + let directories = std::fs::read_dir(&root) + .map_err(|e| format!("read backup folder: {e}"))? + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .collect::>(); + let mut latest: Option<(u128, PathBuf)> = None; + for directory in directories { + let dir = directory.path(); + let marker = match std::fs::read_to_string(dir.join(".complete")) { + Ok(value) => value, + Err(_) => continue, + }; + let (filename, manifest_created_at) = match parse_completed_marker(&marker) { + Some(value) => value, + None => continue, + }; + let candidate_name = Path::new(&filename); + if candidate_name.file_name().and_then(|name| name.to_str()) != Some(filename.as_str()) + || !matches!( + candidate_name.extension().and_then(|ext| ext.to_str()), + Some("db" | "linetta") + ) + { + continue; + } + let candidate = dir.join(&filename); + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if !metadata.is_file() || metadata.len() == 0 { + continue; + } + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let score = manifest_created_at + .map(|value| value as u128) + .unwrap_or(modified); + if latest + .as_ref() + .map(|(best, _)| score > *best) + .unwrap_or(true) + { + latest = Some((score, candidate)); + } + } + latest + .map(|(_, path)| path) + .ok_or_else(|| "no completed database backup was found".to_string()) +} + +fn parse_completed_marker(marker: &str) -> Option<(String, Option)> { + let trimmed = marker.trim(); + if trimmed.starts_with('{') { + let value: Value = serde_json::from_str(trimmed).ok()?; + if value.get("format").and_then(Value::as_str) != Some("linetta-library-backup") + || value.get("format_version").and_then(Value::as_u64) != Some(1) + { + return None; + } + return Some(( + value.get("database")?.as_str()?.to_string(), + value.get("created_at").and_then(Value::as_u64), + )); + } + Some((trimmed.to_string(), None)) +} + +fn restore_backup_database(home: &Path, backup: &Path) -> Result, String> { + let db = home.join("library.db"); + let tmp = home.join("library.db.restore.tmp"); + let _ = std::fs::remove_file(&tmp); + std::fs::copy(backup, &tmp).map_err(|e| format!("copy recovery database: {e}"))?; + std::fs::File::open(&tmp) + .and_then(|file| file.sync_all()) + .map_err(|e| format!("sync recovery database: {e}"))?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let quarantined = if db.exists() { + let path = home.join(format!("library.db.corrupt-{timestamp}")); + std::fs::rename(&db, &path).map_err(|e| format!("quarantine current database: {e}"))?; + Some(path) + } else { + None + }; + + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{}", db.to_string_lossy(), suffix)); + if sidecar.exists() { + let quarantine = home.join(format!("library.db{suffix}.corrupt-{timestamp}")); + if let Err(e) = std::fs::rename(&sidecar, quarantine) { + if let Some(previous) = &quarantined { + let _ = std::fs::rename(previous, &db); + } + let _ = std::fs::remove_file(&tmp); + return Err(format!("quarantine database sidecar: {e}")); + } + } + } + + if let Err(e) = std::fs::rename(&tmp, &db) { + if let Some(previous) = &quarantined { + let _ = std::fs::rename(previous, &db); + } + let _ = std::fs::remove_file(&tmp); + return Err(format!("publish recovered database: {e}")); + } + Ok(quarantined) +} + +#[cfg(test)] +mod recovery_tests { + use super::{latest_completed_backup, restore_backup_database}; + + #[test] + fn finds_completed_backup_and_quarantines_current_database() { + let home = tempdir(); + let day = home.join("backups/2026-07-12"); + std::fs::create_dir_all(&day).unwrap(); + let backup = day.join("library-090000.db"); + std::fs::write(&backup, b"recovered database").unwrap(); + std::fs::write(day.join(".complete"), b"library-090000.db\n").unwrap(); + std::fs::write(home.join("library.db"), b"broken database").unwrap(); + + let manual = home.join("backups/recovery-20260712-100000.000"); + std::fs::create_dir_all(&manual).unwrap(); + let archive = manual.join("library.linetta"); + std::fs::write(&archive, b"newest recovered database").unwrap(); + std::fs::write( + manual.join(".complete"), + br#"{"format":"linetta-library-backup","format_version":1,"database":"library.linetta","created_at":9000000000000}"#, + ) + .unwrap(); + + assert_eq!(latest_completed_backup(&home).unwrap(), archive); + let quarantined = restore_backup_database(&home, &archive).unwrap().unwrap(); + assert_eq!( + std::fs::read(home.join("library.db")).unwrap(), + b"newest recovered database" + ); + assert_eq!(std::fs::read(quarantined).unwrap(), b"broken database"); + } + + fn tempdir() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "linetta-recovery-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + path + } +} + +#[cfg(test)] +mod security_boundary_tests { + use super::{is_renderer_engine_method, validate_open_path}; + + #[test] + fn renderer_rpc_allowlist_rejects_internal_and_unknown_methods() { + assert!(is_renderer_engine_method("projects.list")); + assert!(is_renderer_engine_method("companion.references.list")); + assert!(!is_renderer_engine_method("diagnostics.version")); + assert!(!is_renderer_engine_method("debug.execute")); + } + + #[test] + fn open_path_only_allows_the_app_home_and_backup_directory() { + let root = tempdir(); + let home = root.join("app-data"); + let backups = home.join("backups"); + let sibling = root.join("private"); + std::fs::create_dir_all(&backups).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + + assert_eq!( + validate_open_path(&home, &home).unwrap(), + home.canonicalize().unwrap() + ); + assert_eq!( + validate_open_path(&backups, &home).unwrap(), + backups.canonicalize().unwrap() + ); + assert!(validate_open_path(&sibling, &home).is_err()); + assert!(validate_open_path(&backups.join("2026-07-12"), &home).is_err()); + } + + fn tempdir() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "linetta-security-boundary-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + path + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 70fb545..e76e810 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -6,6 +6,7 @@ import { EngineGate } from "./components/EngineGate"; import { I18nProvider, useI18n } from "./lib/i18n"; import { settings as settingsApi } from "./lib/rpc"; import type { Settings } from "./lib/types"; +import { subscribeAppEvent } from "./lib/appEvents"; const LibraryAll = lazy(() => import("./routes/LibraryAll").then((m) => ({ default: m.LibraryAll })), @@ -72,14 +73,13 @@ function SettingsVisualBridge() { .catch(() => { if (!cancelled) applyVisualSettings({ theme: "system", editor_font_size: 20, editor_line_height: 1.92 }); }); - const onSettings = (event: Event) => { - const detail = (event as CustomEvent).detail; + const onSettings = (detail: Settings) => { if (detail) applyVisualSettings(detail); }; - window.addEventListener("linetta:settings-updated", onSettings); + const unsubscribe = subscribeAppEvent("linetta:settings-updated", onSettings); return () => { cancelled = true; - window.removeEventListener("linetta:settings-updated", onSettings); + unsubscribe(); }; }, []); return null; diff --git a/apps/desktop/src/components/EngineGate.test.tsx b/apps/desktop/src/components/EngineGate.test.tsx index ac8ddb4..887049c 100644 --- a/apps/desktop/src/components/EngineGate.test.tsx +++ b/apps/desktop/src/components/EngineGate.test.tsx @@ -6,11 +6,15 @@ import { I18nProvider } from "../lib/i18n"; const mocks = vi.hoisted(() => ({ engineStatus: vi.fn(), + openRecoveryFolder: vi.fn(), + restoreLatestBackup: vi.fn(), settingsGet: vi.fn(), })); vi.mock("../lib/rpc", () => ({ engineStatus: mocks.engineStatus, + openRecoveryFolder: mocks.openRecoveryFolder, + restoreLatestBackup: mocks.restoreLatestBackup, settings: { get: mocks.settingsGet, }, @@ -20,6 +24,7 @@ describe("EngineGate", () => { beforeEach(() => { vi.clearAllMocks(); mocks.settingsGet.mockResolvedValue({ language: "ko" }); + mocks.openRecoveryFolder.mockResolvedValue(undefined); }); const renderGate = () => render( @@ -52,4 +57,28 @@ describe("EngineGate", () => { await waitFor(() => expect(screen.getByText("Library loaded")).toBeInTheDocument()); expect(mocks.engineStatus).toHaveBeenCalledTimes(2); }); + + it("offers backup access and confirmed latest-backup restore when startup fails", async () => { + const user = userEvent.setup(); + mocks.engineStatus.mockResolvedValue({ + ok: false, + error: "database is malformed", + home: "/tmp/linetta", + db_path: "/tmp/linetta/library.db", + }); + mocks.restoreLatestBackup.mockResolvedValue({ + backup_path: "/tmp/linetta/backups/2026-07-12/library-090000.db", + quarantined_path: "/tmp/linetta/library.db.corrupt-123", + }); + vi.spyOn(window, "confirm").mockReturnValue(true); + + renderGate(); + + await user.click(await screen.findByRole("button", { name: "백업 폴더 열기" })); + expect(mocks.openRecoveryFolder).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole("button", { name: "최신 백업으로 복구" })); + expect(mocks.restoreLatestBackup).toHaveBeenCalledOnce(); + expect(await screen.findByText(/앱을 다시 시작/)).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/components/EngineGate.tsx b/apps/desktop/src/components/EngineGate.tsx index 6c31f02..91cb222 100644 --- a/apps/desktop/src/components/EngineGate.tsx +++ b/apps/desktop/src/components/EngineGate.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; -import { engineStatus } from "../lib/rpc"; +import { engineStatus, openRecoveryFolder, restoreLatestBackup } from "../lib/rpc"; import type { EngineStatus } from "../lib/types"; import { useI18n } from "../lib/i18n"; @@ -11,12 +11,22 @@ export function EngineGate({ children }: Props) { const { t } = useI18n(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); + const [recoveryMessage, setRecoveryMessage] = useState(null); + const [recovering, setRecovering] = useState(false); const refresh = () => { setLoading(true); engineStatus() .then(setStatus) - .catch((e) => setStatus({ ok: false, error: String(e) })) + .catch((e) => setStatus({ + ok: false, + error: String(e), + version: null, + home: null, + db_path: null, + migration_version: null, + migration_count: null, + })) .finally(() => setLoading(false)); }; @@ -42,6 +52,8 @@ export function EngineGate({ children }: Props) { {t("engine.failedDescription")}

{status?.error &&

{status.error}

} + {status?.home &&

{t("engine.recoveryDescription")}

} + {recoveryMessage &&

{recoveryMessage}

}
{diagnostics}
@@ -51,6 +63,38 @@ export function EngineGate({ children }: Props) { > {t("engine.copyDiagnostics")} + {status?.home && ( + <> + + + + )}
); diff --git a/apps/desktop/src/components/FactBookPanel.tsx b/apps/desktop/src/components/FactBookPanel.tsx index 774185f..4a85509 100644 --- a/apps/desktop/src/components/FactBookPanel.tsx +++ b/apps/desktop/src/components/FactBookPanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BookOpen, CornerDownLeft, ExternalLink, Search, Trash2, X } from "lucide-react"; import { facts as factsApi } from "../lib/rpc"; import type { CompanionProposal, FactCard, FactStatus } from "../lib/types"; -import { useI18n } from "../lib/i18n"; +import { useI18n, type MessageKey } from "../lib/i18n"; import { extractApplyOpsProposal, stripProposalBlock } from "../lib/companionDisplay"; import { useCompanion } from "../hooks/useCompanion"; import { ChoiceCard } from "./companion/ChoiceCard"; @@ -93,7 +93,7 @@ function isRejectedSourceContext(context: string): boolean { ].some((pattern) => pattern.test(context)); } -function statusKey(status: FactStatus): string { +function statusKey(status: FactStatus): MessageKey { switch (status) { case "verified": return "factBook.status.verified"; case "intentional_fiction": return "factBook.status.intentionalFiction"; @@ -227,11 +227,11 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ setFeedbackNote(t("factBook.saveNotApplied")); }, [awaitingFactSave, busy, directFactClaim, directSaving, feedbackNote, isNewFeedback, latestAssistant, latestProposal, saveDirectURL, t]); - const markFeedbackStart = () => { + const markFeedbackStart = useCallback(() => { setFeedbackAnchor(messages.length); setFeedbackNote(""); setFeedbackKind("ok"); - }; + }, [messages.length]); const startReview = async () => { if (reviewing || status === "streaming") return; @@ -262,7 +262,7 @@ export function FactBookPanel({ projectId, nodeId, sceneLabel, selectedClaimRequ await beforeReview?.(); await send(buildFactCheckPrompt(t, claim)); })(); - }, [beforeReview, busy, directSaving, selectedClaimRequest, send]); + }, [beforeReview, busy, directSaving, markFeedbackStart, selectedClaimRequest, send, t]); const submitReply = async () => { const text = replyDraft.trim(); diff --git a/apps/desktop/src/components/RelationshipPicker.tsx b/apps/desktop/src/components/RelationshipPicker.tsx index 817dd99..03330e6 100644 --- a/apps/desktop/src/components/RelationshipPicker.tsx +++ b/apps/desktop/src/components/RelationshipPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { entities, relationships } from "../lib/rpc"; import type { Entity } from "../lib/types"; import { X, Plus } from "../lib/icons"; @@ -31,7 +31,7 @@ export function RelationshipPicker({ const [error, setError] = useState(null); const debounceRef = useRef(null); - const hide = new Set([fromEntityId, ...excludeIds]); + const hide = useMemo(() => new Set([fromEntityId, ...excludeIds]), [excludeIds, fromEntityId]); const labelPresets = relationshipLabelPresets(language); useEffect(() => { @@ -47,7 +47,7 @@ export function RelationshipPicker({ return () => { if (debounceRef.current) window.clearTimeout(debounceRef.current); }; - }, [query, projectId, fromEntityId]); + }, [hide, projectId, query]); const onSave = async () => { if (!target || label.trim() === "") return; diff --git a/apps/desktop/src/components/ShortcutsModal.tsx b/apps/desktop/src/components/ShortcutsModal.tsx index 09cd0ab..1dcb897 100644 --- a/apps/desktop/src/components/ShortcutsModal.tsx +++ b/apps/desktop/src/components/ShortcutsModal.tsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; -import { useI18n } from "../lib/i18n"; +import { useI18n, type MessageKey } from "../lib/i18n"; import "./ShortcutsModal.css"; interface Shortcut { keys: string; - labelKey: string; + labelKey: MessageKey; } const SHORTCUTS: Shortcut[] = [ diff --git a/apps/desktop/src/components/StatsSection.tsx b/apps/desktop/src/components/StatsSection.tsx index d9a8f8b..533917a 100644 --- a/apps/desktop/src/components/StatsSection.tsx +++ b/apps/desktop/src/components/StatsSection.tsx @@ -80,7 +80,7 @@ export function StatsSection({ project, refreshKey = null, episodeCharTarget = 5 }; }, [project.id, refreshKey]); - const days = snapshot?.days ?? []; + const days = useMemo(() => snapshot?.days ?? [], [snapshot?.days]); const summary = snapshot?.summary ?? emptySummary(); const maxChars = useMemo( () => days.reduce((max, day) => Math.max(max, day.chars_added), 0), diff --git a/apps/desktop/src/components/ai/AIContextChecklist.tsx b/apps/desktop/src/components/ai/AIContextChecklist.tsx index 43bdd42..41205f1 100644 --- a/apps/desktop/src/components/ai/AIContextChecklist.tsx +++ b/apps/desktop/src/components/ai/AIContextChecklist.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; import "./AIContextChecklist.css"; import type { AIContextKey, AIContextPreview, AIContextSelection, ContextCounts } from "../../lib/types"; -import { useI18n } from "../../lib/i18n"; +import { useI18n, type MessageKey } from "../../lib/i18n"; // Re-export so existing consumers that import { ContextCounts } from this file // continue to work without changes. @@ -52,7 +52,7 @@ export function contextSectionLabel( variant: "ai" | "companion", fallback: string, ): string { - const key = id === "current_scene" && variant === "companion" + const key: MessageKey = id === "current_scene" && variant === "companion" ? "ai.context.section.current_scene.companion" : `ai.context.section.${id}`; const label = t(key); diff --git a/apps/desktop/src/components/ai/AISetupStart.tsx b/apps/desktop/src/components/ai/AISetupStart.tsx index 1ff577e..5618ec8 100644 --- a/apps/desktop/src/components/ai/AISetupStart.tsx +++ b/apps/desktop/src/components/ai/AISetupStart.tsx @@ -1,6 +1,6 @@ import { ExternalLink, X } from "lucide-react"; import { useMemo } from "react"; -import { useI18n } from "../../lib/i18n"; +import { useI18n, type MessageKey } from "../../lib/i18n"; import { OPENROUTER_DEFAULT_MODEL_OPTIONS, OPENROUTER_RECOMMENDED_MODELS, @@ -26,7 +26,7 @@ export interface SetupGuide { type Translate = ReturnType["t"]; -const SETUP_GUIDE_LINKS: Record> = { +const SETUP_GUIDE_LINKS: Record> = { "openrouter-safe": [ { labelKey: "settings.setup.openrouter.linkKeys", href: "https://openrouter.ai/keys" }, { labelKey: "settings.setup.openrouter.linkLimits", href: "https://openrouter.ai/docs/api/reference/limits" }, diff --git a/apps/desktop/src/components/companion/CompanionPanel.tsx b/apps/desktop/src/components/companion/CompanionPanel.tsx index dbdbe6e..db239ab 100644 --- a/apps/desktop/src/components/companion/CompanionPanel.tsx +++ b/apps/desktop/src/components/companion/CompanionPanel.tsx @@ -45,7 +45,8 @@ import { AISetupStart, guideForProvider, type GuideID } from "../ai/AISetupStart import { ProposalCard } from "./ProposalCard"; import { ChoiceCard } from "./ChoiceCard"; import { Markdown } from "./Markdown"; -import { useI18n } from "../../lib/i18n"; +import { localeForLanguage, useI18n, type MessageKey } from "../../lib/i18n"; +import { dispatchAppEvent } from "../../lib/appEvents"; import { OPENROUTER_DEFAULT_MODEL_OPTIONS, OPENROUTER_SMART_DEFAULT_MODEL, @@ -88,9 +89,9 @@ interface CompanionActionPreset { id: string; scope: "scene" | "work"; icon: LucideIcon; - labelKey: string; - descriptionKey: string; - promptKey: string; + labelKey: MessageKey; + descriptionKey: MessageKey; + promptKey: MessageKey; } const COMPANION_SCENE_ACTIONS: CompanionActionPreset[] = [ @@ -459,7 +460,7 @@ function MessageCopyButton({ ); } -function aiSetupBodyKey(issue: AISetupIssue): string { +function aiSetupBodyKey(issue: AISetupIssue): MessageKey { switch (issue) { case "missing_key": return "companion.aiSetup.body.missingKey"; @@ -537,7 +538,7 @@ export function CompanionPanel({ aiDraft, selectionRewriteRequest, }: Props) { - const { t } = useI18n(); + const { language, t } = useI18n(); const [contextSelection, setContextSelection] = useState(DEFAULT_AI_CONTEXT_SELECTION); const currentNodeId = currentNodeIdProp ?? nodeIdRef.current; const [historyScope, setHistoryScope] = useState(() => currentNodeId ? "scene" : "project"); @@ -914,7 +915,7 @@ export function CompanionPanel({ setAISetupGuideId(guideForProvider(provider)); try { const next = await settingsApi.set({ provider }); - window.dispatchEvent(new CustomEvent("linetta:settings-updated", { detail: next })); + dispatchAppEvent("linetta:settings-updated", next); } catch { // The inline setup sheet is an entry point; Settings remains the detailed // surface for showing provider-specific save/test errors. @@ -941,7 +942,7 @@ export function CompanionPanel({ setAISetupOpenRouterKeyDraft(next.providers?.openrouter?.api_key ?? ""); setAISetupOpenRouterKeySaved(next.providers?.openrouter?.api_key_set ?? false); setAISetupOpenRouterModelDraft(next.providers?.openrouter?.model?.trim() || OPENROUTER_SMART_DEFAULT_MODEL); - window.dispatchEvent(new CustomEvent("linetta:settings-updated", { detail: next })); + dispatchAppEvent("linetta:settings-updated", next); if (!options.quiet) { setAISetupOpenRouterMsg({ kind: "ok", text: t("settings.setup.openrouter.saved") }); } @@ -1153,7 +1154,7 @@ export function CompanionPanel({ {m.nodeLabel} )} - companion + {t("companion.speaker")} {m.nodeLabel} )} - companion + {t("companion.speaker")}
{m.errored ? m.content : } @@ -1205,7 +1206,7 @@ export function CompanionPanel({ })} {!aiDraft && isStreaming && (
- companion + {t("companion.speaker")}
{referenceStatusLabel(t, ref.status)} - {ref.char_count.toLocaleString()} chars + {t("companion.reference.charCount", { + count: ref.char_count.toLocaleString(localeForLanguage(language)), + })}
{ref.status === "disabled" ? ( diff --git a/apps/desktop/src/components/contextual/ContextChangeWizard.tsx b/apps/desktop/src/components/contextual/ContextChangeWizard.tsx index 7072b99..75c6e60 100644 --- a/apps/desktop/src/components/contextual/ContextChangeWizard.tsx +++ b/apps/desktop/src/components/contextual/ContextChangeWizard.tsx @@ -77,7 +77,7 @@ export function ContextChangeWizard({ projectId, initialEntityId, initialText, a contextual.checkConsistency({ project_id: projectId, old_terms: [term], language }) .then((nextReport) => setReport(nextReport)) .catch((err) => setError(String(err))); - }, [autoCheck, autoCheckKey, initialText, projectId]); + }, [autoCheck, autoCheckKey, initialText, language, projectId]); useEffect(() => { const query = oldTerm.trim(); diff --git a/apps/desktop/src/components/editor/MentionExtension.ts b/apps/desktop/src/components/editor/MentionExtension.ts index 7edb260..5a619c6 100644 --- a/apps/desktop/src/components/editor/MentionExtension.ts +++ b/apps/desktop/src/components/editor/MentionExtension.ts @@ -1,4 +1,5 @@ import Mention from "@tiptap/extension-mention"; +import { dispatchAppEvent } from "../../lib/appEvents"; export interface MentionItem { /** Existing entity id, or undefined for the "new entity" sentinel. */ @@ -100,11 +101,11 @@ export function buildMentionExtension(opts: BuildOpts) { } else { // Hand off to the Workspace via a custom DOM event so it can create // the entity, then splice in the mention. - window.dispatchEvent( - new CustomEvent("linetta:mention-pick-new", { - detail: { query: item.name, range: currentRange, editor: currentEditor }, - }), - ); + dispatchAppEvent("linetta:mention-pick-new", { + query: item.name, + range: currentRange, + editor: currentEditor, + }); opts.onStateChange(null); } }; diff --git a/apps/desktop/src/components/editor/NoteMarkerExtension.ts b/apps/desktop/src/components/editor/NoteMarkerExtension.ts index 96e99b1..b083ebc 100644 --- a/apps/desktop/src/components/editor/NoteMarkerExtension.ts +++ b/apps/desktop/src/components/editor/NoteMarkerExtension.ts @@ -1,4 +1,5 @@ import { Node, mergeAttributes, type RawCommands } from "@tiptap/core"; +import { dispatchAppEvent } from "../../lib/appEvents"; declare module "@tiptap/core" { interface Commands { @@ -49,21 +50,15 @@ export const NoteMarkerExtension = Node.create({ dom.textContent = "☘︎"; const onEnter = () => { - window.dispatchEvent(new CustomEvent("linetta:note-hover", { - detail: { noteId: node.attrs.noteId, target: dom }, - })); + dispatchAppEvent("linetta:note-hover", { noteId: node.attrs.noteId, target: dom }); }; const onLeave = () => { - window.dispatchEvent(new CustomEvent("linetta:note-hover-end", { - detail: { noteId: node.attrs.noteId }, - })); + dispatchAppEvent("linetta:note-hover-end", { noteId: node.attrs.noteId }); }; const onClick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - window.dispatchEvent(new CustomEvent("linetta:note-click", { - detail: { noteId: node.attrs.noteId, target: dom }, - })); + dispatchAppEvent("linetta:note-click", { noteId: node.attrs.noteId, target: dom }); }; dom.addEventListener("mouseenter", onEnter); diff --git a/apps/desktop/src/hooks/useDebouncedCallback.test.ts b/apps/desktop/src/hooks/useDebouncedCallback.test.ts new file mode 100644 index 0000000..f197203 --- /dev/null +++ b/apps/desktop/src/hooks/useDebouncedCallback.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useKeyedDebouncedCallback } from "./useDebouncedCallback"; + +describe("useKeyedDebouncedCallback", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps pending work isolated by scene key", () => { + vi.useFakeTimers(); + const save = vi.fn(); + const { result } = renderHook(() => useKeyedDebouncedCallback(save, 800)); + + act(() => { + result.current("scene-a", { text: "A draft" }); + result.current("scene-b", { text: "B draft" }); + vi.advanceTimersByTime(800); + }); + + expect(save).toHaveBeenCalledTimes(2); + expect(save).toHaveBeenNthCalledWith(1, "scene-a", { text: "A draft" }); + expect(save).toHaveBeenNthCalledWith(2, "scene-b", { text: "B draft" }); + }); + + it("only replaces pending work for the same scene", () => { + vi.useFakeTimers(); + const save = vi.fn(); + const { result } = renderHook(() => useKeyedDebouncedCallback(save, 800)); + + act(() => { + result.current("scene-a", { text: "old" }); + result.current("scene-a", { text: "latest" }); + vi.advanceTimersByTime(800); + }); + + expect(save).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledWith("scene-a", { text: "latest" }); + }); +}); diff --git a/apps/desktop/src/hooks/useDebouncedCallback.ts b/apps/desktop/src/hooks/useDebouncedCallback.ts index 9858b54..3f30294 100644 --- a/apps/desktop/src/hooks/useDebouncedCallback.ts +++ b/apps/desktop/src/hooks/useDebouncedCallback.ts @@ -1,20 +1,85 @@ import { useEffect, useMemo, useRef } from "react"; +export type KeyedDebouncedCallback = + ((key: K, ...args: Args) => void) & { + cancel: (key?: K) => void; + flush: (key?: K) => void; + }; + /** Returns a stable callback that delays running `fn` until `delayMs` have * elapsed without a new call. The latest `fn` is always used. */ -export function useDebouncedCallback void>( - fn: T, +export function useDebouncedCallback( + fn: (...args: Args) => void, delayMs: number, -): T { +): (...args: Args) => void { const ref = useRef(fn); useEffect(() => { ref.current = fn; }, [fn]); const timer = useRef(undefined); return useMemo( () => - ((...args: any[]) => { + (...args: Args) => { if (timer.current !== undefined) window.clearTimeout(timer.current); timer.current = window.setTimeout(() => ref.current(...args), delayMs); - }) as T, + }, [delayMs], ); } + +/** + * Debounces work independently per key. Arguments are captured when the work + * is scheduled, so a later render cannot redirect an old scene's save to the + * currently active scene. + */ +export function useKeyedDebouncedCallback( + fn: (key: K, ...args: Args) => void, + delayMs: number, +): KeyedDebouncedCallback { + const fnRef = useRef(fn); + useEffect(() => { fnRef.current = fn; }, [fn]); + + const pendingRef = useRef(new Map()); + const callback = useMemo(() => { + const invoke = ((key: K, ...args: Args) => { + const previous = pendingRef.current.get(key); + if (previous) window.clearTimeout(previous.timer); + const timer = window.setTimeout(() => { + pendingRef.current.delete(key); + fnRef.current(key, ...args); + }, delayMs); + pendingRef.current.set(key, { timer, args }); + }) as KeyedDebouncedCallback; + + invoke.cancel = (key?: K) => { + if (key !== undefined) { + const pending = pendingRef.current.get(key); + if (pending) window.clearTimeout(pending.timer); + pendingRef.current.delete(key); + return; + } + for (const pending of pendingRef.current.values()) { + window.clearTimeout(pending.timer); + } + pendingRef.current.clear(); + }; + + invoke.flush = (key?: K) => { + const flushOne = (pendingKey: K) => { + const pending = pendingRef.current.get(pendingKey); + if (!pending) return; + window.clearTimeout(pending.timer); + pendingRef.current.delete(pendingKey); + fnRef.current(pendingKey, ...pending.args); + }; + if (key !== undefined) { + flushOne(key); + return; + } + for (const pendingKey of [...pendingRef.current.keys()]) flushOne(pendingKey); + }; + + return invoke; + }, [delayMs]); + + useEffect(() => () => callback.cancel(), [callback]); + return callback; +} diff --git a/apps/desktop/src/hooks/useThrottledCallback.ts b/apps/desktop/src/hooks/useThrottledCallback.ts index 085afd8..69fe14b 100644 --- a/apps/desktop/src/hooks/useThrottledCallback.ts +++ b/apps/desktop/src/hooks/useThrottledCallback.ts @@ -2,19 +2,19 @@ import { useEffect, useMemo, useRef } from "react"; /** Calls `fn` at most once every `intervalMs`. The first call is immediate; * subsequent calls within the window are coalesced and dispatched at window end. */ -export function useThrottledCallback void>( - fn: T, +export function useThrottledCallback( + fn: (...args: Args) => void, intervalMs: number, -): T { +): (...args: Args) => void { const ref = useRef(fn); useEffect(() => { ref.current = fn; }, [fn]); const lastRun = useRef(0); - const queued = useRef<{ args: any[] } | null>(null); + const queued = useRef<{ args: Args } | null>(null); const timer = useRef(undefined); return useMemo( () => - ((...args: any[]) => { + (...args: Args) => { const now = Date.now(); const elapsed = now - lastRun.current; if (elapsed >= intervalMs) { @@ -33,7 +33,7 @@ export function useThrottledCallback void>( ref.current(...q.args); } }, intervalMs - elapsed); - }) as T, + }, [intervalMs], ); } diff --git a/apps/desktop/src/lib/appEvents.test.ts b/apps/desktop/src/lib/appEvents.test.ts new file mode 100644 index 0000000..07098b9 --- /dev/null +++ b/apps/desktop/src/lib/appEvents.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatchAppEvent, subscribeAppEvent } from "./appEvents"; +import type { Settings } from "./types"; + +describe("typed app events", () => { + it("delivers typed settings payloads and unsubscribes", () => { + const listener = vi.fn(); + const unsubscribe = subscribeAppEvent("linetta:settings-updated", listener); + const settings = { language: "ko" } as Settings; + + dispatchAppEvent("linetta:settings-updated", settings); + unsubscribe(); + dispatchAppEvent("linetta:settings-updated", { ...settings, language: "en" }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(settings); + }); +}); diff --git a/apps/desktop/src/lib/appEvents.ts b/apps/desktop/src/lib/appEvents.ts new file mode 100644 index 0000000..293c824 --- /dev/null +++ b/apps/desktop/src/lib/appEvents.ts @@ -0,0 +1,30 @@ +import type { Settings } from "./types"; +import type { Editor } from "@tiptap/react"; + +export interface LinettaEventMap { + "linetta:settings-updated": Settings; + "linetta:note-hover": { noteId: string; target: HTMLElement }; + "linetta:note-hover-end": { noteId: string }; + "linetta:note-click": { noteId: string; target: HTMLElement }; + "linetta:mention-pick-new": { + query: string; + range: { from: number; to: number }; + editor: Editor; + }; +} + +export function dispatchAppEvent( + name: Name, + detail: LinettaEventMap[Name], +): void { + window.dispatchEvent(new CustomEvent(name, { detail })); +} + +export function subscribeAppEvent( + name: Name, + listener: (detail: LinettaEventMap[Name]) => void, +): () => void { + const handler = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener(name, handler); + return () => window.removeEventListener(name, handler); +} diff --git a/apps/desktop/src/lib/i18n.catalog.test.ts b/apps/desktop/src/lib/i18n.catalog.test.ts new file mode 100644 index 0000000..ef54a82 --- /dev/null +++ b/apps/desktop/src/lib/i18n.catalog.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { messageCatalogs } from "./i18n"; + +const placeholders = (value: string) => + [...value.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]).sort(); + +describe("i18n message catalogs", () => { + it("keeps the same keys in Korean, English, and Japanese", () => { + const baseline = Object.keys(messageCatalogs.ko).sort(); + expect(Object.keys(messageCatalogs.en).sort()).toEqual(baseline); + expect(Object.keys(messageCatalogs.ja).sort()).toEqual(baseline); + }); + + it("keeps interpolation placeholders aligned across languages", () => { + for (const key of Object.keys(messageCatalogs.ko) as Array) { + expect(placeholders(messageCatalogs.en[key]), `English placeholders for ${key}`).toEqual( + placeholders(messageCatalogs.ko[key]), + ); + expect(placeholders(messageCatalogs.ja[key]), `Japanese placeholders for ${key}`).toEqual( + placeholders(messageCatalogs.ko[key]), + ); + } + }); +}); diff --git a/apps/desktop/src/lib/i18n.tsx b/apps/desktop/src/lib/i18n.tsx index ca0868c..0ec03c8 100644 --- a/apps/desktop/src/lib/i18n.tsx +++ b/apps/desktop/src/lib/i18n.tsx @@ -11,304 +11,7 @@ export const APP_LANGUAGES: Array<{ value: AppLanguage; label: string; nativeLab { value: "ja", label: "Japanese", nativeLabel: "日本語" }, ]; -type MessageKey = string - | "app.loading" - | "common.loading" - | "common.cancel" - | "common.save" - | "common.saving" - | "common.close" - | "common.deleteKey" - | "project.length.flash" - | "project.length.short" - | "project.length.novella" - | "project.length.novel" - | "project.length.series" - | "project.pov.first" - | "project.pov.thirdLimited" - | "project.pov.omniscient" - | "project.words.empty" - | "project.words.count" - | "project.words.compact" - | "library.menu.label" - | "library.menu.dataFolder" - | "library.menu.backupFolder" - | "library.menu.importMarkdown" - | "library.menu.search" - | "library.menu.settings" - | "library.menu.diagnostics" - | "library.brand" - | "library.tagline" - | "library.projectCount" - | "library.noExternalTransfer" - | "library.dataLocal" - | "library.newProject" - | "library.importing" - | "library.recentProjects" - | "library.allProjects" - | "library.archiveBox" - | "library.startNewProject" - | "library.projectActionsLabel" - | "library.projectBackup" - | "library.deleteProject" - | "library.active" - | "library.archived" - | "library.itemCount" - | "library.empty" - | "library.archive" - | "library.restore" - | "library.confirm.deleteProject" - | "library.safety.title" - | "library.safety.data" - | "library.safety.backup" - | "library.safety.checking" - | "library.safety.configured" - | "library.safety.disabled" - | "library.safety.gitSettings" - | "library.safety.dismiss" - | "library.diagnostics.title" - | "library.toast.appStateFailed" - | "library.toast.importFailed" - | "library.toast.importComplete" - | "library.toast.importEmpty" - | "library.toast.importWarnings" - | "library.toast.openBackupFailed" - | "library.toast.openDataFailed" - | "library.toast.diagnosticsFailed" - | "library.toast.safetySaveFailed" - | "library.toast.projectBackupComplete" - | "library.toast.projectBackupFailed" - | "library.toast.projectArchiveSuccess" - | "library.toast.projectArchiveFailed" - | "library.toast.projectDeleteSuccess" - | "library.toast.projectDeleteFailed" - | "newProject.defaultGenre.scifi" - | "newProject.defaultGenre.fantasy" - | "newProject.defaultGenre.mystery" - | "newProject.defaultGenre.literary" - | "newProject.title" - | "newProject.subtitle" - | "newProject.titleLabel" - | "newProject.titlePlaceholder" - | "newProject.genres" - | "newProject.length" - | "newProject.pov" - | "newProject.titleRequired" - | "newProject.creating" - | "newProject.create" - | "settings.backToLibrary" - | "settings.brand" - | "settings.title" - | "settings.language.title" - | "settings.language.description" - | "settings.language.label" - | "settings.aiWizard.title" - | "settings.aiWizard.badge" - | "settings.aiWizard.description" - | "settings.aiWizard.current" - | "settings.aiWizard.policy" - | "settings.aiWizard.officialGuide" - | "settings.aiWizard.selected" - | "settings.aiWizard.choiceTabs" - | "settings.aiWizard.stepsSummary" - | "settings.aiAdvanced.title" - | "settings.aiAdvanced.description" - | "settings.aiAdvanced.changeNote" - | "settings.provider.apiKey" - | "settings.provider.apiKeySavedPlaceholder" - | "settings.provider.apiKeyPlaceholder" - | "settings.provider.apiKeyHelp" - | "settings.provider.baseUrl" - | "settings.provider.baseUrlPlaceholder" - | "settings.provider.cliPath" - | "settings.provider.cliPathPlaceholder" - | "settings.provider.cliDetecting" - | "settings.provider.cliDetect" - | "settings.provider.cliHelp" - | "settings.provider.model" - | "settings.provider.modelPlaceholder" - | "settings.provider.refreshingModels" - | "settings.provider.refreshModels" - | "settings.provider.cliNoModels" - | "settings.provider.oauthNoModels" - | "settings.provider.modelHelp" - | "settings.provider.storageHelp" - | "settings.provider.test" - | "settings.provider.testing" - | "settings.provider.testHelp" - | "settings.provider.detectFound" - | "settings.provider.detectMissing" - | "settings.provider.testOk" - | "settings.provider.testError" - | "settings.provider.stateNeedsConnection" - | "settings.provider.stateCodexLogin" - | "settings.provider.stateApiSaved" - | "settings.provider.stateApiNeeded" - | "settings.provider.stateCliSaved" - | "settings.provider.stateCliLegacy" - | "settings.provider.stateNeedsSettings" - | "settings.provider.openaiCodex.label" - | "settings.provider.openaiCodex.desc" - | "settings.provider.openai.label" - | "settings.provider.openai.desc" - | "settings.provider.openrouter.label" - | "settings.provider.openrouter.desc" - | "settings.provider.anthropic.label" - | "settings.provider.anthropic.desc" - | "settings.provider.gemini.label" - | "settings.provider.gemini.desc" - | "settings.provider.claudeCli.label" - | "settings.provider.claudeCli.desc" - | "settings.provider.restrictedNote" - | "settings.writing.title" - | "settings.writing.typewriter" - | "settings.writing.typewriterDescription" - | "settings.writing.focus" - | "settings.writing.focusDescription" - | "settings.editor.title" - | "settings.editor.theme" - | "settings.editor.themeSystem" - | "settings.editor.themeLight" - | "settings.editor.themeDark" - | "settings.editor.fontSize" - | "settings.editor.lineHeight" - | "settings.tools.title" - | "settings.tools.description" - | "settings.tools.webSearchProvider" - | "settings.tools.webSearchApiKey" - | "settings.tools.keySavedPlaceholder" - | "settings.tools.keyPlaceholder" - | "settings.tools.keyHelp" - | "settings.tools.webSearchTest" - | "settings.tools.webSearchTesting" - | "settings.tools.webSearchTestHelp" - | "settings.tools.webSearchTestOk" - | "settings.tools.webSearchTestError" - | "settings.git.title" - | "settings.git.description" - | "settings.git.folder" - | "settings.git.folderPlaceholder" - | "settings.git.pickFolder" - | "settings.git.commitTemplate" - | "settings.git.commitTemplatePlaceholder" - | "settings.git.commitTemplateHelp" - | "settings.git.runNow" - | "settings.git.init" - | "settings.git.unavailableNote" - | "settings.folder.title" - | "settings.folder.description" - | "settings.folder.folder" - | "settings.folder.folderPlaceholder" - | "settings.folder.pickFolder" - | "settings.folder.enable" - | "settings.folder.help" - | "settings.folder.runNow" - | "settings.backup.title" - | "settings.backup.description" - | "settings.backup.folder" - | "settings.ops.backupStatus" - | "settings.ops.gitStatus" - | "settings.ops.folderStatus" - | "settings.ops.summarizerStatus" - | "settings.ops.companionStatus" - | "settings.ops.summarizerRecentFailure" - | "settings.ops.companionRecentFailure" - | "settings.ops.lastRun" - | "settings.ops.clearError" - | "settings.ops.noRuns" - | "settings.ops.ok" - | "settings.ops.failed" - | "settings.ops.gitOk" - | "settings.ops.folderOk" - | "settings.ops.backupOk" - | "settings.ops.summarizerOk" - | "settings.ops.companionOk" - | "settings.saved" - | "settings.setup.openrouter.title" - | "settings.setup.openrouter.badge" - | "settings.setup.openrouter.summary" - | "settings.setup.openrouter.policy" - | "settings.setup.openrouter.action" - | "settings.setup.openrouter.step1" - | "settings.setup.openrouter.step2" - | "settings.setup.openrouter.step3" - | "settings.setup.openrouter.step4" - | "settings.setup.openrouter.step5" - | "settings.setup.openrouter.linkKeys" - | "settings.setup.openrouter.linkLimits" - | "settings.setup.openrouter.keyInfoTitle" - | "settings.setup.openrouter.keyInfoRefresh" - | "settings.setup.openrouter.keyInfoLoading" - | "settings.setup.openrouter.keyInfoUnavailable" - | "settings.setup.openrouter.keyInfoRemaining" - | "settings.setup.openrouter.keyInfoLimit" - | "settings.setup.openrouter.keyInfoMonthlyUsage" - | "settings.setup.openrouter.oauthAction" - | "settings.setup.openrouter.oauthWaiting" - | "settings.setup.openrouter.oauthManualLink" - | "settings.setup.openrouter.oauthStarted" - | "settings.setup.openrouter.oauthError" - | "settings.setup.openrouter.apiKeyLabel" - | "settings.setup.openrouter.apiKeyPlaceholder" - | "settings.setup.openrouter.apiKeySavedPlaceholder" - | "settings.setup.openrouter.saveKey" - | "settings.setup.openrouter.saved" - | "settings.setup.openrouter.inlineHelp" - | "settings.setup.openrouter.modelLabel" - | "settings.setup.openrouter.modelHelp" - | "settings.setup.openrouter.recommendedModels" - | "settings.setup.openrouter.modelPreset.quality" - | "settings.setup.openrouter.modelPreset.qualityHelp" - | "settings.setup.openrouter.modelPreset.fast" - | "settings.setup.openrouter.modelPreset.fastHelp" - | "settings.setup.openrouter.modelPreset.auto" - | "settings.setup.openrouter.modelPreset.autoHelp" - | "settings.setup.openrouter.refreshModels" - | "settings.setup.openrouter.modelsLoading" - | "settings.setup.chatgpt.title" - | "settings.setup.chatgpt.badge" - | "settings.setup.chatgpt.summary" - | "settings.setup.chatgpt.policy" - | "settings.setup.chatgpt.action" - | "settings.setup.chatgpt.step1" - | "settings.setup.chatgpt.step2" - | "settings.setup.chatgpt.step3" - | "settings.setup.chatgpt.step4" - | "settings.setup.chatgpt.step5" - | "settings.setup.openai.title" - | "settings.setup.openai.badge" - | "settings.setup.openai.summary" - | "settings.setup.openai.policy" - | "settings.setup.openai.action" - | "settings.setup.openai.step1" - | "settings.setup.openai.step2" - | "settings.setup.openai.step3" - | "settings.setup.openai.step4" - | "settings.setup.openai.step5" - | "settings.setup.claude.title" - | "settings.setup.claude.badge" - | "settings.setup.claude.summary" - | "settings.setup.claude.policy" - | "settings.setup.claude.action" - | "settings.setup.claude.step1" - | "settings.setup.claude.step2" - | "settings.setup.claude.step3" - | "settings.setup.claude.step4" - | "settings.setup.claude.step5" - | "settings.setup.gemini.title" - | "settings.setup.gemini.badge" - | "settings.setup.gemini.summary" - | "settings.setup.gemini.policy" - | "settings.setup.gemini.action" - | "settings.setup.gemini.step1" - | "settings.setup.gemini.step2" - | "settings.setup.gemini.step3" - | "settings.setup.gemini.step4" - | "settings.setup.gemini.step5"; - -type Messages = Record; - -const messages: Record = { +const messages = { ko: { "app.loading": "불러오는 중…", "common.loading": "불러오는 중…", @@ -444,7 +147,7 @@ const messages: Record = { "workspace.addBeat": "비트 추가", "workspace.addBeatToNextScene": "다음 씬에 비트", "workspace.noStorylineHint": "스토리라인이 없어요. 명령 팔레트에서 \"{command}\"로 시작하세요.", - "workspace.markSceneAsThread": "이 씬을 새 Thread로 표시", + "workspace.markSceneAsThread": "이 씬을 새 스토리라인으로 등록", "workspace.searchShortcut": "검색 (Cmd+F)", "workspace.commandPaletteShortcut": "명령 팔레트 (Cmd+P)", "workspace.companion": "컴패니언", @@ -518,10 +221,11 @@ const messages: Record = { "workspace.command.renameNode": "이름 바꾸기", "workspace.command.moveSceneUp": "이 씬 위로", "workspace.command.moveSceneDown": "이 씬 아래로", - "workspace.command.flowThreadView": "흐름 (Thread View)", + "workspace.command.flowThreadView": "스토리라인 보기", "workspace.command.sceneVersions": "편집 히스토리 / 버전 비교", "workspace.command.sceneVersionsHint": "버전 · 복원 · diff", "workspace.command.restore": "복원", + "workspace.outlineStructureFormat": "{preset}: {part} > {chapter} > {scene} (예: {partExample} > {chapterExample} > {sceneExample})", "workspace.command.exportProject": "프로젝트 (.md)", "workspace.command.exportScene": "이 씬 (.md)", "workspace.command.copyEpisodeText": "이 화 본문 복사", @@ -725,6 +429,8 @@ const messages: Record = { "companion.placeholder": "메시지… (Enter 전송, Shift+Enter 줄바꿈)", "companion.stop": "중지", "companion.send": "전송", + "companion.speaker": "컴패니언", + "companion.reference.charCount": "{count}자", "companion.retry": "다시 시도", "companion.aiSetup.title": "AI 연결이 필요해요", "companion.aiSetup.body.missingKey": "아직 이 AI 연결에 필요한 키가 저장되지 않았습니다. 몇 번만 눌러 안전한 시작 경로를 고를 수 있어요.", @@ -849,6 +555,12 @@ const messages: Record = { "engine.failedDescription": "Linetta의 내장 Go 엔진이 응답하지 않습니다. 앱을 다시 시도하거나 아래 진단 정보를 확인하세요.", "engine.retry": "다시 시도", "engine.copyDiagnostics": "진단 복사", + "engine.recoveryDescription": "엔진을 시작할 수 없어도 백업 폴더를 열거나 완료된 최신 DB 백업으로 복구할 수 있습니다.", + "engine.openBackupFolder": "백업 폴더 열기", + "engine.restoreLatest": "최신 백업으로 복구", + "engine.restoreConfirm": "현재 library.db를 격리 보관하고 완료된 최신 백업으로 교체할까요? 복구 후 앱을 다시 시작해야 합니다.", + "engine.restoreComplete": "{path}에서 복구했습니다. 앱을 다시 시작해 주세요.", + "engine.recoveryFailed": "복구 작업 실패: {error}", "threadView.workspace": "작업실", "threadView.title": "흐름", "threadView.empty": "아직 스토리라인이 없어요. Cmd+P → \"{command}\"로 시작하세요.", @@ -1011,6 +723,7 @@ const messages: Record = { "library.menu.label": "라이브러리 옵션", "library.menu.dataFolder": "데이터 폴더 열기", "library.menu.backupFolder": "백업 폴더 열기", + "library.menu.createRecoveryBackup": "전체 복구 백업 만들기", "library.menu.importMarkdown": "가져오기 (.md)", "library.menu.search": "검색", "library.menu.settings": "설정", @@ -1018,7 +731,7 @@ const messages: Record = { "library.brand": "local-first writing", "library.tagline": "한 줄에서 한 권으로. 당신의 컴퓨터 안에서만 자라는 장편소설 작업실.", "library.projectCount": "작품", - "library.noExternalTransfer": "외부 전송 없음", + "library.noExternalTransfer": "기본 데이터는 로컬 저장", "library.dataLocal": "데이터 · {local}", "library.local": "로컬", "library.newProject": "새 작품", @@ -1028,7 +741,7 @@ const messages: Record = { "library.archiveBox": "보관함", "library.startNewProject": "새 작품 시작", "library.projectActionsLabel": "{title} 작품 옵션", - "library.projectBackup": "작품 백업 (.md)", + "library.projectBackup": "읽기용 Markdown 내보내기", "library.deleteProject": "작품 삭제", "library.active": "진행 중", "library.archived": "보관됨", @@ -1044,6 +757,7 @@ const messages: Record = { "library.safety.configured": "설정됨", "library.safety.disabled": "비활성", "library.safety.gitSettings": "Git sync 설정", + "library.safety.gitSync": "Git 동기화", "library.safety.dismiss": "다시 보지 않기", "library.diagnostics.title": "진단 정보", "library.toast.appStateFailed": "앱 상태 불러오기 실패: {error}", @@ -1055,8 +769,10 @@ const messages: Record = { "library.toast.openDataFailed": "데이터 폴더 열기 실패: {error}", "library.toast.diagnosticsFailed": "진단 정보 불러오기 실패: {error}", "library.toast.safetySaveFailed": "체크리스트 저장 실패: {error}", - "library.toast.projectBackupComplete": "작품 백업 저장됨", - "library.toast.projectBackupFailed": "작품 백업 실패: {error}", + "library.toast.projectBackupComplete": "읽기용 Markdown을 저장했습니다", + "library.toast.projectBackupFailed": "Markdown 내보내기 실패: {error}", + "library.toast.recoveryBackupComplete": "전체 라이브러리 복구 백업을 만들었습니다", + "library.toast.recoveryBackupFailed": "복구 백업 실패: {error}", "library.toast.projectArchiveSuccess": "작품을 보관했습니다", "library.toast.projectArchiveFailed": "작품 보관 실패: {error}", "library.toast.projectDeleteSuccess": "작품을 삭제했습니다", @@ -1097,6 +813,14 @@ const messages: Record = { "settings.aiWizard.description": "API 키나 모델 이름을 몰라도 괜찮습니다. 아래에서 쓰고 싶은 AI를 고르면 Linetta가 필요한 단계만 보여줍니다.", "settings.aiWizard.current": "현재 선택", "settings.aiWizard.policy": "Claude와 Gemini 구독 로그인은 각 회사의 공식 제품 안에서 쓰는 흐름이라 Linetta에서는 제공하지 않습니다. ChatGPT 구독은 OpenAI Codex 공식 로그인만 지원하고, Claude/Gemini는 API 키로 연결합니다.", + "settings.aiConsent.title": "AI 데이터 전송 동의", + "settings.aiConsent.description": "AI 기능을 사용하면 프롬프트, 선택한 원고와 주변 문맥, 작품 설정·레퍼런스, 첨부 이미지가 현재 선택한 제3자 AI 공급자({provider})에게 전송됩니다. 개발자는 이 데이터를 받지 않으며, 공급자가 자체 정책에 따라 처리·보관할 수 있습니다.", + "settings.aiConsent.control": "동의 전에는 원고를 AI 공급자에게 보내지 않습니다. 공급자나 사용자 지정 서버를 바꾸면 다시 동의해야 하며, 언제든 여기서 철회할 수 있습니다.", + "settings.aiConsent.privacyLink": "개인정보 처리방침 보기", + "settings.aiConsent.statusGranted": "현재 공급자에 대한 전송 동의가 활성화되어 있습니다.", + "settings.aiConsent.statusRequired": "AI를 사용하려면 제3자 전송에 명시적으로 동의해야 합니다.", + "settings.aiConsent.grant": "동의하고 AI 사용", + "settings.aiConsent.withdraw": "동의 철회", "settings.aiWizard.officialGuide": "공식 가이드", "settings.aiWizard.selected": "선택됨", "settings.aiWizard.choiceTabs": "AI 연결 방식", @@ -1458,9 +1182,9 @@ const messages: Record = { "workspace.noStoryline": "No storyline", "workspace.beatTitleEnter": "Beat title (Enter)", "workspace.addBeat": "Add beat", - "workspace.addBeatToNextScene": "Beat in next scene", + "workspace.addBeatToNextScene": "Add beat to next scene", "workspace.noStorylineHint": "No storyline yet. Start with \"{command}\" in the command palette.", - "workspace.markSceneAsThread": "Mark this scene as a new Thread", + "workspace.markSceneAsThread": "Register this scene as a new storyline", "workspace.searchShortcut": "Search (Cmd+F)", "workspace.commandPaletteShortcut": "Command palette (Cmd+P)", "workspace.companion": "Companion", @@ -1534,10 +1258,11 @@ const messages: Record = { "workspace.command.renameNode": "Rename", "workspace.command.moveSceneUp": "Move this scene up", "workspace.command.moveSceneDown": "Move this scene down", - "workspace.command.flowThreadView": "Thread View", + "workspace.command.flowThreadView": "Storyline view", "workspace.command.sceneVersions": "Edit history / version compare", "workspace.command.sceneVersionsHint": "Versions · restore · diff", "workspace.command.restore": "Restore", + "workspace.outlineStructureFormat": "{preset}: {part} > {chapter} > {scene} (example: {partExample} > {chapterExample} > {sceneExample})", "workspace.command.exportProject": "Project (.md)", "workspace.command.exportScene": "This scene (.md)", "workspace.command.copyEpisodeText": "Copy this episode text", @@ -1673,14 +1398,14 @@ const messages: Record = { "companion.scope.label": "Companion conversation scope", "companion.scope.scene": "Current scene", "companion.scope.project": "Whole work", - "companion.empty": "Hand off a stuck point or explore the next move for this scene.", + "companion.empty": "Bring me a sticking point, or explore what comes next in this scene.", "companion.emptyTitle": "What should we work on first?", "companion.actions.ariaLabel": "Companion writer actions", "companion.actions.title": "Writer actions", "companion.actions.sceneTitle": "Current scene actions", "companion.actions.sceneDescription": "Work on this scene's prose, voice, dialogue, and tension.", "companion.actions.workTitle": "Whole-work actions", - "companion.actions.workDescription": "Plan plot, outline, settings, and other work-wide changes.", + "companion.actions.workDescription": "Plan the plot, outline, story world, and other work-wide changes.", "companion.actions.curatedLabel": "Recommended actions", "companion.actions.continueScene.label": "Continue the scene", "companion.actions.continueScene.description": "Carry forward the current voice and tension.", @@ -1741,9 +1466,11 @@ const messages: Record = { "companion.placeholder": "Message... (Enter to send, Shift+Enter for line break)", "companion.stop": "Stop", "companion.send": "Send", + "companion.speaker": "Companion", + "companion.reference.charCount": "{count} chars", "companion.retry": "Retry", "companion.aiSetup.title": "AI connection needed", - "companion.aiSetup.body.missingKey": "This AI connection does not have a saved key yet. Choose a safe starter path in a few clicks.", + "companion.aiSetup.body.missingKey": "This AI connection does not have a saved key yet. Choose a setup option to connect an AI provider.", "companion.aiSetup.body.authRequired": "The saved credentials are expired or do not have enough permission. Reconnect, then retry the same question.", "companion.aiSetup.body.modelUnavailable": "The selected model is not available right now. Switch to a starter path that does not require model names.", "companion.aiSetup.body.rateOrSpendLimit": "A usage, quota, or credit limit appears to be blocking the request. Check the limit, then retry.", @@ -1791,7 +1518,7 @@ const messages: Record = { "factBook.reviewing": "Reviewing…", "factBook.reviewHint": "Pick claims that need real-world checking first; only the selected claim is searched and saved.", "factBook.applied": "Fact Book refreshed.", - "factBook.ai.savePrefix": "Save to dossier after search", + "factBook.ai.savePrefix": "Check sources, then save to Fact Book", "factBook.ai.review": "Find real-world claims in the current scene \"{sceneLabel}\" that would benefit from a web-search fact check.\nDo not run web_search or web_fetch yet.\nIf there are candidates, keep the explanation short and present them in exactly one linetta-choices block.\nEvery options item must be written as `{savePrefix}: `.\nWhen the writer picks a candidate, verify source URLs with web_search and web_fetch, then save it to the dossier with create_fact_card.\nIf web_fetch returns 404/403 or too little text, drop that URL as a source candidate and find alternatives.\nIf web_search fails because no API key is set, do not give up — ask the writer to paste a source URL directly; verify it with web_fetch before saving.\ncreate_fact_card without a source URL is forbidden. If there are no candidates, briefly say why.", "factBook.ai.factCheck": "Selected claim: {claim}\nRight now, use web_search to find trustworthy source candidates and verify body access for at least one URL with web_fetch.\nIf web_fetch returns 404/403 or too little text, drop that URL and search for alternatives in the same turn.\nOnce a verified source URL exists, save it to the dossier with linetta_apply_ops create_fact_card in the same turn.\nNever claim it is saved without calling create_fact_card. If saving fails, state only the failure reason and the next step, briefly.\nIf web_search or web_fetch fails, do not give up — ask for a source URL to be pasted directly.", "factBook.ai.altSource": "Selected claim: {claim}\nThis source URL just failed in the app's save path: {failedURL}\nFailure reason: {error}\nExclude this URL from the save candidates.\nRight now, use web_search to find trustworthy alternative sources and verify body access for at least one URL with web_fetch.\nIf web_fetch returns 404/403 or too little text, drop that URL too and search for more alternatives in the same turn.\nOnce a verified source URL exists, save it to the dossier with linetta_apply_ops create_fact_card in the same turn.\nIf no verified alternative exists, do not claim it was saved — only ask for a source URL to be pasted directly.", @@ -1857,7 +1584,7 @@ const messages: Record = { "ai.context.section.notes": "Author notes", "ai.context.section.project_meta": "Project settings (genre/length/POV)", "ai.context.section.style_notes": "Author style notes", - "ai.context.section.facts": "Fact dossier", + "ai.context.section.facts": "Fact Book", "ai.context.section.memories": "Companion memories", "ai.context.section.references": "Additional references", "engine.checking": "Checking engine status...", @@ -1865,6 +1592,12 @@ const messages: Record = { "engine.failedDescription": "Linetta's embedded Go engine is not responding. Try again or check the diagnostics below.", "engine.retry": "Retry", "engine.copyDiagnostics": "Copy diagnostics", + "engine.recoveryDescription": "Even when the engine cannot start, you can open the backup folder or restore the latest completed database backup.", + "engine.openBackupFolder": "Open backup folder", + "engine.restoreLatest": "Restore latest backup", + "engine.restoreConfirm": "Quarantine the current library.db and replace it with the latest completed backup? Restart the app after recovery.", + "engine.restoreComplete": "Restored from {path}. Restart the app to continue.", + "engine.recoveryFailed": "Recovery failed: {error}", "threadView.workspace": "Workspace", "threadView.title": "Flow", "threadView.empty": "No storylines yet. Start with Cmd+P → \"{command}\".", @@ -2027,6 +1760,7 @@ const messages: Record = { "library.menu.label": "Library options", "library.menu.dataFolder": "Open data folder", "library.menu.backupFolder": "Open backup folder", + "library.menu.createRecoveryBackup": "Create full recovery backup", "library.menu.importMarkdown": "Import (.md)", "library.menu.search": "Search", "library.menu.settings": "Settings", @@ -2034,17 +1768,17 @@ const messages: Record = { "library.brand": "local-first writing", "library.tagline": "From one line to a full book. A long-form fiction studio that grows only on your computer.", "library.projectCount": "projects", - "library.noExternalTransfer": "No external transfer", + "library.noExternalTransfer": "Core data stays local", "library.dataLocal": "Data · {local}", "library.local": "local", "library.newProject": "New project", "library.importing": "Importing...", "library.recentProjects": "Recent projects", - "library.allProjects": "All library →", + "library.allProjects": "View all projects →", "library.archiveBox": "Archive", "library.startNewProject": "Start a new project", "library.projectActionsLabel": "{title} project options", - "library.projectBackup": "Back up project (.md)", + "library.projectBackup": "Export readable Markdown", "library.deleteProject": "Delete project", "library.active": "Active", "library.archived": "Archived", @@ -2060,6 +1794,7 @@ const messages: Record = { "library.safety.configured": "Configured", "library.safety.disabled": "Disabled", "library.safety.gitSettings": "Git sync settings", + "library.safety.gitSync": "Git sync", "library.safety.dismiss": "Do not show again", "library.diagnostics.title": "Diagnostics", "library.toast.appStateFailed": "Failed to load app state: {error}", @@ -2071,8 +1806,10 @@ const messages: Record = { "library.toast.openDataFailed": "Failed to open data folder: {error}", "library.toast.diagnosticsFailed": "Failed to load diagnostics: {error}", "library.toast.safetySaveFailed": "Failed to save checklist: {error}", - "library.toast.projectBackupComplete": "Project backup saved", - "library.toast.projectBackupFailed": "Project backup failed: {error}", + "library.toast.projectBackupComplete": "Readable Markdown exported", + "library.toast.projectBackupFailed": "Markdown export failed: {error}", + "library.toast.recoveryBackupComplete": "Full library recovery backup created", + "library.toast.recoveryBackupFailed": "Recovery backup failed: {error}", "library.toast.projectArchiveSuccess": "Project archived", "library.toast.projectArchiveFailed": "Failed to archive project: {error}", "library.toast.projectDeleteSuccess": "Project deleted", @@ -2113,6 +1850,14 @@ const messages: Record = { "settings.aiWizard.description": "You do not need to know API keys or model names yet. Pick the AI you want to use and Linetta will show only the steps you need.", "settings.aiWizard.current": "Current selection", "settings.aiWizard.policy": "Claude and Gemini subscription logins are designed for each company's official products, so Linetta does not provide them. ChatGPT subscriptions are supported only through official OpenAI Codex login, while Claude/Gemini use API keys.", + "settings.aiConsent.title": "AI Data Sharing Consent", + "settings.aiConsent.description": "When you use AI features, prompts, selected manuscript and surrounding context, story settings and references, and attached images are sent to the selected third-party AI provider ({provider}). The developer does not receive this data; the provider may process and retain it under its own policy.", + "settings.aiConsent.control": "No manuscript is sent before consent. Changing the provider or a custom server requires renewed consent, and you can withdraw consent here at any time.", + "settings.aiConsent.privacyLink": "View Privacy Policy", + "settings.aiConsent.statusGranted": "Data-sharing consent is active for the current provider.", + "settings.aiConsent.statusRequired": "Explicit consent to third-party transfer is required before using AI.", + "settings.aiConsent.grant": "Consent and enable AI", + "settings.aiConsent.withdraw": "Withdraw consent", "settings.aiWizard.officialGuide": "official guide", "settings.aiWizard.selected": "Selected", "settings.aiWizard.choiceTabs": "AI connection options", @@ -2314,7 +2059,7 @@ const messages: Record = { "settings.setup.claude.title": "Connect with Claude API key", "settings.setup.claude.badge": "API only", "settings.setup.claude.summary": "Use an Anthropic Console API key instead of Claude subscription login.", - "settings.setup.claude.policy": "Claude subscription harnesses are not supported in Linetta. To avoid policy risk, Linetta guides only API-key usage.", + "settings.setup.claude.policy": "Claude subscription sign-in is not supported in Linetta. Linetta supports Claude through API keys only.", "settings.setup.claude.action": "Select Claude API", "settings.setup.claude.step1": "Open Anthropic Console.", "settings.setup.claude.step2": "Complete billing or credit setup.", @@ -2357,16 +2102,16 @@ const messages: Record = { "onboarding.previous": "前へ", "onboarding.next": "次へ", "onboarding.finish": "完了", - "onboarding.library.brand.title": "Linettaを案内", + "onboarding.library.brand.title": "Linettaを見てみる", "onboarding.library.brand.body": "ライブラリは作品を始め、戻ってくる場所です。原稿と設定はこのコンピュータ内に保管されます。", "onboarding.library.new.title": "新しい作品を始める", "onboarding.library.new.body": "ジャンル、分量、視点を決めるだけで、すぐ最初のシーンに入れます。", "onboarding.library.import.title": "既存原稿を取り込む", - "onboarding.library.import.body": "Markdownを読み込み、作品とシーン構造に分けて、これまでの原稿を続けられます。", + "onboarding.library.import.body": "Markdownを読み込み、作品とシーン構造に分けて、既存の原稿もそのまま執筆を続けられます。", "onboarding.library.search.title": "作品全体を検索", "onboarding.library.search.body": "タイトル、シーン、本文をまとめて検索します。覚えている一語から長い原稿へ戻れます。", "onboarding.library.settings.title": "設定とバックアップ", - "onboarding.library.settings.body": "言語、AI接続、GitHub sync、バックアップ場所、このツアーは設定で管理します。", + "onboarding.library.settings.body": "言語、AI接続、GitHub同期、バックアップ場所、このツアーは設定で管理します。", "onboarding.workspace.outline.title": "アウトライン", "onboarding.workspace.outline.body": "左側で章とシーンを管理します。追加、名前変更、移動、削除はここから始めます。", "onboarding.workspace.editor.title": "執筆エディタ", @@ -2406,16 +2151,16 @@ const messages: Record = { "workspace.projectOverview": "作品概要", "workspace.projectOverviewPlaceholder": "ログライン、テーマ、大きな流れ", "workspace.synopsis": "シノプシス", - "workspace.rewriting": "再作成中", - "workspace.rewrite": "再作成", + "workspace.rewriting": "書き直し中", + "workspace.rewrite": "書き直す", "workspace.clear": "クリア", "workspace.copyText": "本文をコピー", "workspace.projectSynopsis": "作品シノプシス", "workspace.synopsisPlaceholder": "現在のあらすじ要約", - "workspace.mentions": "登場", + "workspace.mentions": "メンション", "workspace.scanning": "スキャン中", "workspace.scanScene": "シーンをスキャン", - "workspace.noMentionedEntities": "このシーンに紐づいた人物はありません", + "workspace.noMentionedEntities": "このシーンでメンションされた世界観要素はありません", "workspace.saved": "保存済み", "workspace.justSaved": "今保存しました", "workspace.savedSecondsAgo": "{seconds}秒前に保存", @@ -2474,9 +2219,9 @@ const messages: Record = { "workspace.noStoryline": "ストーリーラインなし", "workspace.beatTitleEnter": "ビートタイトル (Enter)", "workspace.addBeat": "ビート追加", - "workspace.addBeatToNextScene": "次のシーンにビート", + "workspace.addBeatToNextScene": "次のシーンにビートを追加", "workspace.noStorylineHint": "ストーリーラインがありません。コマンドパレットで「{command}」から始めてください。", - "workspace.markSceneAsThread": "このシーンを新しい Thread としてマーク", + "workspace.markSceneAsThread": "このシーンを新しいストーリーラインとして登録", "workspace.searchShortcut": "検索 (Cmd+F)", "workspace.commandPaletteShortcut": "コマンドパレット (Cmd+P)", "workspace.companion": "コンパニオン", @@ -2550,10 +2295,11 @@ const messages: Record = { "workspace.command.renameNode": "名前を変更", "workspace.command.moveSceneUp": "このシーンを上へ", "workspace.command.moveSceneDown": "このシーンを下へ", - "workspace.command.flowThreadView": "Thread View", + "workspace.command.flowThreadView": "ストーリーライン表示", "workspace.command.sceneVersions": "編集履歴 / バージョン比較", - "workspace.command.sceneVersionsHint": "バージョン · 復元 · diff", + "workspace.command.sceneVersionsHint": "バージョン · 復元 · 差分", "workspace.command.restore": "復元", + "workspace.outlineStructureFormat": "{preset}: {part} > {chapter} > {scene}(例:{partExample} > {chapterExample} > {sceneExample})", "workspace.command.exportProject": "プロジェクト (.md)", "workspace.command.exportScene": "このシーン (.md)", "workspace.command.copyEpisodeText": "この話の本文をコピー", @@ -2562,8 +2308,8 @@ const messages: Record = { "workspace.command.syncNow": "今すぐ GitHub に同期", "workspace.command.openZen": "ZEN モードを開く", "workspace.command.exitEsc": "ESCで終了", - "workspace.command.toggleFocusOn": "Focus モードをオン", - "workspace.command.toggleFocusOff": "Focus モードをオフ", + "workspace.command.toggleFocusOn": "集中モードをオン", + "workspace.command.toggleFocusOff": "集中モードをオフ", "workspace.command.closeCompanion": "コンパニオンを閉じる", "workspace.command.openWritingCompanion": "執筆コンパニオンを開く", "workspace.command.contextualEdit": "文脈編集を開く", @@ -2757,13 +2503,15 @@ const messages: Record = { "companion.placeholder": "メッセージ... (Enterで送信、Shift+Enterで改行)", "companion.stop": "停止", "companion.send": "送信", + "companion.speaker": "コンパニオン", + "companion.reference.charCount": "{count}字", "companion.retry": "再試行", "companion.aiSetup.title": "AI 接続が必要です", "companion.aiSetup.body.missingKey": "この AI 接続に必要なキーがまだ保存されていません。安全な開始方法を数クリックで選べます。", "companion.aiSetup.body.authRequired": "保存済みの認証情報が期限切れ、または権限不足です。再接続してから同じ質問を再送できます。", "companion.aiSetup.body.modelUnavailable": "選択したモデルは現在利用できません。モデル名を知らなくても使える開始方法に切り替えられます。", "companion.aiSetup.body.rateOrSpendLimit": "使用量、クォータ、またはクレジット制限に達した可能性があります。上限を確認してから再試行できます。", - "companion.aiSetup.body.unknownProviderError": "AI provider の設定確認が必要です。元のエラーは詳細に残します。", + "companion.aiSetup.body.unknownProviderError": "AIプロバイダーの設定確認が必要です。元のエラーは詳細に残します。", "companion.aiSetup.connectEasy": "いちばん簡単な方法で接続", "companion.aiSetup.connectSubscription": "サブスクリプションで接続", "companion.aiSetup.connectDirect": "API キーを直接入力", @@ -2881,6 +2629,12 @@ const messages: Record = { "engine.failedDescription": "Linetta の内蔵 Go エンジンが応答していません。再試行するか、下の診断情報を確認してください。", "engine.retry": "再試行", "engine.copyDiagnostics": "診断をコピー", + "engine.recoveryDescription": "エンジンを開始できない場合でも、バックアップフォルダを開くか、完了済みの最新データベースバックアップから復元できます。", + "engine.openBackupFolder": "バックアップフォルダを開く", + "engine.restoreLatest": "最新バックアップから復元", + "engine.restoreConfirm": "現在の library.db を隔離して、完了済みの最新バックアップに置き換えますか?復元後にアプリを再起動してください。", + "engine.restoreComplete": "{path} から復元しました。アプリを再起動してください。", + "engine.recoveryFailed": "復元に失敗しました:{error}", "threadView.workspace": "作業場", "threadView.title": "流れ", "threadView.empty": "まだストーリーラインがありません。Cmd+P →「{command}」から始めてください。", @@ -2895,7 +2649,7 @@ const messages: Record = { "shortcuts.globalSearch": "全体検索を開く", "shortcuts.exitZenDialog": "ZEN モード終了 / ダイアログをキャンセル", "shortcuts.escape": "ダイアログを閉じる · ZEN 終了 · 選択解除", - "shortcuts.focusToggle": "Focus モード切替", + "shortcuts.focusToggle": "集中モード切替", "shortcuts.undoBody": "本文を元に戻す", "shortcuts.redoBody": "本文をやり直す", "shortcuts.mentionSearch": "世界観要素メンション検索", @@ -3037,12 +2791,13 @@ const messages: Record = { "project.pov.first": "一人称", "project.pov.thirdLimited": "三人称限定", "project.pov.omniscient": "全知視点", - "project.words.empty": "下書き前", + "project.words.empty": "まだ原稿がありません", "project.words.count": "{count}字", "project.words.compact": "{count}k字", "library.menu.label": "ライブラリオプション", "library.menu.dataFolder": "データフォルダを開く", "library.menu.backupFolder": "バックアップフォルダを開く", + "library.menu.createRecoveryBackup": "ライブラリ全体の復旧バックアップを作成", "library.menu.importMarkdown": "インポート (.md)", "library.menu.search": "検索", "library.menu.settings": "設定", @@ -3050,17 +2805,17 @@ const messages: Record = { "library.brand": "local-first writing", "library.tagline": "一行から一冊へ。あなたのコンピュータの中だけで育つ長編小説スタジオ。", "library.projectCount": "作品", - "library.noExternalTransfer": "外部送信なし", + "library.noExternalTransfer": "基本データはローカル保存", "library.dataLocal": "データ · {local}", "library.local": "ローカル", "library.newProject": "新しい作品", "library.importing": "インポート中...", "library.recentProjects": "最近の作品", - "library.allProjects": "全ライブラリ →", - "library.archiveBox": "保管箱", + "library.allProjects": "すべての作品を見る →", + "library.archiveBox": "アーカイブ", "library.startNewProject": "新しい作品を始める", "library.projectActionsLabel": "{title} の作品オプション", - "library.projectBackup": "作品をバックアップ (.md)", + "library.projectBackup": "閲覧用Markdownを書き出す", "library.deleteProject": "作品を削除", "library.active": "進行中", "library.archived": "保管済み", @@ -3076,6 +2831,7 @@ const messages: Record = { "library.safety.configured": "設定済み", "library.safety.disabled": "無効", "library.safety.gitSettings": "Git sync 設定", + "library.safety.gitSync": "Git同期", "library.safety.dismiss": "今後表示しない", "library.diagnostics.title": "診断情報", "library.toast.appStateFailed": "アプリ状態の読み込みに失敗: {error}", @@ -3087,8 +2843,10 @@ const messages: Record = { "library.toast.openDataFailed": "データフォルダを開けませんでした: {error}", "library.toast.diagnosticsFailed": "診断情報の読み込みに失敗: {error}", "library.toast.safetySaveFailed": "チェックリスト保存失敗: {error}", - "library.toast.projectBackupComplete": "作品バックアップを保存しました", - "library.toast.projectBackupFailed": "作品バックアップに失敗: {error}", + "library.toast.projectBackupComplete": "閲覧用Markdownを書き出しました", + "library.toast.projectBackupFailed": "Markdownの書き出しに失敗しました:{error}", + "library.toast.recoveryBackupComplete": "ライブラリ全体の復旧バックアップを作成しました", + "library.toast.recoveryBackupFailed": "復旧バックアップに失敗しました:{error}", "library.toast.projectArchiveSuccess": "作品を保管しました", "library.toast.projectArchiveFailed": "作品の保管に失敗: {error}", "library.toast.projectDeleteSuccess": "作品を削除しました", @@ -3129,12 +2887,20 @@ const messages: Record = { "settings.aiWizard.description": "API キーやモデル名が分からなくても大丈夫です。使いたい AI を選ぶと、Linetta が必要な手順だけを表示します。", "settings.aiWizard.current": "現在の選択", "settings.aiWizard.policy": "Claude と Gemini のサブスクリプションログインは各社公式製品内の利用フローなので、Linetta では提供しません。ChatGPT サブスクリプションは OpenAI Codex の公式ログインのみ対応し、Claude/Gemini は API キーで接続します。", + "settings.aiConsent.title": "AIデータ送信への同意", + "settings.aiConsent.description": "AI機能を使用すると、プロンプト、選択した原稿と周辺コンテキスト、作品設定・参考資料、添付画像が、現在選択中の第三者AIプロバイダー({provider})へ送信されます。開発者はこのデータを受け取りませんが、プロバイダーが自社のポリシーに従って処理・保存する場合があります。", + "settings.aiConsent.control": "同意するまでは原稿をAIプロバイダーへ送信しません。プロバイダーまたはカスタムサーバーを変更した場合は再同意が必要で、ここからいつでも撤回できます。", + "settings.aiConsent.privacyLink": "プライバシーポリシーを見る", + "settings.aiConsent.statusGranted": "現在のプロバイダーへのデータ送信に同意済みです。", + "settings.aiConsent.statusRequired": "AIを使用するには、第三者への送信に明示的に同意する必要があります。", + "settings.aiConsent.grant": "同意してAIを使用", + "settings.aiConsent.withdraw": "同意を撤回", "settings.aiWizard.officialGuide": "公式ガイド", "settings.aiWizard.selected": "選択済み", "settings.aiWizard.choiceTabs": "AI 接続方式", "settings.aiWizard.stepsSummary": "接続手順", "settings.aiAdvanced.title": "詳細 AI 設定", - "settings.aiAdvanced.description": "ウィザードで選んだ接続方式の実際の provider、API キー、モデル値を調整します。初めて使う場合は上の手順を進めて、必要な欄だけ入力してください。", + "settings.aiAdvanced.description": "ウィザードで選んだ接続方式の実際のプロバイダー、API キー、モデル値を調整します。初めて使う場合は上の手順を進めて、必要な欄だけ入力してください。", "settings.aiAdvanced.changeNote": "変更は次回の AI 呼び出しから反映されます。", "settings.provider.apiKey": "API キー", "settings.provider.apiKeySavedPlaceholder": "保存済み API キーあり - 新しいキーを貼り付けると置き換え", @@ -3153,11 +2919,11 @@ const messages: Record = { "settings.provider.refreshModels": "モデルを更新", "settings.provider.cliNoModels": "Claude Code CLI はモデル一覧取得に対応していません。手動で入力してください。", "settings.provider.oauthNoModels": "OpenAI Codex ログインはモデル一覧取得に対応していません。モデル ID を手動入力するか、空にして Codex の既定モデルを使えます。", - "settings.provider.modelHelp": "更新すると上の API キーで provider のモデル一覧を取得します。手動入力も可能です。", + "settings.provider.modelHelp": "更新すると上の API キーでプロバイダーのモデル一覧を取得します。手動入力も可能です。", "settings.provider.storageHelp": "API キーは macOS Keychain に保存されます。モデルや Base URL などの一般設定だけがローカル settings.json に保存されます。Claude Code CLI は既存ユーザー互換用で、新規ユーザーには Claude API 接続を推奨します。", "settings.provider.test": "接続テスト", "settings.provider.testing": "テスト中...", - "settings.provider.testHelp": "短い AI リクエストを 1 回送信します。API キー方式では provider のポリシーにより少量の使用量が記録される場合があります。", + "settings.provider.testHelp": "短い AI リクエストを 1 回送信します。API キー方式ではプロバイダーのポリシーにより少量の使用量が記録される場合があります。", "settings.provider.detectFound": "見つかりました: {path}", "settings.provider.detectMissing": "claude 実行ファイルを見つけられませんでした。パスを手動で入力してください。", "settings.provider.testOk": "接続成功: {message}", @@ -3213,7 +2979,7 @@ const messages: Record = { "settings.tools.keyHelp": "キーは macOS Keychain に保存されます。settings.json には保存有無だけが表示されます。", "settings.tools.webSearchTest": "web_search 接続テスト", "settings.tools.webSearchTesting": "テスト中...", - "settings.tools.webSearchTestHelp": "保存済みの web_search API キーで短い検索を 1 回実行します。provider のポリシーにより少量の使用量が記録される場合があります。", + "settings.tools.webSearchTestHelp": "保存済みの web_search API キーで短い検索を 1 回実行します。プロバイダーのポリシーにより少量の使用量が記録される場合があります。", "settings.tools.webSearchTestOk": "web_search 接続成功: {message}", "settings.tools.webSearchTestError": "web_search 接続失敗: {message}", "settings.git.title": "GitHub 同期", @@ -3357,7 +3123,10 @@ const messages: Record = { "settings.git.errorInitFailed": "初期化失敗: {error}", "settings.git.alreadyRepo": "すでに git リポジトリです", }, -}; +} as const; + +export type MessageKey = keyof typeof messages.ko; +export const messageCatalogs = messages; interface I18nContextValue { language: AppLanguage; @@ -3372,8 +3141,8 @@ export function normalizeLanguage(value: unknown): AppLanguage { } export function translate(language: AppLanguage, key: MessageKey, values: Record = {}) { - const template = messages[language][key] ?? messages[DEFAULT_LANGUAGE][key] ?? key; - return Object.entries(values).reduce( + const template: string = messages[language][key] ?? messages[DEFAULT_LANGUAGE][key] ?? key; + return Object.entries(values).reduce( (out, [name, value]) => out.replaceAll(`{${name}}`, String(value)), template, ); diff --git a/apps/desktop/src/lib/outlineCreate.ts b/apps/desktop/src/lib/outlineCreate.ts index fa164d0..847a85e 100644 --- a/apps/desktop/src/lib/outlineCreate.ts +++ b/apps/desktop/src/lib/outlineCreate.ts @@ -1,8 +1,9 @@ import type { TreeNode } from "../hooks/useFirstLeaf"; +import type { MessageKey } from "./i18n"; import { flatten } from "../hooks/useFirstLeaf"; import { outlineNumberLabel, type OutlineStructurePreset } from "./outlineRepair"; -type Labeler = (key: string, values?: Record) => string; +type Labeler = (key: MessageKey, values?: Record) => string; type NodeKind = "leaf" | "container"; export type CreateNodeStep = diff --git a/apps/desktop/src/lib/outlineRepair.ts b/apps/desktop/src/lib/outlineRepair.ts index aa49351..bcd9476 100644 --- a/apps/desktop/src/lib/outlineRepair.ts +++ b/apps/desktop/src/lib/outlineRepair.ts @@ -1,4 +1,5 @@ import type { TreeNode } from "../hooks/useFirstLeaf"; +import type { MessageKey } from "./i18n"; import type { NodeRow } from "./types"; type OutlineKind = "leaf" | "container"; @@ -23,19 +24,19 @@ export type OutlineRepairRPC = { rename: (id: string, label: string, title: string) => Promise<{ ok: true }>; }; -type Labeler = (key: string, values?: Record) => string; +type Labeler = (key: MessageKey, values?: Record) => string; type OutlineRole = "part" | "chapter" | "scene"; type OutlineLevelSpec = { - numberKey: string; - nameKey: string; + numberKey: MessageKey; + nameKey: MessageKey; structuralPatterns: RegExp[]; titlePatterns: RegExp[]; }; export type OutlineStructurePreset = { id: "novel" | "webnovel"; - nameKey: string; + nameKey: MessageKey; levels: Record; }; diff --git a/apps/desktop/src/lib/rpc.test.ts b/apps/desktop/src/lib/rpc.test.ts new file mode 100644 index 0000000..8ed734c --- /dev/null +++ b/apps/desktop/src/lib/rpc.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { RpcError, rpcCall } from "./rpc"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +describe("rpcCall", () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it("preserves JSON-RPC code, data, method, and message", async () => { + vi.mocked(invoke).mockRejectedValue({ + code: -32009, + message: "node content changed", + data: { current_content_version: 4 }, + request_id: 17, + }); + + let failure: RpcError | undefined; + try { + await rpcCall("nodes.update_content", { id: "scene-1" }); + } catch (error) { + failure = error as RpcError; + } + + expect(failure instanceof RpcError).toBe(true); + expect(failure?.code).toBe(-32009); + expect(failure?.data).toEqual({ current_content_version: 4 }); + expect(failure?.method).toBe("nodes.update_content"); + expect(failure?.requestId).toBe(17); + expect(failure?.message).toBe("node content changed"); + }); +}); diff --git a/apps/desktop/src/lib/rpc.ts b/apps/desktop/src/lib/rpc.ts index 05e8fa5..d40f28c 100644 --- a/apps/desktop/src/lib/rpc.ts +++ b/apps/desktop/src/lib/rpc.ts @@ -89,6 +89,17 @@ export async function engineStatus(): Promise { return invoke("engine_status"); } +export async function openRecoveryFolder(): Promise { + return invoke("open_recovery_folder"); +} + +export async function restoreLatestBackup(): Promise<{ + backup_path: string; + quarantined_path: string | null; +}> { + return invoke("restore_latest_backup"); +} + export async function openPath(path: string): Promise { return invoke("open_path", { path }); } @@ -101,8 +112,44 @@ export async function folderSyncNow(): Promise { return invoke("folder_sync_now"); } +export class RpcError extends Error { + readonly code?: number; + readonly data?: unknown; + readonly method: string; + readonly requestId?: number; + + constructor(method: string, message: string, code?: number, data?: unknown, requestId?: number) { + super(message); + this.name = "RpcError"; + this.method = method; + this.code = code; + this.data = data; + this.requestId = requestId; + } +} + +function normalizeRpcError(method: string, error: unknown): RpcError { + if (error && typeof error === "object") { + const wire = error as { code?: unknown; message?: unknown; data?: unknown; request_id?: unknown }; + if (typeof wire.message === "string") { + return new RpcError( + method, + wire.message, + typeof wire.code === "number" ? wire.code : undefined, + wire.data, + typeof wire.request_id === "number" ? wire.request_id : undefined, + ); + } + } + return new RpcError(method, error instanceof Error ? error.message : String(error)); +} + export async function rpcCall(method: string, params?: unknown): Promise { - return invoke("engine_call", { method, params: params ?? null }); + try { + return await invoke("engine_call", { method, params: params ?? null }); + } catch (error) { + throw normalizeRpcError(method, error); + } } function mapContextPreviewResponse(r: ContextPreviewResponse): AIContextPreview { @@ -151,8 +198,14 @@ export const projects = { export const nodes = { get: (id: string) => rpcCall("nodes.get", { id }), - updateContent: (id: string, doc: string) => - rpcCall("nodes.update_content", { id, doc }), + updateContent: (id: string, doc: string, expectedContentVersion?: number) => + rpcCall("nodes.update_content", { + id, + doc, + ...(expectedContentVersion === undefined + ? {} + : { expected_content_version: expectedContentVersion }), + }), setLastOpened: (projectId: string, nodeId: string) => rpcCall<{ ok: true }>("nodes.set_last_opened", { project_id: projectId, node_id: nodeId }), listTree: (projectId: string) => @@ -206,6 +259,10 @@ export const settings = { set: (patch: SettingsPatch) => rpcCall("settings.set", patch), }; +export const backupApi = { + createRecovery: () => rpcCall<{ path: string; format_version: number }>("backup.create_recovery"), +}; + export const providers = { listModels: (provider: ProviderID) => rpcCall<{ models: string[] }>("providers.list_models", { provider }), diff --git a/apps/desktop/src/lib/sceneSaveQueue.test.ts b/apps/desktop/src/lib/sceneSaveQueue.test.ts new file mode 100644 index 0000000..343b33a --- /dev/null +++ b/apps/desktop/src/lib/sceneSaveQueue.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; + +import { SceneSaveQueue } from "./sceneSaveQueue"; + +describe("SceneSaveQueue", () => { + it("serializes saves per scene and advances the expected version", async () => { + const persist = vi.fn() + .mockResolvedValueOnce({ content_version: 1 }) + .mockResolvedValueOnce({ content_version: 2 }); + const queue = new SceneSaveQueue(persist); + queue.seed("scene-a", 0); + + const first = queue.save("scene-a", "first"); + const second = queue.save("scene-a", "second"); + await Promise.all([first, second]); + + expect(persist).toHaveBeenNthCalledWith(1, "scene-a", "first", 0); + expect(persist).toHaveBeenNthCalledWith(2, "scene-a", "second", 1); + }); + + it("does not replace a newer externally seeded version with an older response", async () => { + let resolveSave!: (value: { content_version: number }) => void; + const persist = vi.fn() + .mockImplementationOnce(() => new Promise<{ content_version: number }>((resolve) => { + resolveSave = resolve; + })) + .mockResolvedValueOnce({ content_version: 3 }); + const queue = new SceneSaveQueue(persist); + queue.seed("scene-a", 0); + + const pending = queue.save("scene-a", "draft"); + await Promise.resolve(); + queue.seed("scene-a", 2); + resolveSave({ content_version: 1 }); + await pending; + + await queue.save("scene-a", "after restore"); + expect(persist).toHaveBeenLastCalledWith("scene-a", "after restore", 2); + }); +}); diff --git a/apps/desktop/src/lib/sceneSaveQueue.ts b/apps/desktop/src/lib/sceneSaveQueue.ts new file mode 100644 index 0000000..730db99 --- /dev/null +++ b/apps/desktop/src/lib/sceneSaveQueue.ts @@ -0,0 +1,44 @@ +export interface VersionedSaveResult { + content_version?: number; +} + +type Persist = ( + nodeId: string, + doc: string, + expectedContentVersion: number, +) => Promise; + +/** Serializes writes for each scene and carries optimistic-lock versions. */ +export class SceneSaveQueue { + private readonly versions = new Map(); + private readonly tails = new Map>(); + + constructor(private readonly persist: Persist) {} + + seed(nodeId: string, contentVersion: number): void { + const current = this.versions.get(nodeId); + if (current === undefined || contentVersion > current) { + this.versions.set(nodeId, contentVersion); + } + } + + save(nodeId: string, doc: string): Promise { + const previous = this.tails.get(nodeId) ?? Promise.resolve(); + const run = previous.then(async () => { + const expected = this.versions.get(nodeId); + if (expected === undefined) { + throw new Error(`content version is not seeded for node ${nodeId}`); + } + const updated = await this.persist(nodeId, doc, expected); + const nextVersion = updated.content_version ?? expected + 1; + this.seed(nodeId, nextVersion); + return updated; + }); + const tail = run.then(() => undefined, () => undefined); + this.tails.set(nodeId, tail); + void tail.then(() => { + if (this.tails.get(nodeId) === tail) this.tails.delete(nodeId); + }); + return run; + } +} diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts index 17e3653..99024c9 100644 --- a/apps/desktop/src/lib/types.ts +++ b/apps/desktop/src/lib/types.ts @@ -363,12 +363,12 @@ export interface AICancelled { export interface EngineStatus { ok: boolean; - error?: string; - version?: string; - home?: string; - db_path?: string; - migration_version?: number; - migration_count?: number; + error: string | null; + version: string | null; + home: string | null; + db_path: string | null; + migration_version: number | null; + migration_count: number | null; } export interface OpsStatus { @@ -575,6 +575,8 @@ export interface Settings { safety_checklist_dismissed: boolean; onboarding_tour_enabled: boolean; onboarding_tour_seen_version: string; + ai_data_sharing_consent_version?: number; + ai_data_sharing_consented_at?: number; web_search_provider: WebSearchProvider; web_search_api_key: string; web_search_api_key_set?: boolean; @@ -597,6 +599,8 @@ export interface SettingsPatch { safety_checklist_dismissed?: boolean; onboarding_tour_enabled?: boolean; onboarding_tour_seen_version?: string; + ai_data_sharing_consent_version?: number; + ai_data_sharing_consented_at?: number; web_search_provider?: WebSearchProvider; web_search_api_key?: string; } diff --git a/apps/desktop/src/routes/Library.test.tsx b/apps/desktop/src/routes/Library.test.tsx index 07d5f42..6b80002 100644 --- a/apps/desktop/src/routes/Library.test.tsx +++ b/apps/desktop/src/routes/Library.test.tsx @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ diagnosticsGet: vi.fn(), searchQuery: vi.fn(), openPath: vi.fn(), + createRecoveryBackup: vi.fn(), })); vi.mock("../lib/rpc", () => ({ @@ -48,6 +49,9 @@ vi.mock("../lib/rpc", () => ({ query: mocks.searchQuery, }, openPath: mocks.openPath, + backupApi: { + createRecovery: mocks.createRecoveryBackup, + }, })); vi.mock("../lib/exportSave", () => ({ @@ -93,6 +97,7 @@ describe("Library", () => { markdown: "# Quiet City\n", }); mocks.saveExportedMarkdown.mockResolvedValue("/tmp/quiet-city.md"); + mocks.createRecoveryBackup.mockResolvedValue({ path: "/tmp/library.linetta", format_version: 1 }); mocks.settingsGet.mockResolvedValue({ language: "ko", provider: "claude-code-cli", @@ -221,6 +226,29 @@ describe("Library", () => { expect(mocks.openPath).toHaveBeenCalledWith("/tmp/linetta"); }); + it("creates a versioned full-library recovery backup from the library menu", async () => { + const user = userEvent.setup(); + mocks.settingsGet.mockResolvedValue({ + language: "ko", + provider: "claude-code-cli", + typewriter_default: false, + focus_default: false, + git_sync_dir: "", + git_sync_commit_template: "", + backup_dir: "/tmp/linetta/backups", + safety_checklist_dismissed: true, + onboarding_tour_enabled: true, + onboarding_tour_seen_version: "library-workspace-v1", + }); + renderLibrary(); + + await user.click(await screen.findByLabelText("라이브러리 옵션")); + await user.click(screen.getByRole("menuitem", { name: "전체 복구 백업 만들기" })); + + expect(mocks.createRecoveryBackup).toHaveBeenCalledTimes(1); + expect(await screen.findByText("전체 라이브러리 복구 백업을 만들었습니다")).toBeInTheDocument(); + }); + it("always exposes the archive from the home shelf", async () => { mocks.projectsList.mockResolvedValue([]); mocks.settingsGet.mockResolvedValue({ @@ -248,7 +276,7 @@ describe("Library", () => { renderLibrary(); await user.click(await screen.findByLabelText("Quiet City 작품 옵션")); - await user.click(screen.getByRole("menuitem", { name: "작품 백업 (.md)" })); + await user.click(screen.getByRole("menuitem", { name: "읽기용 Markdown 내보내기" })); expect(mocks.exportProject).toHaveBeenCalledWith("project-1"); expect(mocks.saveExportedMarkdown).toHaveBeenCalledWith({ diff --git a/apps/desktop/src/routes/Library.tsx b/apps/desktop/src/routes/Library.tsx index 89143f0..d01af82 100644 --- a/apps/desktop/src/routes/Library.tsx +++ b/apps/desktop/src/routes/Library.tsx @@ -6,6 +6,7 @@ import { settings as settingsApi, diagnostics as diagnosticsApi, exportApi, + backupApi, openPath, } from "../lib/rpc"; import type { @@ -165,6 +166,16 @@ export function Library() { } }; + const createRecoveryBackup = async () => { + setMenuOpen(false); + try { + await backupApi.createRecovery(); + showToast(t("library.toast.recoveryBackupComplete")); + } catch (err) { + showToast(t("library.toast.recoveryBackupFailed", { error: String(err) })); + } + }; + const openDataFolder = async () => { setMenuOpen(false); setProjectMenuOpenId(null); @@ -334,6 +345,9 @@ export function Library() { + @@ -492,7 +506,7 @@ export function Library() { {settingsRow?.backup_dir ?? t("library.safety.checking")}
  • - Git sync + {t("library.safety.gitSync")} {settingsRow?.git_sync_dir ? t("library.safety.configured") : t("library.safety.disabled")}
  • diff --git a/apps/desktop/src/routes/Settings.test.tsx b/apps/desktop/src/routes/Settings.test.tsx index dd0186e..26e86d9 100644 --- a/apps/desktop/src/routes/Settings.test.tsx +++ b/apps/desktop/src/routes/Settings.test.tsx @@ -79,6 +79,8 @@ const baseSettings = { web_search_provider: "brave" as const, web_search_api_key: "", web_search_api_key_set: true, + ai_data_sharing_consent_version: 0, + ai_data_sharing_consented_at: 0, }; async function clickAdvancedProvider(user: ReturnType, label: RegExp, desc: string) { @@ -224,6 +226,24 @@ describe("Settings", () => { ); }); + it("records and withdraws explicit third-party AI data-sharing consent", async () => { + const user = userEvent.setup(); + renderSettings(); + + expect(await screen.findByText("AI 데이터 전송 동의")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "동의하고 AI 사용" })); + expect(mocks.settingsSet).toHaveBeenCalledWith(expect.objectContaining({ + ai_data_sharing_consent_version: 1, + ai_data_sharing_consented_at: expect.any(Number), + })); + + await user.click(screen.getByRole("button", { name: "동의 철회" })); + expect(mocks.settingsSet).toHaveBeenLastCalledWith(expect.objectContaining({ + ai_data_sharing_consent_version: 0, + ai_data_sharing_consented_at: 0, + })); + }); + it("selecting the OpenRouter beginner guide persists the OpenRouter provider", async () => { const user = userEvent.setup(); renderSettings(); diff --git a/apps/desktop/src/routes/Settings.tsx b/apps/desktop/src/routes/Settings.tsx index 4a672ec..819e815 100644 --- a/apps/desktop/src/routes/Settings.tsx +++ b/apps/desktop/src/routes/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ChevronLeft } from "lucide-react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; @@ -14,6 +14,7 @@ import { diagnostics as diagnosticsApi, } from "../lib/rpc"; import { APP_LANGUAGES, localeForLanguage, useI18n } from "../lib/i18n"; +import { dispatchAppEvent } from "../lib/appEvents"; import { OPENROUTER_DEFAULT_MODEL_OPTIONS, OPENROUTER_SMART_DEFAULT_MODEL, @@ -44,6 +45,8 @@ const JOB_GIT_SYNC = "git_sync"; const JOB_FOLDER_SYNC = "folder_sync"; const JOB_SUMMARIZER = "summarizer"; const JOB_COMPANION = "companion.persistence"; +const AI_DATA_SHARING_CONSENT_VERSION = 1; +const PRIVACY_POLICY_URL = "https://github.com/devlikebear/linetta/blob/main/docs/privacy-policy.md"; interface ProviderMeta { id: ProviderID; @@ -153,7 +156,7 @@ export function Settings() { }) .catch((e) => { if (!cancelled) setError(String(e)); }); return () => { cancelled = true; }; - }, []); + }, [setLanguage]); // Reset the per-provider drafts whenever the active provider changes (or on // first load) so each provider's stored config shows in the fields. @@ -206,7 +209,7 @@ export function Settings() { setLanguage(next.language); setEditorFontSizeDraft(String(next.editor_font_size ?? 20)); setEditorLineHeightDraft(String(next.editor_line_height ?? 1.92)); - window.dispatchEvent(new CustomEvent("linetta:settings-updated", { detail: next })); + dispatchAppEvent("linetta:settings-updated", next); setSavedAt(Date.now()); } catch (e) { setError(String(e)); @@ -333,9 +336,9 @@ export function Settings() { const credentialState = getCredentialState(t, activeMeta, activeConfig); const webSearchKeyPlaceholder = current ? getWebSearchKeyPlaceholder(t, current) : t("settings.tools.keyPlaceholder"); - const refreshOpenRouterKeyInfo = async () => { - const cfg = current?.providers?.openrouter; - if (!cfg?.api_key_set) { + const openRouterKeySet = current?.providers?.openrouter?.api_key_set ?? false; + const refreshOpenRouterKeyInfo = useCallback(async () => { + if (!openRouterKeySet) { setOpenRouterKeyInfo(null); setOpenRouterKeyInfoError(""); return; @@ -350,7 +353,7 @@ export function Settings() { } finally { setOpenRouterKeyInfoLoading(false); } - }; + }, [openRouterKeySet]); const persistOpenRouterGuide = async (options: { clearAPIKey?: boolean; quiet?: boolean } = {}) => { const model = openRouterModelDraft.trim() || OPENROUTER_SMART_DEFAULT_MODEL; @@ -370,7 +373,7 @@ export function Settings() { setLanguage(next.language); setGuideId("openrouter-safe"); syncOpenRouterDrafts(next); - window.dispatchEvent(new CustomEvent("linetta:settings-updated", { detail: next })); + dispatchAppEvent("linetta:settings-updated", next); setSavedAt(Date.now()); if (!options.quiet) { setOpenRouterSetupMsg({ kind: "ok", text: t("settings.setup.openrouter.saved") }); @@ -471,7 +474,7 @@ export function Settings() { syncOpenRouterDrafts(next); setGuideId("openrouter-safe"); setProviderTestMsg({ kind: "ok", text: t("settings.provider.testOk", { message: finished.message }) }); - window.dispatchEvent(new CustomEvent("linetta:settings-updated", { detail: next })); + dispatchAppEvent("linetta:settings-updated", next); setSavedAt(Date.now()); try { setOpenRouterKeyInfo(await openRouterApi.keyInfo()); @@ -489,13 +492,13 @@ export function Settings() { useEffect(() => { if (!current || (current.provider !== "openrouter" && guideId !== "openrouter-safe")) return; - if (!current.providers?.openrouter?.api_key_set) { + if (!openRouterKeySet) { setOpenRouterKeyInfo(null); setOpenRouterKeyInfoError(""); return; } void refreshOpenRouterKeyInfo(); - }, [current?.provider, current?.providers?.openrouter?.api_key_set, guideId]); + }, [current, guideId, openRouterKeySet, refreshOpenRouterKeyInfo]); const replayOnboardingTour = () => { clearStoredPhase(WORKSPACE_PENDING_STORAGE_KEY); @@ -543,6 +546,51 @@ export function Settings() {

    {t("settings.provider.restrictedNote")}

    )} +
    +

    {t("settings.aiConsent.title")}

    +

    + {t("settings.aiConsent.description", { + provider: activeMeta?.label ?? current.provider, + })} +

    +

    {t("settings.aiConsent.control")}

    +

    + + {t("settings.aiConsent.privacyLink")} + +

    +

    + {current.ai_data_sharing_consent_version === AI_DATA_SHARING_CONSENT_VERSION + ? t("settings.aiConsent.statusGranted") + : t("settings.aiConsent.statusRequired")} +

    + {current.ai_data_sharing_consent_version === AI_DATA_SHARING_CONSENT_VERSION ? ( + + ) : ( + + )} +
    + ${chapter} > ${scene} (예: ${outlineNumberLabel(outlinePreset, "part", 1, t)} > ${outlineNumberLabel(outlinePreset, "chapter", 1, t)} > ${outlineNumberLabel(outlinePreset, "scene", 1, t)})`; + return t("workspace.outlineStructureFormat", { + preset: t(outlinePreset.nameKey), + part, + chapter, + scene, + partExample: outlineNumberLabel(outlinePreset, "part", 1, t), + chapterExample: outlineNumberLabel(outlinePreset, "chapter", 1, t), + sceneExample: outlineNumberLabel(outlinePreset, "scene", 1, t), + }); }, [outlinePreset, t]); const [factBookSelectedClaimRequest, setFactBookSelectedClaimRequest] = useState<{ id: string; claim: string } | null>(null); const [companionRewriteRequest, setCompanionRewriteRequest] = useState<{ id: string; text: string; kind?: SelectionRewriteKind } | null>(null); @@ -207,9 +217,18 @@ export function Workspace() { const factBookSelectionSeqRef = useRef(0); const companionRewriteSeqRef = useRef(0); const loadRef = useRef(null); + const sceneSaveQueueRef = useRef | null>(null); + if (!sceneSaveQueueRef.current) { + sceneSaveQueueRef.current = new SceneSaveQueue((nodeId, doc, expectedVersion) => + nodes.updateContent(nodeId, doc, expectedVersion)); + } + const sceneSaveQueue = sceneSaveQueueRef.current; useEffect(() => { loadRef.current = load; }, [load]); + useEffect(() => { + if (load) sceneSaveQueue.seed(load.node.id, load.node.content_version ?? 0); + }, [load, sceneSaveQueue]); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const compact = window.matchMedia("(max-width: 860px)"); @@ -279,33 +298,30 @@ export function Workspace() { } | null>(null); useEffect(() => { - const onHover = (e: Event) => { - const ce = e as CustomEvent<{ noteId: string; target: HTMLElement }>; + const onHover = (detail: LinettaEventMap["linetta:note-hover"]) => { setNotePopover((cur) => { - if (cur && cur.mode === "edit" && cur.noteId === ce.detail.noteId) return cur; - return { noteId: ce.detail.noteId, targetEl: ce.detail.target, mode: "read" }; + if (cur && cur.mode === "edit" && cur.noteId === detail.noteId) return cur; + return { noteId: detail.noteId, targetEl: detail.target, mode: "read" }; }); }; - const onHoverEnd = (e: Event) => { - const ce = e as CustomEvent<{ noteId: string }>; + const onHoverEnd = (detail: LinettaEventMap["linetta:note-hover-end"]) => { setNotePopover((cur) => { if (!cur) return null; if (cur.mode === "edit") return cur; - if (cur.noteId !== ce.detail.noteId) return cur; + if (cur.noteId !== detail.noteId) return cur; return null; }); }; - const onClick = (e: Event) => { - const ce = e as CustomEvent<{ noteId: string; target: HTMLElement }>; - setNotePopover({ noteId: ce.detail.noteId, targetEl: ce.detail.target, mode: "edit" }); + const onClick = (detail: LinettaEventMap["linetta:note-click"]) => { + setNotePopover({ noteId: detail.noteId, targetEl: detail.target, mode: "edit" }); }; - window.addEventListener("linetta:note-hover", onHover); - window.addEventListener("linetta:note-hover-end", onHoverEnd); - window.addEventListener("linetta:note-click", onClick); + const unsubscribeHover = subscribeAppEvent("linetta:note-hover", onHover); + const unsubscribeHoverEnd = subscribeAppEvent("linetta:note-hover-end", onHoverEnd); + const unsubscribeClick = subscribeAppEvent("linetta:note-click", onClick); return () => { - window.removeEventListener("linetta:note-hover", onHover); - window.removeEventListener("linetta:note-hover-end", onHoverEnd); - window.removeEventListener("linetta:note-click", onClick); + unsubscribeHover(); + unsubscribeHoverEnd(); + unsubscribeClick(); }; }, []); @@ -722,18 +738,18 @@ export function Workspace() { } })(); return () => { cancelled = true; }; - }, [projectId, fetchTree]); + }, [fetchTree, location.pathname, location.state, navigate, projectId]); + const activeMentionNodeId = load?.node.id; // Refresh mentioned entities when the active node changes. useEffect(() => { - if (load) refreshMentioned(load.node.id); - }, [load?.node.id, refreshMentioned]); + if (activeMentionNodeId) refreshMentioned(activeMentionNodeId); + }, [activeMentionNodeId, refreshMentioned]); // Listen for "new entity" event dispatched by MentionExtension. useEffect(() => { if (!projectId) return; - const handler = async (e: Event) => { - const detail = (e as CustomEvent).detail as { query: string; range: { from: number; to: number }; editor: any }; + const handler = async (detail: LinettaEventMap["linetta:mention-pick-new"]) => { try { const created = await entitiesApi.create({ project_id: projectId, kind: "character", name: detail.query }); detail.editor.chain().focus().deleteRange(detail.range).insertContent([ @@ -746,8 +762,7 @@ export function Workspace() { showToast(t("workspace.toast.entityCreateFailed", { error: String(err) })); } }; - window.addEventListener("linetta:mention-pick-new", handler); - return () => window.removeEventListener("linetta:mention-pick-new", handler); + return subscribeAppEvent("linetta:mention-pick-new", handler); }, [projectId, showToast, t]); // Global Cmd+R reload + Cmd+P palette toggle + Cmd+F search + Cmd+Shift+F contextual edit + Cmd+I companion draft mode. @@ -798,21 +813,23 @@ export function Workspace() { }, []); const saveNow = useCallback( - async (doc: object) => { - if (!load) return; - setSaveStatus({ kind: "saving" }); + async (nodeId: string, doc: object) => { + const isActive = () => loadRef.current?.node.id === nodeId; + if (isActive()) setSaveStatus({ kind: "saving" }); try { - await nodes.updateContent(load.node.id, JSON.stringify(doc)); - setSaveStatus({ kind: "saved", at: Date.now() }); - refreshMentioned(load.node.id); + await sceneSaveQueue.save(nodeId, JSON.stringify(doc)); + if (isActive()) setSaveStatus({ kind: "saved", at: Date.now() }); + refreshMentioned(nodeId); } catch (e) { - setSaveStatus({ kind: "error", message: String(e) }); - setError(String(e)); + if (isActive()) { + setSaveStatus({ kind: "error", message: String(e) }); + setError(String(e)); + } } }, - [load, refreshMentioned], + [refreshMentioned, sceneSaveQueue], ); - const debouncedSave = useDebouncedCallback(saveNow, SAVE_DEBOUNCE_MS); + const debouncedSave = useKeyedDebouncedCallback(saveNow, SAVE_DEBOUNCE_MS); const idleDirtyRef = useRef(false); const handleIdleCheckpoint = useCallback(async () => { if (!idleDirtyRef.current) return; @@ -840,9 +857,10 @@ export function Workspace() { const currentLoad = loadRef.current; const doc = editorRef.current?.getDoc(); if (!currentLoad || !doc) return; + debouncedSave.cancel(currentLoad.node.id); setSaveStatus({ kind: "saving" }); try { - const updated = await nodes.updateContent(currentLoad.node.id, JSON.stringify(doc)); + const updated = await sceneSaveQueue.save(currentLoad.node.id, JSON.stringify(doc)); setLoad((prev) => { if (!prev || prev.node.id !== updated.id) return prev; return { ...prev, node: updated, initialDoc: doc }; @@ -855,7 +873,7 @@ export function Workspace() { setError(String(e)); throw e; } - }, [refreshMentioned]); + }, [debouncedSave, refreshMentioned, sceneSaveQueue]); const throttledLastOpened = useThrottledCallback( useCallback(() => { if (!load) return; @@ -883,9 +901,10 @@ export function Workspace() { const handleManualSave = useCallback( async (doc: object) => { if (!load) return; + debouncedSave.cancel(load.node.id); setSaveStatus({ kind: "saving" }); try { - await nodes.updateContent(load.node.id, JSON.stringify(doc)); + await sceneSaveQueue.save(load.node.id, JSON.stringify(doc)); await snapshots.createManual(load.node.id, JSON.stringify(doc)); setSaveStatus({ kind: "saved", at: Date.now() }); showToast(t("workspace.toast.snapshotSaved")); @@ -894,7 +913,7 @@ export function Workspace() { setError(String(e)); } }, - [load, showToast, t], + [debouncedSave, load, sceneSaveQueue, showToast, t], ); const handleAutoMentionScene = useCallback(async () => { @@ -911,8 +930,9 @@ export function Workspace() { return; } setSaveStatus({ kind: "saving" }); + debouncedSave.cancel(load.node.id); editor.commands.setContent(result.doc); - const updated = await nodes.updateContent(load.node.id, JSON.stringify(result.doc)); + const updated = await sceneSaveQueue.save(load.node.id, JSON.stringify(result.doc)); setLoad((prev) => (prev ? { ...prev, node: updated, initialDoc: result.doc } : prev)); setCharCount(updated.word_count); await refreshMentioned(load.node.id); @@ -924,7 +944,7 @@ export function Workspace() { } finally { setAutoMentionBusy(false); } - }, [load, refreshMentioned, showToast, t]); + }, [debouncedSave, load, refreshMentioned, sceneSaveQueue, showToast, t]); const handleSceneTitleCommit = useCallback( async (title: string) => { @@ -1439,7 +1459,7 @@ export function Workspace() { run: () => setShortcutsOpen(true), }); return cmds; - }, [load, navigateToNode, refreshTreeAndNavigateTo, refreshTreeKeepNode, navigate, promptDialog, enterZen, focus, companionOpen, railCollapsed, outlinePreset, handleCreateSceneFromOutline, handleCreateChapterFromOutline, requestInlineRenameNode, handleMoveSceneFromOutline, handleDeleteSceneFromOutline, copyNodeText, showToast, language, t, toggleFactBook, toggleContextualEdit, gitSyncAvailable]); + }, [load, navigateToNode, navigate, promptDialog, enterZen, focus, companionOpen, railCollapsed, outlinePreset, handleCreateSceneFromOutline, handleCreateChapterFromOutline, requestInlineRenameNode, handleMoveSceneFromOutline, handleDeleteSceneFromOutline, copyNodeText, showToast, language, t, toggleFactBook, toggleContextualEdit, gitSyncAvailable]); // Breadcrumb chain: ancestor container labels + the current scene label. const crumbChain = useMemo(() => { @@ -1737,7 +1757,7 @@ export function Workspace() { ref={editorRef} initialDoc={load.initialDoc} onChange={(doc) => { - debouncedSave(doc); + debouncedSave(load.node.id, doc); idleDirtyRef.current = true; markActivity(); throttledLastOpened(); @@ -1813,6 +1833,8 @@ export function Workspace() { focusEditor(); }} onRestored={(updatedNode) => { + debouncedSave.cancel(updatedNode.id); + sceneSaveQueue.seed(updatedNode.id, updatedNode.content_version ?? 0); const docStr = updatedNode.content_doc ?? `{"type":"doc","content":[{"type":"paragraph"}]}`; setLoad((prev) => prev ? { ...prev, node: updatedNode, initialDoc: JSON.parse(docStr) } : prev); setCharCount(updatedNode.word_count); @@ -1883,6 +1905,10 @@ export function Workspace() { if (!load) return; refreshTreeKeepNode(load.node.id); const changedCurrentNode = event?.changed_nodes?.find((change) => change.node_id === load.node.id); + if (changedCurrentNode) { + debouncedSave.cancel(changedCurrentNode.node_id); + sceneSaveQueue.seed(changedCurrentNode.node_id, changedCurrentNode.content_version); + } refreshMentioned(changedCurrentNode?.node_id ?? load.node.id); }} /> @@ -2027,7 +2053,7 @@ export function Workspace() { sceneLabel={currentNodeLabel} target={isWebnovelProject ? episodeCharTarget : 0} onChange={(doc) => { - debouncedSave(doc); + debouncedSave(load.node.id, doc); idleDirtyRef.current = true; markActivity(); }} diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index f00e464..1a82dad 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,37 +1,39 @@ # Linetta 개인정보 처리방침 / Privacy Policy -_최종 업데이트 / Last updated / 最終更新: 2026-07-05_ +_최종 업데이트 / Last updated / 最終更新: 2026-07-12_ --- ## 한국어 -Linetta("앱")는 장편소설·웹소설 집필을 위한 **로컬 우선(local-first)** 데스크톱 앱입니다. 개발자(Changheon Shin / devlikebear)는 이 앱을 통해 **어떠한 개인정보도 수집·전송·저장하지 않습니다.** +Linetta("앱")는 장편소설·웹소설 집필을 위한 **로컬 우선(local-first)** 데스크톱 앱입니다. 개발자(Changheon Shin / devlikebear)는 분석·광고·계정 서버를 운영하지 않으며 사용자 데이터를 수집하지 않습니다. 다만 사용자가 선택적 AI 기능에 명시적으로 동의해 사용할 때에는 아래와 같이 선택한 제3자 AI 공급자에게 데이터가 직접 전송됩니다. ### 1. 수집하는 정보 -**없습니다.** 앱은 다음을 하지 않습니다. +**개발자가 수집하는 정보는 없습니다.** 앱은 다음을 하지 않습니다. - 사용자 계정 또는 로그인이 없습니다. - 분석(analytics)·추적(tracking)·텔레메트리·광고 식별자를 사용하지 않습니다. - 크래시 리포팅 등으로 사용자 데이터를 개발자 서버로 보내지 않습니다. ### 2. 데이터 저장 위치 -사용자가 작성한 원고·프로젝트·설정 등 모든 데이터는 **사용자 기기 내에 로컬로만** 저장됩니다. +사용자가 작성한 원고·프로젝트·설정 등 기본 데이터는 **사용자 기기에 로컬로** 저장됩니다. - 집필 데이터: 기기의 로컬 데이터베이스 - API 키 등 민감 정보: 운영체제의 보안 저장소(예: macOS Keychain) 개발자는 이 데이터에 접근할 수 없습니다. ### 3. 선택적 AI 기능 -앱의 선택적 "AI 컴패니언" 기능을 사용할 경우, 사용자가 **직접 설정한 제3자 LLM 제공자**(예: OpenRouter 등)에게, **사용자 본인의 API 키/자격증명**을 사용하여 사용자가 입력·요청한 텍스트가 전송됩니다. -- 이 전송은 사용자가 해당 기능을 사용할 때에만 발생합니다. -- 해당 데이터 처리는 사용자가 선택한 제공자의 개인정보 처리방침을 따릅니다. +앱의 선택적 AI 기능을 사용할 경우, 사용자가 **직접 선택한 제3자 LLM 제공자**(예: OpenAI, OpenRouter, Anthropic, Google 등)에게 사용자의 자격증명을 통해 다음 데이터가 전송될 수 있습니다: 프롬프트, 선택한 원고와 주변 문맥, 작품 설정·레퍼런스, 첨부 이미지. +- 앱은 첫 전송 전에 수신 공급자와 전송 범위를 알리고 명시적 동의를 받습니다. 공급자 또는 사용자 지정 서버를 변경하면 다시 동의해야 합니다. +- 전송 목적은 사용자가 요청한 생성·교정·요약·검색 보조 결과를 제공하는 것입니다. +- 보관 기간과 삭제 방식은 선택한 공급자의 개인정보 처리방침 및 계정 설정을 따릅니다. 개발자는 공급자 측 보관 데이터에 접근하거나 대신 삭제할 수 없습니다. +- 사용자는 앱의 `설정 → AI 데이터 전송 동의`에서 언제든 동의를 철회할 수 있습니다. 철회 후에는 새 AI 요청이 차단됩니다. 이미 전송된 데이터의 삭제는 해당 공급자에게 요청해야 합니다. - 개발자는 이 데이터를 수신·중계·저장하지 않습니다. ### 4. 데이터 판매·공유 -개발자는 사용자 데이터를 수집하지 않으므로, **판매·공유·제3자 제공이 일절 없습니다.** +개발자는 사용자 데이터를 판매하거나 자체 목적으로 공유하지 않습니다. 단, 사용자가 명시적으로 동의해 AI 기능을 실행하면 위 3항의 데이터가 선택한 공급자에게 직접 제공됩니다. ### 5. 아동의 개인정보 -앱은 어떠한 개인정보도 수집하지 않으며, 아동을 포함한 모든 사용자에게 동일하게 적용됩니다. +개발자는 아동을 포함한 사용자의 개인정보를 수집하지 않습니다. 아동 사용자는 제3자 AI 공급자의 연령 요건과 보호자 동의 요건도 확인해야 합니다. ### 6. 변경 사항 본 방침이 변경되면 이 페이지를 갱신하고 상단의 "최종 업데이트" 날짜를 수정합니다. @@ -43,32 +45,34 @@ Linetta("앱")는 장편소설·웹소설 집필을 위한 **로컬 우선(local ## English -Linetta (the "App") is a **local-first** desktop app for writing long-form fiction. The developer (Changheon Shin / devlikebear) **does not collect, transmit, or store any personal data** through this App. +Linetta (the "App") is a **local-first** desktop app for writing long-form fiction. The developer (Changheon Shin / devlikebear) operates no analytics, advertising, or account server and does not collect user data. If you explicitly consent to and use optional AI features, data is sent directly to the third-party AI provider you select as described below. ### 1. Information We Collect -**None.** The App: +**The developer collects none.** The App: - has no user accounts or sign-in; - uses no analytics, tracking, telemetry, or advertising identifiers; - does not send any user data to the developer (including crash reports). ### 2. Where Data Is Stored -All data you create — manuscripts, projects, and settings — is stored **locally on your device only**. +Core data you create — manuscripts, projects, and settings — is stored **locally on your device**. - Writing data: a local database on your device. - Sensitive values such as API keys: your operating system's secure store (e.g., macOS Keychain). The developer has no access to this data. ### 3. Optional AI Features -If you use the App's optional "AI Companion" feature, the text you submit is sent to a **third-party LLM provider that you configure yourself** (e.g., OpenRouter), using **your own API key/credentials**. -- This transmission occurs only when you use the feature. -- That processing is governed by the privacy policy of the provider you choose. +If you use optional AI features, the following data may be sent with your credentials to the **third-party LLM provider you select** (such as OpenAI, OpenRouter, Anthropic, or Google): prompts, selected manuscript and surrounding context, story settings and references, and attached images. +- Before the first transfer, the App identifies the recipient and data scope and asks for explicit consent. Changing the provider or a custom server requires renewed consent. +- The purpose is to provide generation, revision, summarization, or research assistance you request. +- Retention and deletion are governed by the selected provider's privacy policy and account settings. The developer cannot access or delete provider-held data for you. +- You can withdraw consent at any time under `Settings → AI Data Sharing Consent`; new AI requests are then blocked. To delete data already transferred, contact the applicable provider. - The developer does not receive, relay, or store this data. ### 4. Selling or Sharing Data -Because the developer collects no user data, there is **no selling, sharing, or disclosure** of personal data to third parties. +The developer does not sell user data or share it for the developer's own purposes. When you explicitly consent and run an AI feature, the data described in Section 3 is disclosed directly to your selected provider. ### 5. Children's Privacy -The App collects no personal data from any user, including children. +The developer collects no personal data from users, including children. Child users must also satisfy the selected AI provider's age and parental-consent requirements. ### 6. Changes If this policy changes, we will update this page and revise the "Last updated" date above. @@ -80,32 +84,34 @@ Contact: devlikebear@gmail.com ## 日本語 -Linetta(以下「本アプリ」)は、長編小説・Web小説の執筆のための**ローカルファースト**なデスクトップアプリです。開発者(Changheon Shin / devlikebear)は、本アプリを通じて**いかなる個人情報も収集・送信・保存しません。** +Linetta(以下「本アプリ」)は、長編小説・Web小説の執筆のための**ローカルファースト**なデスクトップアプリです。開発者(Changheon Shin / devlikebear)は、分析・広告・アカウント用サーバーを運営せず、ユーザーデータを収集しません。ただし、ユーザーが任意のAI機能に明示的に同意して利用する場合、以下のとおり選択した第三者AIプロバイダーへデータが直接送信されます。 ### 1. 収集する情報 -**ありません。** 本アプリは以下を行いません。 +**開発者が収集する情報はありません。** 本アプリは以下を行いません。 - ユーザーアカウントやログインはありません。 - 分析(analytics)・トラッキング・テレメトリ・広告識別子を使用しません。 - クラッシュレポートなどでユーザーデータを開発者のサーバーへ送信しません。 ### 2. データの保存場所 -ユーザーが作成した原稿・プロジェクト・設定などのすべてのデータは、**ユーザーの端末内にのみローカルで**保存されます。 +ユーザーが作成した原稿・プロジェクト・設定などの基本データは、**ユーザーの端末内にローカルで**保存されます。 - 執筆データ: 端末内のローカルデータベース - APIキーなどの機密情報: OS のセキュアストア(例: macOS Keychain) 開発者はこのデータにアクセスできません。 ### 3. オプションのAI機能 -本アプリのオプション機能「AIコンパニオン」を使用する場合、ユーザーが**自分で設定した第三者のLLMプロバイダー**(例: OpenRouter など)に対し、**ユーザー自身のAPIキー/認証情報**を用いて、ユーザーが入力・要求したテキストが送信されます。 -- この送信は、ユーザーが当該機能を使用するときにのみ発生します。 -- そのデータ処理は、ユーザーが選択したプロバイダーのプライバシーポリシーに従います。 +任意のAI機能を使用する場合、ユーザーが**選択した第三者LLMプロバイダー**(OpenAI、OpenRouter、Anthropic、Google など)へ、ユーザーの認証情報を用いて次のデータが送信される場合があります:プロンプト、選択した原稿と周辺コンテキスト、作品設定・参考資料、添付画像。 +- 初回送信前に、送信先とデータ範囲を表示し、明示的な同意を求めます。プロバイダーまたはカスタムサーバーを変更した場合は再同意が必要です。 +- 送信目的は、ユーザーが依頼した生成・推敲・要約・調査支援を提供することです。 +- 保存期間と削除方法は、選択したプロバイダーのプライバシーポリシーおよびアカウント設定に従います。開発者はプロバイダー側のデータにアクセスしたり、代理で削除したりできません。 +- `設定 → AIデータ送信への同意` からいつでも同意を撤回できます。撤回後は新しいAIリクエストがブロックされます。送信済みデータの削除は該当プロバイダーへ依頼してください。 - 開発者はこのデータを受信・中継・保存しません。 ### 4. データの販売・共有 -開発者はユーザーデータを収集しないため、個人情報の**販売・共有・第三者提供は一切ありません。** +開発者はユーザーデータを販売せず、開発者自身の目的で共有しません。ただし、ユーザーが明示的に同意してAI機能を実行すると、第3項のデータが選択したプロバイダーへ直接提供されます。 ### 5. 子どものプライバシー -本アプリはいかなる個人情報も収集せず、子どもを含むすべてのユーザーに同一に適用されます。 +開発者は子どもを含むユーザーの個人情報を収集しません。子どもの利用者は、選択したAIプロバイダーの年齢要件および保護者同意要件も満たす必要があります。 ### 6. 変更 本ポリシーが変更された場合、このページを更新し、上部の「最終更新」日を改訂します。 diff --git a/engine/go.mod b/engine/go.mod index 9c92f74..04e57b7 100644 --- a/engine/go.mod +++ b/engine/go.mod @@ -1,6 +1,6 @@ module github.com/devlikebear/linetta/engine -go 1.26.2 +go 1.26.5 require ( github.com/devlikebear/tars v0.34.3 diff --git a/engine/internal/ai/client.go b/engine/internal/ai/client.go index 61c5b1b..d1566d0 100644 --- a/engine/internal/ai/client.go +++ b/engine/internal/ai/client.go @@ -1,6 +1,7 @@ package ai import ( + "errors" "os" "strings" @@ -8,17 +9,20 @@ import ( "github.com/devlikebear/tars/pkg/llm" ) +var ErrDataSharingConsentRequired = errors.New("AI data sharing consent is required before sending manuscript content to a provider") + // ResolvedProvider is the per-call provider configuration handed to the factory: // the active provider id plus its model, credential, optional CLI path override, // and the working directory. type ResolvedProvider struct { - Provider string - Model string - APIKey string - BaseURL string - CliPath string - WorkDir string - MaxTokens int + Provider string + Model string + APIKey string + BaseURL string + CliPath string + WorkDir string + MaxTokens int + DataSharingConsent bool } // ClientFactory creates an llm.Client from a resolved provider config. Wraps @@ -29,6 +33,9 @@ type ClientFactory func(p ResolvedProvider) (llm.Client, error) // configured CLI path via the CLAUDE_CODE_CLI_PATH env var that tars reads // (NewClaudeCodeCLIClient has no path parameter, so the env var is the only hook). func DefaultClientFactory(p ResolvedProvider) (llm.Client, error) { + if !p.DataSharingConsent { + return nil, ErrDataSharingConsentRequired + } if err := guardProvider(p.Provider); err != nil { return nil, err } diff --git a/engine/internal/ai/client_test.go b/engine/internal/ai/client_test.go index c572eea..b8c0d24 100644 --- a/engine/internal/ai/client_test.go +++ b/engine/internal/ai/client_test.go @@ -5,6 +5,7 @@ package ai import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -19,14 +20,22 @@ func TestDefaultClientFactorySetsClaudeCliPath(t *testing.T) { // claude-code-cli with a bogus path: NewProvider may fail to find the binary, // but the env var must be set first — that is the behavior under test. _, _ = DefaultClientFactory(ResolvedProvider{ - Provider: "claude-code-cli", - CliPath: "/tmp/does-not-exist-claude", + Provider: "claude-code-cli", + CliPath: "/tmp/does-not-exist-claude", + DataSharingConsent: true, }) if got := os.Getenv("CLAUDE_CODE_CLI_PATH"); got != "/tmp/does-not-exist-claude" { t.Fatalf("CLAUDE_CODE_CLI_PATH=%q, want /tmp/does-not-exist-claude", got) } } +func TestDefaultClientFactoryRejectsMissingDataSharingConsent(t *testing.T) { + _, err := DefaultClientFactory(ResolvedProvider{Provider: settings.ProviderOpenAI}) + if !errors.Is(err, ErrDataSharingConsentRequired) { + t.Fatalf("err = %v, want ErrDataSharingConsentRequired", err) + } +} + func TestProviderOptionsForTarsMapsOpenRouterToOpenAICompatible(t *testing.T) { got := providerOptionsForTars(ResolvedProvider{ Provider: settings.ProviderOpenRouter, @@ -79,11 +88,12 @@ func TestDefaultClientFactoryOpenRouterSendsMaxTokens(t *testing.T) { defer server.Close() client, err := DefaultClientFactory(ResolvedProvider{ - Provider: settings.ProviderOpenRouter, - Model: settings.DefaultOpenRouterModel, - APIKey: "or-test", - BaseURL: server.URL + "/v1", - MaxTokens: 64, + Provider: settings.ProviderOpenRouter, + Model: settings.DefaultOpenRouterModel, + APIKey: "or-test", + BaseURL: server.URL + "/v1", + MaxTokens: 64, + DataSharingConsent: true, }) if err != nil { t.Fatalf("DefaultClientFactory: %v", err) diff --git a/engine/internal/ai/context_test.go b/engine/internal/ai/context_test.go index c999055..b9bd14a 100644 --- a/engine/internal/ai/context_test.go +++ b/engine/internal/ai/context_test.go @@ -14,6 +14,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/note" "github.com/devlikebear/linetta/engine/internal/plot" "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/relationship" "github.com/devlikebear/linetta/engine/internal/store" "github.com/devlikebear/linetta/engine/internal/thread" @@ -226,7 +227,7 @@ func TestBuildContext_plotBeatsForCurrentNode(t *testing.T) { // Thread bound to the current node via two beats. th, _ := tr.Create(context.Background(), thread.NewInput{ProjectID: p.ID, Name: "잃어버린 시간", Color: "#c08a3e"}) - _ = tr.Update(context.Background(), thread.UpdateInput{ID: th.ID, Summary: "요약"}) + _ = tr.Update(context.Background(), thread.UpdateInput{ID: th.ID, Summary: ptrutil.To("요약")}) nID := *p.LastOpenedNodeID _, _ = br.Create(context.Background(), beat.NewInput{ThreadID: th.ID, NodeID: &nID, Label: "마디 1"}) _, _ = br.Create(context.Background(), beat.NewInput{ThreadID: th.ID, NodeID: &nID, Label: "마디 2"}) diff --git a/engine/internal/atomicfile/atomicfile.go b/engine/internal/atomicfile/atomicfile.go new file mode 100644 index 0000000..4977a4a --- /dev/null +++ b/engine/internal/atomicfile/atomicfile.go @@ -0,0 +1,41 @@ +// Package atomicfile publishes complete file replacements through a temporary +// file in the destination directory followed by an atomic rename. +package atomicfile + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" +) + +func Write(target string, data []byte, mode fs.FileMode) (err error) { + dir := filepath.Dir(target) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmpPath) + } + }() + if err = tmp.Chmod(mode); err != nil { + return fmt.Errorf("chmod temp file: %w", err) + } + if _, err = tmp.Write(data); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + if err = tmp.Sync(); err != nil { + return fmt.Errorf("sync temp file: %w", err) + } + if err = tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err = os.Rename(tmpPath, target); err != nil { + return fmt.Errorf("publish file: %w", err) + } + return nil +} diff --git a/engine/internal/atomicfile/atomicfile_test.go b/engine/internal/atomicfile/atomicfile_test.go new file mode 100644 index 0000000..e0319f1 --- /dev/null +++ b/engine/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,34 @@ +package atomicfile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWritePublishesCompleteReplacementWithoutTempFiles(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "project.md") + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + content := strings.Repeat("new manuscript\n", 1024) + if err := Write(target, []byte(content), 0o644); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != content { + t.Fatalf("published content length = %d, want %d", len(got), len(content)) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 || entries[0].Name() != "project.md" { + t.Fatalf("unexpected files after publish: %v", entries) + } +} diff --git a/engine/internal/backup/backup.go b/engine/internal/backup/backup.go index 9145aaf..7ba53bd 100644 --- a/engine/internal/backup/backup.go +++ b/engine/internal/backup/backup.go @@ -6,16 +6,35 @@ package backup import ( "context" "database/sql" + "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" + "strings" "time" + + _ "modernc.org/sqlite" ) const retentionDays = 14 -// RunDailyIfNeeded creates one backup if today's directory does not yet exist. +const RecoveryFormatVersion = 1 + +type ManualRecoveryResult struct { + Path string `json:"path"` + FormatVersion int `json:"format_version"` +} + +type recoveryManifest struct { + Format string `json:"format"` + FormatVersion int `json:"format_version"` + Database string `json:"database"` + CreatedAt int64 `json:"created_at"` +} + +// RunDailyIfNeeded creates one backup if today's completed marker is absent. // Returns the backup file path (or "" if skipped), didRun, and any error. // The function is safe to call repeatedly on the same day. func RunDailyIfNeeded(ctx context.Context, db *sql.DB, home string, now time.Time) (string, bool, error) { @@ -23,10 +42,11 @@ func RunDailyIfNeeded(ctx context.Context, db *sql.DB, home string, now time.Tim root := filepath.Join(home, "backups") dir := filepath.Join(root, day) - if _, err := os.Stat(dir); err == nil { - return "", false, nil // already ran today - } else if !errors.Is(err, os.ErrNotExist) { - return "", false, fmt.Errorf("stat backup dir: %w", err) + if completed, ok, err := completedBackupPath(dir); err != nil { + return "", false, err + } else if ok { + _ = completed + return "", false, nil } if err := os.MkdirAll(dir, 0o700); err != nil { @@ -34,14 +54,177 @@ func RunDailyIfNeeded(ctx context.Context, db *sql.DB, home string, now time.Tim } filename := fmt.Sprintf("library-%s.db", now.Format("150405")) + dst, err := createPublishedBackup(ctx, db, dir, filename) + if err != nil { + return "", false, err + } + return dst, true, nil +} + +// RunPreMigration snapshots the current database before applying targetVersion. +// The same completion marker used by daily backups makes it discoverable by +// the startup recovery screen. +func RunPreMigration(ctx context.Context, db *sql.DB, home string, targetVersion int, now time.Time) (string, error) { + dir := filepath.Join(home, "backups", now.Format("2006-01-02")) + filename := fmt.Sprintf("library-pre-migration-v%04d-%s.db", targetVersion, now.Format("150405")) + return createPublishedBackup(ctx, db, dir, filename) +} + +// RunManualRecovery creates a complete, versioned SQLite library snapshot. +// Unlike the project Markdown export, this archive preserves every table and +// can be restored by the startup recovery screen. +func RunManualRecovery(ctx context.Context, db *sql.DB, home string, now time.Time) (ManualRecoveryResult, error) { + dir := filepath.Join(home, "backups", "recovery-"+now.Format("20060102-150405.000")) + filename := "library.linetta" + path, err := createPublishedBackup(ctx, db, dir, filename) + if err != nil { + return ManualRecoveryResult{}, err + } + manifest, err := json.Marshal(recoveryManifest{ + Format: "linetta-library-backup", + FormatVersion: RecoveryFormatVersion, + Database: filename, + CreatedAt: now.UnixMilli(), + }) + if err != nil { + _ = os.RemoveAll(dir) + return ManualRecoveryResult{}, fmt.Errorf("marshal recovery manifest: %w", err) + } + marker := filepath.Join(dir, ".complete") + markerTmp := marker + ".tmp" + if err := os.WriteFile(markerTmp, append(manifest, '\n'), 0o600); err != nil { + _ = os.RemoveAll(dir) + return ManualRecoveryResult{}, fmt.Errorf("write recovery manifest: %w", err) + } + if err := os.Rename(markerTmp, marker); err != nil { + _ = os.RemoveAll(dir) + return ManualRecoveryResult{}, fmt.Errorf("publish recovery manifest: %w", err) + } + return ManualRecoveryResult{Path: path, FormatVersion: RecoveryFormatVersion}, nil +} + +func createPublishedBackup(ctx context.Context, db *sql.DB, dir, filename string) (string, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("mkdir: %w", err) + } dst := filepath.Join(dir, filename) + tmp := dst + ".tmp" + _ = os.Remove(tmp) // VACUUM INTO accepts a single-quoted string literal; sanitize is not strictly // needed (we control the path) but escape any embedded single quotes. - stmt := "VACUUM INTO '" + escapeSQLString(dst) + "'" + stmt := "VACUUM INTO '" + escapeSQLString(tmp) + "'" if _, err := db.ExecContext(ctx, stmt); err != nil { - return "", false, fmt.Errorf("VACUUM INTO: %w", err) + _ = os.Remove(tmp) + return "", fmt.Errorf("VACUUM INTO: %w", err) } - return dst, true, nil + info, err := os.Stat(tmp) + if err != nil || info.Size() == 0 { + _ = os.Remove(tmp) + if err != nil { + return "", fmt.Errorf("stat backup temp file: %w", err) + } + return "", errors.New("backup temp file is empty") + } + if err := verifyBackup(ctx, tmp); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("verify backup: %w", err) + } + file, err := os.OpenFile(tmp, os.O_RDWR, 0) + if err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("open backup for sync: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return "", fmt.Errorf("sync backup: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("close backup after sync: %w", err) + } + // A prior attempt may have published the database but failed before the + // completion marker. Replace that incomplete artifact on retry. + _ = os.Remove(dst) + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("publish backup: %w", err) + } + marker := filepath.Join(dir, ".complete") + markerTmp := marker + ".tmp" + if err := os.WriteFile(markerTmp, []byte(filename+"\n"), 0o600); err != nil { + return "", fmt.Errorf("write backup marker: %w", err) + } + if err := os.Rename(markerTmp, marker); err != nil { + _ = os.Remove(markerTmp) + return "", fmt.Errorf("publish backup marker: %w", err) + } + return dst, nil +} + +func verifyBackup(ctx context.Context, path string) error { + slashPath := filepath.ToSlash(path) + if volume := filepath.VolumeName(path); volume != "" && !strings.HasPrefix(slashPath, "/") { + slashPath = "/" + slashPath + } + dsn := (&url.URL{Scheme: "file", Path: slashPath, RawQuery: "mode=ro"}).String() + checkDB, err := sql.Open("sqlite", dsn) + if err != nil { + return fmt.Errorf("open snapshot: %w", err) + } + defer checkDB.Close() + checkDB.SetMaxOpenConns(1) + + rows, err := checkDB.QueryContext(ctx, `PRAGMA quick_check`) + if err != nil { + return fmt.Errorf("quick_check: %w", err) + } + defer rows.Close() + found := false + for rows.Next() { + found = true + var result string + if err := rows.Scan(&result); err != nil { + return fmt.Errorf("scan quick_check: %w", err) + } + if result != "ok" { + return fmt.Errorf("quick_check: %s", result) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("quick_check rows: %w", err) + } + if !found { + return errors.New("quick_check returned no result") + } + return nil +} + +func completedBackupPath(dir string) (string, bool, error) { + marker := filepath.Join(dir, ".complete") + raw, err := os.ReadFile(marker) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("read backup marker: %w", err) + } + filename := strings.TrimSpace(string(raw)) + if filename == "" || filepath.Base(filename) != filename || filepath.Ext(filename) != ".db" { + return "", false, nil + } + path := filepath.Join(dir, filename) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("stat completed backup: %w", err) + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return "", false, nil + } + return path, true, nil } // Prune deletes backups/YYYY-MM-DD directories whose date is older than 14 days. diff --git a/engine/internal/backup/backup_test.go b/engine/internal/backup/backup_test.go index d0faab6..974cd69 100644 --- a/engine/internal/backup/backup_test.go +++ b/engine/internal/backup/backup_test.go @@ -1,12 +1,15 @@ -package backup +package backup_test import ( "context" + "database/sql" + "encoding/json" "os" "path/filepath" "testing" "time" + "github.com/devlikebear/linetta/engine/internal/backup" "github.com/devlikebear/linetta/engine/internal/project" "github.com/devlikebear/linetta/engine/internal/store" ) @@ -32,7 +35,7 @@ func openSeededStore(t *testing.T) (*store.Store, string) { func TestRunDailyIfNeeded_createsBackupFileFirstTime(t *testing.T) { s, home := openSeededStore(t) now := time.Date(2026, 5, 26, 9, 15, 30, 0, time.UTC) - path, did, err := RunDailyIfNeeded(context.Background(), s.DB(), home, now) + path, did, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, now) if err != nil { t.Fatalf("RunDaily: %v", err) } @@ -57,12 +60,12 @@ func TestRunDailyIfNeeded_createsBackupFileFirstTime(t *testing.T) { func TestRunDailyIfNeeded_skipsWhenTodayDirExists(t *testing.T) { s, home := openSeededStore(t) now := time.Date(2026, 5, 26, 9, 0, 0, 0, time.UTC) - if _, _, err := RunDailyIfNeeded(context.Background(), s.DB(), home, now); err != nil { + if _, _, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, now); err != nil { t.Fatalf("first: %v", err) } // Same day, later time. later := time.Date(2026, 5, 26, 18, 0, 0, 0, time.UTC) - _, did, err := RunDailyIfNeeded(context.Background(), s.DB(), home, later) + _, did, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, later) if err != nil { t.Fatalf("second: %v", err) } @@ -71,6 +74,100 @@ func TestRunDailyIfNeeded_skipsWhenTodayDirExists(t *testing.T) { } } +func TestRunDailyIfNeeded_retriesSameDayAfterFailedAttempt(t *testing.T) { + s, home := openSeededStore(t) + now := time.Date(2026, 5, 26, 9, 0, 0, 0, time.UTC) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, did, err := backup.RunDailyIfNeeded(cancelled, s.DB(), home, now); err == nil || did { + t.Fatalf("cancelled backup = did %v, err %v; want failure", did, err) + } + + path, did, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, now.Add(time.Hour)) + if err != nil { + t.Fatalf("retry: %v", err) + } + if !did || path == "" { + t.Fatalf("retry = path %q, did %v; want completed backup", path, did) + } +} + +func TestRunDailyIfNeeded_retriesWhenCompletedFileIsMissing(t *testing.T) { + s, home := openSeededStore(t) + now := time.Date(2026, 5, 26, 9, 0, 0, 0, time.UTC) + path, _, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, now) + if err != nil { + t.Fatalf("first: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove completed file: %v", err) + } + + retryPath, did, err := backup.RunDailyIfNeeded(context.Background(), s.DB(), home, now.Add(time.Hour)) + if err != nil { + t.Fatalf("retry: %v", err) + } + if !did || retryPath == path { + t.Fatalf("retry = %q, did %v; want a new completed backup", retryPath, did) + } +} + +func TestRunPreMigration_createsDiscoverableCompletedBackup(t *testing.T) { + s, home := openSeededStore(t) + now := time.Date(2026, 5, 26, 8, 30, 0, 0, time.UTC) + + path, err := backup.RunPreMigration(context.Background(), s.DB(), home, 15, now) + if err != nil { + t.Fatalf("RunPreMigration: %v", err) + } + if filepath.Base(path) != "library-pre-migration-v0015-083000.db" { + t.Fatalf("backup path = %q", path) + } + marker, err := os.ReadFile(filepath.Join(filepath.Dir(path), ".complete")) + if err != nil { + t.Fatalf("read marker: %v", err) + } + if string(marker) != filepath.Base(path)+"\n" { + t.Fatalf("marker = %q", marker) + } +} + +func TestRunManualRecovery_createsVersionedFullLibraryArchive(t *testing.T) { + s, home := openSeededStore(t) + now := time.Date(2026, 7, 12, 23, 50, 0, 0, time.UTC) + + result, err := backup.RunManualRecovery(context.Background(), s.DB(), home, now) + if err != nil { + t.Fatalf("RunManualRecovery: %v", err) + } + if result.FormatVersion != 1 || filepath.Ext(result.Path) != ".linetta" { + t.Fatalf("result = %+v", result) + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(result.Path), ".complete")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var manifest map[string]any + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("manifest JSON: %v", err) + } + if manifest["format"] != "linetta-library-backup" || manifest["database"] != filepath.Base(result.Path) { + t.Fatalf("manifest = %#v", manifest) + } + restored, err := sql.Open("sqlite", "file:"+result.Path+"?mode=ro") + if err != nil { + t.Fatalf("open archive read-only: %v", err) + } + defer restored.Close() + var projectCount int + if err := restored.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM projects`).Scan(&projectCount); err != nil { + t.Fatalf("query archive: %v", err) + } + if projectCount != 1 { + t.Fatalf("project count in archive = %d, want 1", projectCount) + } +} + func TestPrune_removesDirsOlderThan14Days(t *testing.T) { _, home := openSeededStore(t) root := filepath.Join(home, "backups") @@ -80,7 +177,7 @@ func TestPrune_removesDirsOlderThan14Days(t *testing.T) { } } now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) - if err := Prune(home, now); err != nil { + if err := backup.Prune(home, now); err != nil { t.Fatalf("Prune: %v", err) } if _, err := os.Stat(filepath.Join(root, "2026-05-26")); err != nil { @@ -108,7 +205,7 @@ func TestStart_runsImmediatelyAndCallsRetention(t *testing.T) { } return nil } - onTick := func(result TickResult) { + onTick := func(result backup.TickResult) { if !result.OK() { t.Errorf("unexpected tick error: %+v", result) } @@ -120,10 +217,13 @@ func TestStart_runsImmediatelyAndCallsRetention(t *testing.T) { default: } } - sleepFn := func(d time.Duration) { time.Sleep(10 * time.Millisecond) } - stop := Start(context.Background(), s.DB(), home, retention, + waitFn := func(context.Context, time.Duration) bool { + time.Sleep(10 * time.Millisecond) + return true + } + stop := backup.Start(context.Background(), s.DB(), home, retention, func() time.Time { return time.Date(2026, 5, 26, 23, 59, 59, 0, time.Local) }, - sleepFn, + waitFn, onTick) defer stop() @@ -150,11 +250,14 @@ func TestStartReportsBackupErrorsToOnTick(t *testing.T) { if err := s.Close(); err != nil { t.Fatalf("Close: %v", err) } - calls := make(chan TickResult, 1) - stop := Start(context.Background(), s.DB(), home, nil, + calls := make(chan backup.TickResult, 1) + stop := backup.Start(context.Background(), s.DB(), home, nil, func() time.Time { return time.Date(2026, 5, 26, 9, 0, 0, 0, time.Local) }, - func(time.Duration) { time.Sleep(10 * time.Millisecond) }, - func(result TickResult) { calls <- result }) + func(context.Context, time.Duration) bool { + time.Sleep(10 * time.Millisecond) + return true + }, + func(result backup.TickResult) { calls <- result }) defer stop() select { @@ -169,3 +272,32 @@ func TestStartReportsBackupErrorsToOnTick(t *testing.T) { t.Fatal("immediate run did not report tick result") } } + +func TestStartStopCancelsLongWait(t *testing.T) { + s, home := openSeededStore(t) + waiting := make(chan struct{}) + stop := backup.Start(context.Background(), s.DB(), home, nil, + func() time.Time { return time.Date(2026, 5, 26, 9, 0, 0, 0, time.Local) }, + func(ctx context.Context, _ time.Duration) bool { + close(waiting) + <-ctx.Done() + return false + }, + nil) + + select { + case <-waiting: + case <-time.After(2 * time.Second): + t.Fatal("scheduler did not enter wait") + } + done := make(chan struct{}) + go func() { + stop() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("stop did not cancel scheduler wait within one second") + } +} diff --git a/engine/internal/backup/scheduler.go b/engine/internal/backup/scheduler.go index 0bd20b9..82c662a 100644 --- a/engine/internal/backup/scheduler.go +++ b/engine/internal/backup/scheduler.go @@ -12,6 +12,11 @@ import ( // out node_snapshots, etc. type RetentionFn func(ctx context.Context) error +// WaitFn waits for the next run and returns false when the context is +// cancelled. It is injectable so scheduler timing remains deterministic in +// tests without making production shutdown depend on time.Sleep. +type WaitFn func(ctx context.Context, duration time.Duration) bool + type TickResult struct { StartedAt int64 `json:"started_at"` FinishedAt int64 `json:"finished_at"` @@ -42,22 +47,29 @@ func (r TickResult) Error() string { // The returned stop func cancels the loop and waits for the current iteration // to finish. // -// nowFn / sleepFn / onTick are injection points used by tests; production wires -// them to time.Now, time.Sleep, and a no-op. +// nowFn / waitFn / onTick are injection points used by tests. func Start( ctx context.Context, db *sql.DB, home string, retention RetentionFn, nowFn func() time.Time, - sleepFn func(time.Duration), + waitFn WaitFn, onTick func(TickResult), ) (stop func()) { ctx, cancel := context.WithCancel(ctx) + if waitFn == nil { + waitFn = Wait + } done := make(chan struct{}) go func() { defer close(done) for { + select { + case <-ctx.Done(): + return + default: + } now := nowFn() result := TickResult{StartedAt: now.UnixMilli()} if path, didRun, err := RunDailyIfNeeded(ctx, db, home, now); err != nil { @@ -83,16 +95,8 @@ func Start( } next := nextLocalMidnight(now) wait := next.Sub(now) + time.Minute - select { - case <-ctx.Done(): + if !waitFn(ctx, wait) { return - default: - } - sleepFn(wait) - select { - case <-ctx.Done(): - return - default: } } }() @@ -102,6 +106,18 @@ func Start( } } +// Wait is the production context-aware timer used between daily runs. +func Wait(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + func nextLocalMidnight(t time.Time) time.Time { t = t.In(time.Local) return time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location()) diff --git a/engine/internal/backup/verify_test.go b/engine/internal/backup/verify_test.go new file mode 100644 index 0000000..63018c8 --- /dev/null +++ b/engine/internal/backup/verify_test.go @@ -0,0 +1,19 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestVerifyBackupRejectsInvalidSQLite(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.db") + if err := os.WriteFile(path, []byte("not a sqlite database"), 0o600); err != nil { + t.Fatalf("write invalid database: %v", err) + } + + if err := verifyBackup(context.Background(), path); err == nil { + t.Fatal("verifyBackup accepted an invalid SQLite database") + } +} diff --git a/engine/internal/companion/tools.go b/engine/internal/companion/tools.go index 7ab966d..bd1b774 100644 --- a/engine/internal/companion/tools.go +++ b/engine/internal/companion/tools.go @@ -14,6 +14,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/relationship" "github.com/devlikebear/linetta/engine/internal/snapshot" "github.com/devlikebear/linetta/engine/internal/thread" @@ -375,7 +376,7 @@ func (s *Service) applyOneOp( return err } if strings.TrimSpace(op.Summary) != "" { - if err := s.threads.Update(ctx, thread.UpdateInput{ID: th.ID, Summary: op.Summary}); err != nil { + if err := s.threads.Update(ctx, thread.UpdateInput{ID: th.ID, Summary: ptrutil.To(op.Summary)}); err != nil { return err } } @@ -385,13 +386,15 @@ func (s *Service) applyOneOp( } return nil case "update_thread": - in := thread.UpdateInput{ID: op.ThreadID, Name: op.Name, Color: op.Color, Summary: op.Summary} - if strings.TrimSpace(op.Summary) == "" { - cur, err := s.threads.Get(ctx, op.ThreadID) - if err != nil { - return err - } - in.Summary = cur.Summary + in := thread.UpdateInput{ID: op.ThreadID} + if strings.TrimSpace(op.Name) != "" { + in.Name = ptrutil.To(op.Name) + } + if strings.TrimSpace(op.Color) != "" { + in.Color = ptrutil.To(op.Color) + } + if strings.TrimSpace(op.Summary) != "" { + in.Summary = ptrutil.To(op.Summary) } return s.threads.Update(ctx, in) case "add_beat": @@ -457,14 +460,11 @@ func (s *Service) applyOneOp( } attrs := cleanEntityAttributes(op.Attributes) if strings.TrimSpace(op.Summary) != "" || len(attrs) > 0 { - if err := s.entities.Update(ctx, now(), entity.UpdateInput{ - ID: ent.ID, - Kind: ent.Kind, - Name: ent.Name, - Role: ent.Role, - Summary: op.Summary, - Attributes: optionalEntityAttributes(attrs), - }); err != nil { + in := entity.UpdateInput{ID: ent.ID, Attributes: optionalEntityAttributes(attrs)} + if strings.TrimSpace(op.Summary) != "" { + in.Summary = ptrutil.To(op.Summary) + } + if err := s.entities.Update(ctx, now(), in); err != nil { return err } } @@ -482,18 +482,18 @@ func (s *Service) applyOneOp( if err != nil { return err } - in := entity.UpdateInput{ID: entityID, Kind: cur.Kind, Name: cur.Name, Role: cur.Role, Summary: cur.Summary} + in := entity.UpdateInput{ID: entityID} if strings.TrimSpace(op.Kind) != "" { - in.Kind = op.Kind + in.Kind = ptrutil.To(op.Kind) } if strings.TrimSpace(op.Name) != "" { - in.Name = op.Name + in.Name = ptrutil.To(op.Name) } if strings.TrimSpace(op.Role) != "" { - in.Role = op.Role + in.Role = ptrutil.To(op.Role) } if strings.TrimSpace(op.Summary) != "" { - in.Summary = op.Summary + in.Summary = ptrutil.To(op.Summary) } if attrs := cleanEntityAttributes(op.Attributes); len(attrs) > 0 { merged := mergeEntityAttributes(cur.Attributes, attrs) @@ -748,7 +748,10 @@ func (s *Service) applyCreateOutlineNode( if err := s.nodes.Rename(ctx, n.ID, n.Label, op.Title, now()); err != nil { return err } - n, _ = s.nodes.Get(ctx, n.ID) + n, err = s.nodes.Get(ctx, n.ID) + if err != nil { + return err + } } if op.Ref != "" { nodeRefs[op.Ref] = n.ID diff --git a/engine/internal/contextualedit/contextualedit.go b/engine/internal/contextualedit/contextualedit.go index ba7fa68..15bf360 100644 --- a/engine/internal/contextualedit/contextualedit.go +++ b/engine/internal/contextualedit/contextualedit.go @@ -11,6 +11,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/fact" "github.com/devlikebear/linetta/engine/internal/manuscriptedit" "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/relationship" ) @@ -410,10 +411,10 @@ func (s *Service) applyMetadata(ctx context.Context, c MetadataCandidate, projec attrs := e.Attributes return s.entities.Update(ctx, now, entity.UpdateInput{ ID: e.ID, - Kind: e.Kind, - Name: c.After, - Role: e.Role, - Summary: e.Summary, + Kind: ptrutil.To(e.Kind), + Name: ptrutil.To(c.After), + Role: ptrutil.To(e.Role), + Summary: ptrutil.To(e.Summary), Attributes: &attrs, }) } diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 502b653..6e2f9a2 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -63,11 +63,12 @@ type providerSource struct{ store *settings.Store } func (p providerSource) Resolve() ai.ResolvedProvider { r := p.store.Resolve() return ai.ResolvedProvider{ - Provider: r.Provider, - Model: r.Model, - APIKey: r.APIKey, - BaseURL: r.BaseURL, - CliPath: r.CliPath, + Provider: r.Provider, + Model: r.Model, + APIKey: r.APIKey, + BaseURL: r.BaseURL, + CliPath: r.CliPath, + DataSharingConsent: p.store.HasAIDataSharingConsent(), } } @@ -139,6 +140,12 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error }) nodes.SetWritingStatsRecorder(writingStats) nodes.SetManuscriptIndexer(manuscriptIndexer) + if err := manuscriptIndexer.RebuildAll(ctx); err != nil { + fmt.Fprintf(os.Stderr, "manuscript index rebuild: %v\n", err) + } + if err := mentions.RebuildAll(ctx); err != nil { + fmt.Fprintf(os.Stderr, "mention rebuild: %v\n", err) + } settingsStore, err := settings.NewForHome(home) if err != nil { @@ -189,7 +196,7 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error return nil } stopBackup := backup.Start(ctx, st.DB(), home, retentionFn, - time.Now, time.Sleep, func(result backup.TickResult) { + time.Now, backup.Wait, func(result backup.TickResult) { _ = ops.Record(ctx, opsstatus.JobBackup, result.StartedAt, result.FinishedAt, result.OK(), result.Error(), result) }) @@ -219,6 +226,7 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error s.Handle("diagnostics.get", handlers.DiagnosticsGet(st, ops, home, DefaultVersion, caps)) s.Handle("ops_status.get", handlers.GetOpsStatus(ops)) s.Handle("ops_status.clear_error", handlers.ClearOpsStatusError(ops)) + s.Handle("backup.create_recovery", handlers.CreateRecoveryBackup(st, home, clock)) s.Handle("search.query", handlers.Search(searchRepo)) s.Handle("manuscript.search", handlers.SearchManuscript(manuscriptSearcher)) s.Handle("manuscript.replace_preview", handlers.ReplacePreview(manuscriptEditor)) diff --git a/engine/internal/entity/entity.go b/engine/internal/entity/entity.go index f6c2da9..540a81d 100644 --- a/engine/internal/entity/entity.go +++ b/engine/internal/entity/entity.go @@ -66,14 +66,14 @@ type NewInput struct { Role string `json:"role"` } -// UpdateInput is what `entities.update` accepts. Fields with their zero value -// are left unchanged. Use a nil map to leave attributes unchanged; use an empty -// map to clear them. +// UpdateInput is what `entities.update` accepts. Nil pointers leave fields +// unchanged; pointers to empty strings explicitly clear optional text fields. +// Use a nil map to leave attributes unchanged; use an empty map to clear them. type UpdateInput struct { ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - Role string `json:"role"` - Summary string `json:"summary"` + Kind *string `json:"kind,omitempty"` + Name *string `json:"name,omitempty"` + Role *string `json:"role,omitempty"` + Summary *string `json:"summary,omitempty"` Attributes *map[string]string `json:"attributes,omitempty"` } diff --git a/engine/internal/entity/repo.go b/engine/internal/entity/repo.go index 72a9559..31a67e6 100644 --- a/engine/internal/entity/repo.go +++ b/engine/internal/entity/repo.go @@ -54,8 +54,8 @@ func (r *Repo) Get(ctx context.Context, id string) (Entity, error) { return e, err } -// Update applies a partial input. Empty strings leave fields alone; an Attributes -// pointer to a (possibly empty) map overwrites the JSON column. +// Update applies a partial input. Non-nil pointers overwrite fields, including +// clearing optional strings with an explicit empty value. func (r *Repo) Update(ctx context.Context, now int64, in UpdateInput) error { if in.ID == "" { return fmt.Errorf("update entity: id required") @@ -76,14 +76,18 @@ func (r *Repo) Update(ctx context.Context, now int64, in UpdateInput) error { return err } - if in.Kind != "" { - cur.Kind = in.Kind + if in.Kind != nil { + cur.Kind = *in.Kind } - if in.Name != "" { - cur.Name = in.Name + if in.Name != nil { + cur.Name = *in.Name + } + if in.Role != nil { + cur.Role = *in.Role + } + if in.Summary != nil { + cur.Summary = *in.Summary } - cur.Role = in.Role - cur.Summary = in.Summary if in.Attributes != nil { cur.Attributes = *in.Attributes } diff --git a/engine/internal/entity/repo_test.go b/engine/internal/entity/repo_test.go index c9beacb..9e7c72c 100644 --- a/engine/internal/entity/repo_test.go +++ b/engine/internal/entity/repo_test.go @@ -2,10 +2,12 @@ package entity import ( "context" + "encoding/json" "path/filepath" "testing" "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/store" ) @@ -88,7 +90,7 @@ func TestRepo_Update_partial(t *testing.T) { ctx := context.Background() e, _ := r.Create(ctx, 100, NewInput{ProjectID: p.ID, Kind: KindCharacter, Name: "해진"}) attrs := map[string]string{"나이": "32", "직업": "사진작가"} - if err := r.Update(ctx, 200, UpdateInput{ID: e.ID, Role: "POV", Summary: "사진을 찍는 사람", Attributes: &attrs}); err != nil { + if err := r.Update(ctx, 200, UpdateInput{ID: e.ID, Role: ptrutil.To("POV"), Summary: ptrutil.To("사진을 찍는 사람"), Attributes: &attrs}); err != nil { t.Fatalf("Update: %v", err) } got, _ := r.Get(ctx, e.ID) @@ -100,6 +102,41 @@ func TestRepo_Update_partial(t *testing.T) { } } +func TestRepo_Update_JSONPatchDistinguishesOmittedAndExplicitEmpty(t *testing.T) { + s, p := openStoreAndProject(t) + r := NewRepo(s) + ctx := context.Background() + e, _ := r.Create(ctx, 100, NewInput{ProjectID: p.ID, Kind: KindCharacter, Name: "해진", Role: "POV"}) + seedSummary := "사진을 찍는 사람" + if err := r.Update(ctx, 150, UpdateInput{ID: e.ID, Summary: &seedSummary}); err != nil { + t.Fatalf("seed summary: %v", err) + } + + var omitted UpdateInput + if err := json.Unmarshal([]byte(`{"id":"`+e.ID+`","name":"새 이름"}`), &omitted); err != nil { + t.Fatalf("unmarshal omitted patch: %v", err) + } + if err := r.Update(ctx, 200, omitted); err != nil { + t.Fatalf("update omitted patch: %v", err) + } + got, _ := r.Get(ctx, e.ID) + if got.Role != "POV" || got.Summary != seedSummary { + t.Fatalf("omitted fields were overwritten: %+v", got) + } + + var clear UpdateInput + if err := json.Unmarshal([]byte(`{"id":"`+e.ID+`","role":"","summary":""}`), &clear); err != nil { + t.Fatalf("unmarshal clear patch: %v", err) + } + if err := r.Update(ctx, 300, clear); err != nil { + t.Fatalf("clear patch: %v", err) + } + got, _ = r.Get(ctx, e.ID) + if got.Role != "" || got.Summary != "" { + t.Fatalf("explicit empty values did not clear fields: %+v", got) + } +} + func TestRepo_Search_emptyQuery_returnsRecent(t *testing.T) { s, p := openStoreAndProject(t) r := NewRepo(s) diff --git a/engine/internal/export/project.go b/engine/internal/export/project.go index 3336193..e1ea022 100644 --- a/engine/internal/export/project.go +++ b/engine/internal/export/project.go @@ -216,3 +216,9 @@ func slugify(s string) string { } return out } + +// SyncFilename returns the collision-resistant filename used by unattended +// folder and Git sync. Manual exports keep the shorter SuggestedFilename. +func SyncFilename(title, projectID string) string { + return slugify(title) + "--" + slugify(projectID) + ".md" +} diff --git a/engine/internal/export/project_test.go b/engine/internal/export/project_test.go index 2ebc021..1725c81 100644 --- a/engine/internal/export/project_test.go +++ b/engine/internal/export/project_test.go @@ -134,6 +134,17 @@ func TestExportNode_returnsLeafBodyOnly(t *testing.T) { } } +func TestSyncFilename_keepsProjectsWithSameTitleDistinct(t *testing.T) { + first := SyncFilename("Quiet City", "11111111-1111-1111-1111-111111111111") + second := SyncFilename("Quiet City", "22222222-2222-2222-2222-222222222222") + if first == second { + t.Fatalf("sync filenames collided: %q", first) + } + if first != "quiet-city--11111111-1111-1111-1111-111111111111.md" { + t.Fatalf("first filename = %q", first) + } +} + func min(a, b int) int { if a < b { return a diff --git a/engine/internal/export/sync_manifest.go b/engine/internal/export/sync_manifest.go new file mode 100644 index 0000000..2b0a63c --- /dev/null +++ b/engine/internal/export/sync_manifest.go @@ -0,0 +1,42 @@ +package export + +import ( + "encoding/json" + "time" + + "github.com/devlikebear/linetta/engine/internal/project" +) + +const SyncManifestFilename = ".linetta-sync.json" + +type syncManifest struct { + Format string `json:"format"` + FormatVersion int `json:"format_version"` + GeneratedAt int64 `json:"generated_at"` + Projects []syncManifestProject `json:"projects"` +} + +type syncManifestProject struct { + ID string `json:"id"` + Title string `json:"title"` + Filename string `json:"filename"` +} + +// BuildSyncManifest records the exact project-to-file mapping for one complete +// sync pass. Consumers can detect missing files without guessing from titles. +func BuildSyncManifest(projects []project.Project, generatedAt time.Time) ([]byte, error) { + manifest := syncManifest{ + Format: "linetta-markdown-sync", + FormatVersion: 1, + GeneratedAt: generatedAt.UnixMilli(), + Projects: make([]syncManifestProject, 0, len(projects)), + } + for _, item := range projects { + manifest.Projects = append(manifest.Projects, syncManifestProject{ + ID: item.ID, + Title: item.Title, + Filename: SyncFilename(item.Title, item.ID), + }) + } + return json.MarshalIndent(manifest, "", " ") +} diff --git a/engine/internal/foldersync/foldersync.go b/engine/internal/foldersync/foldersync.go index d88df03..b135595 100644 --- a/engine/internal/foldersync/foldersync.go +++ b/engine/internal/foldersync/foldersync.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/devlikebear/linetta/engine/internal/atomicfile" "github.com/devlikebear/linetta/engine/internal/entity" "github.com/devlikebear/linetta/engine/internal/export" "github.com/devlikebear/linetta/engine/internal/node" @@ -73,19 +74,38 @@ func (s *Syncer) exportAll(ctx context.Context, destDir string) (int, error) { return 0, err } written := 0 + failures := make([]string, 0) for _, p := range projs { payload, err := export.ExportProject(ctx, s.Projects, s.Nodes, s.Entities, s.Relationships, p.ID) if err != nil { - fmt.Fprintf(os.Stderr, "folder sync: project %s: %v\n", p.ID, err) + msg := fmt.Sprintf("project %s export: %v", p.ID, err) + fmt.Fprintf(os.Stderr, "folder sync: %s\n", msg) + failures = append(failures, msg) continue } - dest := filepath.Join(destDir, payload.SuggestedFilename) - if err := os.WriteFile(dest, []byte(payload.Markdown), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "folder sync: project %s: %v\n", p.ID, err) + dest := filepath.Join(destDir, export.SyncFilename(p.Title, p.ID)) + if err := atomicfile.Write(dest, []byte(payload.Markdown), 0o644); err != nil { + msg := fmt.Sprintf("project %s write: %v", p.ID, err) + fmt.Fprintf(os.Stderr, "folder sync: %s\n", msg) + failures = append(failures, msg) continue } written++ } + if len(failures) > 0 { + return written, fmt.Errorf("%d project(s) failed: %s", len(failures), strings.Join(failures, "; ")) + } + nowFn := s.Now + if nowFn == nil { + nowFn = time.Now + } + manifest, err := export.BuildSyncManifest(projs, nowFn()) + if err != nil { + return written, fmt.Errorf("build sync manifest: %w", err) + } + if err := atomicfile.Write(filepath.Join(destDir, export.SyncManifestFilename), manifest, 0o644); err != nil { + return written, fmt.Errorf("write sync manifest: %w", err) + } return written, nil } diff --git a/engine/internal/foldersync/foldersync_test.go b/engine/internal/foldersync/foldersync_test.go index 77a3fcd..b95035a 100644 --- a/engine/internal/foldersync/foldersync_test.go +++ b/engine/internal/foldersync/foldersync_test.go @@ -4,10 +4,12 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/export" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/opsstatus" "github.com/devlikebear/linetta/engine/internal/project" @@ -65,8 +67,38 @@ func TestRunOnceWritesMarkdown(t *testing.T) { t.Fatalf("FilesWritten = %d, want 1", res.FilesWritten) } entries, _ := os.ReadDir(target) - if len(entries) != 1 { - t.Fatalf("target has %d files, want 1", len(entries)) + if len(entries) != 2 { + t.Fatalf("target has %d files, want Markdown plus manifest", len(entries)) + } + manifest, err := os.ReadFile(filepath.Join(target, export.SyncManifestFilename)) + if err != nil || !strings.Contains(string(manifest), `"format_version": 1`) { + t.Fatalf("sync manifest missing or invalid: %s err=%v", manifest, err) + } +} + +func TestRunOnceReportsProjectWriteFailure(t *testing.T) { + s, st, _ := newFixture(t) + ctx := context.Background() + target := t.TempDir() + projs, err := s.Projects.List(ctx, project.ListFilter{Limit: 10}) + if err != nil || len(projs) != 1 { + t.Fatalf("projects = %v, err = %v", projs, err) + } + blocked := filepath.Join(target, export.SyncFilename(projs[0].Title, projs[0].ID)) + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatalf("block target: %v", err) + } + enabled := true + if _, err := st.Set(ctx, settings.Patch{FolderSyncDir: &target, FolderSyncEnabled: &enabled}); err != nil { + t.Fatalf("Set: %v", err) + } + + res, err := s.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if res.Error == "" || res.FilesWritten != 0 { + t.Fatalf("write failure reported as success: %+v", res) } } @@ -128,8 +160,8 @@ func TestStageWritesToContainer(t *testing.T) { if err != nil { t.Fatalf("Stage: %v", err) } - if len(res.Files) != 1 { - t.Fatalf("Files = %d, want 1", len(res.Files)) + if len(res.Files) != 2 { + t.Fatalf("Files = %d, want Markdown plus manifest", len(res.Files)) } if filepath.Dir(res.StagingDir) != home { t.Fatalf("staging dir %q not under home %q", res.StagingDir, home) @@ -137,4 +169,11 @@ func TestStageWritesToContainer(t *testing.T) { if _, err := os.Stat(filepath.Join(res.StagingDir, res.Files[0])); err != nil { t.Fatalf("staged file missing: %v", err) } + foundManifest := false + for _, name := range res.Files { + foundManifest = foundManifest || name == export.SyncManifestFilename + } + if !foundManifest { + t.Fatalf("staged files missing manifest: %v", res.Files) + } } diff --git a/engine/internal/gitsync/gitsync.go b/engine/internal/gitsync/gitsync.go index bd853f5..170aa6a 100644 --- a/engine/internal/gitsync/gitsync.go +++ b/engine/internal/gitsync/gitsync.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/devlikebear/linetta/engine/internal/atomicfile" "github.com/devlikebear/linetta/engine/internal/export" "github.com/devlikebear/linetta/engine/internal/opsstatus" "github.com/devlikebear/linetta/engine/internal/project" @@ -116,20 +117,38 @@ func (s *Syncer) RunOnce(ctx context.Context) (summary ResultSummary, err error) return ResultSummary{}, fmt.Errorf("projects.List: %w", err) } written := 0 + failures := make([]string, 0) for _, p := range projs { payload, err := export.ExportProject(ctx, s.Projects, s.Nodes, s.Entities, s.Relationships, p.ID) if err != nil { - fmt.Fprintf(os.Stderr, "gitsync: export project %s: %v\n", p.ID, err) + msg := fmt.Sprintf("project %s export: %v", p.ID, err) + fmt.Fprintf(os.Stderr, "gitsync: %s\n", msg) + failures = append(failures, msg) continue } - target := filepath.Join(dir, payload.SuggestedFilename) - if err := os.WriteFile(target, []byte(payload.Markdown), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "gitsync: write %s: %v\n", target, err) + target := filepath.Join(dir, export.SyncFilename(p.Title, p.ID)) + if err := atomicfile.Write(target, []byte(payload.Markdown), 0o644); err != nil { + msg := fmt.Sprintf("project %s write: %v", p.ID, err) + fmt.Fprintf(os.Stderr, "gitsync: %s\n", msg) + failures = append(failures, msg) continue } written++ } summary = ResultSummary{FilesWritten: written} + if len(failures) > 0 { + summary.Error = fmt.Sprintf("%d project(s) failed: %s", len(failures), strings.Join(failures, "; ")) + return summary, nil + } + manifest, err := export.BuildSyncManifest(projs, nowFn()) + if err != nil { + summary.Error = "build sync manifest: " + err.Error() + return summary, nil + } + if err := atomicfile.Write(filepath.Join(dir, export.SyncManifestFilename), manifest, 0o644); err != nil { + summary.Error = "write sync manifest: " + err.Error() + return summary, nil + } run := s.Run if run == nil { run = runGitProd diff --git a/engine/internal/gitsync/gitsync_test.go b/engine/internal/gitsync/gitsync_test.go index e09dd3b..091c81d 100644 --- a/engine/internal/gitsync/gitsync_test.go +++ b/engine/internal/gitsync/gitsync_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/export" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/opsstatus" "github.com/devlikebear/linetta/engine/internal/project" @@ -181,6 +182,10 @@ func TestRunOnce_writesFilesAndCommitsAndPushes(t *testing.T) { if !foundMD { t.Errorf("expected a .md file in %s, got entries=%v", repoDir, entries) } + manifest, err := os.ReadFile(filepath.Join(repoDir, export.SyncManifestFilename)) + if err != nil || !strings.Contains(string(manifest), `"format_version": 1`) { + t.Fatalf("sync manifest missing or invalid: %s err=%v", manifest, err) + } // Verify git calls in order. gotNames := runner.cmdNames() wantNames := []string{"add", "status", "commit", "push"} @@ -194,6 +199,33 @@ func TestRunOnce_writesFilesAndCommitsAndPushes(t *testing.T) { } } +func TestRunOnce_stopsBeforeGitWhenProjectWriteFails(t *testing.T) { + s, runner, repoDir, _ := newFixture(t) + ctx := context.Background() + projs, err := s.Projects.List(ctx, project.ListFilter{Limit: 10}) + if err != nil || len(projs) != 1 { + t.Fatalf("projects = %v, err = %v", projs, err) + } + blocked := filepath.Join(repoDir, export.SyncFilename(projs[0].Title, projs[0].ID)) + if err := os.Mkdir(blocked, 0o755); err != nil { + t.Fatalf("block target: %v", err) + } + if _, err := s.Settings.Set(ctx, settings.Patch{GitSyncDir: strPtr(repoDir)}); err != nil { + t.Fatalf("Set: %v", err) + } + + res, err := s.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if res.Error == "" || res.FilesWritten != 0 { + t.Fatalf("write failure reported as success: %+v", res) + } + if calls := runner.cmdNames(); len(calls) != 0 { + t.Fatalf("git ran after partial export failure: %v", calls) + } +} + func TestRunOnce_writesOutlinePresetMetadata(t *testing.T) { s, runner, repoDir, _ := newFixture(t) ctx := context.Background() @@ -215,7 +247,11 @@ func TestRunOnce_writesOutlinePresetMetadata(t *testing.T) { if _, err := s.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } - body, err := os.ReadFile(filepath.Join(repoDir, "quiet-city.md")) + matches, err := filepath.Glob(filepath.Join(repoDir, "quiet-city--*.md")) + if err != nil || len(matches) != 1 { + t.Fatalf("find exported markdown: matches=%v err=%v", matches, err) + } + body, err := os.ReadFile(matches[0]) if err != nil { t.Fatalf("read exported markdown: %v", err) } diff --git a/engine/internal/importmd/builder.go b/engine/internal/importmd/builder.go index 5c4c068..4b46311 100644 --- a/engine/internal/importmd/builder.go +++ b/engine/internal/importmd/builder.go @@ -9,6 +9,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/mdmeta" "github.com/devlikebear/linetta/engine/internal/node" "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/relationship" ) @@ -68,7 +69,7 @@ func BuildProject(ctx context.Context, pr *project.Repo, nr *node.Repo, now int6 for _, root := range outline.Roots { created, err := insertNode(ctx, nr, now, root, "", refID, &res) if err != nil { - return BuildResult{}, err + return res, err } // Chain off the most-recently-inserted root so outline order is preserved // (CreateSibling inserts immediately after refID; without chaining, roots @@ -76,11 +77,11 @@ func BuildProject(ctx context.Context, pr *project.Repo, nr *node.Repo, now int6 refID = created.ID } if err := nr.Delete(ctx, seedID, now); err != nil { - return BuildResult{}, err + return res, err } final, err := pr.Get(ctx, p.ID) if err != nil { - return BuildResult{}, err + return res, err } res.Project = final return res, nil @@ -197,10 +198,10 @@ func RestoreMetadata(ctx context.Context, er *entity.Repo, rr *relationship.Repo if in.Summary != "" || len(attrs) > 0 { if err := er.Update(ctx, now, entity.UpdateInput{ ID: created.ID, - Kind: kind, - Name: name, - Role: stringsTrim(in.Role), - Summary: stringsTrim(in.Summary), + Kind: ptrutil.To(kind), + Name: ptrutil.To(name), + Role: ptrutil.To(stringsTrim(in.Role)), + Summary: ptrutil.To(stringsTrim(in.Summary)), Attributes: &attrs, }); err != nil { return err diff --git a/engine/internal/manuscript/indexer.go b/engine/internal/manuscript/indexer.go index 54b31c4..b2068e8 100644 --- a/engine/internal/manuscript/indexer.go +++ b/engine/internal/manuscript/indexer.go @@ -20,13 +20,79 @@ func (i *Indexer) Upsert(ctx context.Context, projectID, nodeID, contentDoc stri if i == nil || i.db == nil { return nil } - if _, err := i.db.ExecContext(ctx, `DELETE FROM manuscript_fts WHERE node_id = ?`, nodeID); err != nil { + tx, err := i.db.BeginTx(ctx, nil) + if err != nil { return err } - _, err := i.db.ExecContext(ctx, ` + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM manuscript_fts WHERE node_id = ?`, nodeID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` INSERT INTO manuscript_fts (plain, node_id, project_id) -VALUES (?, ?, ?)`, docToPlainText(contentDoc), nodeID, projectID) - return err +VALUES (?, ?, ?)`, docToPlainText(contentDoc), nodeID, projectID); err != nil { + return err + } + return tx.Commit() +} + +// RebuildAll reconciles the entire derived FTS index from committed leaf +// content. It is safe to run at startup and removes orphaned rows left by an +// interrupted or older best-effort indexing path. +func (i *Indexer) RebuildAll(ctx context.Context) error { + if i == nil || i.db == nil { + return nil + } + rows, err := i.db.QueryContext(ctx, ` +SELECT id, project_id, COALESCE(content_doc, '') + FROM nodes + WHERE kind = 'leaf' + ORDER BY project_id, ordinal`) + if err != nil { + return err + } + type entry struct { + nodeID string + projectID string + doc string + } + entries := []entry{} + for rows.Next() { + var item entry + if err := rows.Scan(&item.nodeID, &item.projectID, &item.doc); err != nil { + rows.Close() + return err + } + entries = append(entries, item) + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + + tx, err := i.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM manuscript_fts`); err != nil { + return err + } + stmt, err := tx.PrepareContext(ctx, ` +INSERT INTO manuscript_fts (plain, node_id, project_id) +VALUES (?, ?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, item := range entries { + if _, err := stmt.ExecContext(ctx, docToPlainText(item.doc), item.nodeID, item.projectID); err != nil { + return err + } + } + return tx.Commit() } func (i *Indexer) Delete(ctx context.Context, nodeID string) error { diff --git a/engine/internal/manuscript/manuscript_test.go b/engine/internal/manuscript/manuscript_test.go index 82174d6..4128c57 100644 --- a/engine/internal/manuscript/manuscript_test.go +++ b/engine/internal/manuscript/manuscript_test.go @@ -102,6 +102,33 @@ func TestIndexerRebuildIgnoresContainers(t *testing.T) { } } +func TestIndexerRebuildAllRestoresLeavesAndRemovesOrphans(t *testing.T) { + st, p, nr := manuscriptFixture(t) + ctx := context.Background() + indexer := NewIndexer(st.DB()) + sceneID := *p.LastOpenedNodeID + if err := nr.UpdateContent(ctx, sceneID, tiptapDoc("복구할 은빛 원고"), 2000); err != nil { + t.Fatalf("UpdateContent: %v", err) + } + if _, err := st.DB().ExecContext(ctx, `INSERT INTO manuscript_fts (plain, node_id, project_id) VALUES ('orphan', 'missing', 'missing')`); err != nil { + t.Fatalf("seed orphan: %v", err) + } + + if err := indexer.RebuildAll(ctx); err != nil { + t.Fatalf("RebuildAll: %v", err) + } + var restored, orphan int + if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM manuscript_fts WHERE node_id = ? AND plain LIKE '%은빛%'`, sceneID).Scan(&restored); err != nil { + t.Fatalf("count restored: %v", err) + } + if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM manuscript_fts WHERE node_id = 'missing'`).Scan(&orphan); err != nil { + t.Fatalf("count orphan: %v", err) + } + if restored != 1 || orphan != 0 { + t.Fatalf("restored=%d orphan=%d, want 1/0", restored, orphan) + } +} + func TestBuildTrigramMatch(t *testing.T) { got, ok := buildTrigramMatch("진홍빛 눈동자") if !ok { diff --git a/engine/internal/mention/repo.go b/engine/internal/mention/repo.go index cd409fd..c63e4a3 100644 --- a/engine/internal/mention/repo.go +++ b/engine/internal/mention/repo.go @@ -43,6 +43,45 @@ VALUES (?, ?, ?, ?, ?)`, uuid.NewString(), nodeID, f.EntityID, f.Position, f.Sur return tx.Commit() } +// RebuildAll reconstructs the derived mention table from every persisted leaf +// document. Documents are loaded before writes begin because the store uses a +// single SQLite connection. +func (r *Repo) RebuildAll(ctx context.Context) error { + rows, err := r.s.DB().QueryContext(ctx, ` +SELECT id, content_doc + FROM nodes + WHERE kind = 'leaf' AND content_doc IS NOT NULL`) + if err != nil { + return err + } + type document struct { + nodeID string + doc string + } + documents := make([]document, 0) + for rows.Next() { + var item document + if err := rows.Scan(&item.nodeID, &item.doc); err != nil { + _ = rows.Close() + return err + } + documents = append(documents, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range documents { + if err := r.ResyncForNode(ctx, item.nodeID, Collect([]byte(item.doc))); err != nil { + return err + } + } + return nil +} + // ListForNode returns raw mention rows ordered by position. func (r *Repo) ListForNode(ctx context.Context, nodeID string) ([]Mention, error) { rows, err := r.s.DB().QueryContext(ctx, ` diff --git a/engine/internal/mention/repo_test.go b/engine/internal/mention/repo_test.go index f13a773..003efd6 100644 --- a/engine/internal/mention/repo_test.go +++ b/engine/internal/mention/repo_test.go @@ -15,6 +15,7 @@ type fixture struct { store *store.Store mr *Repo er *entity.Repo + nr *node.Repo pID string nID string } @@ -31,7 +32,7 @@ func newFixture(t *testing.T) fixture { p, _ := pr.Create(context.Background(), 1000, project.NewInput{ Title: "T", Genres: []string{"SF"}, LengthTarget: "short", DefaultPOV: "first", }) - return fixture{store: s, mr: NewRepo(s), er: entity.NewRepo(s), pID: p.ID, nID: *p.LastOpenedNodeID} + return fixture{store: s, mr: NewRepo(s), er: entity.NewRepo(s), nr: node.NewRepo(s), pID: p.ID, nID: *p.LastOpenedNodeID} } func TestResyncForNode_insertsValidMentions_dropsUnknown(t *testing.T) { @@ -70,6 +71,30 @@ func TestResyncForNode_replacesPreviousSet(t *testing.T) { } } +func TestRebuildAll_restoresMentionsFromPersistedDocuments(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + e, err := f.er.Create(ctx, 1, entity.NewInput{ProjectID: f.pID, Name: "해진", Kind: entity.KindCharacter}) + if err != nil { + t.Fatalf("create entity: %v", err) + } + doc := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"` + e.ID + `","label":"해진"}}]}]}` + if err := f.nr.UpdateContent(ctx, f.nID, doc, 2); err != nil { + t.Fatalf("update content: %v", err) + } + if got, err := f.mr.ListForNode(ctx, f.nID); err != nil || len(got) != 0 { + t.Fatalf("mentions should start empty: got=%v err=%v", got, err) + } + + if err := f.mr.RebuildAll(ctx); err != nil { + t.Fatalf("RebuildAll: %v", err) + } + got, err := f.mr.ListForNode(ctx, f.nID) + if err != nil || len(got) != 1 || got[0].EntityID != e.ID { + t.Fatalf("rebuilt mentions = %+v, err=%v", got, err) + } +} + func TestListEntitiesForNode_hydratesEntities(t *testing.T) { f := newFixture(t) ctx := context.Background() diff --git a/engine/internal/node/repo.go b/engine/internal/node/repo.go index 2cb8960..6a27652 100644 --- a/engine/internal/node/repo.go +++ b/engine/internal/node/repo.go @@ -18,6 +18,7 @@ var ( ErrInvalidKind = errors.New("invalid node kind") ErrInvalidStatus = errors.New("invalid node status") ErrContentOnContainer = errors.New("cannot update content for a container node") + ErrContentConflict = errors.New("node content changed since it was loaded") ErrNodeProjectMismatch = errors.New("node does not belong to project") ErrInvalidMove = errors.New("invalid node move") ) @@ -81,6 +82,17 @@ func (r *Repo) Get(ctx context.Context, id string) (Node, error) { // updates `projects.word_count` (= sum of leaf word_counts in that project), // and touches `updated_at` on both rows. func (r *Repo) UpdateContent(ctx context.Context, id string, doc string, now int64) error { + return r.updateContent(ctx, id, doc, nil, now) +} + +// UpdateContentIfVersion applies a content change only while the persisted +// version still matches expectedVersion. It prevents delayed autosaves from +// overwriting a newer restore, companion edit, or save from another window. +func (r *Repo) UpdateContentIfVersion(ctx context.Context, id string, doc string, expectedVersion int, now int64) error { + return r.updateContent(ctx, id, doc, &expectedVersion, now) +} + +func (r *Repo) updateContent(ctx context.Context, id string, doc string, expectedVersion *int, now int64) error { count := CountChars([]byte(doc)) tx, err := r.s.DB().BeginTx(ctx, nil) @@ -90,11 +102,12 @@ func (r *Repo) UpdateContent(ctx context.Context, id string, doc string, now int defer tx.Rollback() var ( - kind string - projectID string - previousCount int + kind string + projectID string + previousCount int + currentVersion int ) - if err := tx.QueryRowContext(ctx, `SELECT kind, project_id, word_count FROM nodes WHERE id = ?`, id).Scan(&kind, &projectID, &previousCount); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT kind, project_id, word_count, content_version FROM nodes WHERE id = ?`, id).Scan(&kind, &projectID, &previousCount, ¤tVersion); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } @@ -103,17 +116,29 @@ func (r *Repo) UpdateContent(ctx context.Context, id string, doc string, now int if kind != KindLeaf { return ErrContentOnContainer } + if expectedVersion != nil && currentVersion != *expectedVersion { + return ErrContentConflict + } - res, err := tx.ExecContext(ctx, ` + updateSQL := ` UPDATE nodes SET content_doc = ?, word_count = ?, updated_at = ?, content_version = content_version + 1 - WHERE id = ?`, doc, count, now, id) + WHERE id = ?` + args := []any{doc, count, now, id} + if expectedVersion != nil { + updateSQL += ` AND content_version = ?` + args = append(args, *expectedVersion) + } + res, err := tx.ExecContext(ctx, updateSQL, args...) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { + if expectedVersion != nil { + return ErrContentConflict + } return ErrNotFound } @@ -159,7 +184,7 @@ UPDATE projects r.indexUpsertBestEffort(ctx, projectID, id, doc) if r.resync != nil { if err := r.resync(ctx, id, doc); err != nil { - return err + log.Printf("mention resync failed for node %s: %v", id, err) } } return nil diff --git a/engine/internal/node/repo_test.go b/engine/internal/node/repo_test.go index 8cad4e0..3022f8f 100644 --- a/engine/internal/node/repo_test.go +++ b/engine/internal/node/repo_test.go @@ -118,6 +118,23 @@ func TestRepo_UpdateContent_manuscriptIndexFailureDoesNotBlockSave(t *testing.T) } } +func TestRepo_UpdateContent_mentionResyncFailureDoesNotReportCommittedSaveAsFailed(t *testing.T) { + s, p := newStoreAndProject(t) + r := NewRepo(s) + r.SetMentionResyncer(func(context.Context, string, string) error { + return errors.New("mention index unavailable") + }) + doc := textDoc("본문은 먼저 커밋된다") + + if err := r.UpdateContent(context.Background(), *p.LastOpenedNodeID, doc, 9999); err != nil { + t.Fatalf("committed content returned error: %v", err) + } + got, err := r.Get(context.Background(), *p.LastOpenedNodeID) + if err != nil || got.ContentDoc == nil || *got.ContentDoc != doc { + t.Fatalf("content was not committed: node=%+v err=%v", got, err) + } +} + func TestValidStatus_allowsPublished(t *testing.T) { if !ValidStatus(StatusPublished) { t.Fatalf("published status should be valid") @@ -292,6 +309,33 @@ func TestRepo_UpdateContent_rejectsContainerNode(t *testing.T) { } } +func TestRepo_UpdateContentIfVersion_rejectsStaleWriteWithoutChangingContent(t *testing.T) { + s, p := newStoreAndProject(t) + r := NewRepo(s) + ctx := context.Background() + nodeID := *p.LastOpenedNodeID + + first := textDoc("복원된 최신 본문") + if err := r.UpdateContentIfVersion(ctx, nodeID, first, 0, 1000); err != nil { + t.Fatalf("first versioned update: %v", err) + } + stale := textDoc("지연된 자동 저장") + if err := r.UpdateContentIfVersion(ctx, nodeID, stale, 0, 2000); !errors.Is(err, ErrContentConflict) { + t.Fatalf("stale update err = %v, want ErrContentConflict", err) + } + + got, err := r.Get(ctx, nodeID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ContentDoc == nil || *got.ContentDoc != first { + t.Fatalf("stale update changed content: %v", got.ContentDoc) + } + if got.ContentVersion != 1 { + t.Fatalf("content_version = %d, want 1", got.ContentVersion) + } +} + func TestRepo_ListByProject_returnsTreeOrdered(t *testing.T) { s, p := newStoreAndProject(t) r := NewRepo(s) diff --git a/engine/internal/ptrutil/ptrutil.go b/engine/internal/ptrutil/ptrutil.go new file mode 100644 index 0000000..7b647a1 --- /dev/null +++ b/engine/internal/ptrutil/ptrutil.go @@ -0,0 +1,5 @@ +// Package ptrutil provides explicit pointers for JSON PATCH-style domain inputs. +package ptrutil + +// To returns a pointer to value. +func To[T any](value T) *T { return &value } diff --git a/engine/internal/rpc/handlers/backup.go b/engine/internal/rpc/handlers/backup.go new file mode 100644 index 0000000..2df4161 --- /dev/null +++ b/engine/internal/rpc/handlers/backup.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "context" + "encoding/json" + "time" + + "github.com/devlikebear/linetta/engine/internal/backup" + "github.com/devlikebear/linetta/engine/internal/rpc" + "github.com/devlikebear/linetta/engine/internal/store" +) + +// CreateRecoveryBackup creates a versioned full-library snapshot that the +// native startup recovery screen can restore. +func CreateRecoveryBackup(st *store.Store, home string, now Clock) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + result, err := backup.RunManualRecovery(ctx, st.DB(), home, time.UnixMilli(now())) + if err != nil { + return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } + return json.Marshal(result) + } +} diff --git a/engine/internal/rpc/handlers/beats.go b/engine/internal/rpc/handlers/beats.go index ca0b90e..9bea90d 100644 --- a/engine/internal/rpc/handlers/beats.go +++ b/engine/internal/rpc/handlers/beats.go @@ -84,7 +84,10 @@ func UpdateBeat(repo *beat.Repo) rpc.Handler { } return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } - got, _ := repo.Get(ctx, in.ID) + got, err := repo.Get(ctx, in.ID) + if err != nil { + return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } return json.Marshal(got) } } diff --git a/engine/internal/rpc/handlers/imports.go b/engine/internal/rpc/handlers/imports.go index aaf6182..4945fe1 100644 --- a/engine/internal/rpc/handlers/imports.go +++ b/engine/internal/rpc/handlers/imports.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "strings" "github.com/devlikebear/linetta/engine/internal/entity" @@ -42,14 +43,14 @@ func ImportMarkdown(pr *project.Repo, nr *node.Repo, er *entity.Repo, rr *relati ts := now() built, err := importmd.BuildProject(ctx, pr, nr, ts, doc.Outline, fallback) if err != nil { - return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + return nil, importFailure(ctx, pr, built.Project.ID, err) } built.Warnings = append(doc.Warnings, built.Warnings...) if err := restoreProjectOutlinePreset(ctx, pr, ts, doc.Metadata.OutlinePreset, &built); err != nil { - return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + return nil, importFailure(ctx, pr, built.Project.ID, err) } if err := importmd.RestoreMetadata(ctx, er, rr, ts, built.Project.ID, doc.Metadata, &built); err != nil { - return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + return nil, importFailure(ctx, pr, built.Project.ID, err) } out := importMarkdownResult{ ProjectID: built.Project.ID, @@ -66,6 +67,16 @@ func ImportMarkdown(pr *project.Repo, nr *node.Repo, er *entity.Repo, rr *relati } } +func importFailure(ctx context.Context, pr *project.Repo, projectID string, cause error) *rpc.MethodError { + message := cause.Error() + if projectID != "" { + if err := pr.Delete(context.WithoutCancel(ctx), projectID); err != nil { + message = fmt.Sprintf("%s; rollback partial import: %v", message, err) + } + } + return &rpc.MethodError{Code: rpc.CodeInternalError, Message: message} +} + func restoreProjectOutlinePreset(ctx context.Context, pr *project.Repo, now int64, preset string, built *importmd.BuildResult) error { preset = strings.TrimSpace(preset) if preset == "" { diff --git a/engine/internal/rpc/handlers/imports_test.go b/engine/internal/rpc/handlers/imports_test.go index eae12fb..dbdbb1b 100644 --- a/engine/internal/rpc/handlers/imports_test.go +++ b/engine/internal/rpc/handlers/imports_test.go @@ -72,6 +72,34 @@ func TestImportMarkdownHandler_createsProjectFromContent(t *testing.T) { } } +func TestImportMarkdownHandler_removesPartialProjectWhenMetadataRestoreFails(t *testing.T) { + pr, nr, er, rr := setupImportFixtureFull(t) + h := ImportMarkdown(pr, nr, er, rr, func() int64 { return 5000 }) + ctx := context.Background() + md := "---\n" + + "linetta:\n" + + " version: 1\n" + + " entities:\n" + + " - id: first\n" + + " name: 중복 이름\n" + + " - id: second\n" + + " name: 중복 이름\n" + + "---\n\n" + + "# 실패해야 하는 가져오기\n## 1부\n### 씬 1\n본문\n" + params, _ := json.Marshal(map[string]any{"file_name": "broken.md", "content": md}) + + if _, err := h(ctx, params); err == nil { + t.Fatal("expected metadata restore failure") + } + projects, err := pr.List(ctx, project.ListFilter{IncludeArchived: true, Limit: 10}) + if err != nil { + t.Fatalf("list projects: %v", err) + } + if len(projects) != 0 { + t.Fatalf("partial import remained after failure: %+v", projects) + } +} + func TestImportMarkdownHandler_restoresLinettaMetadataWithoutAppendixNodes(t *testing.T) { pr, nr, er, rr := setupImportFixtureFull(t) h := ImportMarkdown(pr, nr, er, rr, func() int64 { return 5000 }) diff --git a/engine/internal/rpc/handlers/nodes.go b/engine/internal/rpc/handlers/nodes.go index 2e3ec82..e303f92 100644 --- a/engine/internal/rpc/handlers/nodes.go +++ b/engine/internal/rpc/handlers/nodes.go @@ -10,8 +10,9 @@ import ( ) type updateContentParams struct { - ID string `json:"id"` - Doc string `json:"doc"` + ID string `json:"id"` + Doc string `json:"doc"` + ExpectedContentVersion *int `json:"expected_content_version"` } // GetNode returns a handler for nodes.get. @@ -42,13 +43,22 @@ func UpdateNodeContent(nodes *node.Repo, now Clock, postUpdate func(nodeID strin return nil, &rpc.MethodError{Code: rpc.CodeInvalidParams, Message: "id and doc required"} } t := now() - if err := nodes.UpdateContent(ctx, p.ID, p.Doc, t); err != nil { + var err error + if p.ExpectedContentVersion != nil { + err = nodes.UpdateContentIfVersion(ctx, p.ID, p.Doc, *p.ExpectedContentVersion, t) + } else { + err = nodes.UpdateContent(ctx, p.ID, p.Doc, t) + } + if err != nil { if errors.Is(err, node.ErrNotFound) { return nil, &rpc.MethodError{Code: rpc.CodeInvalidParams, Message: "node not found"} } if errors.Is(err, node.ErrContentOnContainer) { return nil, &rpc.MethodError{Code: rpc.CodeInvalidParams, Message: err.Error()} } + if errors.Is(err, node.ErrContentConflict) { + return nil, &rpc.MethodError{Code: rpc.CodeContentConflict, Message: err.Error()} + } return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } diff --git a/engine/internal/rpc/handlers/nodes_test.go b/engine/internal/rpc/handlers/nodes_test.go index 21d16ee..83eae5e 100644 --- a/engine/internal/rpc/handlers/nodes_test.go +++ b/engine/internal/rpc/handlers/nodes_test.go @@ -357,6 +357,20 @@ func TestNodeHandlers_returnInvalidParamsForIntegrityErrors(t *testing.T) { } } +func TestUpdateNodeContent_returnsConflictForStaleVersion(t *testing.T) { + f := newNodeFixture(t) + h := UpdateNodeContent(f.nodes, func() int64 { return 10_000 }, nil) + ctx := context.Background() + + if _, err := h(ctx, json.RawMessage(`{"id":"`+f.nID+`","doc":"{}","expected_content_version":0}`)); err != nil { + t.Fatalf("first update: %v", err) + } + _, err := h(ctx, json.RawMessage(`{"id":"`+f.nID+`","doc":"{\"stale\":true}","expected_content_version":0}`)) + if !rpcErrorCode(err, rpc.CodeContentConflict) { + t.Fatalf("stale update err = %v, want content conflict", err) + } +} + func rpcErrorCode(err error, code int) bool { if err == nil { return false diff --git a/engine/internal/rpc/handlers/threads.go b/engine/internal/rpc/handlers/threads.go index 240e1eb..871982a 100644 --- a/engine/internal/rpc/handlers/threads.go +++ b/engine/internal/rpc/handlers/threads.go @@ -102,7 +102,10 @@ func CloseThread(repo *thread.Repo, now Clock) rpc.Handler { } return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } - got, _ := repo.Get(ctx, p.ID) + got, err := repo.Get(ctx, p.ID) + if err != nil { + return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } return json.Marshal(got) } } @@ -120,7 +123,10 @@ func ReopenThread(repo *thread.Repo) rpc.Handler { } return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} } - got, _ := repo.Get(ctx, p.ID) + got, err := repo.Get(ctx, p.ID) + if err != nil { + return nil, &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } return json.Marshal(got) } } diff --git a/engine/internal/rpc/server.go b/engine/internal/rpc/server.go index 2bfcd14..b78036a 100644 --- a/engine/internal/rpc/server.go +++ b/engine/internal/rpc/server.go @@ -52,11 +52,12 @@ func (n *serverNotifier) Notify(method string, params any) error { // Standard JSONRPC error codes (subset). const ( - CodeParseError = -32700 - CodeInvalidRequest = -32600 - CodeMethodNotFound = -32601 - CodeInvalidParams = -32602 - CodeInternalError = -32603 + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 + CodeContentConflict = -32009 ) // MethodError lets a handler return a typed JSONRPC error. diff --git a/engine/internal/settings/settings.go b/engine/internal/settings/settings.go index af6e973..3545a08 100644 --- a/engine/internal/settings/settings.go +++ b/engine/internal/settings/settings.go @@ -27,6 +27,10 @@ const ( ProviderGeminiNative = "gemini-native" ) +// AIDataSharingConsentVersion increments whenever the disclosed third-party +// data-sharing terms materially change and renewed consent is required. +const AIDataSharingConsentVersion = 1 + // DefaultOpenAICodexModel is the ChatGPT-account compatible Codex default. // Leaving the model empty lets tars fall back to gpt-5.3-codex, which is only // supported for API-backed Codex and returns 400 for ChatGPT account auth. @@ -97,48 +101,52 @@ type ProviderSettings struct { // Config is the on-disk JSON. backup_dir is computed at Load time and not // persisted (the field is omitted from JSON marshalling on write). type Config struct { - Language string `json:"language"` - Provider string `json:"provider"` - Providers map[string]ProviderConfig `json:"providers,omitempty"` - TypewriterDefault bool `json:"typewriter_default"` - FocusDefault bool `json:"focus_default"` - Theme string `json:"theme"` - EditorFontSize int `json:"editor_font_size"` - EditorLineHeight float64 `json:"editor_line_height"` - CopyProfile string `json:"copy_profile"` - BackupDir string `json:"backup_dir,omitempty"` - GitSyncDir string `json:"git_sync_dir"` - GitSyncCommitTemplate string `json:"git_sync_commit_template"` - FolderSyncDir string `json:"folder_sync_dir"` - FolderSyncEnabled bool `json:"folder_sync_enabled"` - SafetyChecklistDismissed bool `json:"safety_checklist_dismissed"` - OnboardingTourEnabled bool `json:"onboarding_tour_enabled"` - OnboardingTourSeenVersion string `json:"onboarding_tour_seen_version"` - WebSearchProvider string `json:"web_search_provider"` - WebSearchAPIKey string `json:"web_search_api_key,omitempty"` // write-only in settings.set; redacted from settings.get and disk - WebSearchAPIKeySet bool `json:"web_search_api_key_set,omitempty"` // read-only presence flag for settings.get + Language string `json:"language"` + Provider string `json:"provider"` + Providers map[string]ProviderConfig `json:"providers,omitempty"` + TypewriterDefault bool `json:"typewriter_default"` + FocusDefault bool `json:"focus_default"` + Theme string `json:"theme"` + EditorFontSize int `json:"editor_font_size"` + EditorLineHeight float64 `json:"editor_line_height"` + CopyProfile string `json:"copy_profile"` + BackupDir string `json:"backup_dir,omitempty"` + GitSyncDir string `json:"git_sync_dir"` + GitSyncCommitTemplate string `json:"git_sync_commit_template"` + FolderSyncDir string `json:"folder_sync_dir"` + FolderSyncEnabled bool `json:"folder_sync_enabled"` + SafetyChecklistDismissed bool `json:"safety_checklist_dismissed"` + OnboardingTourEnabled bool `json:"onboarding_tour_enabled"` + OnboardingTourSeenVersion string `json:"onboarding_tour_seen_version"` + AIDataSharingConsentVersion int `json:"ai_data_sharing_consent_version"` + AIDataSharingConsentedAt int64 `json:"ai_data_sharing_consented_at"` + WebSearchProvider string `json:"web_search_provider"` + WebSearchAPIKey string `json:"web_search_api_key,omitempty"` // write-only in settings.set; redacted from settings.get and disk + WebSearchAPIKeySet bool `json:"web_search_api_key_set,omitempty"` // read-only presence flag for settings.get } // Patch holds optional updates. Nil pointers mean "leave the field alone". type Patch struct { - Language *string `json:"language,omitempty"` - Provider *string `json:"provider,omitempty"` - Providers map[string]ProviderConfig `json:"providers,omitempty"` - TypewriterDefault *bool `json:"typewriter_default,omitempty"` - FocusDefault *bool `json:"focus_default,omitempty"` - Theme *string `json:"theme,omitempty"` - EditorFontSize *int `json:"editor_font_size,omitempty"` - EditorLineHeight *float64 `json:"editor_line_height,omitempty"` - CopyProfile *string `json:"copy_profile,omitempty"` - GitSyncDir *string `json:"git_sync_dir,omitempty"` - GitSyncCommitTemplate *string `json:"git_sync_commit_template,omitempty"` - FolderSyncDir *string `json:"folder_sync_dir,omitempty"` - FolderSyncEnabled *bool `json:"folder_sync_enabled,omitempty"` - SafetyChecklistDismissed *bool `json:"safety_checklist_dismissed,omitempty"` - OnboardingTourEnabled *bool `json:"onboarding_tour_enabled,omitempty"` - OnboardingTourSeenVersion *string `json:"onboarding_tour_seen_version,omitempty"` - WebSearchProvider *string `json:"web_search_provider,omitempty"` - WebSearchAPIKey *string `json:"web_search_api_key,omitempty"` + Language *string `json:"language,omitempty"` + Provider *string `json:"provider,omitempty"` + Providers map[string]ProviderConfig `json:"providers,omitempty"` + TypewriterDefault *bool `json:"typewriter_default,omitempty"` + FocusDefault *bool `json:"focus_default,omitempty"` + Theme *string `json:"theme,omitempty"` + EditorFontSize *int `json:"editor_font_size,omitempty"` + EditorLineHeight *float64 `json:"editor_line_height,omitempty"` + CopyProfile *string `json:"copy_profile,omitempty"` + GitSyncDir *string `json:"git_sync_dir,omitempty"` + GitSyncCommitTemplate *string `json:"git_sync_commit_template,omitempty"` + FolderSyncDir *string `json:"folder_sync_dir,omitempty"` + FolderSyncEnabled *bool `json:"folder_sync_enabled,omitempty"` + SafetyChecklistDismissed *bool `json:"safety_checklist_dismissed,omitempty"` + OnboardingTourEnabled *bool `json:"onboarding_tour_enabled,omitempty"` + OnboardingTourSeenVersion *string `json:"onboarding_tour_seen_version,omitempty"` + AIDataSharingConsentVersion *int `json:"ai_data_sharing_consent_version,omitempty"` + AIDataSharingConsentedAt *int64 `json:"ai_data_sharing_consented_at,omitempty"` + WebSearchProvider *string `json:"web_search_provider,omitempty"` + WebSearchAPIKey *string `json:"web_search_api_key,omitempty"` } // Store reads and writes the settings file with internal locking. @@ -255,6 +263,8 @@ func (s *Store) load() error { s.cfg.OnboardingTourEnabled = disk.OnboardingTourEnabled } s.cfg.OnboardingTourSeenVersion = disk.OnboardingTourSeenVersion + s.cfg.AIDataSharingConsentVersion = disk.AIDataSharingConsentVersion + s.cfg.AIDataSharingConsentedAt = disk.AIDataSharingConsentedAt if disk.WebSearchProvider != "" { s.cfg.WebSearchProvider = disk.WebSearchProvider } @@ -315,6 +325,13 @@ func (s *Store) Provider() string { return s.cfg.Provider } +func (s *Store) HasAIDataSharingConsent() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.AIDataSharingConsentVersion == AIDataSharingConsentVersion && + s.cfg.AIDataSharingConsentedAt > 0 +} + // Resolve returns the active provider plus its per-provider config. Consulted on // every AI call so settings changes take effect without an engine restart. func (s *Store) Resolve() ProviderSettings { @@ -369,6 +386,7 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { s.mu.RLock() next := s.cfg s.mu.RUnlock() + previousRecipient := aiDataRecipient(next) if p.Language != nil { if !slices.Contains(validLanguages(), *p.Language) { @@ -448,6 +466,23 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { if p.OnboardingTourSeenVersion != nil { next.OnboardingTourSeenVersion = *p.OnboardingTourSeenVersion } + if p.AIDataSharingConsentVersion != nil { + if *p.AIDataSharingConsentVersion != 0 && *p.AIDataSharingConsentVersion != AIDataSharingConsentVersion { + return Config{}, fmt.Errorf("settings: unsupported ai data sharing consent version %d", *p.AIDataSharingConsentVersion) + } + next.AIDataSharingConsentVersion = *p.AIDataSharingConsentVersion + } + if p.AIDataSharingConsentedAt != nil { + if *p.AIDataSharingConsentedAt < 0 { + return Config{}, fmt.Errorf("settings: ai_data_sharing_consented_at must be non-negative") + } + next.AIDataSharingConsentedAt = *p.AIDataSharingConsentedAt + } + if next.AIDataSharingConsentVersion == 0 { + next.AIDataSharingConsentedAt = 0 + } else if next.AIDataSharingConsentedAt == 0 { + return Config{}, fmt.Errorf("settings: consent timestamp required") + } if p.WebSearchProvider != nil { if !slices.Contains(validWebSearchProviders(), *p.WebSearchProvider) { return Config{}, fmt.Errorf("settings: unknown web_search_provider %q", *p.WebSearchProvider) @@ -466,6 +501,10 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { if next.Language == "" { next.Language = "ko" } + if aiDataRecipient(next) != previousRecipient && p.AIDataSharingConsentVersion == nil { + next.AIDataSharingConsentVersion = 0 + next.AIDataSharingConsentedAt = 0 + } next = normalizeEditorPreferences(next) if err := s.persist(next); err != nil { @@ -479,26 +518,34 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { return s.Get(ctx) } +func aiDataRecipient(c Config) string { + provider := c.Provider + cfg := normalizeProviderConfig(provider, c.Providers[provider]) + return provider + "\x00" + cfg.BaseURL +} + func (s *Store) persist(next Config) error { next = sanitizeConfigForDisk(next) persistable := Config{ - Language: next.Language, - Provider: next.Provider, - Providers: next.Providers, - TypewriterDefault: next.TypewriterDefault, - FocusDefault: next.FocusDefault, - Theme: next.Theme, - EditorFontSize: next.EditorFontSize, - EditorLineHeight: next.EditorLineHeight, - CopyProfile: next.CopyProfile, - GitSyncDir: next.GitSyncDir, - GitSyncCommitTemplate: next.GitSyncCommitTemplate, - FolderSyncDir: next.FolderSyncDir, - FolderSyncEnabled: next.FolderSyncEnabled, - SafetyChecklistDismissed: next.SafetyChecklistDismissed, - OnboardingTourEnabled: next.OnboardingTourEnabled, - OnboardingTourSeenVersion: next.OnboardingTourSeenVersion, - WebSearchProvider: next.WebSearchProvider, + Language: next.Language, + Provider: next.Provider, + Providers: next.Providers, + TypewriterDefault: next.TypewriterDefault, + FocusDefault: next.FocusDefault, + Theme: next.Theme, + EditorFontSize: next.EditorFontSize, + EditorLineHeight: next.EditorLineHeight, + CopyProfile: next.CopyProfile, + GitSyncDir: next.GitSyncDir, + GitSyncCommitTemplate: next.GitSyncCommitTemplate, + FolderSyncDir: next.FolderSyncDir, + FolderSyncEnabled: next.FolderSyncEnabled, + SafetyChecklistDismissed: next.SafetyChecklistDismissed, + OnboardingTourEnabled: next.OnboardingTourEnabled, + OnboardingTourSeenVersion: next.OnboardingTourSeenVersion, + AIDataSharingConsentVersion: next.AIDataSharingConsentVersion, + AIDataSharingConsentedAt: next.AIDataSharingConsentedAt, + WebSearchProvider: next.WebSearchProvider, } body, err := json.MarshalIndent(persistable, "", " ") if err != nil { diff --git a/engine/internal/settings/settings_test.go b/engine/internal/settings/settings_test.go index 36c1b19..2e6a3a9 100644 --- a/engine/internal/settings/settings_test.go +++ b/engine/internal/settings/settings_test.go @@ -288,6 +288,54 @@ func TestSet_safetyChecklistDismissed_persists(t *testing.T) { } } +func TestSet_aiDataSharingConsent_persistsAndCanBeWithdrawn(t *testing.T) { + s := newStoreOnTemp(t) + ctx := context.Background() + version := AIDataSharingConsentVersion + consentedAt := int64(1_720_000_000_000) + if _, err := s.Set(ctx, Patch{ + AIDataSharingConsentVersion: &version, + AIDataSharingConsentedAt: &consentedAt, + }); err != nil { + t.Fatalf("grant consent: %v", err) + } + if !s.HasAIDataSharingConsent() { + t.Fatal("consent should be active") + } + nextProvider := ProviderOpenRouter + if _, err := s.Set(ctx, Patch{Provider: &nextProvider}); err != nil { + t.Fatalf("change provider: %v", err) + } + if s.HasAIDataSharingConsent() { + t.Fatal("changing the data recipient should require renewed consent") + } + if _, err := s.Set(ctx, Patch{ + AIDataSharingConsentVersion: &version, + AIDataSharingConsentedAt: &consentedAt, + }); err != nil { + t.Fatalf("renew consent: %v", err) + } + reloaded, err := NewWithSecretStore(s.secrets) + if err != nil { + t.Fatalf("reload: %v", err) + } + got, _ := reloaded.Get(ctx) + if got.AIDataSharingConsentVersion != version || got.AIDataSharingConsentedAt != consentedAt { + t.Fatalf("consent not persisted: %+v", got) + } + withdrawn := 0 + zeroTime := int64(0) + if _, err := reloaded.Set(ctx, Patch{ + AIDataSharingConsentVersion: &withdrawn, + AIDataSharingConsentedAt: &zeroTime, + }); err != nil { + t.Fatalf("withdraw consent: %v", err) + } + if reloaded.HasAIDataSharingConsent() { + t.Fatal("consent should be withdrawn") + } +} + func TestLoad_legacyFileDefaultsOnboardingTourEnabled(t *testing.T) { dir := t.TempDir() t.Setenv("LINETTA_HOME", dir) diff --git a/engine/internal/store/migrations.go b/engine/internal/store/migrations.go index a1ef12b..15247a8 100644 --- a/engine/internal/store/migrations.go +++ b/engine/internal/store/migrations.go @@ -2,7 +2,9 @@ package store import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "fmt" "io/fs" "path" @@ -38,47 +40,154 @@ func ApplyMigrations(ctx context.Context, db *sql.DB) error { if err != nil { return err } - if _, done := applied[version]; done { - continue - } body, err := fs.ReadFile(MigrationsFS, path.Join("migrations", e.Name())) if err != nil { return fmt.Errorf("read %s: %w", e.Name(), err) } - if err := applyOne(ctx, db, version, string(body)); err != nil { + checksum := migrationChecksum(body) + if recorded, done := applied[version]; done { + if recorded == "" { + if _, err := db.ExecContext(ctx, `UPDATE schema_migrations SET checksum = ? WHERE version = ?`, checksum, version); err != nil { + return fmt.Errorf("backfill checksum for %s: %w", e.Name(), err) + } + continue + } + if recorded != checksum { + return fmt.Errorf("migration %s checksum changed after it was applied", e.Name()) + } + continue + } + if err := applyOne(ctx, db, version, string(body), checksum); err != nil { return fmt.Errorf("apply %s: %w", e.Name(), err) } } return nil } +func migrationPending(ctx context.Context, db *sql.DB) (bool, int, error) { + entries, err := fs.ReadDir(MigrationsFS, "migrations") + if err != nil { + return false, 0, fmt.Errorf("read embedded migrations: %w", err) + } + latest := 0 + versions := make([]int, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + version, err := parseVersion(entry.Name()) + if err != nil { + return false, 0, err + } + versions = append(versions, version) + if version > latest { + latest = version + } + } + var hasMigrationTable int + if err := db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&hasMigrationTable); err != nil { + return false, latest, err + } + if hasMigrationTable == 0 { + var userTables int + if err := db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`).Scan(&userTables); err != nil { + return false, latest, err + } + return userTables > 0, latest, nil + } + applied, err := appliedVersionNumbers(ctx, db) + if err != nil { + return false, latest, err + } + for version := range applied { + if version > latest { + return false, latest, fmt.Errorf("database schema version %d is newer than this app supports (%d)", version, latest) + } + } + for _, version := range versions { + if _, ok := applied[version]; !ok { + return true, latest, nil + } + } + return false, latest, nil +} + +func appliedVersionNumbers(ctx context.Context, db *sql.DB) (map[int]struct{}, error) { + rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]struct{}{} + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + return nil, err + } + out[version] = struct{}{} + } + return out, rows.Err() +} + func ensureMigrationTable(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, - applied_at INTEGER NOT NULL + applied_at INTEGER NOT NULL, + checksum TEXT NOT NULL DEFAULT '' )`) + if err != nil { + return err + } + rows, err := db.QueryContext(ctx, `PRAGMA table_info(schema_migrations)`) + if err != nil { + return err + } + hasChecksum := false + for rows.Next() { + var cid int + var name, columnType string + var notNull, primaryKey int + var defaultValue any + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + rows.Close() + return err + } + if name == "checksum" { + hasChecksum = true + } + } + if err := rows.Close(); err != nil { + return err + } + if !hasChecksum { + _, err = db.ExecContext(ctx, `ALTER TABLE schema_migrations ADD COLUMN checksum TEXT NOT NULL DEFAULT ''`) + } return err } -func appliedVersions(ctx context.Context, db *sql.DB) (map[int]struct{}, error) { - rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`) +func appliedVersions(ctx context.Context, db *sql.DB) (map[int]string, error) { + rows, err := db.QueryContext(ctx, `SELECT version, checksum FROM schema_migrations`) if err != nil { return nil, err } defer rows.Close() - out := map[int]struct{}{} + out := map[int]string{} for rows.Next() { var v int - if err := rows.Scan(&v); err != nil { + var checksum string + if err := rows.Scan(&v, &checksum); err != nil { return nil, err } - out[v] = struct{}{} + out[v] = checksum } return out, rows.Err() } -func applyOne(ctx context.Context, db *sql.DB, version int, body string) error { +func applyOne(ctx context.Context, db *sql.DB, version int, body, checksum string) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -87,13 +196,18 @@ func applyOne(ctx context.Context, db *sql.DB, version int, body string) error { if _, err := tx.ExecContext(ctx, body); err != nil { return err } - if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, - version, time.Now().UnixMilli()); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at, checksum) VALUES(?, ?, ?)`, + version, time.Now().UnixMilli(), checksum); err != nil { return err } return tx.Commit() } +func migrationChecksum(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + // parseVersion accepts filenames like "0001_init.sql" and returns 1. func parseVersion(name string) (int, error) { base := strings.TrimSuffix(name, ".sql") diff --git a/engine/internal/store/migrations_test.go b/engine/internal/store/migrations_test.go index 026b883..c89513f 100644 --- a/engine/internal/store/migrations_test.go +++ b/engine/internal/store/migrations_test.go @@ -32,6 +32,24 @@ func TestApplyMigrations_appliesOnce(t *testing.T) { } } +func TestApplyMigrations_rejectsChangedAppliedMigrationChecksum(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + ctx := context.Background() + if err := ApplyMigrations(ctx, db); err != nil { + t.Fatalf("apply: %v", err) + } + if _, err := db.ExecContext(ctx, `UPDATE schema_migrations SET checksum = 'tampered' WHERE version = (SELECT MIN(version) FROM schema_migrations)`); err != nil { + t.Fatalf("tamper checksum: %v", err) + } + if err := ApplyMigrations(ctx, db); err == nil { + t.Fatal("expected changed migration checksum to be rejected") + } +} + func TestApplyMigrations_createsProjectsTable(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { @@ -216,3 +234,26 @@ SELECT node_id FROM manuscript_fts WHERE manuscript_fts MATCH '진홍빛'`).Scan t.Fatalf("nodeID = %q, want n1", nodeID) } } + +func TestMigrationPending_detectsMissingEmbeddedVersion(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + ctx := context.Background() + if err := ApplyMigrations(ctx, db); err != nil { + t.Fatalf("apply: %v", err) + } + pending, latest, err := migrationPending(ctx, db) + if err != nil || pending { + t.Fatalf("fully migrated = pending %v latest %d err %v", pending, latest, err) + } + if _, err := db.ExecContext(ctx, `DELETE FROM schema_migrations WHERE version = ?`, latest); err != nil { + t.Fatalf("delete latest version: %v", err) + } + pending, gotLatest, err := migrationPending(ctx, db) + if err != nil || !pending || gotLatest != latest { + t.Fatalf("missing migration = pending %v latest %d err %v", pending, gotLatest, err) + } +} diff --git a/engine/internal/store/store.go b/engine/internal/store/store.go index ec32b54..7177bdb 100644 --- a/engine/internal/store/store.go +++ b/engine/internal/store/store.go @@ -4,7 +4,10 @@ import ( "context" "database/sql" "fmt" + "path/filepath" + "time" + "github.com/devlikebear/linetta/engine/internal/backup" _ "modernc.org/sqlite" ) @@ -39,6 +42,16 @@ func Open(ctx context.Context, path string) (*Store, error) { return nil, fmt.Errorf("pragma %q: %w", pragma, err) } } + if pending, targetVersion, err := migrationPending(ctx, db); err != nil { + _ = db.Close() + return nil, fmt.Errorf("inspect migrations: %w", err) + } else if pending { + home := filepath.Dir(path) + if _, err := backup.RunPreMigration(ctx, db, home, targetVersion, time.Now()); err != nil { + _ = db.Close() + return nil, fmt.Errorf("pre-migration backup: %w", err) + } + } if err := ApplyMigrations(ctx, db); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate: %w", err) diff --git a/engine/internal/thread/repo.go b/engine/internal/thread/repo.go index 58a2d80..d7fdf16 100644 --- a/engine/internal/thread/repo.go +++ b/engine/internal/thread/repo.go @@ -72,7 +72,8 @@ func (r *Repo) ListByProject(ctx context.Context, projectID string, includeClose return out, rows.Err() } -// Update applies a partial input. Empty strings leave fields alone. +// Update applies a partial input. Non-nil pointers overwrite fields, including +// clearing the summary with an explicit empty value. func (r *Repo) Update(ctx context.Context, in UpdateInput) error { if in.ID == "" { return fmt.Errorf("update thread: id required") @@ -90,13 +91,15 @@ func (r *Repo) Update(ctx context.Context, in UpdateInput) error { } return err } - if in.Name != "" { - cur.Name = in.Name + if in.Name != nil { + cur.Name = *in.Name } - if in.Color != "" { - cur.Color = in.Color + if in.Color != nil { + cur.Color = *in.Color + } + if in.Summary != nil { + cur.Summary = *in.Summary } - cur.Summary = in.Summary if _, err := tx.ExecContext(ctx, ` UPDATE threads SET name = ?, color = ?, summary = ? WHERE id = ?`, cur.Name, cur.Color, cur.Summary, in.ID); err != nil { diff --git a/engine/internal/thread/repo_test.go b/engine/internal/thread/repo_test.go index de940f9..8ad6f7f 100644 --- a/engine/internal/thread/repo_test.go +++ b/engine/internal/thread/repo_test.go @@ -2,10 +2,12 @@ package thread import ( "context" + "encoding/json" "path/filepath" "testing" "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/ptrutil" "github.com/devlikebear/linetta/engine/internal/store" ) @@ -85,7 +87,7 @@ func TestRepo_Update_partial(t *testing.T) { r := NewRepo(s) ctx := context.Background() th, _ := r.Create(ctx, NewInput{ProjectID: p.ID, Name: "원본"}) - if err := r.Update(ctx, UpdateInput{ID: th.ID, Name: "수정됨", Summary: "요약 한 줄"}); err != nil { + if err := r.Update(ctx, UpdateInput{ID: th.ID, Name: ptrutil.To("수정됨"), Summary: ptrutil.To("요약 한 줄")}); err != nil { t.Fatalf("Update: %v", err) } got, _ := r.Get(ctx, th.ID) @@ -94,6 +96,41 @@ func TestRepo_Update_partial(t *testing.T) { } } +func TestRepo_Update_JSONPatchDistinguishesOmittedAndExplicitEmpty(t *testing.T) { + s, p := openStoreAndProject(t) + r := NewRepo(s) + ctx := context.Background() + th, _ := r.Create(ctx, NewInput{ProjectID: p.ID, Name: "원본", Color: "#123456"}) + seedSummary := "기존 요약" + if err := r.Update(ctx, UpdateInput{ID: th.ID, Summary: &seedSummary}); err != nil { + t.Fatalf("seed summary: %v", err) + } + + var omitted UpdateInput + if err := json.Unmarshal([]byte(`{"id":"`+th.ID+`","name":"수정됨"}`), &omitted); err != nil { + t.Fatalf("unmarshal omitted patch: %v", err) + } + if err := r.Update(ctx, omitted); err != nil { + t.Fatalf("update omitted patch: %v", err) + } + got, _ := r.Get(ctx, th.ID) + if got.Color != "#123456" || got.Summary != seedSummary { + t.Fatalf("omitted fields were overwritten: %+v", got) + } + + var clear UpdateInput + if err := json.Unmarshal([]byte(`{"id":"`+th.ID+`","summary":""}`), &clear); err != nil { + t.Fatalf("unmarshal clear patch: %v", err) + } + if err := r.Update(ctx, clear); err != nil { + t.Fatalf("clear patch: %v", err) + } + got, _ = r.Get(ctx, th.ID) + if got.Summary != "" { + t.Fatalf("explicit empty summary did not clear field: %+v", got) + } +} + func TestRepo_CloseAndReopen(t *testing.T) { s, p := openStoreAndProject(t) r := NewRepo(s) diff --git a/engine/internal/thread/thread.go b/engine/internal/thread/thread.go index 128ed6f..65acabd 100644 --- a/engine/internal/thread/thread.go +++ b/engine/internal/thread/thread.go @@ -19,10 +19,11 @@ type NewInput struct { Color string `json:"color"` } -// UpdateInput holds a partial patch. Empty strings leave fields alone. +// UpdateInput holds a partial patch. Nil leaves a field unchanged; a pointer to +// an empty string explicitly clears optional text. type UpdateInput struct { - ID string `json:"id"` - Name string `json:"name"` - Color string `json:"color"` - Summary string `json:"summary"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Color *string `json:"color,omitempty"` + Summary *string `json:"summary,omitempty"` } From 3acadc4ba192e4d9234111c695f0d5d097cde5c5 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Tue, 14 Jul 2026 10:07:52 +0900 Subject: [PATCH 2/3] chore: record full audit findings and remediation --- .analysis/outputs/SUMMARY.json | 22 + .analysis/outputs/architecture.md | 17 + .analysis/outputs/dependency-graph.json | 14 + .analysis/outputs/full-audit.md | 500 ++++++++++++++++++ .analysis/outputs/glossary.md | 10 + .analysis/outputs/implementation-checklist.md | 29 + .analysis/outputs/issue-candidates.md | 36 ++ .analysis/outputs/module-map.json | 10 + .analysis/outputs/overview.md | 7 + .analysis/outputs/technologies.md | 9 + .gitignore | 6 + 11 files changed, 660 insertions(+) create mode 100644 .analysis/outputs/SUMMARY.json create mode 100644 .analysis/outputs/architecture.md create mode 100644 .analysis/outputs/dependency-graph.json create mode 100644 .analysis/outputs/full-audit.md create mode 100644 .analysis/outputs/glossary.md create mode 100644 .analysis/outputs/implementation-checklist.md create mode 100644 .analysis/outputs/issue-candidates.md create mode 100644 .analysis/outputs/module-map.json create mode 100644 .analysis/outputs/overview.md create mode 100644 .analysis/outputs/technologies.md diff --git a/.analysis/outputs/SUMMARY.json b/.analysis/outputs/SUMMARY.json new file mode 100644 index 0000000..e8129ad --- /dev/null +++ b/.analysis/outputs/SUMMARY.json @@ -0,0 +1,22 @@ +{ + "session_id": "analyze-20260712-130508", + "mode": "analyze", + "scope": ".", + "status": "completed", + "commit": "1020096a14b65c7bd21ca081947f1747978e3b93", + "visited_count": 51, + "frontier_count": 0, + "modules": [], + "outputs": [ + "full-audit.md", + "overview.md", + "architecture.md", + "technologies.md", + "glossary.md", + "module-map.json", + "dependency-graph.json", + "issue-candidates.md", + "implementation-checklist.md" + ], + "checkpoints_count": 2 +} diff --git a/.analysis/outputs/architecture.md b/.analysis/outputs/architecture.md new file mode 100644 index 0000000..6c3343c --- /dev/null +++ b/.analysis/outputs/architecture.md @@ -0,0 +1,17 @@ +# Linetta 아키텍처 + +```text +React UI → TypeScript rpc.ts → Tauri command → Rust FFI → Go C ABI + → engineapp/rpc handlers → domain repositories → SQLite +``` + +주요 경계: + +- UI/RPC: `apps/desktop/src/lib/rpc.ts` +- Tauri: `apps/desktop/src-tauri/src/lib.rs` +- FFI: `apps/desktop/src-tauri/src/ffi.rs`, `engine/cmd/linetta-ffi/ffi.go` +- composition root: `engine/internal/engineapp/engineapp.go` +- persistence: `engine/internal/store`, domain repositories +- recovery/export: `engine/internal/backup`, `export`, `importmd`, `foldersync`, `gitsync` + +상세 그래프와 이슈는 `full-audit.md`를 참조한다. diff --git a/.analysis/outputs/dependency-graph.json b/.analysis/outputs/dependency-graph.json new file mode 100644 index 0000000..cb878a1 --- /dev/null +++ b/.analysis/outputs/dependency-graph.json @@ -0,0 +1,14 @@ +{ + "apps/desktop/src/routes/Workspace.tsx":["apps/desktop/src/lib/rpc.ts","apps/desktop/src/components/editor/Tiptap.tsx","apps/desktop/src/hooks/useDebouncedCallback.ts"], + "apps/desktop/src/lib/rpc.ts":["apps/desktop/src-tauri/src/lib.rs"], + "apps/desktop/src-tauri/src/lib.rs":["apps/desktop/src-tauri/src/ffi.rs","apps/desktop/src-tauri/src/folder_sync.rs"], + "apps/desktop/src-tauri/src/ffi.rs":["engine/cmd/linetta-ffi/ffi.go"], + "engine/cmd/linetta-ffi/ffi.go":["engine/internal/engineapp/engineapp.go"], + "engine/internal/engineapp/engineapp.go":["engine/internal/rpc/server.go","engine/internal/rpc/handlers","engine/internal/store/store.go","engine/internal/backup","engine/internal/companion"], + "engine/internal/rpc/handlers":["engine/internal/project","engine/internal/node","engine/internal/entity","engine/internal/thread","engine/internal/export","engine/internal/importmd"], + "engine/internal/project":["engine/internal/store/store.go"], + "engine/internal/node":["engine/internal/store/store.go","engine/internal/manuscript","engine/internal/mention"], + "engine/internal/backup":["engine/internal/store/store.go"], + "engine/internal/foldersync":["engine/internal/export"], + "engine/internal/gitsync":["engine/internal/export"] +} diff --git a/.analysis/outputs/full-audit.md b/.analysis/outputs/full-audit.md new file mode 100644 index 0000000..2a7a2ae --- /dev/null +++ b/.analysis/outputs/full-audit.md @@ -0,0 +1,500 @@ +# Linetta 코드베이스 전면 감사 + +> 기준 커밋: `1020096a14b65c7bd21ca081947f1747978e3b93` +> 감사일: 2026-07-12 +> 범위: 커밋된 파일만, 제품 코드 수정 없음 +> 판정: **Blocking** — 사용자 데이터 덮어쓰기 경로 1건과 다음 App Store 업데이트 전에 정리해야 할 개인정보 고지 공백이 있다. + +## 0. 검증 요약 + +- 시작/종료 작업 트리: `main...origin/main`, 제품 변경 없음 +- 전체 CI: 통과 +- 프론트: 50개 test file, 281 tests 통과; TypeScript/Vite production build 통과 +- Go: `go test ./...`, `go test -race ./...`, MAS build-tag suite 통과 +- Rust: 기본/MAS feature test 각 3건 통과 +- 배포 메타데이터/Plist: `make validate-distribution`, `plutil -lint` 통과 +- 의존성 감사: + - `govulncheck`: 실제 호출 가능 Go 표준 라이브러리 취약점 5건 + - `pnpm audit --prod`: moderate 1건 + - `cargo audit`: 취약점 2건, unsound/unmaintained 경고 별도 + +기존 테스트 통과는 안전 판정이 아니다. 아래 위험 경로는 현재 suite에 재현 테스트가 없다. + +--- + +# 워크스트림 A — 아키텍처, IPC, SQLite, 로컬 데이터 안전성 + +## A-1. 디렉터리와 모듈 의존성 + +```text +React routes/components + ├─ Tiptap editor state + └─ apps/desktop/src/lib/rpc.ts + │ invoke("engine_call") + ▼ +apps/desktop/src-tauri/src/lib.rs + │ spawn_blocking + ▼ +apps/desktop/src-tauri/src/ffi.rs + │ JSON-RPC envelope + C ABI + ▼ +engine/cmd/linetta-ffi/ffi.go + ▼ +engine/internal/engineapp.App + ├─ rpc.Server → rpc/handlers → domain repos/services + ├─ backup scheduler + ├─ summarizer + └─ companion service + ▼ +engine/internal/store.Store → SQLite library.db +``` + +근거: `apps/desktop/src/App.tsx:35-53`, `apps/desktop/src/lib/rpc.ts:84-106`, `apps/desktop/src-tauri/src/lib.rs:55-64`, `apps/desktop/src-tauri/src/ffi.rs:228-293`, `engine/cmd/linetta-ffi/ffi.go:29-69,125-158`, `engine/internal/engineapp/engineapp.go:82-113,115-211,217-328`. + +역방향 알림은 Go notifier → C callback → Rust `notification_event` → Tauri event → `useEngineEvent`/Companion 순이다. + +## A-2. IPC 전수 대조 + +- TypeScript JSON-RPC method: 110 +- Go build-variant 합집합: 115 +- 프런트에서 호출하지만 Go registry에 없는 method: 0 +- Go 전용: `ping`, `diagnostics.version`, `folder_sync.run`, `folder_sync.stage`, `folder_sync.report` +- 직접 Tauri command 5개도 모두 등록: `engine_ping`, `engine_status`, `open_path`, `set_folder_sync_dir`, `folder_sync_now` + +method 누락은 없지만 오류 구조와 일부 wire type은 드리프트한다. + +## Critical + +### A-C01. 지연 자동 저장이 다른 씬 또는 복원 직후 원고를 덮어쓴다 + +- 위치: `apps/desktop/src/hooks/useDebouncedCallback.ts:9-19`, `apps/desktop/src/routes/Workspace.tsx:686-700,800-815,1735-1744`, `engine/internal/node/repo.go:107-111` +- 문제: timer 인자는 이전 씬의 `doc`인데 실행 함수는 `ref.current`의 최신 `saveNow`다. 씬 A 입력 후 800ms 안에 B로 이동하면 A 문서가 B ID로 저장된다. restore/contextual edit/Companion apply 뒤 pending save도 새 결과를 재덮을 수 있다. +- 근거: debounce에 scene change/unmount cancel이 없고 백엔드 update에 기대 `content_version` 조건도 없다. +- 수정 방향: 먼저 실패 테스트를 추가하고 `{nodeId, doc, expectedContentVersion}`를 예약 시점에 고정한다. debounce에 `cancel/flush`와 cleanup을 추가하고, node 이동·restore·export·Companion apply 전 flush 완료를 기다린다. 백엔드는 낙관적 잠금을 추가한다. +- 예상 작업량: 1~2일 +- 확신도: 매우 높음. fake timer로 결정적 재현 가능. + +## High + +### A-H01. 실패한 일일 백업이 하루 동안 완료로 간주된다 + +- 위치: `engine/internal/backup/backup.go:21-44`, `engine/internal/backup/backup_test.go:57-72` +- 문제/근거: 날짜 디렉터리를 먼저 만들고 directory 존재만으로 skip한다. `VACUUM INTO` 실패나 중단 뒤 빈 directory가 남으면 그날 재시도하지 않는다. +- 수정 방향: 임시 DB → `PRAGMA quick_check` → fsync → atomic rename → success manifest 순으로 바꾸고 실패 artifact를 정리한다. +- 작업량: 0.5~1일 + +### A-H02. backup scheduler가 앱 종료를 최대 하루 막을 수 있다 + +- 위치: `engine/internal/backup/scheduler.go:84-102`, `engine/internal/engineapp/engineapp.go:191-199,341-350`, `engine/cmd/linetta-ffi/ffi.go:61-69`, `apps/desktop/src-tauri/src/ffi.rs:296-299` +- 문제/근거: `time.Sleep`은 context cancel로 깨지지 않는데 stop은 `<-done`을 무기한 기다린다. +- 수정 방향: `time.NewTimer`와 `select`로 cancel-aware 대기, shutdown deadline과 sleep-branch test 추가. +- 작업량: 0.5일 + +### A-H03. 폴더/Git 동기화가 누락·충돌·부분 복사를 성공으로 보고한다 + +- 위치: `engine/internal/foldersync/foldersync.go:66-89,92-123`, `engine/internal/gitsync/gitsync.go:114-132`, `engine/internal/export/project.go:126-129,189-217`, `engine/internal/store/migrations/0001_init.sql:2-14`, `apps/desktop/src-tauri/src/folder_sync.rs:8-16,125-149` +- 문제: project별 오류를 log 후 계속하고, 중복 가능한 title slug만 filename으로 쓴다. direct write/copy라 중단 시 partial file이 남으며 MAS 중간 실패는 이미 복사한 수도 0으로 보고한다. +- 수정 방향: ID 포함 filename과 manifest, expected/actual count 검증, temp+fsync+rename, 하나라도 실패하면 job 실패, partial count/failed filename 보고. +- 작업량: 2~3일 + +### A-H04. UI의 “작품 백업 (.md)”은 무손실 복구 포맷이 아니다 + +- 위치: `apps/desktop/src/routes/Library.tsx:220-228,433-437`, `engine/internal/export/project.go:65-93,132-164`, `engine/internal/export/markdown.go:43-125`, `engine/internal/mdmeta/metadata.go:18-23`, `engine/internal/importmd/tree.go:97-109`, `engine/internal/importmd/builder.go:45-53` +- 문제: 3단계 이상 outline 평탄화, 다수 project/node/domain metadata와 note/fact/thread/beat/snapshot/Companion 기록 손실, unknown Tiptap 구조와 Markdown 특수문자 손실 가능성이 있다. +- 수정 방향: 즉시 “읽기용 Markdown 내보내기”로 명칭 변경. 중기에는 versioned `.linetta` archive/JSON/SQLite 복구 포맷 추가. +- 작업량: 2~4일 + +### A-H05. DB/migration 실패 시 backup이 있어도 앱 내부 복구 진입점이 없다 + +- 위치: `engine/internal/store/store.go:18-46`, `engine/internal/engineapp/engineapp.go:96-111,191-199`, `engine/cmd/linetta-ffi/ffi.go:125-130`, `apps/desktop/src-tauri/src/ffi.rs:255-260`, `apps/desktop/src/components/EngineGate.tsx:36-59` +- 문제: engine 시작 전에 실패하면 오류가 code 1로 축약되고 EngineGate는 retry/diagnostics copy만 제공한다. pre-migration backup도 없다. +- 수정 방향: ABI에 상세 start error, Rust 독립 recovery screen, `quick_check` 기반 backup 선택 복원, 원본 보존, migration 전 snapshot. +- 작업량: 3~5일 + +## Medium + +| ID | 위치 | 문제/근거 | 수정 방향 | 작업량 | 확신도 | +|---|---|---|---|---:|---| +| A-M01 | `engine/internal/store/store.go:23-35`, `engine/internal/backup/backup.go:36-44`, `apps/desktop/src-tauri/src/lib.rs:190-209` | 단일 SQLite connection에서 `VACUUM INTO`가 일반 IPC를 막을 수 있고 일반 `engine_call`에는 timeout이 없다 | DB 크기별 p95/p99 측정 후 writer queue/read/backup 정책 분리 | 2~3일 | 중간; benchmark 필요 | +| A-M02 | `engine/internal/importmd/builder.go:37-87,92-139,168-223`, `engine/internal/rpc/handlers/imports.go:43-53` | project/node/entity/relationship import가 하나의 transaction이 아니어서 중간 실패 시 partial project가 남는다 | 단일 `sql.Tx` 또는 staging project 후 activate | 2~3일 | 높음 | +| A-M03 | `engine/internal/rpc/server.go:62-69,187-198`, `apps/desktop/src-tauri/src/ffi.rs:284-290`, `apps/desktop/src/lib/rpc.ts:104-106` | Go error code/data가 Rust에서 message string으로 소실 | `{code,message,data,method,requestId}` 보존, TS guard | 1~2일 | 매우 높음 | +| A-M04 | `apps/desktop/src/lib/types.ts:272-279,658-663`, `engine/internal/entity/entity.go:69-78`, `engine/internal/entity/repo.go:79-86`, `engine/internal/thread/thread.go:22-28`, `engine/internal/thread/repo.go:93-105` | TS optional patch가 Go value field로 들어가 누락 필드를 empty overwrite할 수 있다 | pointer patch 또는 PUT/PATCH 분리, absent/empty/null contract test | 1일 | 높음 | +| A-M05 | `engine/internal/node/repo.go:156-164,541-556`, `engine/internal/manuscript/indexer.go:19-29`, `engine/internal/manuscript/searcher.go:69-81` | 원문 commit 후 best-effort FTS DELETE/INSERT가 별도 transaction이며 일부 누락은 rebuild되지 않는다 | FTS transaction, content version, reconcile | 1~2일 | 높음 | +| A-M06 | `apps/desktop/src-tauri/src/folder_sync.rs:31-57` | settings path를 먼저 바꾸고 bookmark 생성/직접 write를 나중에 해 실패 시 불일치 | bookmark temp+검증+rename 후 settings, rollback | 1일 | 매우 높음 | +| A-M07 | `engine/internal/node/repo.go:156-165`, `engine/internal/rpc/handlers/nodes.go:45-53`, `apps/desktop/src/routes/Workspace.tsx:803-811` | 원고 commit 뒤 mention resync 실패가 전체 save 실패처럼 보인다 | 원고 저장 성공과 derived warning 분리, retry marker | 1일 | 높음 | + +## Low / 확인 필요 + +| ID | 위치 | 판정 | 문제/근거와 수정 방향 | 작업량 | +|---|---|---|---|---:| +| A-L01 | `apps/desktop/src/lib/types.ts:364-372,560-581,621-626`, `apps/desktop/src-tauri/src/lib.rs:72-81`, `engine/internal/settings/settings.go:97-120`, `engine/internal/rpc/handlers/imports.go:21-28` | Low | Rust Option은 null인데 TS는 optional only, settings/import result 필드 drift. fixture/schema contract test 추가 | 1일 | +| A-L02 | `apps/desktop/src-tauri/src/ffi.rs:182-204` | Low | notification invalid JSON과 emit 실패를 조용히 버림. parse/emit log와 run ID 추가 | 0.5일 | +| A-L03 | `engine/internal/store/migrations.go:15-52,55-78` | Low | migration checksum과 newer-schema guard 없음. checksum 검증과 read-only recovery guard | 1일 | +| A-Q01 | `engine/internal/store/store.go:32-36` | 확인 필요 | WAL `synchronous=NORMAL`과 `FULL`의 Mac 전원 손실/latency 비교 후 정책 결정 | 0.5일 측정 | +| A-Q02 | `engine/internal/store/store.go:31`, `engine/internal/backup/backup.go:40-43` | 확인 필요 | 100MB/500MB/1GB DB backup 중 autosave p50/p95/p99 측정 | 0.5일 측정 | + +--- + +# 워크스트림 B — 리팩터링 로드맵 + +## 발견 + +| ID | 근거 | 독립 PR 방향 | +|---|---|---| +| TIDY-001 | `apps/desktop/src/routes/Workspace.historyPanel.test.ts:1-18`, `Workspace.responsive.test.ts:1-35`, `Workspace.ipadInput.test.ts:1-20`, `Settings.iosReduction.test.ts:1-40` | TSX 원문 문자열 검사를 사용자 관찰 기반 테스트로 전환 | +| DUP-001 | `engine/internal/ai/context.go:558-611`, `summarizer/summarizer.go:299-340`, `snapshot/repo.go:134-181`, `contextualedit/contextualedit.go:563-604`, `manuscriptedit/replace.go:369-405` | 동일 출력 정책 5개만 `internal/tiptapdoc` walker로 통합 | +| DUP-002 | `apps/desktop/src/routes/Settings.tsx:110-124,336-455`, `components/companion/CompanionPanel.tsx:575-587,924-1026` | API/state transition만 `useOpenRouterSetup`으로 추출; OAuth/UI는 호출자 유지 | +| TIDY-002 | `apps/desktop/src/routes/Workspace.tsx:1-310,428-704,800-927,960-1444,1589-2060` | 테스트 seam 후 outline → persistence → render-only 순차 추출. 전면 재작성 금지 | +| TIDY-003 | `apps/desktop/src/App.tsx:65-84`, `Settings.tsx:199-215,355-379`, `CompanionPanel.tsx:912-948`, `NoteMarkerExtension.ts:43-80`, `MentionExtension.ts:101-108`, `Workspace.tsx:281-309,728-754` | `lib/appEvents.ts` typed dispatch/subscribe | +| TIDY-004 | `engine/internal/rpc/handlers/beats.go:75-89`, `threads.go:71-125`, `engine/internal/companion/tools.go:747-752` | update 후 Get error를 `CodeInternalError`로 전파하고 fake seam 테스트 | +| TIDY-005 | `apps/desktop/package.json:7-13,32-45`, `FocusExtension.ts:21-29,84-85`, `MentionExtension.ts:48-62,113-131`, `SearchHighlightExtension.ts:44-58`, `Tiptap.tsx:229-234`, `autoMention.ts:39-75,118-129`, `useDebouncedCallback.ts:5-18`, `useThrottledCallback.ts:5-37` | ESLint/React Hooks CI와 Tiptap/JSON `any` 축소 | +| DUP-003 | `apps/desktop/src/routes/Library.tsx:406-450`, `LibraryAll.tsx:108-147`, `components/ProjectCard.tsx:1-14` | 현재 book UI 공용 카드 추출 | +| DUP-004 | `engine/internal/node/repo.go:281-340,343-393,396-443` | 위치 계산은 유지하고 `insertNodeTx`, `initialContentDoc`만 추출 | +| TIDY-006 | `engine/internal/companion/companion.go:143-227,367-559,705-739` | 기능 수정과 맞닿을 때만 package 내부 파일 분리 | +| TIDY-007 | `relationshipPresets.ts:1-22`, `rpc.ts:84-86`, `ProjectCard.tsx:1-14`, `companion.go:163-225`, `runner.go:181-193` | build graph/계획 확인 후 dead code 제거 | +| TIDY-008 | `engine/internal/node/repo.go:291-297,353-359,488-493,582-598,691-705,776-782,822-828,983-1028` | `errors.Is(err, sql.ErrNoRows)` 기계 통일; DUP-004에 포함 | + +## 독립 머지 PR 순서 + +```text +HOTFIX-A-C01 (새 선행 작업) + └─ B01 행동 테스트 seam ──> B09 Workspace outline ──> B10 persistence 추출 + +B03 typed events ──> B07 OpenRouter controller +B02 Tiptap walker, B04 RPC error, B05 node helper, +B06 lint/types, B08 book card는 독립 실행 가능 +``` + +중재: 원래 B10 persistence 추출을 B01/B09 뒤에 두는 로드맵은 구조 정리에는 맞지만 A-C01을 기다리게 하면 안 된다. 먼저 최소 핫픽스와 회귀 테스트를 머지하고, 이후 동작 보존 리팩터링을 한다. + +### 주말 단위 PR 로드맵 + +| PR | 작업 | 선행 | 리스크 | 예상 | 실패 테스트/회귀 검증 | +|---|---|---|---|---:|---| +| B01 | TSX 원문 검사 → 행동 테스트 | 없음 | 중간 | 0.5~1일 | 새 layout seam import red → render/interaction green; responsive/history/iOS reduction tests + build | +| B02 | 공용 Go Tiptap walker | 없음 | 중간 | 1일 | text/mention/paragraph/heading/blockquote/nested/malformed table test; 관련 5 package + full Go/MAS/mobile | +| B03 | typed app event API | 없음 | 낮음 | 0.5일 | dispatch payload/unsubscribe test; editor/Settings/Companion tests | +| B04 | RPC 재조회 error 전파 | 없음 | 낮음~중간 | 0.5일 | update 성공+Get 실패 fake로 `CodeInternalError`; handler race/full Go | +| B05 | node insert helper + `errors.Is` | 없음 | 중간 | 0.5~1일 | leaf/container content, ordinal, project timestamp table test; node race | +| B06 | ESLint/hooks gate + 타입 정리 | 없음 | 중간 | 1일 | lint config로 현재 violation red → explicit any/hooks 정리; test/build/lint | +| B07 | OpenRouter setup controller | B03 | 중간 | 1~1.5일 | key save/delete, keyInfo, model order, draft-before-test, failure preservation | +| B08 | 공용 project book card | 없음 | 낮음 | 0.5~1일 | title/count/action/navigation component test; Library/LibraryAll tests | +| B09 | Workspace outline coordinator | B01, B03, HOTFIX | 중간 | 1~1.5일 | create/move/delete fallback/repair/undo hook test | +| B10 | Workspace persistence hook | B01, B09, HOTFIX | 중간 | 1일 | debounce/manual/idle snapshot, Companion flush, node switch cancel | + +각 PR은 구조 변경과 동작 변경을 분리한다. 대표 검증은 `pnpm test && pnpm build`, `go test ./...`, 변경 패키지 `go test -race`다. + +--- + +# 워크스트림 C — ko/en/ja 로컬라이제이션 QA + +## 자동 대조 + +| 검사 | 결과 | +|---|---:| +| ko / en / ja 키 | 각각 1,014 | +| 언어별 누락 키 | 0 | +| placeholder 불일치 | 0 | +| 정적 참조 키 / 사전에 없는 참조 | 806 / 0 | +| 최초 미사용 후보 / 동적 경로 해소 / 확정 고아 | 137 / 117 / 20 | + +동적 참조 근거: `ContextPanel.tsx:153`, `OutlinePanel.tsx:362-366`, `VersionSheet.tsx:114-117`, `AIContextChecklist.tsx:55-57`, `CompanionPanel.tsx:263-267`, `i18n.tsx:3382-3506`. + +`MessageKey`가 `string | ...`로 시작해 리터럴 유니언이 무력화된다(`apps/desktop/src/lib/i18n.tsx:14-312`). 기존 테스트는 노드 label 6건뿐이다(`apps/desktop/src/lib/i18n.displayNodeLabel.test.ts:1-33`). 키/placeholder/동적 allowlist 검사를 CI에 추가해야 한다. + +## High: “외부 전송 없음”이 실제 AI 동작과 모순된다 + +- 절대 표현: `apps/desktop/src/lib/i18n.tsx:1019-1021,2035-2037,3051-3053`; 노출 `apps/desktop/src/routes/Library.tsx:363-373` +- 실제 AI 전송 안내: `apps/desktop/src/lib/i18n.tsx:816,1128,1832,2144,2848,3160` + +| 현재 값 | 제안 값 | 이유 | +|---|---|---| +| 외부 전송 없음 | 원고는 기본적으로 로컬 저장 | AI 사용 예외 반영 | +| No external transfer | Manuscripts are stored locally by default | 절대 주장 제거 | +| 外部送信なし | 原稿は基本的にローカル保存 | 전송 예외 반영 | +| Manuscripts and settings stay on this computer. | Manuscripts and settings are stored locally. AI features send only the context you choose to the connected provider. | 실제 동작 명시 | +| 原稿と設定はこのコンピュータ内に保管されます。 | 原稿と設定はローカルに保存されます。AI機能では、選択したコンテキストのみ接続先へ送信されます。 | 실제 동작 명시 | + +## 사용자 노출 하드코딩 + +| 위치 | 현재 | 제안 | +|---|---|---| +| `routes/Library.tsx:495` | `Git sync` | `library.safety.gitSync` | +| `components/companion/CompanionPanel.tsx:1156,1176,1208` | `companion` | `companion.speaker` | +| `CompanionPanel.tsx:1454-1456` | `{count} chars` | locale 숫자 + `companion.reference.charCount` | +| `routes/Workspace.tsx:175-180` | `(예:)` | `workspace.outlineStructureFormat` | + +## 영어 수정표 + +| 현재 값 / 위치 | 제안 값 | 이유 | +|---|---|---| +| `…so older drafts can keep moving.` `i18n.tsx:1349` | `…so you can continue working on existing drafts.` | 직역투 | +| `The center is the manuscript surface.` `:1357` | `Write your manuscript in the center editor.` | 부자연스러운 명사화 | +| `Published {published} eps · stock {stock} eps` `:1383` | `{published} published · {stock} ready to publish` | 한국식 용어 직역 | +| `Work total {count}` `:1388` | `Total: {count} chars` | 단위/어순 | +| `Beat in next scene` `:1461` | `Add beat to next scene` | 동작 명확화 | +| `Hand off a stuck point…` `:1676` | `Bring me a sticking point, or explore what comes next in this scene.` | 자연스러운 안내 | +| `Plan plot, outline, settings…` `:1683` | `Plan the plot, outline, story world, and other work-wide changes.` | 앱 설정 오독 방지 | +| `Choose a safe starter path…` `:1746` | `Choose a setup option to connect an AI provider.` | 근거 없는 safe 제거 | +| `Save to dossier after search` `:1794` | `Check sources, then save to Fact Book` | 기능명 통일 | +| `Fact dossier` `:1860` | `Fact Book` | 기능명 통일 | +| `All library →` `:2043` | `View all projects →` | 자연스러운 탐색 | +| `Claude subscription harnesses…` `:2317` | `Claude subscription sign-in is not supported in Linetta. Linetta supports Claude through API keys only.` | 명백한 오역 | + +## 일본어 수정표 + +| 현재 값 / 위치 | 제안 값 | 이유 | +|---|---|---| +| `Linettaを案内` `i18n.tsx:2360` | `Linettaを見てみる` | 비문 수정 | +| `これまでの原稿を続けられます` `:2365` | `既存の原稿もそのまま執筆を続けられます` | 목적 관계 | +| `GitHub sync` `:2369` | `GitHub同期` | 혼용 제거 | +| `再作成中 / 再作成` `:2409-2410` | `書き直し中 / 書き直す` | 집필 용어 | +| `登場` `:2415` | `メンション` | entity 종류 반영 | +| `このシーンに紐づいた人物はありません` `:2418` | `このシーンでメンションされた世界観要素はありません` | 실제 모델 반영 | +| `次のシーンにビート` `:2477` | `次のシーンにビートを追加` | 동작 명확화 | +| `このシーンを新しい Thread としてマーク` `:2479` | `このシーンを新しいストーリーラインとして登録` | 도메인 용어 통일 | +| `Thread View` `:2553` | `ストーリーライン表示` | 번역 누락 | +| `バージョン · 復元 · diff` `:2555` | `バージョン · 復元 · 差分` | 일반 UI 용어 | +| `Focus モード` `:2565-2566` | `集中モード` | 기능 의미 | +| `流れ` `:2885` | `ストーリーライン` | 모호함 제거 | +| `下書き前` `:3040` | `まだ原稿がありません` | 자연스러운 상태 | +| `全ライブラリ →` `:3059` | `すべての作品を見る →` | 자연스러운 탐색 | +| `保管箱` `:3060` | `アーカイブ` | 표준 UI 용어 | +| `provider` `:3137,3156,3160,3209,3216` | `プロバイダー` | 표기 통일 | + +설명/완료 문구는 대체로 です/ます로 일관된다. 모델 지시문 `〜して`체는 UI 문구가 아니며 일관돼 있어 결함으로 세지 않았다. + +## 고아 키 20개 + +- `onboarding.workspace.ai.title/body`: `i18n.tsx:344-345,1360-1361,2376-2377`; 실제 tour `Workspace.tsx:1479-1510` +- `workspace.newPart/newChapter/prompt.renameLabel`: `i18n.tsx:397-398,459,1413-1414,1475,2429-2430,2491` +- `workspace.toast.copyTextSuccess`, `workspace.command.restore`: `i18n.tsx:494,524,1510,1540,2526,2556`; 현재 호출 `Workspace.tsx:1110-1113` +- `contextual.projectReplace.disabled`, `contextual.snapshotNotice`: `i18n.tsx:574,583,1590,1599,2606,2615` +- `companion.examplesLabel/examplesTitle/example.{conflict,outline,search,fetch,apply}`: `i18n.tsx:702-708,1718-1724,2734-2740`; 현재 UI `CompanionPanel.tsx:1105-1110` +- `ai.mode.replace`: `i18n.tsx:804,1820,2836`; 현재 `AIPanel.tsx:122-129` +- `settings.git.runNow`, `settings.ops.ok/failed`: `i18n.tsx:1195,1219-1220,2211,2235-2236,3227,3251-3252` + +## UI 길이 확인 필요 + +1. 340px 패널의 3등분 contextual tabs: `App.css:462-466`, `ContextualEditPanel.css:9-28`, `ContextualEditPanel.tsx:280-308`, 영어 `i18n.tsx:1567-1569`. +2. Companion reference 4열/nowrap: `CompanionPanel.css:475-502`, `CompanionPanel.tsx:1448-1456`, 일본어 `i18n.tsx:2680-2688`. + +검증: 340px/320px, ko/en/ja, 글자 크기 100%/125% 시각 회귀. 확정 파손으로 단정하지 않는다. + +## 제안 diff + +```diff +diff --git a/apps/desktop/src/routes/Library.tsx b/apps/desktop/src/routes/Library.tsx +@@ +- Git sync ++ {t("library.safety.gitSync")} +diff --git a/apps/desktop/src/components/companion/CompanionPanel.tsx b/apps/desktop/src/components/companion/CompanionPanel.tsx +@@ +-import { useI18n } from "../../lib/i18n"; ++import { localeForLanguage, useI18n } from "../../lib/i18n"; +@@ +- const { t } = useI18n(); ++ const { language, t } = useI18n(); +@@ +-companion ++{t("companion.speaker")} +@@ +-{ref.char_count.toLocaleString()} chars ++{t("companion.reference.charCount", { ++ count: ref.char_count.toLocaleString(localeForLanguage(language)), ++})} +diff --git a/apps/desktop/src/routes/Workspace.tsx b/apps/desktop/src/routes/Workspace.tsx +@@ +-return `${t(outlinePreset.nameKey)}: ${part} > ${chapter} > ${scene} (예: ...)`; ++return t("workspace.outlineStructureFormat", { ++ preset: t(outlinePreset.nameKey), part, chapter, scene, ++ partExample: outlineNumberLabel(outlinePreset, "part", 1, t), ++ chapterExample: outlineNumberLabel(outlinePreset, "chapter", 1, t), ++ sceneExample: outlineNumberLabel(outlinePreset, "scene", 1, t), ++}); +``` + +추가 키: + +```diff ++ "library.safety.gitSync": "Git 동기화" | "Git sync" | "Git同期" ++ "companion.speaker": "컴패니언" | "Companion" | "コンパニオン" ++ "companion.reference.charCount": "{count}자" | "{count} chars" | "{count}字" ++ "workspace.outlineStructureFormat": ++ "{preset}: {part} > {chapter} > {scene} (예: {partExample} > {chapterExample} > {sceneExample})" ++ "{preset}: {part} > {chapter} > {scene} (example: {partExample} > {chapterExample} > {sceneExample})" ++ "{preset}: {part} > {chapter} > {scene}(例:{partExample} > {chapterExample} > {sceneExample})" +``` + +--- + +# 워크스트림 D — 배포와 보안 체크리스트 + +현재 정책 판단은 Apple/Tauri 공식 문서와 2026-07-12 취약점 DB를 기준으로 했다. + +| 판정 | 항목 | 코드 근거 | 판단/조치 | +|---|---|---|---| +| 통과 | CSP script 경계 | `apps/desktop/src-tauri/tauri.conf.json:24-29` | `script-src 'self'`, eval/inline script 없음. `style-src 'unsafe-inline'`은 CSS에만 한정 | +| 통과 | capability가 main window로 제한 | `apps/desktop/src-tauri/capabilities/default.json:1-15` | local `main`만 대상 | +| 통과 | shell default는 실행/spawn을 허용하지 않음 | `capabilities/default.json:7-14`, `Cargo.toml:16-26` | 공식 Tauri `shell:default`는 URL open만 허용. 현재 JS shell 사용처도 없음 | +| 통과 | FS가 picker 흐름과 결합 | `exportSave.ts:1-14`, `importLoad.ts:1-21`, `CompanionPanel.tsx:726` | dialog가 부여한 runtime scope와 text read/write 사용 | +| 실패 | custom `open_path`가 Tauri opener scope를 우회 | `apps/desktop/src-tauri/src/lib.rs:169-179`, 호출 `Library.tsx:157-175` | command는 임의 path를 받는다. 실제 UI는 engine 제공 home/backup만 쓰지만 XSS 시 기존 파일/app 열기 blast radius. 허용 base path를 Rust에서 검증 | +| 확인 필요 | 단일 동적 `engine_call`이 전체 Go RPC를 노출 | `src-tauri/src/lib.rs:93-101`, `rpc.ts:104-106`, `engineapp.go:217-328` | Tauri custom command는 기본적으로 모든 local window/webview에 허용된다. 현재 1개 local window라 즉시 exploit은 아니나 XSS 시 delete/settings/AI까지 전부 가능. future window 전에 read/write capability 또는 Rust method allowlist 설계 | +| 통과 | MAS sandbox/entitlement | `tauri.mas.conf.json:3-8`, `linetta-mas.entitlements:5-16` | sandbox, outbound network, user-selected RW, app-scoped bookmark 최소 권한 | +| 통과 | bookmark 기반 unattended folder access | `folder_sync.rs:28-57,79-149`, `macos_bookmarks.rs:18-56` | API 선택은 적절. A-M06 원자성 문제는 별도 | +| 통과 | 코드에 분석/광고/크래시 SDK 없음 | `apps/desktop/package.json:14-44`, `engine/go.mod:5-24` | 별도 analytics/tracker 발견 없음 | +| 실패 | 앱 안에서 privacy policy를 쉽게 열 수 없음 | Settings UI `Settings.tsx:520-600`, provider guide `AISetupStart.tsx:300-369`; 저장소 전체 링크 검색 0건 | Apple 5.1.1(i)는 App Store metadata와 앱 내부 접근을 모두 요구. Settings/About에 링크 추가 | +| 실패 | 제3자 AI 전송 전 명시 고지/동의가 없음 | provider 설명 `i18n.tsx:1104-1152`, 선택 UI `AISetupStart.tsx:300-369`, 전송 동작 `CompanionPanel.tsx`/`rpc.ts:310-318` | Apple 5.1.2(i)는 third-party AI 공유 위치 고지와 explicit permission을 요구. 첫 provider 연결/첫 전송 전 공급자·전송 범위·취소 방법 동의 저장 | +| 실패 | privacy policy가 자체 모순이고 필수 항목이 부족 | `docs/privacy-policy.md:9-31,46-68,83-105` | “전송하지 않음/제3자 제공 없음”과 AI provider 전송이 모순. third party 동등 보호 확인, 보존/삭제, 동의 철회 방법도 없음 | +| 확인 필요 | `PrivacyInfo.xcprivacy` 부재 | `tauri.conf.json:31-44`, `build.rs:1-13`, `release-mas-local.sh:32-52`에 resource 포함 없음 | required-reason API 선언 의무는 Apple 문서상 macOS에는 동일 적용되지 않으므로 부재 자체를 즉시 실패로 단정하지 않는다. 그러나 data collection은 모든 platform 대상이다. ASC privacy label과 provider retention을 확인한 후 유효 manifest가 필요한지 판단; 무근거 빈 manifest 금지 | +| 통과 | MAS package signing 기본 검증 | `release-mas-local.sh:34-52`, entitlement plist | app/package 서명과 plist lint 통과 | +| 확인 필요 | App Store server-side preflight 자동화 없음 | `release-mas-local.sh:45-55` | 로컬 codesign/pkgutil까지만 검증. 실제 upload 전 Transporter/App Store Connect validation 기록 확인 | +| 실패 | Go 1.26.2에 실제 호출 가능 stdlib 취약점 5건 | `engine/go.mod:1-9`, CI `ci.yml:25-29`, 호출 `openrouter/oauth.go:97-140`, `keyinfo.go:71-128`, `websearch.go:70-108` | `GO-2026-5856/5039/5037/4971/4918`; Go 1.26.5 이상으로 올리고 재스캔 | +| 실패 | React Router 6.30.3 advisory | `package.json:26-30`, `pnpm-lock.yaml:53-55,1435-1444` | GHSA-2j2x-hqr9-3h42, fix 6.30.4. 현재 navigation은 내부 경로라 exploit 가능성 낮지만 patch update 권장 | +| 확인 필요 | Rust `quick-xml` 0.39.4 advisory 2건 | `Cargo.lock:2654-2661`, path `quick-xml → plist → Tauri` | RUSTSEC-2026-0194/0195. macOS path에 포함되나 Linetta가 untrusted XML을 직접 파싱하는 사용처는 발견되지 않음. Tauri/plist update로 해결하고 직접 우회 금지 | +| 확인 필요 | Rust `anyhow` unsound 경고 | `Cargo.toml:24`, `Cargo.lock:44-48` | RUSTSEC-2026-0190; `downcast_mut` 사용처 0. direct unused dependency 제거 또는 1.0.103+ update | +| 확인 필요 | GTK3 unmaintained 경고 | `Cargo.lock:1298-1315,1362-1375` | Linux target의 Tauri transitive dependency. MAS와 무관하며 Linetta 단독 GTK4 전환은 하지 말고 upstream 추적 | +| 실패 | CI dependency audit gate 없음 | `.github/workflows/ci.yml:43-55`, `Makefile:9-20` | frozen lock/install은 통과하지만 govulncheck/pnpm audit/cargo audit가 없다. OS별 target noise 정책과 allowlist 만료일을 둔다 | +| 통과 | secret literal 노출 없음 | Keychain `engine/internal/settings/secrets_darwin.go:82-155`, CI secret env `build.yml:83-167` | 키는 OS secure store/CI secrets 사용 | +| 통과 | Go race 및 MAS tests | 실행 결과 | `go test -race ./...`, `cargo test --features mas` 통과 | +| 실패 | 공개 배포 문구와 유료 상태 컨텍스트 불일치 | `README.md:44-50` | README가 “free”라고 단정. App Store Connect 가격을 확인하고 유료 배포 상태와 동기화 | + +공식 근거: + +- [Apple App Review Guidelines 5.1](https://developer.apple.com/app-store/review/guidelines/) +- [Apple App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/) +- [Apple privacy manifest files](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files) +- [Apple macOS privacy manifest bundle location](https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk) +- [Tauri capabilities](https://v2.tauri.app/security/capabilities/) +- [Tauri file-system permissions](https://v2.tauri.app/plugin/file-system/) +- [Go vulnerability database](https://pkg.go.dev/vuln/) + +--- + +# 통합 교차 검증 + +## 일치하는 결론 + +1. A-C01과 B의 `Workspace`/debounce 복잡도는 같은 원인을 가리킨다. 구조 정리 전에 데이터 덮어쓰기 핫픽스가 선행한다. +2. C의 “외부 전송 없음” 모순과 D의 Apple 5.1.2 제3자 AI 고지/명시 동의 공백은 같은 배포 위험이다. +3. A의 IPC error/type drift, B의 strict TS 우회/lint 부재, C의 무력화된 `MessageKey`는 compile-time 계약이 충분하지 않다는 같은 결론이다. +4. A의 Markdown backup/partial sync 문제와 C의 local-first 절대 표현은 “데이터 안전성”을 제품 문구가 실제보다 강하게 약속한다는 같은 결론이다. + +## 모순과 중재 + +| 충돌 | 중재안 | +|---|---| +| B10 persistence refactor가 B01/B09 뒤이지만 A-C01은 즉시 수정 필요 | 최소 hotfix+실패 테스트를 독립 PR로 먼저 머지. 이후 B01/B09/B10은 동작 보존 구조 변경 | +| privacy manifest 부재를 배포 실패로 볼지 여부 | macOS required-reason API 규칙은 iOS 계열과 다르다. 현재는 “확인 필요”. 먼저 ASC privacy label과 provider retention을 확인하고 실제 수집 data type만 선언 | +| single SQLite connection을 늘리면 concurrency가 나아질 수 있음 | 단순 `SetMaxOpenConns` 증가는 write contention을 되살릴 수 있다. benchmark 후 writer queue/maintenance 분리로 해결 | +| 모든 Tiptap text 변환을 하나로 합치면 중복 감소 | 출력 정책이 동일한 5개만 합치고 search/companion 변형은 유지 | +| GTK3 unmaintained를 즉시 제거 | Linux Tauri upstream 경계다. Linetta 단독 대전환은 비용 대비 효과가 낮아 모니터링만 | + +--- + +# 전체 우선순위 큐 + +정렬은 사용자 데이터 손실 → 심사/배포 차단 → 유지보수 절감 순이다. + +| 순위 | 항목 | 이유 | 목표 | +|---:|---|---|---| +| 1 | A-C01 cross-scene stale autosave | 현재 원고 직접 덮어쓰기 | 즉시 hotfix | +| 2 | A-H01 backup 실패 당일 재시도 차단 | 실제 복구본 부재를 성공으로 오인 | 이번 주말 | +| 3 | A-H02 종료 hang | 강제 종료와 사용자 신뢰 저하 | 이번 주말 | +| 4 | A-H03 partial/title-collision sync | 외부 백업의 누락·덮어쓰기·성공 오판 | 이번 달 | +| 5 | A-H04 Markdown backup 오표기 | 사용자가 무손실 복구로 오해 | 명칭 즉시, 포맷 이번 달 | +| 6 | A-H05 startup recovery 부재 | 손상 시 backup에 접근도 못함 | 이번 달 | +| 7 | A-M02 import atomicity | 실패 시 partial project | 이번 달 | +| 8 | A-M05/A-M07 derived index drift | 검색/mention 불일치 | 이번 달 | +| 9 | D privacy link/AI consent/policy + C wording | 다음 심사/업데이트 리젝과 개인정보 고지 위험 | 다음 MAS 제출 전, 가능하면 이번 주말 | +| 10 | Go 1.26.5+와 React Router 6.30.4 | 현재 audit 실패, Go는 실제 호출 가능 | 이번 주말 | +| 11 | CI dependency audit gate | 취약점 재유입 방지 | 이번 달 | +| 12 | A-M03/A-M04/L01 contract tests | 에러/patch/null drift | 이번 달 | +| 13 | C i18n catalog test + 20 orphan 삭제 | 컴파일러가 키를 보호하지 못함 | 이번 달 | +| 14 | B01→B09→B10 Workspace 분리 | 장기 변경 비용/회귀 범위 축소 | 이번 달 이후 순차 | +| 15 | B02 Tiptap walker | 5개 구현 drift 방지 | 이번 달 | +| 16 | B03/B07 typed events/OpenRouter controller | 중복과 문자열 계약 축소 | 이번 달 | +| 17 | C 영어/일본어 표현 및 UI length QA | 품질/레이아웃 | 이번 달 | + +--- + +# 실행 계획 + +## 이번 주말에 할 것 + +### 1. `fix: prevent stale scene autosave overwrite` — 최우선, 1~2일 + +실패 테스트 먼저: + +- A 입력 → 800ms 전 B 전환 → timer 진행 시 A/B ID/doc가 섞이지 않음 +- restore/Companion apply 전 pending save flush +- unmount/node change cancel +- backend stale `content_version` reject + +완료 조건: node ID/version-bound save queue, `cancel/flush`, conflict UI, 전체 frontend/Go/race 통과. + +### 2. `fix: make daily backup retryable and shutdown cancellable` — 0.5~1일 + +실패 테스트 먼저: + +- 첫 backup 실패 후 같은 날 재호출은 실행됨 +- scheduler가 다음 자정 sleep 중이어도 stop은 1초 내 반환 +- temp/invalid DB는 완료 marker가 되지 않음 + +### 3. `fix: align App Store AI privacy disclosure` — 0.5일 + +- Settings/About에 privacy policy 링크 +- 첫 제3자 AI 전송 전에 provider/전송 context/보존 정책 링크/철회 방법과 명시 동의 +- ko/en/ja 절대 문구를 “기본적으로 로컬 저장”으로 수정 +- privacy policy에 third-party, retention/deletion, revoke/withdrawal 내용 반영 +- App Store Connect privacy answers를 실제 provider 동작과 대조 + +### 4. `chore: patch audited dependencies` — 0.25일 + +- Go 1.26.5 이상, React Router 6.30.4 이상 +- `govulncheck`, `pnpm audit --prod`, target-aware `cargo audit` 재실행 +- Cargo advisories는 reachability를 문서화하고 가능한 Tauri/plist patch update만 수행 + +주말이 이틀로 엄격히 제한되면 1→2 순으로 끝내고, 3은 다음 MAS 제출을 막는 release gate로 등록한다. 4는 patch update라 1/2 사이에 끼워 넣을 수 있다. + +## 이번 달에 할 것 + +1. sync/export 무결성 PR: ID filename, manifest, atomic write, partial failure reporting. +2. Markdown을 “읽기용 내보내기”로 이름 변경하고 `.linetta` versioned recovery 포맷 설계/구현. +3. startup recovery screen과 pre-migration backup/quick-check restore. +4. atomic import와 FTS/mention reconcile. +5. IPC error envelope, patch semantics, null/field fixture contract tests. +6. i18n catalog/placeholder/dynamic allowlist test, 20 orphan 키 제거, 제안 diff/번역표 반영. +7. B01 테스트 seam 후 `Workspace` outline/persistence를 작은 PR로 분리. +8. 공용 Tiptap walker, typed app events, OpenRouter setup controller, ESLint/React Hooks gate. +9. 340px/320px × ko/en/ja × 100/125% 시각 QA. +10. dependency audit CI. Rust target-specific allowlist에는 근거와 만료일을 둔다. + +## 안 해도 되는 것 + +1. `Workspace` 전면 재작성이나 Redux/Zustand 도입 — 작은 hook 추출로 충분하다. +2. SQLite connection 수를 근거 없이 늘리기 — 먼저 backup latency benchmark가 필요하다. +3. 모든 Tiptap 변환기를 만능 parser 하나로 통합 — 출력 정책 차이를 지운다. +4. Go repository 전체 generic CRUD화 — 현재 중복 helper만 추출한다. +5. Linux GTK3 경고 때문에 Linetta가 독자적으로 GTK4 shell로 전환 — Tauri upstream을 추적한다. +6. 근거 없는 빈 `PrivacyInfo.xcprivacy` 추가 — 잘못된 manifest도 App Store가 거부한다. +7. `Trusted Types`, 대규모 CSP 재설계 — raw HTML/eval sink가 없고 현재 script CSP가 강하다. +8. Companion service를 줄 수만 이유로 전면 분할 — 기능 변경과 맞닿을 때만 파일 이동한다. +9. Rust `engine_ping` command 삭제 — TS wrapper는 미사용이지만 native 상태 점검 경로는 별도 확인 전 유지한다. + +--- + +# 재현/검증 명령 + +```sh +make ci +cd engine && go test -race ./... +cd engine && go test -tags mas ./... +cd apps/desktop/src-tauri && cargo test --features mas +cd apps/desktop && pnpm audit --prod +cd engine && govulncheck ./... +cd apps/desktop/src-tauri && cargo audit +make validate-distribution +``` + +감사 종료 시 제품 작업 트리는 clean이었다. `.analysis/` 아래 감사 산출물만 새로 생성했다. diff --git a/.analysis/outputs/glossary.md b/.analysis/outputs/glossary.md new file mode 100644 index 0000000..bf40024 --- /dev/null +++ b/.analysis/outputs/glossary.md @@ -0,0 +1,10 @@ +# 용어 + +- Engine: Go domain/runtime layer embedded through C ABI. +- FFI: Rust와 Go 사이 C-compatible function boundary. +- RPC: frontend method/params를 Go handler에 전달하는 JSON-RPC contract. +- Snapshot: scene content version history. +- Daily backup: SQLite `VACUUM INTO` copy. +- Folder/Git sync: Markdown export copies; database-complete backup이 아님. +- Fact Book: source-backed story facts UI. +- Companion: AI writing assistant flow. diff --git a/.analysis/outputs/implementation-checklist.md b/.analysis/outputs/implementation-checklist.md new file mode 100644 index 0000000..493c98c --- /dev/null +++ b/.analysis/outputs/implementation-checklist.md @@ -0,0 +1,29 @@ +# 감사 후 구현 체크리스트 + +## Release blockers + +- [x] stale autosave cross-scene regression test를 먼저 추가 +- [x] node/version-bound save, cancel/flush, optimistic lock 구현 +- [x] daily backup 실패 후 당일 retry, SQLite quick_check, cancel-aware scheduler 구현 +- [x] 앱 안 privacy policy 링크와 third-party AI 명시 동의 추가 +- [x] Go/React Router/Rust patch update 후 세 dependency audit 재실행 + +## This month + +- [x] sync manifest/atomic write/partial failure reporting +- [x] Markdown “backup” 명칭 정정과 versioned `.linetta` recovery format +- [x] startup recovery/pre-migration backup/migration checksum +- [x] atomic import cleanup, FTS/mention startup reconcile +- [x] IPC error envelope 및 i18n key/placeholder executable contract tests +- [x] Workspace persistence queue, typed app events, React Hooks lint gate +- [ ] Tiptap walker/OpenRouter controller 추출 — 기능 변경과 맞닿을 때 수행할 비차단 리팩터링 + +## Verification (2026-07-14) + +- [x] `make ci` +- [x] `go test -race ./...` +- [x] `cargo test --features mas` +- [x] `govulncheck ./...`, `pnpm audit --prod`, `cargo audit` +- [x] `make validate-distribution`, `git diff --check` + +전체 완료 기준과 비권장 항목은 `full-audit.md`를 참조한다. diff --git a/.analysis/outputs/issue-candidates.md b/.analysis/outputs/issue-candidates.md new file mode 100644 index 0000000..d77ae29 --- /dev/null +++ b/.analysis/outputs/issue-candidates.md @@ -0,0 +1,36 @@ +# Issue Candidates + +### SEC-001: stale autosave cross-scene overwrite + +- Module: `apps/desktop/src/routes/Workspace.tsx` +- Type: `SEC` +- Evidence: delayed doc argument is executed by latest callback capturing a different node ID. +- Suggested action: node/version-bound save queue, cancel/flush, optimistic locking, regression tests. + +### TIDY-001: Workspace responsibility concentration + +- Module: `apps/desktop/src/routes/Workspace.tsx` +- Type: `TIDY` +- Evidence: UI, outline, save ordering, AI orchestration and render tree live in one 2k-line component. +- Suggested action: after SEC-001 hotfix, extract test seams, outline and persistence hooks incrementally. + +### DUP-001: repeated Tiptap tree walkers + +- Module: `engine/internal` +- Type: `DUP` +- Evidence: five near-identical document-to-text walkers drift independently. +- Suggested action: share only identical output policy in `internal/tiptapdoc`. + +### SEC-002: backup success marker precedes verified backup + +- Module: `engine/internal/backup` +- Type: `SEC` +- Evidence: date directory is created before VACUUM and treated as completion. +- Suggested action: validated temp backup plus atomic completion marker. + +### TIDY-002: RPC and localization contracts lack executable schemas + +- Module: `apps/desktop/src/lib` +- Type: `TIDY` +- Evidence: wire null/optional drift and `MessageKey` begins with `string`. +- Suggested action: fixture contract tests and i18n catalog/placeholder tests. diff --git a/.analysis/outputs/module-map.json b/.analysis/outputs/module-map.json new file mode 100644 index 0000000..2988248 --- /dev/null +++ b/.analysis/outputs/module-map.json @@ -0,0 +1,10 @@ +{ + "frontend": {"path":"apps/desktop/src","responsibility":"React UI, editor state, localization, typed RPC wrappers","key_files":["apps/desktop/src/routes/Workspace.tsx","apps/desktop/src/lib/rpc.ts","apps/desktop/src/lib/types.ts","apps/desktop/src/lib/i18n.tsx"]}, + "tauri_shell": {"path":"apps/desktop/src-tauri","responsibility":"Desktop security boundary, command exposure, Go FFI lifecycle","key_files":["apps/desktop/src-tauri/src/lib.rs","apps/desktop/src-tauri/src/ffi.rs","apps/desktop/src-tauri/capabilities/default.json","apps/desktop/src-tauri/tauri.conf.json"]}, + "engine_composition": {"path":"engine/internal/engineapp","responsibility":"Construct services, register RPC methods, run background jobs","key_files":["engine/internal/engineapp/engineapp.go"]}, + "rpc": {"path":"engine/internal/rpc","responsibility":"JSON-RPC dispatch and input/output adapters","key_files":["engine/internal/rpc/server.go","engine/internal/rpc/handlers"]}, + "persistence": {"path":"engine/internal/store","responsibility":"SQLite connection, migrations, shared repositories","key_files":["engine/internal/store/store.go","engine/internal/store/migrations.go"]}, + "domain": {"path":"engine/internal","responsibility":"Project, node, entity, thread, plot, fact, snapshot, AI and companion behavior","key_files":["engine/internal/node/repo.go","engine/internal/project/repo.go","engine/internal/companion/companion.go"]}, + "recovery_export": {"path":"engine/internal","responsibility":"Backup, Markdown import/export, folder and Git sync","key_files":["engine/internal/backup/backup.go","engine/internal/export/project.go","engine/internal/importmd/builder.go","engine/internal/foldersync/foldersync.go","engine/internal/gitsync/gitsync.go"]}, + "distribution": {"path":"scripts","responsibility":"Build, sign, notarize, MAS package and distribution validation","key_files":["scripts/release-mas-local.sh","scripts/release-macos-local.sh","scripts/validate-distribution.sh"]} +} diff --git a/.analysis/outputs/overview.md b/.analysis/outputs/overview.md new file mode 100644 index 0000000..83e060d --- /dev/null +++ b/.analysis/outputs/overview.md @@ -0,0 +1,7 @@ +# Linetta 분석 개요 + +- 기준: `1020096a14b65c7bd21ca081947f1747978e3b93` +- 구조: React/Tauri Rust/Go FFI/SQLite 단일 로컬 앱 +- Blocking: pending debounce가 최신 scene ID와 결합해 다른 씬을 덮어쓸 수 있음 +- 배포 gate: 제3자 AI 전송의 앱 내 명시 고지/동의와 privacy policy 정합성 +- 전체 보고서: `full-audit.md` diff --git a/.analysis/outputs/technologies.md b/.analysis/outputs/technologies.md new file mode 100644 index 0000000..43314fd --- /dev/null +++ b/.analysis/outputs/technologies.md @@ -0,0 +1,9 @@ +# 기술 구성 + +- Desktop shell: Tauri 2 / Rust 2021 +- Frontend: React 18, TypeScript, Vite, Tiptap +- Engine: Go 1.26.2 at audited commit +- Persistence: SQLite via `modernc.org/sqlite`, WAL, single connection +- IPC: Tauri invoke + JSON-RPC envelope over in-process Go C ABI +- Distribution: Mac App Store sandbox, Developer ID/notarized direct macOS, Windows/Linux packaging +- Localization: ko baseline, en, ja; single TypeScript catalog diff --git a/.gitignore b/.gitignore index 55d5600..e65fbce 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,9 @@ scripts/ascapi/ascapi # Serena tool workspace .serena/ + +# Local audit working state (final reports live in .analysis/outputs) +/.analysis/cache/ +/.analysis/sessions/ +/.analysis/AI_CONTEXT.md +/.analysis/RESUME.md From 555feef8982397a0c5f4781648ea2513d76adec2 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Tue, 14 Jul 2026 10:08:38 +0900 Subject: [PATCH 3/3] chore: bump version to 0.9.2 --- CHANGELOG.md | 7 +++++++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- engine/internal/engineapp/engineapp.go | 2 +- packaging/flathub/com.devlikebear.linetta.yml | 2 +- 7 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6943e1a..f1b0445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.9.2 - 2026-07-14 + +- Prevented stale autosaves from overwriting another scene by serializing per-scene writes and enforcing optimistic content-version checks. +- Added verified daily and pre-migration backups, full-library `.linetta` recovery snapshots, startup restore controls, and atomic sync/import writes with partial-failure reporting. +- Required explicit, revocable consent before manuscript content is sent to a selected AI provider, and aligned the in-app disclosure and Korean/English/Japanese privacy policy. +- Narrowed renderer IPC and file-opening access, preserved structured RPC errors, aligned localization catalogs, and added lint and dependency-audit gates to CI. + ## v0.9.1 - 2026-07-05 - Localized every AI surface for the selected app language (Korean/English/Japanese): the companion, inline editor AI, contextual edit, and Fact Book now build prompts and reply in the app language, including tool status text, apply results, and query outputs. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0eb6519..dc712b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "linetta-desktop", - "version": "0.9.1", + "version": "0.9.2", "license": "AGPL-3.0-only", "private": true, "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 340cc38..8d1f7c3 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1938,7 +1938,7 @@ dependencies = [ [[package]] name = "linetta-desktop" -version = "0.9.1" +version = "0.9.2" dependencies = [ "cc", "libloading", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 91389de..024dd80 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linetta-desktop" -version = "0.9.1" +version = "0.9.2" edition = "2021" rust-version = "1.88" license = "AGPL-3.0-only" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b8cd9f3..1a0e8a2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Linetta", - "version": "0.9.1", + "version": "0.9.2", "identifier": "com.devlikebear.linetta", "build": { "frontendDist": "../dist", diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 6e2f9a2..56101d3 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -41,7 +41,7 @@ import ( "github.com/devlikebear/linetta/engine/internal/thread" ) -const DefaultVersion = "0.9.1" +const DefaultVersion = "0.9.2" // Options configures an embedded engine instance. type Options struct { diff --git a/packaging/flathub/com.devlikebear.linetta.yml b/packaging/flathub/com.devlikebear.linetta.yml index d2fb2eb..4c56efb 100644 --- a/packaging/flathub/com.devlikebear.linetta.yml +++ b/packaging/flathub/com.devlikebear.linetta.yml @@ -20,7 +20,7 @@ modules: sources: - type: git url: https://github.com/devlikebear/linetta.git - tag: v0.9.1 + tag: v0.9.2 commit: REPLACE_WITH_RELEASE_COMMIT build-commands: - cd apps/desktop && corepack enable