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
{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")}
{thinking || (liveProse ? t("companion.writing") : t("companion.preparing"))}
@@ -1453,7 +1454,9 @@ export function CompanionPanel({
{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