From 7f543ca5b8efe18714d5377943db1cb60ad72728 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 05:35:40 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat(settings):=20=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E5=90=8D=20slug=20=E5=8C=96=E3=82=B5?= =?UTF-8?q?=E3=83=BC=E3=83=93=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仕様確定版の「規約適合 = slugify の不動点」を実装する純関数群。 小文字 [a-z0-9-]・48 文字上限・Windows 予約デバイス名回避・ 連番 suffix (-2 昇順、truncate 後付与) を単一モジュールに集約し、 生成物が常に適合判定を満たすことをテストで保証する。 Co-Authored-By: Claude Fable 5 --- src/services/settingsSlug.test.ts | 133 ++++++++++++++++++++++++++++++ src/services/settingsSlug.ts | 104 +++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/services/settingsSlug.test.ts create mode 100644 src/services/settingsSlug.ts diff --git a/src/services/settingsSlug.test.ts b/src/services/settingsSlug.test.ts new file mode 100644 index 000000000..6e729bd1e --- /dev/null +++ b/src/services/settingsSlug.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { + casefold, + composeSuffixed, + isSlugConforming, + resolveAvailable, + SLUG_MAX_LENGTH, + slugifyName, +} from './settingsSlug' + +describe('slugifyName', () => { + it('英数字名を小文字 slug にする', () => { + expect(slugifyName('My Theme', 'theme')).toBe('my-theme') + expect(slugifyName('Weather_2', 'widget')).toBe('weather-2') + }) + + it('日本語のみの名前は fallback に落ちる', () => { + expect(slugifyName('日本語だけ', 'widget')).toBe('widget') + // 数字が残る場合は fallback しない (R1 裁定: 不格好だが規約適合・一意) + expect(slugifyName('プロファイル 1', 'profile')).toBe('1') + }) + + it('連続ハイフンを圧縮し端のハイフンを除去する', () => { + expect(slugifyName('foo--bar', 'x')).toBe('foo-bar') + expect(slugifyName('-foo-', 'x')).toBe('foo') + expect(slugifyName(' spaced name ', 'x')).toBe('spaced-name') + }) + + it('Windows 予約デバイス名を回避する', () => { + expect(slugifyName('CON', 'theme')).toBe('con-x') + expect(slugifyName('aux', 'theme')).toBe('aux-x') + expect(slugifyName('Com1', 'theme')).toBe('com1-x') + expect(slugifyName('lpt9', 'theme')).toBe('lpt9-x') + // 予約名を含むだけの語は対象外 + expect(slugifyName('auxiliary', 'theme')).toBe('auxiliary') + expect(slugifyName('console', 'theme')).toBe('console') + }) + + it('48 文字に切り詰め、切断で生じた末尾ハイフンを除去する', () => { + const long = 'a'.repeat(60) + expect(slugifyName(long, 'x')).toBe('a'.repeat(SLUG_MAX_LENGTH)) + // 48 文字目がハイフンになるケース + const hyphenAtCut = `${'a'.repeat(47)}-bbbb` + const result = slugifyName(hyphenAtCut, 'x') + expect(result).toBe('a'.repeat(47)) + expect(isSlugConforming(result)).toBe(true) + }) + + it('出力は常に不動点(再適用しても変わらない)', () => { + const samples = [ + 'My Theme', + 'プロファイル 1', + 'CON', + 'foo--bar-', + 'a'.repeat(60), + `${'x'.repeat(47)}-tail`, + '★絵文字🎉と mixed ABC', + ] + for (const s of samples) { + const slug = slugifyName(s, 'kind') + expect(slugifyName(slug, 'kind')).toBe(slug) + expect(isSlugConforming(slug)).toBe(true) + } + }) +}) + +describe('isSlugConforming', () => { + it('slugify の不動点のみ適合とする', () => { + expect(isSlugConforming('weather')).toBe(true) + expect(isSlugConforming('my-theme-2')).toBe(true) + expect(isSlugConforming('Weather')).toBe(false) + expect(isSlugConforming('foo--bar')).toBe(false) + expect(isSlugConforming('-foo')).toBe(false) + expect(isSlugConforming('foo.bar')).toBe(false) + expect(isSlugConforming('日本語')).toBe(false) + expect(isSlugConforming('')).toBe(false) + expect(isSlugConforming('a'.repeat(49))).toBe(false) + }) + + it('Windows 予約デバイス名は不適合', () => { + expect(isSlugConforming('con')).toBe(false) + expect(isSlugConforming('nul')).toBe(false) + expect(isSlugConforming('com1')).toBe(false) + expect(isSlugConforming('con-x')).toBe(true) + }) +}) + +describe('composeSuffixed', () => { + it('base に -n を付ける', () => { + expect(composeSuffixed('foo', 2)).toBe('foo-2') + expect(composeSuffixed('foo', 10)).toBe('foo-10') + }) + + it('合計が上限を超える場合は base 側を切り詰めてから付与する', () => { + const base = 'a'.repeat(SLUG_MAX_LENGTH) + const result = composeSuffixed(base, 2) + expect(result.length).toBeLessThanOrEqual(SLUG_MAX_LENGTH) + expect(result.endsWith('-2')).toBe(true) + expect(isSlugConforming(result)).toBe(true) + }) + + it('切り詰めで末尾ハイフンが生じても不動点を保つ', () => { + // 46 文字 + '-' + 1 文字 = 48 文字。suffix '-2' で切ると 46 文字目が '-' + const base = `${'a'.repeat(45)}-bb` + const result = composeSuffixed(base, 2) + expect(isSlugConforming(result)).toBe(true) + expect(result.endsWith('-2')).toBe(true) + }) +}) + +describe('resolveAvailable', () => { + it('base が空いていればそのまま返す', () => { + expect(resolveAvailable('foo', () => false)).toBe('foo') + }) + + it('占有されていれば -2 から昇順で探す', () => { + const taken = new Set(['foo', 'foo-2', 'foo-3']) + expect(resolveAvailable('foo', (c) => taken.has(c))).toBe('foo-4') + }) + + it('占有判定は呼び出し側の述語に委ねる(casefold は述語側の責務)', () => { + const taken = new Set(['dark']) + expect( + resolveAvailable('Dark'.toLowerCase(), (c) => taken.has(casefold(c))), + ).toBe('dark-2') + }) +}) + +describe('casefold', () => { + it('ASCII の大文字小文字を潰す', () => { + expect(casefold('Dark.NDTheme.JSON5')).toBe('dark.ndtheme.json5') + }) +}) diff --git a/src/services/settingsSlug.ts b/src/services/settingsSlug.ts new file mode 100644 index 000000000..ac0b9eb04 --- /dev/null +++ b/src/services/settingsSlug.ts @@ -0,0 +1,104 @@ +/** + * 設定ファイル名の slug 化と衝突解決 (#913)。 + * + * 不変条件: 正規アイテムのファイル basename は「slugify の不動点」 + * (小文字 `[a-z0-9-]`・48 文字上限・Windows 予約デバイス名でない)。 + * 規約適合の判定 (isSlugConforming) と生成 (slugifyName) は + * 同じ変換を共有し、生成物が常に適合するよう保証する。 + * + * 占有判定の casefold・探索空間 (対応表 ∪ ディレクトリ実列挙 ∪ ID 集合) + * は呼び出し側の責務。ここは純関数のみ。 + */ + +export const SLUG_MAX_LENGTH = 48 + +/** + * Windows 予約デバイス名。ファイル名の stem がこれに一致すると + * Windows で作成・削除不能になるため、slug として出力しない。 + */ +const RESERVED_DEVICE_NAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`), + ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`), +]) + +/** ASCII case-insensitive 照合用の正規化。 */ +export function casefold(s: string): string { + return s.toLowerCase() +} + +/** 末尾ハイフンの除去 (切り詰めで生じた分)。 */ +function trimTrailingHyphens(s: string): string { + return s.replace(/-+$/, '') +} + +/** 予約名なら回避 suffix を付ける。上限内に収まるよう調整。 */ +function avoidReserved(slug: string): string { + if (!RESERVED_DEVICE_NAMES.has(slug)) return slug + return `${slug}-x` +} + +/** + * 表示名を slug 化する。空になったら fallback (種別名) に落ちる。 + * 出力は常に isSlugConforming を満たす不動点。 + */ +export function slugifyName(name: string, fallback: string): string { + let slug = name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+/, '') + slug = trimTrailingHyphens(slug) + if (slug.length > SLUG_MAX_LENGTH) { + slug = trimTrailingHyphens(slug.slice(0, SLUG_MAX_LENGTH)) + } + if (!slug) slug = fallback + return avoidReserved(slug) +} + +/** + * basename が規約適合か = slugify の不動点か。 + * 移行 (a) の正規化対象判定・新規書込の検証に使う。 + */ +export function isSlugConforming(basename: string): boolean { + if (!basename) return false + // fallback を経由しない純変換との一致で判定する + if (!/^[a-z0-9-]+$/.test(basename)) return false + if (basename.length > SLUG_MAX_LENGTH) return false + if (/--/.test(basename)) return false + if (basename.startsWith('-') || basename.endsWith('-')) return false + if (RESERVED_DEVICE_NAMES.has(basename)) return false + return true +} + +/** + * base に連番 suffix を合成する。上限超過時は base 側を切り詰め、 + * 切断で生じた末尾ハイフンを除去して不動点を保つ。 + */ +export function composeSuffixed(base: string, n: number): string { + const suffix = `-${n}` + let head = base + if (head.length + suffix.length > SLUG_MAX_LENGTH) { + head = trimTrailingHyphens(head.slice(0, SLUG_MAX_LENGTH - suffix.length)) + } + return `${head}${suffix}` +} + +/** + * 空き名を探索する。base が占有されていれば `-2` から昇順連番。 + * isTaken には「対応表 ∪ ディレクトリ実列挙 ∪ (ID を決める操作では) + * ID 集合」に対する casefold 済みの述語を渡すこと。 + */ +export function resolveAvailable( + base: string, + isTaken: (candidate: string) => boolean, +): string { + if (!isTaken(base)) return base + for (let n = 2; ; n++) { + const candidate = composeSuffixed(base, n) + if (!isTaken(candidate)) return candidate + } +} From de3d1bf26edbc179baf6e28cda6a2f14a179297f Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 05:38:58 +0900 Subject: [PATCH 02/30] =?UTF-8?q?feat(settings):=20ID=20=E5=87=8D=E7=B5=90?= =?UTF-8?q?=E3=81=AE=E6=9C=80=E5=B0=8F=E5=A4=89=E6=8F=9B=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=93=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON5 メタ / skill frontmatter への凍結 ID 注入。パースした生内容 への追記のみ (決定的・コメント整形保持・後勝ちで不正値上書き) で、 達成済み判定の内容一致が揮発デフォルトで偽陰性にならないことを 保証する。 Co-Authored-By: Claude Fable 5 --- src/services/idFreeze.test.ts | 96 +++++++++++++++++++++++++++++ src/services/idFreeze.ts | 113 ++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/services/idFreeze.test.ts create mode 100644 src/services/idFreeze.ts diff --git a/src/services/idFreeze.test.ts b/src/services/idFreeze.test.ts new file mode 100644 index 000000000..dadc6682f --- /dev/null +++ b/src/services/idFreeze.test.ts @@ -0,0 +1,96 @@ +import JSON5 from 'json5' +import { describe, expect, it } from 'vitest' +import { parseSkillFile } from '@/utils/skillFrontmatter' +import { injectFrontmatterId, injectJson5Id } from './idFreeze' + +describe('injectJson5Id', () => { + it('空オブジェクトに ID を注入できる', () => { + const out = injectJson5Id('{}', 'installId', 'wgt-1') + expect(JSON5.parse(out)).toEqual({ installId: 'wgt-1' }) + }) + + it('既存メンバーを保ったまま注入する', () => { + const out = injectJson5Id( + "{ name: 'あいう', autoRun: true }", + 'installId', + 'x.meta.json5', + ) + expect(JSON5.parse(out)).toEqual({ + name: 'あいう', + autoRun: true, + installId: 'x.meta.json5', + }) + }) + + it('trailing comma があっても壊れない', () => { + const out = injectJson5Id("{\n name: 'a',\n}", 'id', 'qry-1') + expect(JSON5.parse(out)).toEqual({ name: 'a', id: 'qry-1' }) + }) + + it('コメントと整形を保持する', () => { + const raw = "{\n // 手書きコメント\n name: 'a', /* inline */\n}" + const out = injectJson5Id(raw, 'id', 'x') + expect(out).toContain('// 手書きコメント') + expect(out).toContain('/* inline */') + expect(JSON5.parse(out)).toEqual({ name: 'a', id: 'x' }) + }) + + it('既存の不正値は後勝ちで上書きされる', () => { + const out = injectJson5Id( + "{ installId: '', name: 'a' }", + 'installId', + 'frozen', + ) + expect(JSON5.parse(out).installId).toBe('frozen') + }) + + it('文字列・コメント内の波括弧に惑わされない', () => { + const out1 = injectJson5Id("{ name: 'a}b' }", 'id', 'x') + expect(JSON5.parse(out1)).toEqual({ name: 'a}b', id: 'x' }) + const out2 = injectJson5Id('{ n: 1 /* } */ }', 'id', 'x') + expect(JSON5.parse(out2)).toEqual({ n: 1, id: 'x' }) + }) + + it('値のクオート・エスケープが正しい', () => { + const tricky = "it's \\ tricky" + const out = injectJson5Id('{}', 'id', tricky) + expect(JSON5.parse(out).id).toBe(tricky) + }) + + it('決定的(同一入力 → 同一出力)', () => { + const raw = "{ name: 'テーマ 1' }" + expect(injectJson5Id(raw, 'id', 'custom-a.ndtheme.json5')).toBe( + injectJson5Id(raw, 'id', 'custom-a.ndtheme.json5'), + ) + }) +}) + +describe('injectFrontmatterId', () => { + it('既存 frontmatter の末尾に id を足す(本文は不変)', () => { + const raw = '---\nname: 天気\nmode: manual\n---\n\n# body\n' + const out = injectFrontmatterId(raw, 'tenki') + const parsed = parseSkillFile(out) + expect(parsed.meta.id).toBe('tenki') + expect(parsed.meta.name).toBe('天気') + expect(parsed.body).toBe('# body\n') + }) + + it('frontmatter が無ければ作る', () => { + const raw = '# 本文だけ\n' + const out = injectFrontmatterId(raw, 'skill-1') + const parsed = parseSkillFile(out) + expect(parsed.meta.id).toBe('skill-1') + expect(parsed.body).toBe('# 本文だけ\n') + }) + + it('既存の不正 id は後勝ちで上書きされる', () => { + const raw = "---\nid: ''\nname: x\n---\nbody" + const out = injectFrontmatterId(raw, 'frozen') + expect(parseSkillFile(out).meta.id).toBe('frozen') + }) + + it('決定的(同一入力 → 同一出力)', () => { + const raw = '---\nname: a\n---\nbody' + expect(injectFrontmatterId(raw, 'x')).toBe(injectFrontmatterId(raw, 'x')) + }) +}) diff --git a/src/services/idFreeze.ts b/src/services/idFreeze.ts new file mode 100644 index 000000000..9a08479df --- /dev/null +++ b/src/services/idFreeze.ts @@ -0,0 +1,113 @@ +/** + * ID 凍結の最小変換 (#913)。 + * + * ID 欠損ファイルへ「現在の実効値」を書き戻す際の変換は、パースした + * 生内容への最小変換 (ID の追記のみ) でなければならない: + * - 同一入力から常に同一バイト列 (揮発デフォルトの混入禁止 — + * 達成済み判定の内容一致が偽陰性になる) + * - 手書きコメント・整形を保持する + * - 既存の不正 id 値は「後勝ち」で上書きする (JSON5 の重複キーと + * frontmatter パーサはどちらも後の宣言が勝つ) + * + * 呼び出し側の契約: raw はパース成功済みであること (パース不能 + * ファイルは凍結対象外)。 + */ + +/** JSON5 の単一クオート文字列としてエスケープする。 */ +function quoteJson5(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + return `'${escaped}'` +} + +/** + * オブジェクトリテラルの「最後の有意文字 2 つ」を探す。 + * 文字列・コメント内の文字は有意と数えない。 + */ +function findTailSignificants( + raw: string, +): { closeBrace: number; before: { ch: string; idx: number } } | null { + let last: { ch: string; idx: number } | null = null + let prev: { ch: string; idx: number } | null = null + let i = 0 + while (i < raw.length) { + const ch = raw[i] as string + if (ch === '/' && raw[i + 1] === '/') { + const nl = raw.indexOf('\n', i) + i = nl === -1 ? raw.length : nl + 1 + continue + } + if (ch === '/' && raw[i + 1] === '*') { + const end = raw.indexOf('*/', i + 2) + i = end === -1 ? raw.length : end + 2 + continue + } + if (ch === '"' || ch === "'") { + // 文字列全体を 1 つの有意トークンとして先頭のみ記録 + prev = last + last = { ch, idx: i } + i++ + while (i < raw.length) { + if (raw[i] === '\\') { + i += 2 + continue + } + if (raw[i] === ch) { + // 文字列の終端も有意扱いにして prev/last を文字列末尾へ寄せる + prev = last + last = { ch: raw[i] as string, idx: i } + i++ + break + } + i++ + } + continue + } + if (!/\s/.test(ch)) { + prev = last + last = { ch, idx: i } + } + i++ + } + if (last?.ch !== '}' || !prev) return null + return { closeBrace: last.idx, before: prev } +} + +/** + * JSON5 オブジェクトファイルへ ID メンバーを注入する。 + * 挿入位置は「最後の有意メンバーの直後」なので、末尾コメントは + * そのまま残り、既存の同名キーには後勝ちで優先する。 + */ +export function injectJson5Id(raw: string, key: string, value: string): string { + const tail = findTailSignificants(raw) + if (!tail) { + throw new Error('injectJson5Id: not a JSON5 object literal') + } + const { before } = tail + const member = `${key}: ${quoteJson5(value)}` + const needsComma = before.ch !== '{' && before.ch !== ',' + const insert = `${needsComma ? ',' : ''}\n ${member},\n` + const at = before.idx + 1 + return raw.slice(0, at) + insert + raw.slice(at) +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)(\r?\n)---(\r?\n)/ + +/** + * skill ファイルの frontmatter へ id を注入する。 + * 既存 frontmatter があれば閉じ `---` の直前に行を足し + * (パーサは後勝ち)、無ければ frontmatter ごと作る。 + */ +export function injectFrontmatterId(raw: string, value: string): string { + const quoted = `'${value.replace(/'/g, "''")}'` + const m = FRONTMATTER_RE.exec(raw) + if (!m || m.index === undefined) { + return `---\nid: ${quoted}\n---\n\n${raw}` + } + const inner = m[1] ?? '' + const innerEnd = m.index + 4 + inner.length + return `${raw.slice(0, innerEnd)}${m[2]}id: ${quoted}${raw.slice(innerEnd)}` +} From 192480911b4f9fe53563fa52f3cad188057657b1 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 06:37:55 +0900 Subject: [PATCH 03/30] =?UTF-8?q?feat(settings):=20sidecar=20=E3=82=B3?= =?UTF-8?q?=E3=83=AC=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=20ID?= =?UTF-8?q?=E2=86=92=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=90=8D=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E8=A1=A8=E5=8C=96=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit widgets/plugins/columnQueries 共通の sidecarFileCollection を #913 仕様へ 書き換える: - loadAll はファイル名の辞書順 (UTF-8 バイト順) で決定的に処理し、各 アイテムに runtime-only の実ファイル基底名 fileBase を保持する - ID 凍結を常設規則化: メタの ID 欠損 (キー不在・空・非文字列・256 文字超。 制御文字は含めない) はメタファイル完全名を injectJson5Id の最小変換で 書き戻す (冪等・コメント保持) - 同一 ID の 2 件目以降は警告 + 読込スキップ (ファイルは削除しない) - 空ソースフォールバック廃止: 「メタあり・ソースなし」はミラー本文からの 再作成、無ければ readOnly (persist 抑止) で可視化 - persist/delete/rename は fileBase 参照 (name からの再計算を全廃)。 新規割当は slugifyName + resolveAvailable。占有判定は 対応表 ∪ 実列挙 (.is/.meta.json5/.history.json5) ∪ ID 集合を casefold 照合 - 書込順 src → meta / 削除順 meta → src (+ 履歴サイドカーも削除)。 rename は src → meta → history、casefold 一致の自己リネームは中間名 2 段 - migrateItems: 規約外名の copy-adopt (生内容 + 凍結 ID・読み戻し検証・ 達成済み判定・suffix 退避)。sweepHistory: 実列挙の主ファイル basename と 対応の取れない .history.json5 を削除 settingsFs には renameQueryFile と isMainDeckWindow (移行の実行主体判定) を 追加し、不要になった per-kind ファイル名ヘルパーを削除。 Co-Authored-By: Claude Fable 5 --- src/services/sidecarFileCollection.test.ts | 631 ++++++++++++++++++--- src/services/sidecarFileCollection.ts | 502 ++++++++++++++-- src/utils/settingsFs.ts | 46 +- 3 files changed, 1033 insertions(+), 146 deletions(-) diff --git a/src/services/sidecarFileCollection.test.ts b/src/services/sidecarFileCollection.test.ts index 9ee70bdfe..211d127b8 100644 --- a/src/services/sidecarFileCollection.test.ts +++ b/src/services/sidecarFileCollection.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSidecarCollection, type SidecarCollectionConfig, + type SidecarItemFile, } from '@/services/sidecarFileCollection' -interface Item { +interface Item extends SidecarItemFile { installId: string name: string src: string @@ -18,9 +19,10 @@ interface FileMeta { active: boolean } -/** インメモリ疑似 FS。ファイル名 → 内容。 */ -function makeFakeFs() { - const files = new Map() +/** インメモリ疑似 FS。ファイル名 → 内容。Rust 側の意味論に合わせる: + * read = 不在で throw / remove = 不在で no-op / rename = 元不在・先在りで throw */ +function makeFakeFs(initial?: Record) { + const files = new Map(Object.entries(initial ?? {})) return { files, list: async () => Array.from(files.keys()), @@ -35,19 +37,31 @@ function makeFakeFs() { remove: async (filename: string) => { files.delete(filename) }, + rename: async (oldFilename: string, newFilename: string) => { + if (!files.has(oldFilename)) throw new Error(`not found: ${oldFilename}`) + if (files.has(newFilename)) + throw new Error(`already exists: ${newFilename}`) + files.set(newFilename, files.get(oldFilename) as string) + files.delete(oldFilename) + }, } } -function makeCollection(fs: ReturnType) { +function makeCollection( + fs: ReturnType, + overrides?: Partial>, +) { const config: SidecarCollectionConfig = { logTag: 'test', - srcFilename: (base) => `${base}.is`, - metaFilename: (base) => `${base}.meta.json5`, + kindFallback: 'widget', + idKey: 'installId', list: fs.list, read: fs.read, write: fs.write, remove: fs.remove, - baseName: (item) => item.name || item.installId, + rename: fs.rename, + idOf: (item) => item.installId, + nameOf: (item) => item.name, srcOf: (item) => item.src, toFileMeta: (item) => ({ installId: item.installId, @@ -60,6 +74,7 @@ function makeCollection(fs: ReturnType) { src, active: meta.active ?? false, }), + ...overrides, } return createSidecarCollection(config) } @@ -72,110 +87,590 @@ const item = (partial: Partial): Item => ({ ...partial, }) +let warn: ReturnType + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + warn.mockRestore() +}) + +describe('loadAll', () => { + it('meta + src ペアをパースして復元し fileBase を保持する', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{ installId: "i-a", name: "a", active: true }', + 'a.is': 'src-a', + 'b.meta.json5': '{ installId: "i-b", name: "b", active: false }', + 'b.is': 'src-b', + }) + const col = makeCollection(fs) + + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items.map((i) => [i.name, i.src, i.active, i.fileBase])).toEqual([ + ['a', 'src-a', true, 'a'], + ['b', 'src-b', false, 'b'], + ]) + }) + + it('ファイル名の辞書順 (バイト順) で決定的に処理する', async () => { + const fs = makeFakeFs() + // list が返す順序をあえて逆順にする + fs.files.set('z.meta.json5', '{ installId: "i-z", name: "z" }') + fs.files.set('z.is', 's') + fs.files.set('A.meta.json5', '{ installId: "i-A", name: "A" }') + fs.files.set('A.is', 's') + const col = makeCollection(fs) + + const { items } = await col.loadAll() + // 'A' (0x41) < 'z' (0x7a) + expect(items.map((i) => i.fileBase)).toEqual(['A', 'z']) + }) + + describe('ID 凍結 (常設規則)', () => { + it('ID キー不在ならメタファイル完全名を注入して書き戻す', async () => { + const fs = makeFakeFs({ + 'My Widget.meta.json5': '{\n name: "My Widget",\n}', + 'My Widget.is': 'code', + }) + const col = makeCollection(fs) + + const { items } = await col.loadAll() + expect(items[0]?.installId).toBe('My Widget.meta.json5') + expect(fs.files.get('My Widget.meta.json5')).toContain( + "installId: 'My Widget.meta.json5'", + ) + // コメントではなくメンバーとして注入されている (パース可能) + const again = await col.loadAll() + expect(again.items[0]?.installId).toBe('My Widget.meta.json5') + }) + + it('凍結は冪等 (2 回目の読込で内容が変わらない)', async () => { + const fs = makeFakeFs({ + 'w.meta.json5': '{ name: "w" }', + 'w.is': 'code', + }) + const col = makeCollection(fs) + await col.loadAll() + const frozen = fs.files.get('w.meta.json5') + await col.loadAll() + expect(fs.files.get('w.meta.json5')).toBe(frozen) + }) + + it('空文字列・非文字列・256 文字超の ID は欠損として凍結する', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{ installId: "", name: "a" }', + 'a.is': 's', + 'b.meta.json5': '{ installId: 42, name: "b" }', + 'b.is': 's', + 'c.meta.json5': `{ installId: "${'x'.repeat(257)}", name: "c" }`, + 'c.is': 's', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + expect(items.map((i) => i.installId)).toEqual([ + 'a.meta.json5', + 'b.meta.json5', + 'c.meta.json5', + ]) + }) + + it('制御文字を含む ID は欠損と判定しない (再凍結しない)', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{ installId: "we\\tird", name: "a" }', + 'a.is': 's', + }) + const col = makeCollection(fs) + const before = fs.files.get('a.meta.json5') + const { items } = await col.loadAll() + expect(items[0]?.installId).toBe('we\tird') + expect(fs.files.get('a.meta.json5')).toBe(before) + }) + + it('凍結はコメントを保持する', async () => { + const fs = makeFakeFs({ + 'w.meta.json5': '{\n // handwritten\n name: "w",\n}', + 'w.is': 'code', + }) + const col = makeCollection(fs) + await col.loadAll() + expect(fs.files.get('w.meta.json5')).toContain('// handwritten') + }) + }) + + describe('重複 ID', () => { + it('同一 ID の 2 件目以降は警告してスキップし、ファイルは削除しない', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{ installId: "dup", name: "a" }', + 'a.is': 'src-a', + 'b.meta.json5': '{ installId: "dup", name: "b" }', + 'b.is': 'src-b', + }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items).toHaveLength(1) + // バイト順で先勝ち + expect(items[0]?.fileBase).toBe('a') + expect(warn).toHaveBeenCalled() + expect(fs.files.has('b.meta.json5')).toBe(true) + expect(fs.files.has('b.is')).toBe(true) + }) + }) + + describe('メタあり・ソースなし', () => { + it('ミラーに同 ID の本文があればソースを再作成して通常読込する', async () => { + const fs = makeFakeFs({ + 'orphan.meta.json5': '{ installId: "o1", name: "orphan" }', + }) + const col = makeCollection(fs, { + mirrorSrcById: (id) => (id === 'o1' ? 'mirror-body' : undefined), + }) + const { items } = await col.loadAll() + expect(items[0]?.src).toBe('mirror-body') + expect(items[0]?.readOnly).toBeUndefined() + expect(fs.files.get('orphan.is')).toBe('mirror-body') + }) + + it('ミラー本文が無ければ readOnly フラグ付きで返す', async () => { + const fs = makeFakeFs({ + 'orphan.meta.json5': '{ installId: "o1", name: "orphan" }', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + expect(items).toHaveLength(1) + expect(items[0]?.readOnly).toBe(true) + expect(items[0]?.src).toBe('') + // 空ソースは書かない + expect(fs.files.has('orphan.is')).toBe(false) + expect(warn).toHaveBeenCalled() + }) + + it('ミラー本文が空文字列なら再作成せず readOnly にする', async () => { + const fs = makeFakeFs({ + 'orphan.meta.json5': '{ installId: "o1", name: "orphan" }', + }) + const col = makeCollection(fs, { mirrorSrcById: () => '' }) + const { items } = await col.loadAll() + expect(items[0]?.readOnly).toBe(true) + expect(fs.files.has('orphan.is')).toBe(false) + }) + }) + + it('パースに失敗した meta はスキップし、他は復元する', async () => { + const fs = makeFakeFs({ + 'broken.meta.json5': '{{{ not json5', + 'ok.meta.json5': '{ installId: "ok", name: "ok" }', + 'ok.is': 's', + }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items).toHaveLength(1) + expect(items[0]?.name).toBe('ok') + expect(warn).toHaveBeenCalled() + }) + + it('ファイルが無ければ entryFileCount=0 で空を返す', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(items).toEqual([]) + expect(entryFileCount).toBe(0) + }) + + it('孤児 .is (メタなし) はアイテム化しない', async () => { + const fs = makeFakeFs({ 'stray.is': 'let x = 1' }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(items).toEqual([]) + expect(entryFileCount).toBe(0) + expect(fs.files.has('stray.is')).toBe(true) + }) +}) + describe('persistItem', () => { - it('src と meta の 2 ファイルを書き込む', async () => { + it('fileBase 未割当なら表示名の slug を割り当て src → meta を書く', async () => { const fs = makeFakeFs() const col = makeCollection(fs) - await col.persistItem(item({ name: 'alpha', src: 'let x = 1' })) + const w = item({ name: 'My Widget!', src: 'let x = 1' }) + await col.persistItem(w, [w]) - expect(fs.files.get('alpha.is')).toBe('let x = 1') - const meta = fs.files.get('alpha.meta.json5') - expect(meta).toContain('installId') - expect(meta).toContain('alpha') + expect(w.fileBase).toBe('my-widget') + expect(fs.files.get('my-widget.is')).toBe('let x = 1') + expect(fs.files.get('my-widget.meta.json5')).toContain('installId') }) - it('name が空なら installId をファイル名の基底に使う', async () => { + it('meta には fileBase / readOnly が漏れない', async () => { const fs = makeFakeFs() const col = makeCollection(fs) - await col.persistItem(item({ name: '', installId: 'p-fallback' })) + const w = item({ name: 'a' }) + await col.persistItem(w, [w]) + const meta = fs.files.get('a.meta.json5') ?? '' + expect(meta).not.toContain('fileBase') + expect(meta).not.toContain('readOnly') + }) + + it('表示名が slug 不能なら種別 fallback に落ちる', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const w = item({ name: 'ウィジェット' }) + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('widget') + }) - expect(fs.files.has('p-fallback.is')).toBe(true) - expect(fs.files.has('p-fallback.meta.json5')).toBe(true) + it('ディレクトリ実列挙との衝突は -2 連番で回避する (casefold)', async () => { + const fs = makeFakeFs({ 'Alpha.meta.json5': '{{{ broken' }) + const col = makeCollection(fs) + const w = item({ name: 'alpha' }) + await col.persistItem(w, [w]) + // 大文字小文字のみ違う既存名も占有とみなす + expect(w.fileBase).toBe('alpha-2') + }) + + it('対応表 (他アイテムの fileBase) と種別内 ID も占有として扱う', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const other = item({ installId: 'x', name: 'other', fileBase: 'alpha' }) + const idOwner = item({ installId: 'alpha-2', name: 'idowner' }) + const w = item({ installId: 'p9', name: 'alpha' }) + await col.persistItem(w, [other, idOwner, w]) + expect(w.fileBase).toBe('alpha-3') + }) + + it('履歴サイドカーの basename も占有として扱う', async () => { + const fs = makeFakeFs({ 'alpha.history.json5': '{ entries: [] }' }) + const col = makeCollection(fs) + const w = item({ name: 'alpha' }) + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('alpha-2') + }) + + it('fileBase 割当済みなら name が変わっても再計算しない', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const w = item({ name: 'a' }) + await col.persistItem(w, [w]) + w.name = 'renamed' + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('a') + expect(fs.files.has('renamed.is')).toBe(false) + expect(fs.files.get('a.meta.json5')).toContain('renamed') + }) + + it('readOnly アイテムは書き込まない (persist 抑止)', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const w = item({ name: 'a', readOnly: true, fileBase: 'a', src: '' }) + await col.persistItem(w, [w]) + expect(fs.files.size).toBe(0) }) }) describe('persistAll', () => { - it('全アイテムのファイルペアを書き込む', async () => { + it('全アイテムを書き、同名は連番で分離する', async () => { const fs = makeFakeFs() const col = makeCollection(fs) - await col.persistAll([item({ name: 'a' }), item({ name: 'b' })]) - + const a = item({ installId: 'i1', name: 'same' }) + const b = item({ installId: 'i2', name: 'same' }) + await col.persistAll([a, b], [a, b]) + expect(a.fileBase).toBe('same') + expect(b.fileBase).toBe('same-2') expect(fs.files.size).toBe(4) - expect(fs.files.has('a.is')).toBe(true) - expect(fs.files.has('b.meta.json5')).toBe(true) }) }) describe('deleteItemFiles', () => { - it('src と meta の両方を削除する', async () => { - const fs = makeFakeFs() + it('meta → src → 履歴サイドカーを削除する', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{}', + 'a.is': 's', + 'a.history.json5': '{ entries: [] }', + }) const col = makeCollection(fs) - await col.persistItem(item({ name: 'a' })) - await col.deleteItemFiles(item({ name: 'a' })) + await col.deleteItemFiles(item({ name: 'a', fileBase: 'a' })) + expect(fs.files.size).toBe(0) + }) + it('履歴サイドカーが無くても失敗しない', async () => { + const fs = makeFakeFs({ 'a.meta.json5': '{}', 'a.is': 's' }) + const col = makeCollection(fs) + await col.deleteItemFiles(item({ name: 'a', fileBase: 'a' })) expect(fs.files.size).toBe(0) }) + + it('fileBase 未割当なら no-op', async () => { + const fs = makeFakeFs({ 'a.meta.json5': '{}' }) + const col = makeCollection(fs) + await col.deleteItemFiles(item({ name: 'a' })) + expect(fs.files.size).toBe(1) + }) }) -describe('loadAll', () => { - it('meta + src ペアをパースして復元する', async () => { - const fs = makeFakeFs() +describe('renameItemFiles', () => { + it('src → meta → 履歴を新 slug へ rename し fileBase を更新する', async () => { + const fs = makeFakeFs({ + 'old.meta.json5': '{ installId: "p1", name: "old" }', + 'old.is': 'code', + 'old.history.json5': '{ entries: [] }', + }) const col = makeCollection(fs) - await col.persistAll([ - item({ name: 'a', src: 'src-a', active: true }), - item({ name: 'b', src: 'src-b', active: false }), - ]) + const w = item({ name: 'New Name', fileBase: 'old' }) + await col.renameItemFiles(w, [w]) - const { items, entryFileCount } = await col.loadAll() - expect(entryFileCount).toBe(2) - expect(items.map((i) => [i.name, i.src, i.active])).toEqual([ - ['a', 'src-a', true], - ['b', 'src-b', false], - ]) + expect(w.fileBase).toBe('new-name') + expect(fs.files.get('new-name.is')).toBe('code') + expect(fs.files.has('new-name.meta.json5')).toBe(true) + expect(fs.files.has('new-name.history.json5')).toBe(true) + expect(fs.files.has('old.is')).toBe(false) + expect(fs.files.has('old.meta.json5')).toBe(false) }) - it('src ファイルが無い meta は src="" で復元する', async () => { + it('履歴サイドカーが無ければ skip する', async () => { + const fs = makeFakeFs({ + 'old.meta.json5': '{}', + 'old.is': 'code', + }) + const col = makeCollection(fs) + const w = item({ name: 'fresh', fileBase: 'old' }) + await col.renameItemFiles(w, [w]) + expect(fs.files.has('fresh.is')).toBe(true) + expect(fs.files.has('fresh.history.json5')).toBe(false) + }) + + it('占有されていれば連番で回避する', async () => { + const fs = makeFakeFs({ + 'old.meta.json5': '{}', + 'old.is': 'code', + 'taken.meta.json5': '{}', + }) + const col = makeCollection(fs) + const w = item({ name: 'taken', fileBase: 'old' }) + await col.renameItemFiles(w, [w]) + expect(w.fileBase).toBe('taken-2') + }) + + it('slug が変わらないリネームは no-op (自身は占有と見なさない)', async () => { + const fs = makeFakeFs({ + 'alpha.meta.json5': '{}', + 'alpha.is': 'code', + }) + const col = makeCollection(fs) + const w = item({ name: 'Alpha', fileBase: 'alpha' }) + await col.renameItemFiles(w, [w]) + expect(w.fileBase).toBe('alpha') + expect(fs.files.has('alpha.is')).toBe(true) + }) + + it('casefold 一致 (大文字小文字のみ違い) は中間名経由の 2 段で行う', async () => { + const fs = makeFakeFs({ + 'Alpha.meta.json5': '{ installId: "p1" }', + 'Alpha.is': 'code', + }) + const col = makeCollection(fs) + const w = item({ name: 'alpha', fileBase: 'Alpha' }) + await col.renameItemFiles(w, [w]) + expect(w.fileBase).toBe('alpha') + expect(fs.files.get('alpha.is')).toBe('code') + expect(fs.files.has('Alpha.is')).toBe(false) + // 中間名の残骸が無い + expect(fs.files.size).toBe(2) + }) + + it('fileBase 未割当なら no-op (persist 側で割当する)', async () => { const fs = makeFakeFs() - fs.files.set('orphan.meta.json5', '{ installId: "o1", name: "orphan" }') const col = makeCollection(fs) + const w = item({ name: 'a' }) + await col.renameItemFiles(w, [w]) + expect(w.fileBase).toBeUndefined() + }) +}) +describe('migrateItems (copy-adopt)', () => { + it('規約外名を新 slug へ copy-adopt し旧ファイルを削除する', async () => { + const fs = makeFakeFs({ + 'My Widget.meta.json5': + '{\n // keep me\n installId: "i1",\n name: "My Widget",\n}', + 'My Widget.is': 'code', + }) + const col = makeCollection(fs) const { items } = await col.loadAll() - expect(items).toHaveLength(1) - expect(items[0]?.src).toBe('') - expect(items[0]?.active).toBe(false) + await col.migrateItems(items) + + expect(items[0]?.fileBase).toBe('my-widget') + // 生メタ内容の最小変換 (コメント保持・再シリアライズしない) + expect(fs.files.get('my-widget.meta.json5')).toContain('// keep me') + expect(fs.files.get('my-widget.is')).toBe('code') + expect(fs.files.has('My Widget.meta.json5')).toBe(false) + expect(fs.files.has('My Widget.is')).toBe(false) }) - it('パースに失敗した meta はスキップし、他は復元する', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const fs = makeFakeFs() - fs.files.set('broken.meta.json5', '{{{ not json5') + it('規約適合名は触らない (冪等)', async () => { + const fs = makeFakeFs({ + 'ok.meta.json5': '{ installId: "i1", name: "ok" }', + 'ok.is': 'code', + }) const col = makeCollection(fs) - await col.persistItem(item({ name: 'ok' })) + const { items } = await col.loadAll() + const before = new Map(fs.files) + await col.migrateItems(items) + expect(fs.files).toEqual(before) + expect(items[0]?.fileBase).toBe('ok') + }) - const { items, entryFileCount } = await col.loadAll() - expect(entryFileCount).toBe(2) - expect(items).toHaveLength(1) - expect(items[0]?.name).toBe('ok') - expect(warn).toHaveBeenCalled() - warn.mockRestore() + it('2 回実行しても結果が変わらない (冪等)', async () => { + const fs = makeFakeFs({ + 'Bad Name.meta.json5': '{ installId: "i1", name: "Bad Name" }', + 'Bad Name.is': 'code', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + const after1 = new Map(fs.files) + await col.migrateItems(items) + expect(fs.files).toEqual(after1) + const reloaded = await col.loadAll() + await col.migrateItems(reloaded.items) + expect(fs.files).toEqual(after1) }) - it('ファイルが無ければ entryFileCount=0 で空を返す', async () => { - const fs = makeFakeFs() + it('ソースを欠く readOnly アイテムは据え置く', async () => { + const fs = makeFakeFs({ + 'Bad Name.meta.json5': '{ installId: "i1", name: "Bad Name" }', + }) const col = makeCollection(fs) + const { items } = await col.loadAll() + expect(items[0]?.readOnly).toBe(true) + await col.migrateItems(items) + expect(fs.files.has('Bad Name.meta.json5')).toBe(true) + expect(fs.files.has('bad-name.meta.json5')).toBe(false) + // 空ソースを書かない + expect(fs.files.has('bad-name.is')).toBe(false) + }) - const { items, entryFileCount } = await col.loadAll() - expect(items).toEqual([]) - expect(entryFileCount).toBe(0) + describe('slug 衝突時の達成済み判定', () => { + it('衝突先が同 ID かつ同内容なら旧削除のみ再実行する', async () => { + // クラッシュ残骸: 新名は書けたが旧削除前に落ちたケース + const oldMeta = '{ installId: "i1", name: "Bad Name" }' + const fs = makeFakeFs({ + 'Bad Name.meta.json5': oldMeta, + 'Bad Name.is': 'code', + 'bad-name.meta.json5': oldMeta, + 'bad-name.is': 'code', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + // バイト順で 'Bad Name' が先勝ち、'bad-name' は重複 ID skip + expect(items).toHaveLength(1) + expect(items[0]?.fileBase).toBe('Bad Name') + + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('bad-name') + expect(fs.files.has('Bad Name.meta.json5')).toBe(false) + expect(fs.files.has('Bad Name.is')).toBe(false) + expect(fs.files.get('bad-name.is')).toBe('code') + // suffix 名は作られない + expect(fs.files.has('bad-name-2.meta.json5')).toBe(false) + }) + + it('同 ID・内容不一致なら削除せず suffix へ退避する', async () => { + const fs = makeFakeFs({ + 'Bad Name.meta.json5': '{ installId: "i1", name: "Bad Name" }', + 'Bad Name.is': 'new-code', + 'bad-name.meta.json5': '{ installId: "i1", name: "Bad Name" }', + 'bad-name.is': 'old-code', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + // 衝突先は温存、対象は suffix 名で正規化 + expect(fs.files.get('bad-name.is')).toBe('old-code') + expect(items[0]?.fileBase).toBe('bad-name-2') + expect(fs.files.get('bad-name-2.is')).toBe('new-code') + expect(fs.files.has('Bad Name.is')).toBe(false) + }) + + it('ID 不一致なら単純な占有として suffix する', async () => { + const fs = makeFakeFs({ + 'Bad Name.meta.json5': '{ installId: "i1", name: "Bad Name" }', + 'Bad Name.is': 'code', + 'bad-name.meta.json5': '{ installId: "other", name: "bad-name" }', + 'bad-name.is': 'other-code', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + const migrated = items.find((i) => i.installId === 'i1') + expect(migrated?.fileBase).toBe('bad-name-2') + expect(fs.files.get('bad-name.is')).toBe('other-code') + }) }) - it('meta 以外のファイル (src 単体等) はエントリとして数えない', async () => { - const fs = makeFakeFs() - fs.files.set('stray.is', 'let x = 1') + it('新旧名が casefold 一致なら中間名経由で移行する', async () => { + const fs = makeFakeFs({ + 'Alpha.meta.json5': '{ installId: "i1", name: "Alpha" }', + 'Alpha.is': 'code', + }) const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('alpha') + expect(fs.files.get('alpha.is')).toBe('code') + expect(fs.files.has('Alpha.is')).toBe(false) + expect(fs.files.has('Alpha.meta.json5')).toBe(false) + // 中間名の残骸が無い (meta/src の 2 ファイルのみ) + expect(fs.files.size).toBe(2) + }) - const { items, entryFileCount } = await col.loadAll() - expect(items).toEqual([]) - expect(entryFileCount).toBe(0) + it('表示名が欠損なら種別 fallback で slug 化する', async () => { + const fs = makeFakeFs({ + '無題.meta.json5': '{ installId: "i1" }', + '無題.is': 'code', + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('widget') + expect(fs.files.has('widget.is')).toBe(true) + }) +}) + +describe('sweepHistory', () => { + it('主ファイルと対応の取れない .history.json5 を削除する', async () => { + const fs = makeFakeFs({ + 'alive.meta.json5': '{ installId: "i1", name: "alive" }', + 'alive.is': 'code', + 'alive.history.json5': '{ entries: [] }', + 'dead.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('alive.history.json5')).toBe(true) + expect(fs.files.has('dead.history.json5')).toBe(false) + }) + + it('パース不能・スキップ個体の主ファイルの履歴は保全する (読込採否を問わない)', async () => { + const fs = makeFakeFs({ + 'broken.meta.json5': '{{{ not json5', + 'broken.history.json5': '{ entries: [] }', + 'orphan.is': 'code', + 'orphan.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('broken.history.json5')).toBe(true) + expect(fs.files.has('orphan.history.json5')).toBe(true) + }) + + it('照合は casefold で行う', async () => { + const fs = makeFakeFs({ + 'Alpha.meta.json5': '{}', + 'alpha.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('alpha.history.json5')).toBe(true) }) }) diff --git a/src/services/sidecarFileCollection.ts b/src/services/sidecarFileCollection.ts index 3ebde625d..c2520d155 100644 --- a/src/services/sidecarFileCollection.ts +++ b/src/services/sidecarFileCollection.ts @@ -1,36 +1,76 @@ import JSON5 from 'json5' +import { injectJson5Id } from '@/services/idFreeze' +import { + casefold, + isSlugConforming, + resolveAvailable, + slugifyName, +} from '@/services/settingsSlug' + /** * 「src + meta サイドカーペアで 1 アイテム」を表すコレクションのファイル - * 永続化サービス (#782 Phase 2)。plugins / widgets が同型実装していた - * persistSingle / persistAll / deleteFiles / initFileStorage のパース部を - * 共通化する。 + * 永続化サービス (#782 Phase 2 → #913 で対応表化)。 + * + * #913 の不変条件: + * - ファイル basename は ASCII slug (slugify の不動点)。参照はファイル内 ID + * - ID → 実ファイル名の対応表が唯一の正。実体は各アイテムの runtime-only + * フィールド `fileBase` (ファイルへは書かない。localStorage ミラーには同乗) + * - ID 凍結は常設規則: ID 欠損のメタを読んだらメタファイル完全名を書き戻す + * - 履歴サイドカー (`.history.json5`) の basename は主ファイルと同一 * * 状態は持たない。reactive state・localStorage・マージ/seed の方針は * 引き続き store 側が持ち、ファイル I/O の手続きだけをここへ委譲する。 + * (同一ウィンドウ内の書込交錯を防ぐ直列化キューのみ内部に持つ) */ -export interface SidecarCollectionConfig { + +/** 各アイテムが持つ runtime-only のファイル対応フィールド。 */ +export interface SidecarItemFile { + /** + * 実ファイル基底名 (拡張子 `.is` / `.meta.json5` を除いた部分)。 + * 未割当 (undefined) = まだファイル化されていない。 + * ファイルへは書かない (toFileMeta に含めないこと)。 + */ + fileBase?: string + /** + * ソース欠損 (localStorage ミラーにも本文なし) の読取専用アイテム。 + * persist は抑止される (空ソースの書き戻しでコードを恒久喪失させない)。 + */ + readOnly?: boolean +} + +export interface SidecarCollectionConfig { /** console.warn の識別子 (例: 'plugins') */ logTag: string - /** 基底名 → src ファイル名 (例: `${name}.is`) */ - srcFilename(base: string): string - /** 基底名 → meta ファイル名 (例: `${name}.meta.json5`) */ - metaFilename(base: string): string + /** slug が空になったときの種別 fallback ('widget' | 'plugin' | 'query') */ + kindFallback: string + /** メタ JSON5 内の ID キー名 ('installId' | 'id') */ + idKey: string list(): Promise read(filename: string): Promise write(filename: string, content: string): Promise remove(filename: string): Promise - /** ファイル名の基底 (慣例: `item.name || item.installId`) */ - baseName(item: T): string + rename(oldFilename: string, newFilename: string): Promise + idOf(item: T): string + /** 表示名 (slug の元)。空なら kindFallback に落ちる */ + nameOf(item: T): string srcOf(item: T): string - /** item → meta ファイルに書く projection */ + /** item → meta ファイルに書く projection。fileBase/readOnly を含めないこと */ toFileMeta(item: T): M /** パース済み meta + src → item。呼び出し側の try/catch はサービスが持つ */ fromFile(meta: M, src: string, metaFile: string): T + /** + * localStorage ミラーから同 ID の本文を引く。 + * 「メタあり・ソースなし」のソース再作成 (読込規則) に使う。 + */ + mirrorSrcById?(id: string): string | undefined } const META_SUFFIX = '.meta.json5' const SRC_SUFFIX = '.is' +const HISTORY_SUFFIX = '.history.json5' +/** ID 凍結の欠損判定に使う上限長。制御文字含有は欠損に含めない (#913) */ +const ID_MAX_LENGTH = 256 export interface LoadAllResult { items: T[] @@ -42,57 +82,419 @@ export interface LoadAllResult { entryFileCount: number } -export function createSidecarCollection( +const encoder = new TextEncoder() + +/** ファイル名の辞書順 (UTF-8 バイト順)。OS 依存の列挙順に依存しない。 */ +function compareBytes(a: string, b: string): number { + const x = encoder.encode(a) + const y = encoder.encode(b) + const n = Math.min(x.length, y.length) + for (let i = 0; i < n; i++) { + const d = (x[i] as number) - (y[i] as number) + if (d !== 0) return d + } + return x.length - y.length +} + +/** ID 凍結の「欠損」判定: キー不在・空・非文字列・上限長超過。 */ +function isValidId(v: unknown): v is string { + return typeof v === 'string' && v.length > 0 && v.length <= ID_MAX_LENGTH +} + +/** 規定拡張子を剥がした basename。規定拡張子でなければ null。 */ +function stripKnownExt(filename: string): string | null { + for (const ext of [META_SUFFIX, HISTORY_SUFFIX, SRC_SUFFIX]) { + if (filename.endsWith(ext)) return filename.slice(0, -ext.length) + } + return null +} + +export function createSidecarCollection( cfg: SidecarCollectionConfig, ) { - async function persistItem(item: T): Promise { - const base = cfg.baseName(item) - const meta = cfg.toFileMeta(item) - await Promise.all([ - cfg.write(cfg.srcFilename(base), cfg.srcOf(item)), - cfg.write(cfg.metaFilename(base), JSON5.stringify(meta, null, 2)), - ]) + // 同一ウィンドウ内の変更系操作を直列化する (空き名探索 → 書込の交錯防止)。 + let chain: Promise = Promise.resolve() + function enqueue(fn: () => Promise): Promise { + const next = chain.then(fn, fn) + chain = next.catch(() => undefined) + return next } - async function persistAll(items: readonly T[]): Promise { - await Promise.all(items.map((item) => persistItem(item))) + /** + * 占有集合 (casefold 済み) を構築する。 + * 探索空間 = 種別ディレクトリの実列挙 (規定拡張子の basename。パース不能・ + * スキップ個体・履歴も含む) ∪ 対応表 (各アイテムの fileBase) + * ∪ (ID を決める操作では) 種別内 ID 集合。 + * 操作対象アイテム自身 (とその実ファイル) は占有とみなさない。 + */ + async function buildTaken( + allItems: readonly T[], + opts: { excludeItem?: T; includeIds: boolean }, + ): Promise> { + const files = await cfg.list() + const excludeBase = opts.excludeItem?.fileBase + const taken = new Set() + for (const f of files) { + const base = stripKnownExt(f) + if (base === null) continue + if (excludeBase !== undefined && base === excludeBase) continue + taken.add(casefold(base)) + } + for (const it of allItems) { + if (it === opts.excludeItem) continue + if (it.fileBase !== undefined) taken.add(casefold(it.fileBase)) + if (opts.includeIds) taken.add(casefold(cfg.idOf(it))) + } + return taken } - async function deleteItemFiles(item: T): Promise { - const base = cfg.baseName(item) - await Promise.all([ - cfg.remove(cfg.srcFilename(base)), - cfg.remove(cfg.metaFilename(base)), - ]) + async function loadAllImpl(): Promise> { + const allFiles = (await cfg.list()).slice().sort(compareBytes) + const fileSet = new Set(allFiles) + const metaFiles = allFiles.filter((f) => f.endsWith(META_SUFFIX)) + + const items: T[] = [] + const seenIds = new Set() + for (const metaFile of metaFiles) { + try { + const base = metaFile.slice(0, -META_SUFFIX.length) + let rawMeta = await cfg.read(metaFile) + let parsed = JSON5.parse(rawMeta) as Record + if (!isValidId(parsed[cfg.idKey])) { + // ID 凍結 (常設規則): 実効値 = メタファイルの完全名を最小変換で注入 + rawMeta = injectJson5Id(rawMeta, cfg.idKey, metaFile) + await cfg.write(metaFile, rawMeta) + parsed = JSON5.parse(rawMeta) as Record + } + const id = parsed[cfg.idKey] as string + if (seenIds.has(id)) { + console.warn( + `[${cfg.logTag}] duplicate id "${id}" in ${metaFile} — skipped (file kept)`, + ) + continue + } + seenIds.add(id) + + const srcFile = base + SRC_SUFFIX + let src = '' + let readOnly = false + if (fileSet.has(srcFile)) { + src = await cfg.read(srcFile) + } else { + const mirror = cfg.mirrorSrcById?.(id) + if (typeof mirror === 'string' && mirror.length > 0) { + // ミラー本文からソースを再作成して通常読込 (移行 (b) と同じ向き) + await cfg.write(srcFile, mirror) + src = mirror + console.warn( + `[${cfg.logTag}] ${srcFile} was missing — recreated from mirror`, + ) + } else { + // 空ソースは書かない。読取専用で可視化する + readOnly = true + console.warn( + `[${cfg.logTag}] ${srcFile} is missing and no mirror body — read-only`, + ) + } + } + + const item = cfg.fromFile(parsed as unknown as M, src, metaFile) + item.fileBase = base + if (readOnly) item.readOnly = true + items.push(item) + } catch (e) { + console.warn(`[${cfg.logTag}] failed to parse ${metaFile}:`, e) + } + } + return { items, entryFileCount: metaFiles.length } } - async function loadAll(): Promise> { - const allFiles = await cfg.list() - const metaFiles = allFiles.filter((f) => f.endsWith(META_SUFFIX)) + async function persistItemImpl( + item: T, + allItems: readonly T[], + ): Promise { + if (item.readOnly) { + console.warn( + `[${cfg.logTag}] skip persisting read-only item "${cfg.idOf(item)}"`, + ) + return + } + if (item.fileBase === undefined) { + // 新規割当 (ID を決める操作): ファイル名と ID 集合の両方に対して空きを探す + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: true, + }) + item.fileBase = resolveAvailable( + slugifyName(cfg.nameOf(item), cfg.kindFallback), + (c) => taken.has(casefold(c)), + ) + } + const base = item.fileBase + // 書込順は src → meta (メタを存在マーカーとする) + await cfg.write(base + SRC_SUFFIX, cfg.srcOf(item)) + await cfg.write( + base + META_SUFFIX, + JSON5.stringify(cfg.toFileMeta(item), null, 2), + ) + } - const results = await Promise.all( - metaFiles.map(async (metaFile) => { + async function persistAllImpl( + items: readonly T[], + allItems: readonly T[], + ): Promise { + // 直列実行 (並行だと空き名探索が交錯して同名を二重割当する) + for (const item of items) { + await persistItemImpl(item, allItems) + } + } + + async function deleteItemFilesImpl(item: T): Promise { + const base = item.fileBase + if (base === undefined) return + // 削除順は meta → src (メタを存在マーカーとする)。履歴サイドカーも削除 + // (残すと同名の新規アイテムが削除済みアイテムの履歴リングを継承する)。 + // remove は missing = no-op 意味論 (settings_store.rs 側)。 + await cfg.remove(base + META_SUFFIX) + await cfg.remove(base + SRC_SUFFIX) + await cfg.remove(base + HISTORY_SUFFIX) + } + + /** src → meta → history の順で rename する。不在は skip (ensure-dest)。 */ + async function renameFileSet(from: string, to: string): Promise { + const files = new Set(await cfg.list()) + for (const ext of [SRC_SUFFIX, META_SUFFIX, HISTORY_SUFFIX]) { + const oldFile = from + ext + const newFile = to + ext + if (!files.has(oldFile)) continue + if (files.has(newFile)) { + console.warn( + `[${cfg.logTag}] rename target already exists, skipped: ${newFile}`, + ) + continue + } + await cfg.rename(oldFile, newFile) + } + } + + /** + * 表示名リネームにファイルを追随させる (ID 不変・ファイル名側のみ占有解決)。 + * fileBase 未割当なら no-op (次の persist が割り当てる)。 + */ + async function renameItemFilesImpl( + item: T, + allItems: readonly T[], + ): Promise { + const oldBase = item.fileBase + if (oldBase === undefined) return + const newBase = slugifyName(cfg.nameOf(item), cfg.kindFallback) + if (newBase === oldBase) return + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: false, + }) + const resolved = resolveAvailable(newBase, (c) => taken.has(casefold(c))) + if (resolved === oldBase) return + if (casefold(resolved) === casefold(oldBase)) { + // 大文字小文字のみ違う自己リネーム: case-insensitive FS では同一ファイル + // へ解決するため、規定拡張子を維持した slug 中間名経由の 2 段で行う + const inter = resolveAvailable( + slugifyName(`${resolved} mv`, cfg.kindFallback), + (c) => taken.has(casefold(c)) || casefold(c) === casefold(resolved), + ) + await renameFileSet(oldBase, inter) + await renameFileSet(inter, resolved) + } else { + await renameFileSet(oldBase, resolved) + } + item.fileBase = resolved + } + + /** 読み戻し検証: 書いた内容と一致するか。 */ + async function verifyWritten( + base: string, + rawSrc: string, + rawMeta: string, + ): Promise { + try { + const [src, meta] = await Promise.all([ + cfg.read(base + SRC_SUFFIX), + cfg.read(base + META_SUFFIX), + ]) + return src === rawSrc && meta === rawMeta + } catch { + return false + } + } + + /** + * 移行 (a): 規約外名 1 アイテムの copy-adopt。 + * 読む → (凍結済みの) 生内容を新 slug 名へ書込 → 検証 → 旧削除。 + */ + async function copyAdoptOne( + item: T, + oldBase: string, + allItems: readonly T[], + ): Promise { + const oldMetaFile = oldBase + META_SUFFIX + const rawMeta = await cfg.read(oldMetaFile) + const rawSrc = await cfg.read(oldBase + SRC_SUFFIX) + const parsed = JSON5.parse(rawMeta) as Record + // slug の元はファイル内メタの表示名 (欠損なら種別 fallback) + const displayName = typeof parsed.name === 'string' ? parsed.name : '' + const candidate = slugifyName(displayName, cfg.kindFallback) + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: false, + }) + const isTaken = (c: string) => taken.has(casefold(c)) + + if (!isTaken(candidate) && casefold(candidate) === casefold(oldBase)) { + // 大文字小文字のみ違い: 中間名経由の 2 段 (copy → 旧削除 → rename) + const inter = resolveAvailable( + slugifyName(`${candidate} mv`, cfg.kindFallback), + (c) => isTaken(c) || casefold(c) === casefold(candidate), + ) + await cfg.write(inter + SRC_SUFFIX, rawSrc) + await cfg.write(inter + META_SUFFIX, rawMeta) + if (!(await verifyWritten(inter, rawSrc, rawMeta))) { + console.warn(`[${cfg.logTag}] migration verify failed for ${inter}`) + await cfg.remove(inter + META_SUFFIX) + await cfg.remove(inter + SRC_SUFFIX) + return + } + await cfg.remove(oldMetaFile) + await cfg.remove(oldBase + SRC_SUFFIX) + await cfg.rename(inter + SRC_SUFFIX, candidate + SRC_SUFFIX) + await cfg.rename(inter + META_SUFFIX, candidate + META_SUFFIX) + item.fileBase = candidate + return + } + + let final: string + if (!isTaken(candidate)) { + final = candidate + } else { + // 達成済み判定: 衝突先 (casefold 一致の実名 meta。自身の旧名は除く) の + // 内部 ID と内容を照合する + const files = await cfg.list() + const occupantMetaFile = files.find( + (f) => + f.endsWith(META_SUFFIX) && + f.slice(0, -META_SUFFIX.length) !== oldBase && + casefold(f.slice(0, -META_SUFFIX.length)) === casefold(candidate), + ) + if (occupantMetaFile !== undefined) { + const occupantBase = occupantMetaFile.slice(0, -META_SUFFIX.length) + let occupantId: unknown + let occupantMeta: string | null = null try { - const srcFile = metaFile.replace(/\.meta\.json5$/, SRC_SUFFIX) - const [metaContent, src] = await Promise.all([ - cfg.read(metaFile), - allFiles.includes(srcFile) - ? cfg.read(srcFile) - : Promise.resolve(''), - ]) - const meta = JSON5.parse(metaContent) as M - return cfg.fromFile(meta, src, metaFile) - } catch (e) { - console.warn(`[${cfg.logTag}] failed to parse ${metaFile}:`, e) - return null + occupantMeta = await cfg.read(occupantMetaFile) + occupantId = (JSON5.parse(occupantMeta) as Record)[ + cfg.idKey + ] + } catch { + // パース不能な衝突先は ID 不一致として扱う (単純占有 → suffix) } - }), - ) - return { - items: results.filter((item): item is Awaited => item !== null), - entryFileCount: metaFiles.length, + if (occupantId === cfg.idOf(item)) { + let occupantSrc: string | null = null + try { + occupantSrc = await cfg.read(occupantBase + SRC_SUFFIX) + } catch { + // 衝突先ソース不在 → 内容不一致扱い + } + if (occupantMeta === rawMeta && occupantSrc === rawSrc) { + // 達成済み (クラッシュ残骸の回収): 旧削除のみ再実行 + await cfg.remove(oldMetaFile) + await cfg.remove(oldBase + SRC_SUFFIX) + item.fileBase = occupantBase + return + } + // ID 一致・内容不一致 (旧形式バックアップ復元等): 削除せず suffix へ。 + // 重複 ID 警告として可視化し手動解決に委ねる + console.warn( + `[${cfg.logTag}] duplicate id "${cfg.idOf(item)}" at ${occupantMetaFile} with different content — kept both`, + ) + } + } + final = resolveAvailable(candidate, isTaken) + } + + await cfg.write(final + SRC_SUFFIX, rawSrc) + await cfg.write(final + META_SUFFIX, rawMeta) + if (!(await verifyWritten(final, rawSrc, rawMeta))) { + console.warn(`[${cfg.logTag}] migration verify failed for ${final}`) + await cfg.remove(final + META_SUFFIX) + await cfg.remove(final + SRC_SUFFIX) + return + } + if (casefold(final) === casefold(oldBase)) { + // 保険: 旧削除前に新旧が同一ファイルへ解決しないことを確認 + console.warn( + `[${cfg.logTag}] migration target resolves to the same file as ${oldBase} — old files kept`, + ) + item.fileBase = final + return + } + await cfg.remove(oldMetaFile) + await cfg.remove(oldBase + SRC_SUFFIX) + item.fileBase = final + } + + /** + * 移行 (a): 対応表のうち規約外名 (slugify の不動点でない) のアイテムを + * copy-adopt で正規化する。ソースを欠く readOnly アイテムは据え置き。 + * 冪等 (失敗分は次回起動でリトライされる)。 + */ + async function migrateItemsImpl(items: readonly T[]): Promise { + for (const item of items) { + const oldBase = item.fileBase + if (oldBase === undefined || item.readOnly) continue + if (isSlugConforming(oldBase)) continue + try { + await copyAdoptOne(item, oldBase, items) + } catch (e) { + console.warn(`[${cfg.logTag}] migration failed for ${oldBase}:`, e) + } } } - return { persistItem, persistAll, deleteItemFiles, loadAll } + /** + * 履歴 sweep: sweep 時点のディレクトリ実列挙に存在する規定拡張子付き + * 全主ファイル (読込採否を問わない) の basename 集合と casefold で照合し、 + * 対応の取れない `.history.json5` を削除する。 + */ + async function sweepHistoryImpl(): Promise { + const files = await cfg.list() + const mainBases = new Set() + for (const f of files) { + if (f.endsWith(HISTORY_SUFFIX)) continue + if (f.endsWith(META_SUFFIX)) { + mainBases.add(casefold(f.slice(0, -META_SUFFIX.length))) + } else if (f.endsWith(SRC_SUFFIX)) { + mainBases.add(casefold(f.slice(0, -SRC_SUFFIX.length))) + } + } + for (const f of files) { + if (!f.endsWith(HISTORY_SUFFIX)) continue + const base = casefold(f.slice(0, -HISTORY_SUFFIX.length)) + if (!mainBases.has(base)) { + await cfg.remove(f) + } + } + } + + return { + loadAll: () => enqueue(loadAllImpl), + persistItem: (item: T, allItems: readonly T[]) => + enqueue(() => persistItemImpl(item, allItems)), + persistAll: (items: readonly T[], allItems: readonly T[]) => + enqueue(() => persistAllImpl(items, allItems)), + deleteItemFiles: (item: T) => enqueue(() => deleteItemFilesImpl(item)), + renameItemFiles: (item: T, allItems: readonly T[]) => + enqueue(() => renameItemFilesImpl(item, allItems)), + migrateItems: (items: readonly T[]) => + enqueue(() => migrateItemsImpl(items)), + sweepHistory: () => enqueue(sweepHistoryImpl), + } } diff --git a/src/utils/settingsFs.ts b/src/utils/settingsFs.ts index 04154054b..7084fdc8e 100644 --- a/src/utils/settingsFs.ts +++ b/src/utils/settingsFs.ts @@ -4,6 +4,17 @@ export const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) +/** + * このウィンドウがメインウィンドウか (#913 マイグレーションの実行主体判定)。 + * サブウィンドウ (カラム分離・ミラーデッキ) は `?profile=` / `?window=` を + * 付けて開かれる (useDeckWindow.ts)。 + */ +export function isMainDeckWindow(): boolean { + if (typeof window === 'undefined') return true + const params = new URLSearchParams(window.location.search) + return !params.has('profile') && !params.has('window') +} + /** Characters not allowed in filenames (Windows + Unix safety). */ const INVALID_CHARS = /[<>:"/\\|?*]/g @@ -315,16 +326,6 @@ export async function openMemoFileInEditor(name: string): Promise { // --- Plugin helpers --- const PLUGINS_DIR = 'plugins' -const PLUGIN_SRC_EXT = '.is' -const PLUGIN_META_EXT = '.meta.json5' - -export function pluginSrcFilename(name: string): string { - return sanitizeFilename(name) + PLUGIN_SRC_EXT -} - -export function pluginMetaFilename(name: string): string { - return sanitizeFilename(name) + PLUGIN_META_EXT -} export async function listPluginFiles(): Promise { return listSettingsFiles(PLUGINS_DIR) @@ -507,16 +508,6 @@ export async function deleteAiSessionFile(filename: string): Promise { // --- Widget helpers --- const WIDGETS_DIR = 'widgets' -const WIDGET_SRC_EXT = '.is' -const WIDGET_META_EXT = '.meta.json5' - -export function widgetSrcFilename(name: string): string { - return sanitizeFilename(name) + WIDGET_SRC_EXT -} - -export function widgetMetaFilename(name: string): string { - return sanitizeFilename(name) + WIDGET_META_EXT -} export async function listWidgetFiles(): Promise { return listSettingsFiles(WIDGETS_DIR) @@ -548,14 +539,6 @@ export async function renameWidgetFile( const QUERIES_DIR = 'queries' -export function querySrcFilename(name: string): string { - return sanitizeFilename(name) + WIDGET_SRC_EXT -} - -export function queryMetaFilename(name: string): string { - return sanitizeFilename(name) + WIDGET_META_EXT -} - export async function listQueryFiles(): Promise { return listSettingsFiles(QUERIES_DIR) } @@ -574,3 +557,10 @@ export async function writeQueryFile( export async function deleteQueryFile(filename: string): Promise { return deleteSettingsFile(QUERIES_DIR, filename) } + +export async function renameQueryFile( + oldFilename: string, + newFilename: string, +): Promise { + return renameSettingsFile(QUERIES_DIR, oldFilename, newFilename) +} From e7a7920799181250a4d8eb3abb92c5f9047c8645 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 06:38:31 +0900 Subject: [PATCH 04/30] =?UTF-8?q?feat(stores):=20widget/plugin/=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=AA=E3=82=92=E5=AF=BE=E5=BF=9C=E8=A1=A8=E5=8F=82?= =?UTF-8?q?=E7=85=A7=20+=20copy-adopt=20=E7=A7=BB=E8=A1=8C=E3=81=AB?= =?UTF-8?q?=E9=85=8D=E7=B7=9A=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 各 store に「初回読込 (対応表確定) + 初回移行」を待つ ready ゲートを 設け、変更系操作 (新規作成・リネーム・保存・削除) のファイル反映を 移行完了後へ直列化する - initFileStorage で (a) 規約外名の copy-adopt 正規化 → (b) ミラー在・ ファイル不在の新 slug 再作成 (空ソースは書かない) → 履歴 sweep を メインウィンドウのみで実行 (isMainDeckWindow・冪等) - リネームを「旧 baseName 削除 + 新名 persist」から rename コマンド追随 (src → meta → history、ID 不変) に置換し、完了を await してから保存する - 保存・削除の直前に localStorage ミラーの対応表を読み直す (別ウィンドウのリネーム後の削除が stale 名で空振りしないように) - 履歴 push/list/revert のキーを fileBase に変更、readOnly 個体の src 編集を抑止、外部エディタ起動 (PluginsContent) を fileBase 参照に変更 Co-Authored-By: Claude Fable 5 --- src/capabilities/builtins/plugins.ts | 6 +- src/capabilities/builtins/widgets.ts | 6 +- src/components/window/PluginsContent.vue | 6 +- src/stores/columnQueries.ts | 107 ++++++++++++--- src/stores/plugins.test.ts | 4 +- src/stores/plugins.ts | 167 +++++++++++++++-------- src/stores/widgets.ts | 159 +++++++++++++-------- 7 files changed, 316 insertions(+), 139 deletions(-) diff --git a/src/capabilities/builtins/plugins.ts b/src/capabilities/builtins/plugins.ts index 15bd9e763..150748659 100644 --- a/src/capabilities/builtins/plugins.ts +++ b/src/capabilities/builtins/plugins.ts @@ -464,7 +464,7 @@ export const pluginsHistoryCapability: Command = { if (!plugin) { throw new Error(`plugins.history: plugin "${installId}" not found`) } - const basename = plugin.name || plugin.installId + const basename = plugin.fileBase ?? (plugin.name || plugin.installId) return await listSnapshots('plugin', basename) }, } @@ -483,7 +483,7 @@ export const pluginsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = usePluginsStore().getPlugin(installId) if (!cur || index < 0) return null - const basename = cur.name || cur.installId + const basename = cur.fileBase ?? (cur.name || cur.installId) const entry = await getSnapshotAt('plugin', basename, index) if (!entry) return null const snap = entry.snapshot @@ -533,7 +533,7 @@ export const pluginsRevertCapability: Command = { if (!plugin) { throw new Error(`plugins.revert: plugin "${installId}" not found`) } - const basename = plugin.name || plugin.installId + const basename = plugin.fileBase ?? (plugin.name || plugin.installId) const entry = await getSnapshotAt('plugin', basename, index) if (!entry) { throw new Error(`plugins.revert: no snapshot at index ${index}`) diff --git a/src/capabilities/builtins/widgets.ts b/src/capabilities/builtins/widgets.ts index d998b7e2e..60ffab8c5 100644 --- a/src/capabilities/builtins/widgets.ts +++ b/src/capabilities/builtins/widgets.ts @@ -381,7 +381,7 @@ export const widgetsHistoryCapability: Command = { if (!widget) { throw new Error(`widgets.history: widget "${installId}" not found`) } - const basename = widget.name || widget.installId + const basename = widget.fileBase ?? (widget.name || widget.installId) return await listSnapshots('widget', basename) }, } @@ -400,7 +400,7 @@ export const widgetsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = useWidgetsStore().getWidget(installId) if (!cur || index < 0) return null - const basename = cur.name || cur.installId + const basename = cur.fileBase ?? (cur.name || cur.installId) const entry = await getSnapshotAt('widget', basename, index) if (!entry) return null return { @@ -445,7 +445,7 @@ export const widgetsRevertCapability: Command = { if (!widget) { throw new Error(`widgets.revert: widget "${installId}" not found`) } - const basename = widget.name || widget.installId + const basename = widget.fileBase ?? (widget.name || widget.installId) const entry = await getSnapshotAt('widget', basename, index) if (!entry) { throw new Error(`widgets.revert: no snapshot at index ${index}`) diff --git a/src/components/window/PluginsContent.vue b/src/components/window/PluginsContent.vue index 65013dbbc..4deaff978 100644 --- a/src/components/window/PluginsContent.vue +++ b/src/components/window/PluginsContent.vue @@ -21,7 +21,6 @@ import { usePluginsStore, } from '@/stores/plugins' import { isProxiable, proxyCssUrl } from '@/utils/mediaProxy' -import { pluginSrcFilename } from '@/utils/settingsFs' const props = defineProps<{ initialPluginId?: string @@ -94,9 +93,10 @@ const { tab, containerRef: editorRef } = useEditorTabs( useWindowExternalFile(() => { if (tab.value !== 'code' || isNewInstall.value) return null const p = plugin.value - if (!p) return null + // 対応表 (fileBase) のファイル名で開く。未割当 = ファイル未作成なら出さない + if (!p?.fileBase) return null return { - name: pluginSrcFilename(p.name || p.installId), + name: `${p.fileBase}.is`, subdir: 'plugins', } }) diff --git a/src/stores/columnQueries.ts b/src/stores/columnQueries.ts index b99cf3d35..c16a834c4 100644 --- a/src/stores/columnQueries.ts +++ b/src/stores/columnQueries.ts @@ -1,6 +1,9 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { createSidecarCollection } from '@/services/sidecarFileCollection' +import { + createSidecarCollection, + type SidecarItemFile, +} from '@/services/sidecarFileCollection' import { useDeckStore } from '@/stores/deck' import * as settingsFs from '@/utils/settingsFs' import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' @@ -16,7 +19,7 @@ import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' * - MisStore 配布クエリは storeId を持つ (導入・差分承認は Phase 3.5) */ -export interface NamedQueryMeta { +export interface NamedQueryMeta extends SidecarItemFile { id: string name: string description?: string @@ -43,14 +46,21 @@ interface QueryFileMeta { const queryFiles = createSidecarCollection({ logTag: 'columnQueries', - srcFilename: (base) => settingsFs.querySrcFilename(base), - metaFilename: (base) => settingsFs.queryMetaFilename(base), + kindFallback: 'query', + idKey: 'id', list: () => settingsFs.listQueryFiles(), read: (filename) => settingsFs.readQueryFile(filename), write: (filename, content) => settingsFs.writeQueryFile(filename, content), remove: (filename) => settingsFs.deleteQueryFile(filename), - baseName: (q) => q.name || q.id, + rename: (oldFilename, newFilename) => + settingsFs.renameQueryFile(oldFilename, newFilename), + idOf: (q) => q.id, + nameOf: (q) => q.name, srcOf: (q) => q.src, + mirrorSrcById: (id) => + getStorageJson(STORAGE_KEYS.columnQueries, []).find( + (q) => q.id === id, + )?.src, toFileMeta: (q) => ({ id: q.id, name: q.name, @@ -81,6 +91,12 @@ export function generateQueryId(): string { export const useColumnQueriesStore = defineStore('columnQueries', () => { const queries = ref([]) let loaded = false + // 変更系操作 (新規作成・リネーム・保存・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) function ensureLoaded() { if (loaded) return @@ -90,23 +106,52 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { [], ) if (settingsFs.isTauri) { - void initFileStorage() + void initFileStorage().finally(() => resolveReady?.()) + } else { + resolveReady?.() } } async function initFileStorage() { try { - const { items, entryFileCount } = await queryFiles.loadAll() - if (entryFileCount > 0) { - // ファイルが正なので localStorage ミラーを上書き - if (items.length > 0) { - queries.value = items - persistMirror() + const { items } = await queryFiles.loadAll() + + // 初期化中にメモリ追加されたクエリと、ミラーにだけ在るクエリ + // (過去の書込が黙って失敗した個体) の集合。 + const fileIds = new Set(items.map((q) => q.id)) + const memoryOnly = queries.value.filter((q) => !fileIds.has(q.id)) + + if (items.length > 0) { + // ファイルが正なので localStorage ミラーを上書き (メモリ分はマージ) + queries.value = [...items, ...memoryOnly] + } + + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等 + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化 + await queryFiles.migrateItems(queries.value) + // (b) ミラー在・ファイル不在 → 新 slug 名で再作成 (空ソースは書かない) + for (const q of memoryOnly) { + if (q.readOnly || !q.src) continue + q.fileBase = undefined // ミラー由来の旧 fileBase は無効 (ファイル不在) + await queryFiles + .persistItem(q, queries.value) + .catch((e) => + console.warn( + '[columnQueries] failed to persist memory-only query:', + e, + ), + ) } - } else if (queries.value.length > 0) { - // 初回: localStorage → ファイルへ片方向移行 - await queryFiles.persistAll(queries.value) + // 履歴 sweep: 主ファイルと対応の取れない .history.json5 を削除 + await queryFiles + .sweepHistory() + .catch((e) => + console.warn('[columnQueries] history sweep failed:', e), + ) } + + persistMirror() } catch (e) { console.warn('[columnQueries] file storage init failed', e) } @@ -116,11 +161,24 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { setStorageJson(STORAGE_KEYS.columnQueries, queries.value) } + /** 保存・削除の直前にミラーの対応表を読み直す (別ウィンドウのリネーム追随)。 */ + function adoptMirrorFileBase(query: NamedQueryMeta) { + const mirrored = getStorageJson( + STORAGE_KEYS.columnQueries, + [], + ).find((q) => q.id === query.id) + if (mirrored?.fileBase) query.fileBase = mirrored.fileBase + } + async function persist(query: NamedQueryMeta) { persistMirror() if (settingsFs.isTauri) { + await ready try { - await queryFiles.persistItem(query) + adoptMirrorFileBase(query) + await queryFiles.persistItem(query, queries.value) + // fileBase 割当をミラーへ反映 + persistMirror() } catch (e) { console.warn('[columnQueries] persist failed', e) } @@ -158,14 +216,21 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { if (idx < 0) return const prev = queries.value[idx] if (!prev) return + if (prev.readOnly && updates.src !== undefined) { + // ソース欠損の読取専用個体: 内容編集と保存を抑止 (#913) + console.warn('[columnQueries] read-only query — src update suppressed') + return + } const next = { ...prev, ...updates, updatedAt: Date.now() } queries.value = queries.value.map((q) => (q.id === id ? next : q)) - // 改名でファイル基底名が変わる場合は旧ファイルを消してから書く + // 改名はファイルを rename で追随させる (ID 不変)。完了を待ってから保存 if (settingsFs.isTauri && updates.name && updates.name !== prev.name) { + await ready try { - await queryFiles.deleteItemFiles(prev) + adoptMirrorFileBase(next) + await queryFiles.renameItemFiles(next, queries.value) } catch (e) { - console.warn('[columnQueries] rename cleanup failed', e) + console.warn('[columnQueries] rename failed', e) } } await persist(next) @@ -182,9 +247,13 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { const idx = queries.value.findIndex((q) => q.id === id) const target = queries.value[idx] if (!target) return undefined + // ミラー上書き前に対応表を読み直す (別ウィンドウのリネーム後の削除が + // stale なファイル名で空振りしないように) + if (settingsFs.isTauri) adoptMirrorFileBase(target) queries.value = queries.value.filter((q) => q.id !== id) persistMirror() if (settingsFs.isTauri) { + await ready try { await queryFiles.deleteItemFiles(target) } catch (e) { diff --git a/src/stores/plugins.test.ts b/src/stores/plugins.test.ts index 7991ce51e..d4894ffc4 100644 --- a/src/stores/plugins.test.ts +++ b/src/stores/plugins.test.ts @@ -3,12 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/utils/settingsFs', () => ({ isTauri: false, - pluginSrcFilename: (n: string) => `${n}.is`, - pluginMetaFilename: (n: string) => `${n}.meta.json5`, + isMainDeckWindow: () => true, writePluginFile: vi.fn(async () => undefined), deletePluginFile: vi.fn(async () => undefined), listPluginFiles: vi.fn(async () => []), readPluginFile: vi.fn(async () => ''), + renamePluginFile: vi.fn(async () => undefined), })) vi.mock('@/utils/storage', () => { diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts index 3afd48a6d..b1695f357 100644 --- a/src/stores/plugins.ts +++ b/src/stores/plugins.ts @@ -3,7 +3,10 @@ import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' import { planBuiltInSeed } from '@/services/builtInSeed' -import { createSidecarCollection } from '@/services/sidecarFileCollection' +import { + createSidecarCollection, + type SidecarItemFile, +} from '@/services/sidecarFileCollection' import { accountScopeKey, useAccountsStore } from '@/stores/accounts' import { pushSnapshot } from '@/utils/historyFs' import * as settingsFs from '@/utils/settingsFs' @@ -87,7 +90,7 @@ export interface PluginConfigDef { default: unknown } -export interface PluginMeta { +export interface PluginMeta extends SidecarItemFile { installId: string name: string version: string @@ -159,15 +162,20 @@ interface PluginFileMeta { /** .is + .meta.json5 ペアのファイル永続化 (#782 Phase 2、widgets と共通) */ const pluginFiles = createSidecarCollection({ logTag: 'plugins', + kindFallback: 'plugin', + idKey: 'installId', // 直接参照ではなくアロー包みで遅延参照する (テストの部分モックと相性を保つ) - srcFilename: (base) => settingsFs.pluginSrcFilename(base), - metaFilename: (base) => settingsFs.pluginMetaFilename(base), list: () => settingsFs.listPluginFiles(), read: (filename) => settingsFs.readPluginFile(filename), write: (filename, content) => settingsFs.writePluginFile(filename, content), remove: (filename) => settingsFs.deletePluginFile(filename), - baseName: (p) => p.name || p.installId, + rename: (oldFilename, newFilename) => + settingsFs.renamePluginFile(oldFilename, newFilename), + idOf: (p) => p.installId, + nameOf: (p) => p.name, srcOf: (p) => p.src, + mirrorSrcById: (id) => + loadPluginsFromStorage().find((p) => p.installId === id)?.src, toFileMeta: (p) => ({ installId: p.installId, name: p.name, @@ -213,6 +221,12 @@ export const usePluginsStore = defineStore('plugins', () => { const plugins = ref([]) let loaded = false const initialized = ref(false) + // 変更系操作 (新規作成・リネーム・保存・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) function ensureLoaded() { if (loaded) return @@ -221,11 +235,12 @@ export const usePluginsStore = defineStore('plugins', () => { // Kick off file-based init (Tauri only) if (settingsFs.isTauri) { - initFileStorage().catch((e) => - console.warn('[plugins] file storage init failed:', e), - ) + initFileStorage() + .catch((e) => console.warn('[plugins] file storage init failed:', e)) + .finally(() => resolveReady?.()) } else { initialized.value = true + resolveReady?.() // ブラウザ環境 (ファイル I/O なし) でも built-in を seed して // 動作確認ができるようにする seedMissingBuiltIns() @@ -239,36 +254,67 @@ export const usePluginsStore = defineStore('plugins', () => { return plugins.value.filter((p) => p.active) }) + /** 保存・削除の直前にミラーの対応表を読み直す (別ウィンドウのリネーム追随)。 */ + function adoptMirrorFileBase(plugin: PluginMeta) { + const mirrored = loadPluginsFromStorage().find( + (p) => p.installId === plugin.installId, + ) + if (mirrored?.fileBase) plugin.fileBase = mirrored.fileBase + } + function persist(plugin?: PluginMeta) { savePluginsToStorage(plugins.value) - if (initialized.value) { - const task = plugin - ? pluginFiles.persistItem(plugin) - : pluginFiles.persistAll(plugins.value) - task.catch((e) => - console.warn('[plugins] failed to persist to files:', e), - ) - } + if (!settingsFs.isTauri) return + void ready + .then(async () => { + if (plugin) { + adoptMirrorFileBase(plugin) + await pluginFiles.persistItem(plugin, plugins.value) + } else { + await pluginFiles.persistAll(plugins.value, plugins.value) + } + // fileBase 割当をミラーへ反映 + savePluginsToStorage(plugins.value) + }) + .catch((e) => console.warn('[plugins] failed to persist to files:', e)) } /** Load plugins from files. Files are source of truth. */ async function initFileStorage(): Promise { - const { items: filePlugins, entryFileCount } = await pluginFiles.loadAll() + const { items: filePlugins } = await pluginFiles.loadAll() + + // 初期化中にメモリ追加された plugin と、ミラーにだけ在る plugin + // (過去の書込が黙って失敗した個体) の集合。 + const fileIds = new Set(filePlugins.map((p) => p.installId)) + const memoryOnly = plugins.value.filter((p) => !fileIds.has(p.installId)) if (filePlugins.length > 0) { - plugins.value = filePlugins - savePluginsToStorage(filePlugins) + plugins.value = [...filePlugins, ...memoryOnly] } - initialized.value = true - - // Migrate: localStorage has plugins but no files exist - if (entryFileCount === 0 && plugins.value.length > 0) { - pluginFiles - .persistAll(plugins.value) - .catch((e) => console.warn('[plugins] migration to files failed:', e)) + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等 + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化 + await pluginFiles.migrateItems(plugins.value) + // (b) ミラー在・ファイル不在 → 新 slug 名で再作成 (空ソースは書かない) + for (const p of memoryOnly) { + if (p.readOnly || !p.src) continue + p.fileBase = undefined // ミラー由来の旧 fileBase は無効 (ファイル不在) + await pluginFiles + .persistItem(p, plugins.value) + .catch((e) => + console.warn('[plugins] failed to persist memory-only plugins:', e), + ) + } + // 履歴 sweep: 主ファイルと対応の取れない .history.json5 を削除 + await pluginFiles + .sweepHistory() + .catch((e) => console.warn('[plugins] history sweep failed:', e)) } + savePluginsToStorage(plugins.value) + initialized.value = true + // Seed built-in plugins (初回起動 + 後追い追加に対応)。 await seedMissingBuiltIns() @@ -301,12 +347,13 @@ export const usePluginsStore = defineStore('plugins', () => { const added = toAdd.map(pluginMetaToFullMeta) plugins.value = [...plugins.value, ...added] savePluginsToStorage(plugins.value) - if (initialized.value) { + if (settingsFs.isTauri) { await pluginFiles - .persistAll(added) + .persistAll(added, plugins.value) .catch((e) => console.warn('[plugins] failed to seed built-in plugin files:', e), ) + savePluginsToStorage(plugins.value) } } setStorageJson(STORAGE_KEYS.pluginsSeededBuiltins, seededIds) @@ -327,6 +374,9 @@ export const usePluginsStore = defineStore('plugins', () => { ensureLoaded() const idx = plugins.value.findIndex((p) => p.installId === installId) const removed = plugins.value[idx] + // ミラー上書き前に対応表を読み直す (別ウィンドウのリネーム後の削除が + // stale なファイル名で空振りしないように) + if (removed && settingsFs.isTauri) adoptMirrorFileBase(removed) // Clean up plugin localStorage entries // undo で戻せるよう消す前にスナップショットを取る (widgets と同型) const storagePrefix = STORAGE_KEYS.aiscriptPlugin(installId) @@ -336,9 +386,9 @@ export const usePluginsStore = defineStore('plugins', () => { // Sync: localStorage only (file deletion handles the rest) savePluginsToStorage(plugins.value) // Delete files - if (initialized.value && removed) { - pluginFiles - .deleteItemFiles(removed) + if (settingsFs.isTauri && removed) { + void ready + .then(() => pluginFiles.deleteItemFiles(removed)) .catch((e) => console.warn('[plugins] failed to delete plugin files:', e), ) @@ -356,9 +406,10 @@ export const usePluginsStore = defineStore('plugins', () => { for (const [key, value] of Object.entries(savedStorage)) { setStorageString(key, value) } - if (initialized.value) { - pluginFiles - .persistItem(removed) + if (settingsFs.isTauri) { + void ready + .then(() => pluginFiles.persistItem(removed, plugins.value)) + .then(() => savePluginsToStorage(plugins.value)) .catch((e) => console.warn('[plugins] failed to restore plugin files:', e), ) @@ -511,14 +562,22 @@ export const usePluginsStore = defineStore('plugins', () => { ensureLoaded() const plugin = plugins.value.find((p) => p.installId === installId) if (plugin) { - // 編集前 src を history sidecar に push (fire-and-forget) - pushSnapshot('plugin', plugin.name || plugin.installId, { - src: plugin.src, - name: plugin.name, - version: plugin.version, - permissions: plugin.permissions, - active: plugin.active, - }).catch((e) => console.warn('[plugins] history push failed:', e)) + if (plugin.readOnly) { + // ソース欠損の読取専用個体: 内容編集と保存を抑止 (#913) + console.warn('[plugins] read-only plugin — src update suppressed') + return + } + // 編集前 src を history sidecar に push (fire-and-forget)。 + // 履歴キーは対応表の fileBase (未割当 = ファイル未作成なら履歴も無し) + if (plugin.fileBase) { + pushSnapshot('plugin', plugin.fileBase, { + src: plugin.src, + name: plugin.name, + version: plugin.version, + permissions: plugin.permissions, + active: plugin.active, + }).catch((e) => console.warn('[plugins] history push failed:', e)) + } plugin.src = src persist(plugin) } @@ -529,19 +588,19 @@ export const usePluginsStore = defineStore('plugins', () => { const plugin = plugins.value.find((p) => p.installId === installId) if (!plugin) return - const oldBaseName = plugin.name || plugin.installId plugin.name = newName - - persist(plugin) - - if (initialized.value && oldBaseName !== newName) { - // Delete old files and write new ones (installId stays the same) - pluginFiles - .deleteItemFiles({ ...plugin, name: oldBaseName }) - .catch((e) => - console.warn('[plugins] failed to delete old plugin files:', e), - ) - } + savePluginsToStorage(plugins.value) + if (!settingsFs.isTauri) return + // ファイルは rename で追随させる (ID 不変・旧削除 + 新書込の並行発火禁止)。 + // rename の完了を待ってから保存する + void ready + .then(async () => { + adoptMirrorFileBase(plugin) + await pluginFiles.renameItemFiles(plugin, plugins.value) + await pluginFiles.persistItem(plugin, plugins.value) + savePluginsToStorage(plugins.value) + }) + .catch((e) => console.warn('[plugins] failed to rename plugin files:', e)) } function getPlugin(installId: string): PluginMeta | undefined { diff --git a/src/stores/widgets.ts b/src/stores/widgets.ts index 6f2bf028e..6f2ba4eaf 100644 --- a/src/stores/widgets.ts +++ b/src/stores/widgets.ts @@ -1,7 +1,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { createSidecarCollection } from '@/services/sidecarFileCollection' +import { + createSidecarCollection, + type SidecarItemFile, +} from '@/services/sidecarFileCollection' import { pushSnapshot } from '@/utils/historyFs' import * as settingsFs from '@/utils/settingsFs' import { @@ -13,7 +16,7 @@ import { setStorageString, } from '@/utils/storage' -export interface WidgetMeta { +export interface WidgetMeta extends SidecarItemFile { installId: string name: string src: string @@ -39,15 +42,20 @@ interface WidgetFileMeta { /** .is + .meta.json5 ペアのファイル永続化 (#782 Phase 2、plugins と共通) */ const widgetFiles = createSidecarCollection({ logTag: 'widgets', + kindFallback: 'widget', + idKey: 'installId', // 直接参照ではなくアロー包みで遅延参照する (テストの部分モックと相性を保つ) - srcFilename: (base) => settingsFs.widgetSrcFilename(base), - metaFilename: (base) => settingsFs.widgetMetaFilename(base), list: () => settingsFs.listWidgetFiles(), read: (filename) => settingsFs.readWidgetFile(filename), write: (filename, content) => settingsFs.writeWidgetFile(filename, content), remove: (filename) => settingsFs.deleteWidgetFile(filename), - baseName: (w) => w.name || w.installId, + rename: (oldFilename, newFilename) => + settingsFs.renameWidgetFile(oldFilename, newFilename), + idOf: (w) => w.installId, + nameOf: (w) => w.name, srcOf: (w) => w.src, + mirrorSrcById: (id) => + loadWidgetsFromStorage().find((w) => w.installId === id)?.src, toFileMeta: (w) => ({ installId: w.installId, name: w.name, @@ -99,6 +107,12 @@ export const useWidgetsStore = defineStore('widgets', () => { const sidebarWidgetIds = ref([]) let loaded = false const initialized = ref(false) + // 変更系操作 (新規作成・リネーム・保存・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) function ensureLoaded() { if (loaded) return @@ -107,11 +121,12 @@ export const useWidgetsStore = defineStore('widgets', () => { sidebarWidgetIds.value = loadSidebarOrderFromStorage() if (settingsFs.isTauri) { - initFileStorage().catch((e) => - console.warn('[widgets] file storage init failed:', e), - ) + initFileStorage() + .catch((e) => console.warn('[widgets] file storage init failed:', e)) + .finally(() => resolveReady?.()) } else { initialized.value = true + resolveReady?.() } } @@ -125,50 +140,70 @@ export const useWidgetsStore = defineStore('widgets', () => { } } + /** 保存・削除の直前にミラーの対応表を読み直す (別ウィンドウのリネーム追随)。 */ + function adoptMirrorFileBase(widget: WidgetMeta) { + const mirrored = loadWidgetsFromStorage().find( + (w) => w.installId === widget.installId, + ) + if (mirrored?.fileBase) widget.fileBase = mirrored.fileBase + } + function persist(widget?: WidgetMeta) { saveWidgetsToStorage(widgets.value) - if (initialized.value) { - const task = widget - ? widgetFiles.persistItem(widget) - : widgetFiles.persistAll(widgets.value) - task.catch((e) => - console.warn('[widgets] failed to persist to files:', e), - ) - } + if (!settingsFs.isTauri) return + void ready + .then(async () => { + if (widget) { + adoptMirrorFileBase(widget) + await widgetFiles.persistItem(widget, widgets.value) + } else { + await widgetFiles.persistAll(widgets.value, widgets.value) + } + // fileBase 割当をミラーへ反映 + saveWidgetsToStorage(widgets.value) + }) + .catch((e) => console.warn('[widgets] failed to persist to files:', e)) } /** Load widgets from files. Files are source of truth. */ async function initFileStorage(): Promise { - const { items: fileWidgets, entryFileCount } = await widgetFiles.loadAll() + const { items: fileWidgets } = await widgetFiles.loadAll() + + // 初期化 (この async 関数が走る間) にメモリ追加された widget と、 + // ミラーにだけ在る widget (過去の書込が黙って失敗した個体) の集合。 + const fileIds = new Set(fileWidgets.map((w) => w.installId)) + const memoryOnly = widgets.value.filter((w) => !fileIds.has(w.installId)) if (fileWidgets.length > 0) { // 並び順を確定するため createdAt 昇順でソート (ファイル列挙順は OS 依存) fileWidgets.sort((a, b) => a.createdAt - b.createdAt) - // 初期化 (この async 関数が走る間) にメモリ追加された widget は - // ファイルにはまだ無いのでマージしないと消える。installId で dedup。 - const fileIds = new Set(fileWidgets.map((w) => w.installId)) - const memoryOnly = widgets.value.filter((w) => !fileIds.has(w.installId)) widgets.value = [...fileWidgets, ...memoryOnly] - saveWidgetsToStorage(widgets.value) - // 在メモリだけだった widget をファイル化 (次回起動時に消えないように) - if (memoryOnly.length > 0) { - widgetFiles - .persistAll(memoryOnly) + } + + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等 + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化 + await widgetFiles.migrateItems(widgets.value) + // (b) ミラー在・ファイル不在 → 新 slug 名で再作成 + // (空ソースは書かない — 読取専用ガードを消さないため) + for (const w of memoryOnly) { + if (w.readOnly || !w.src) continue + w.fileBase = undefined // ミラー由来の旧 fileBase は無効 (ファイル不在) + await widgetFiles + .persistItem(w, widgets.value) .catch((e) => console.warn('[widgets] failed to persist memory-only widgets:', e), ) } + // 履歴 sweep: 主ファイルと対応の取れない .history.json5 を削除 + await widgetFiles + .sweepHistory() + .catch((e) => console.warn('[widgets] history sweep failed:', e)) } + saveWidgetsToStorage(widgets.value) initialized.value = true pruneSidebarOrder() - - // localStorage 由来のデータがあるが files が無い場合の片方向移行 - if (entryFileCount === 0 && widgets.value.length > 0) { - widgetFiles - .persistAll(widgets.value) - .catch((e) => console.warn('[widgets] migration to files failed:', e)) - } } function addWidget(widget: WidgetMeta) { @@ -182,6 +217,9 @@ export const useWidgetsStore = defineStore('widgets', () => { ensureLoaded() const idx = widgets.value.findIndex((w) => w.installId === installId) const removed = widgets.value[idx] + // ミラー上書き前に対応表を読み直す (別ウィンドウのリネーム後の削除が + // stale なファイル名で空振りしないように) + if (removed && settingsFs.isTauri) adoptMirrorFileBase(removed) // AiScript の Mk:save 領域を一掃 (storagePrefix='app-${installId}')。 // undo で戻せるよう消す前にスナップショットを取る const storagePrefix = STORAGE_KEYS.aiscriptStorage(`app-${installId}`) @@ -197,9 +235,9 @@ export const useWidgetsStore = defineStore('widgets', () => { ) saveSidebarOrderToStorage(sidebarWidgetIds.value) } - if (initialized.value && removed) { - widgetFiles - .deleteItemFiles(removed) + if (settingsFs.isTauri && removed) { + void ready + .then(() => widgetFiles.deleteItemFiles(removed)) .catch((e) => console.warn('[widgets] failed to delete widget files:', e), ) @@ -226,9 +264,10 @@ export const useWidgetsStore = defineStore('widgets', () => { ] saveSidebarOrderToStorage(sidebarWidgetIds.value) } - if (initialized.value) { - widgetFiles - .persistItem(removed) + if (settingsFs.isTauri) { + void ready + .then(() => widgetFiles.persistItem(removed, widgets.value)) + .then(() => saveWidgetsToStorage(widgets.value)) .catch((e) => console.warn('[widgets] failed to restore widget files:', e), ) @@ -304,12 +343,20 @@ export const useWidgetsStore = defineStore('widgets', () => { ensureLoaded() const widget = widgets.value.find((w) => w.installId === installId) if (widget) { - // 編集前 src を history sidecar に push (fire-and-forget) - pushSnapshot('widget', widget.name || widget.installId, { - src: widget.src, - name: widget.name, - autoRun: widget.autoRun, - }).catch((e) => console.warn('[widgets] history push failed:', e)) + if (widget.readOnly) { + // ソース欠損の読取専用個体: 内容編集と保存を抑止 (#913) + console.warn('[widgets] read-only widget — src update suppressed') + return + } + // 編集前 src を history sidecar に push (fire-and-forget)。 + // 履歴キーは対応表の fileBase (未割当 = ファイル未作成なら履歴も無し) + if (widget.fileBase) { + pushSnapshot('widget', widget.fileBase, { + src: widget.src, + name: widget.name, + autoRun: widget.autoRun, + }).catch((e) => console.warn('[widgets] history push failed:', e)) + } widget.src = src widget.updatedAt = Date.now() persist(widget) @@ -341,18 +388,20 @@ export const useWidgetsStore = defineStore('widgets', () => { const widget = widgets.value.find((w) => w.installId === installId) if (!widget) return - const oldBaseName = widget.name || widget.installId widget.name = newName widget.updatedAt = Date.now() - persist(widget) - - if (initialized.value && oldBaseName !== newName) { - widgetFiles - .deleteItemFiles({ ...widget, name: oldBaseName }) - .catch((e) => - console.warn('[widgets] failed to delete old widget files:', e), - ) - } + saveWidgetsToStorage(widgets.value) + if (!settingsFs.isTauri) return + // ファイルは rename で追随させる (ID 不変・旧削除 + 新書込の並行発火禁止)。 + // rename の完了を待ってから保存する + void ready + .then(async () => { + adoptMirrorFileBase(widget) + await widgetFiles.renameItemFiles(widget, widgets.value) + await widgetFiles.persistItem(widget, widgets.value) + saveWidgetsToStorage(widgets.value) + }) + .catch((e) => console.warn('[widgets] failed to rename widget files:', e)) } function getWidget(installId: string): WidgetMeta | undefined { From 2260df22dd517d6967d1db46095a1b911af9ca93 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 06:54:54 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat(settings):=20=E5=8D=98=E4=B8=80?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=9E=8B=E3=82=B3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E8=A1=A8=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit テーマ (.ndtheme.json5) / スキル (.md) 共通の「単一ファイルで 1 アイテム」 永続化サービス。sidecarFileCollection と同じ #913 不変条件を実装する: - loadAll はファイル名の辞書順 (UTF-8 バイト順) で決定的に処理し、各 アイテムへ runtime-only の fileBase (ID → 実ファイル名の対応表) を付与 - ID 凍結 (常設規則): ID 欠損 (キー不在・空・非文字列・256 文字超) なら 種別の実効値を idFreeze の最小変換で書き戻す (冪等・コメント保持) - 重複 ID は 2 件目以降を警告 + skip (ファイルは削除しない) - 移行 (a) は copy-adopt (読む → 新 slug 名へ生内容を書込 → 検証 → 旧削除)。 達成済み判定 (ID 一致 + 内容一致 → 旧削除のみ / ID 一致・内容不一致 → 削除せず suffix + 警告) と case-only 中間名 2 段を含む - 占有判定は casefold + 実列挙 ∪ 対応表 ∪ (ID を決める操作では) ID 集合 - 履歴 sweep は実列挙の主ファイル basename 集合と casefold 照合 パース形式 (JSON5 / frontmatter) と凍結実効値は config フックで注入し、 テーマとスキルで共用する。 Co-Authored-By: Claude Fable 5 --- src/services/singleFileCollection.test.ts | 625 ++++++++++++++++++++++ src/services/singleFileCollection.ts | 428 +++++++++++++++ 2 files changed, 1053 insertions(+) create mode 100644 src/services/singleFileCollection.test.ts create mode 100644 src/services/singleFileCollection.ts diff --git a/src/services/singleFileCollection.test.ts b/src/services/singleFileCollection.test.ts new file mode 100644 index 000000000..754c9c300 --- /dev/null +++ b/src/services/singleFileCollection.test.ts @@ -0,0 +1,625 @@ +import JSON5 from 'json5' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { injectFrontmatterId, injectJson5Id } from '@/services/idFreeze' +import { + createSingleFileCollection, + type SingleFileCollectionConfig, + type SingleItemFile, +} from '@/services/singleFileCollection' +import { parseSkillFile } from '@/utils/skillFrontmatter' + +/** テーマ相当 (単一 JSON5 ファイル) のアイテム。 */ +interface Item extends SingleItemFile { + id: string + name: string + props: Record +} + +type Parsed = Record + +const EXT = '.ndtheme.json5' + +/** インメモリ疑似 FS。ファイル名 → 内容。Rust 側の意味論に合わせる: + * read = 不在で throw / remove = 不在で no-op / rename = 元不在・先在りで throw */ +function makeFakeFs(initial?: Record) { + const files = new Map(Object.entries(initial ?? {})) + return { + files, + list: async () => Array.from(files.keys()), + read: async (filename: string) => { + const content = files.get(filename) + if (content === undefined) throw new Error(`not found: ${filename}`) + return content + }, + write: async (filename: string, content: string) => { + files.set(filename, content) + }, + remove: async (filename: string) => { + files.delete(filename) + }, + rename: async (oldFilename: string, newFilename: string) => { + if (!files.has(oldFilename)) throw new Error(`not found: ${oldFilename}`) + if (files.has(newFilename)) + throw new Error(`already exists: ${newFilename}`) + files.set(newFilename, files.get(oldFilename) as string) + files.delete(oldFilename) + }, + } +} + +function makeCollection( + fs: ReturnType, + overrides?: Partial>, +) { + const config: SingleFileCollectionConfig = { + logTag: 'test', + kindFallback: 'theme', + ext: EXT, + list: fs.list, + read: fs.read, + write: fs.write, + remove: fs.remove, + rename: fs.rename, + parse: (raw) => JSON5.parse(raw) as Parsed, + accepts: (p) => !!p && typeof p === 'object' && !!p.props, + rawIdOf: (p) => p.id, + effectiveIdOf: (filename) => `custom-${filename}`, + injectId: (raw, id) => injectJson5Id(raw, 'id', id), + fromFile: (p, id, filename) => ({ + id, + name: typeof p.name === 'string' && p.name ? p.name : filename, + props: p.props as Record, + }), + displayNameOf: (p) => (typeof p.name === 'string' ? p.name : ''), + idOf: (t) => t.id, + nameOf: (t) => t.name, + serialize: (t) => + JSON5.stringify({ id: t.id, name: t.name, props: t.props }, null, 2), + ...overrides, + } + return createSingleFileCollection(config) +} + +const item = (partial: Partial): Item => ({ + id: 't1', + name: 'alpha', + props: { bg: '#000' }, + ...partial, +}) + +const file = (id: string | null, name: string) => + id === null + ? `{ name: '${name}', props: { bg: '#000' } }` + : `{ id: '${id}', name: '${name}', props: { bg: '#000' } }` + +let warn: ReturnType + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(() => { + warn.mockRestore() +}) + +describe('loadAll', () => { + it('規定拡張子のファイルをパースして復元し fileBase を保持する', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: file('i-a', 'a'), + [`b${EXT}`]: file('i-b', 'b'), + 'plain.json5': '{ id: "x", props: {} }', // 規定拡張子でない → 無視 + }) + const col = makeCollection(fs) + + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items.map((i) => [i.id, i.name, i.fileBase])).toEqual([ + ['i-a', 'a', 'a'], + ['i-b', 'b', 'b'], + ]) + }) + + it('ファイル名の辞書順 (バイト順) で決定的に処理する', async () => { + const fs = makeFakeFs() + fs.files.set(`z${EXT}`, file('i-z', 'z')) + fs.files.set(`A${EXT}`, file('i-A', 'A')) + const col = makeCollection(fs) + + const { items } = await col.loadAll() + // 'A' (0x41) < 'z' (0x7a) + expect(items.map((i) => i.fileBase)).toEqual(['A', 'z']) + }) + + describe('ID 凍結 (常設規則)', () => { + it('ID キー不在なら実効値 (custom-<完全ファイル名>) を注入して書き戻す', async () => { + const fs = makeFakeFs({ + [`My Theme${EXT}`]: `{\n name: 'My Theme',\n props: { bg: '#000' },\n}`, + }) + const col = makeCollection(fs) + + const { items } = await col.loadAll() + expect(items[0]?.id).toBe(`custom-My Theme${EXT}`) + expect(fs.files.get(`My Theme${EXT}`)).toContain( + `id: 'custom-My Theme${EXT}'`, + ) + const again = await col.loadAll() + expect(again.items[0]?.id).toBe(`custom-My Theme${EXT}`) + }) + + it('凍結は冪等 (2 回目の読込で内容が変わらない)', async () => { + const fs = makeFakeFs({ [`w${EXT}`]: file(null, 'w') }) + const col = makeCollection(fs) + await col.loadAll() + const frozen = fs.files.get(`w${EXT}`) + await col.loadAll() + expect(fs.files.get(`w${EXT}`)).toBe(frozen) + }) + + it('空文字列・非文字列・256 文字超の ID は欠損として凍結する', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: `{ id: '', props: {} }`, + [`b${EXT}`]: `{ id: 42, props: {} }`, + [`c${EXT}`]: `{ id: '${'x'.repeat(257)}', props: {} }`, + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + expect(items.map((i) => i.id)).toEqual([ + `custom-a${EXT}`, + `custom-b${EXT}`, + `custom-c${EXT}`, + ]) + }) + + it('制御文字を含む ID は欠損と判定しない (再凍結しない)', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: `{ id: 'we\\tird', props: {} }`, + }) + const col = makeCollection(fs) + const before = fs.files.get(`a${EXT}`) + const { items } = await col.loadAll() + expect(items[0]?.id).toBe('we\tird') + expect(fs.files.get(`a${EXT}`)).toBe(before) + }) + + it('凍結はコメントを保持する', async () => { + const fs = makeFakeFs({ + [`w${EXT}`]: `{\n // handwritten\n props: { bg: '#000' },\n}`, + }) + const col = makeCollection(fs) + await col.loadAll() + expect(fs.files.get(`w${EXT}`)).toContain('// handwritten') + }) + }) + + it('accepts が false の個体は警告スキップし、凍結もしない', async () => { + const noProps = `{ name: 'not a theme' }` + const fs = makeFakeFs({ [`x${EXT}`]: noProps }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(items).toEqual([]) + expect(entryFileCount).toBe(1) + expect(fs.files.get(`x${EXT}`)).toBe(noProps) + expect(warn).toHaveBeenCalled() + }) + + it('同一 ID の 2 件目以降は警告してスキップし、ファイルは削除しない', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: file('dup', 'a'), + [`b${EXT}`]: file('dup', 'b'), + }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items).toHaveLength(1) + expect(items[0]?.fileBase).toBe('a') + expect(warn).toHaveBeenCalled() + expect(fs.files.has(`b${EXT}`)).toBe(true) + }) + + it('パースに失敗したファイルはスキップし、他は復元する', async () => { + const fs = makeFakeFs({ + [`broken${EXT}`]: '{{{ not json5', + [`ok${EXT}`]: file('ok', 'ok'), + }) + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(entryFileCount).toBe(2) + expect(items).toHaveLength(1) + expect(items[0]?.id).toBe('ok') + expect(warn).toHaveBeenCalled() + }) + + it('ファイルが無ければ entryFileCount=0 で空を返す', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const { items, entryFileCount } = await col.loadAll() + expect(items).toEqual([]) + expect(entryFileCount).toBe(0) + }) +}) + +describe('persistItem', () => { + it('fileBase 未割当なら表示名の slug を割り当てて書く', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const t = item({ name: 'My Theme!' }) + await col.persistItem(t, [t]) + + expect(t.fileBase).toBe('my-theme') + expect(fs.files.get(`my-theme${EXT}`)).toContain("id: 't1'") + }) + + it('serialize には fileBase が漏れない', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const t = item({ name: 'a' }) + await col.persistItem(t, [t]) + expect(fs.files.get(`a${EXT}`)).not.toContain('fileBase') + }) + + it('表示名が slug 不能なら種別 fallback に落ちる', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const t = item({ name: 'テーマ' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('theme') + }) + + it('ディレクトリ実列挙との衝突は -2 連番で回避する (casefold)', async () => { + const fs = makeFakeFs({ [`Alpha${EXT}`]: '{{{ broken' }) + const col = makeCollection(fs) + const t = item({ name: 'alpha' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('alpha-2') + }) + + it('対応表 (他アイテムの fileBase) と種別内 ID も占有として扱う', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const other = item({ id: 'x', name: 'other', fileBase: 'alpha' }) + const idOwner = item({ id: 'alpha-2', name: 'idowner' }) + const t = item({ id: 'p9', name: 'alpha' }) + await col.persistItem(t, [other, idOwner, t]) + expect(t.fileBase).toBe('alpha-3') + }) + + it('履歴サイドカーの basename も占有として扱う', async () => { + const fs = makeFakeFs({ 'alpha.history.json5': '{ entries: [] }' }) + const col = makeCollection(fs) + const t = item({ name: 'alpha' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('alpha-2') + }) + + it('fileBase 割当済みなら name が変わっても再計算しない', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const t = item({ name: 'a' }) + await col.persistItem(t, [t]) + t.name = 'renamed' + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('a') + expect(fs.files.has(`renamed${EXT}`)).toBe(false) + expect(fs.files.get(`a${EXT}`)).toContain('renamed') + }) +}) + +describe('deleteItemFiles', () => { + it('主ファイルと履歴サイドカーを削除する', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: file('i', 'a'), + 'a.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.deleteItemFiles(item({ fileBase: 'a' })) + expect(fs.files.size).toBe(0) + }) + + it('履歴サイドカーが無くても失敗しない', async () => { + const fs = makeFakeFs({ [`a${EXT}`]: file('i', 'a') }) + const col = makeCollection(fs) + await col.deleteItemFiles(item({ fileBase: 'a' })) + expect(fs.files.size).toBe(0) + }) + + it('fileBase 未割当なら no-op', async () => { + const fs = makeFakeFs({ [`a${EXT}`]: file('i', 'a') }) + const col = makeCollection(fs) + await col.deleteItemFiles(item({})) + expect(fs.files.size).toBe(1) + }) +}) + +describe('renameItemFiles', () => { + it('主ファイル → 履歴を新 slug へ rename し fileBase を更新する', async () => { + const fs = makeFakeFs({ + [`old${EXT}`]: file('p1', 'old'), + 'old.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + const t = item({ name: 'New Name', fileBase: 'old' }) + await col.renameItemFiles(t, [t]) + + expect(t.fileBase).toBe('new-name') + expect(fs.files.has(`new-name${EXT}`)).toBe(true) + expect(fs.files.has('new-name.history.json5')).toBe(true) + expect(fs.files.has(`old${EXT}`)).toBe(false) + expect(fs.files.has('old.history.json5')).toBe(false) + }) + + it('履歴サイドカーが無ければ skip する', async () => { + const fs = makeFakeFs({ [`old${EXT}`]: file('p1', 'old') }) + const col = makeCollection(fs) + const t = item({ name: 'fresh', fileBase: 'old' }) + await col.renameItemFiles(t, [t]) + expect(fs.files.has(`fresh${EXT}`)).toBe(true) + expect(fs.files.has('fresh.history.json5')).toBe(false) + }) + + it('占有されていれば連番で回避する', async () => { + const fs = makeFakeFs({ + [`old${EXT}`]: file('p1', 'old'), + [`taken${EXT}`]: file('p2', 'taken'), + }) + const col = makeCollection(fs) + const t = item({ name: 'taken', fileBase: 'old' }) + await col.renameItemFiles(t, [t]) + expect(t.fileBase).toBe('taken-2') + }) + + it('slug が変わらないリネームは no-op (自身は占有と見なさない)', async () => { + const fs = makeFakeFs({ [`alpha${EXT}`]: file('p1', 'alpha') }) + const col = makeCollection(fs) + const t = item({ name: 'Alpha', fileBase: 'alpha' }) + await col.renameItemFiles(t, [t]) + expect(t.fileBase).toBe('alpha') + expect(fs.files.has(`alpha${EXT}`)).toBe(true) + }) + + it('casefold 一致 (大文字小文字のみ違い) は中間名経由の 2 段で行う', async () => { + const fs = makeFakeFs({ [`Alpha${EXT}`]: file('p1', 'Alpha') }) + const col = makeCollection(fs) + const t = item({ name: 'alpha', fileBase: 'Alpha' }) + await col.renameItemFiles(t, [t]) + expect(t.fileBase).toBe('alpha') + expect(fs.files.has(`alpha${EXT}`)).toBe(true) + expect(fs.files.has(`Alpha${EXT}`)).toBe(false) + // 中間名の残骸が無い + expect(fs.files.size).toBe(1) + }) + + it('fileBase 未割当なら no-op (persist 側で割当する)', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs) + const t = item({ name: 'a' }) + await col.renameItemFiles(t, [t]) + expect(t.fileBase).toBeUndefined() + }) +}) + +describe('migrateItems (copy-adopt)', () => { + it('規約外名を新 slug へ copy-adopt し旧ファイルを削除する (生内容保持)', async () => { + const raw = `{\n // keep me\n id: 'i1',\n name: 'My Theme',\n props: { bg: '#000' },\n}` + const fs = makeFakeFs({ [`My Theme${EXT}`]: raw }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + + expect(items[0]?.fileBase).toBe('my-theme') + // 生内容の最小変換 (コメント保持・再シリアライズしない) + expect(fs.files.get(`my-theme${EXT}`)).toBe(raw) + expect(fs.files.has(`My Theme${EXT}`)).toBe(false) + }) + + it('規約適合名は触らない (冪等)', async () => { + const fs = makeFakeFs({ [`ok${EXT}`]: file('i1', 'ok') }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + const before = new Map(fs.files) + await col.migrateItems(items) + expect(fs.files).toEqual(before) + expect(items[0]?.fileBase).toBe('ok') + }) + + it('2 回実行しても結果が変わらない (冪等)', async () => { + const fs = makeFakeFs({ [`Bad Name${EXT}`]: file('i1', 'Bad Name') }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + const after1 = new Map(fs.files) + await col.migrateItems(items) + expect(fs.files).toEqual(after1) + const reloaded = await col.loadAll() + await col.migrateItems(reloaded.items) + expect(fs.files).toEqual(after1) + }) + + describe('slug 衝突時の達成済み判定', () => { + it('衝突先が同 ID かつ同内容なら旧削除のみ再実行する', async () => { + // クラッシュ残骸: 新名は書けたが旧削除前に落ちたケース + const raw = file('i1', 'Bad Name') + const fs = makeFakeFs({ + [`Bad Name${EXT}`]: raw, + [`bad-name${EXT}`]: raw, + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + expect(items).toHaveLength(1) + expect(items[0]?.fileBase).toBe('Bad Name') + + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('bad-name') + expect(fs.files.has(`Bad Name${EXT}`)).toBe(false) + expect(fs.files.get(`bad-name${EXT}`)).toBe(raw) + expect(fs.files.has(`bad-name-2${EXT}`)).toBe(false) + }) + + it('同 ID・内容不一致なら削除せず suffix へ退避する', async () => { + const fs = makeFakeFs({ + [`Bad Name${EXT}`]: `{ id: 'i1', name: 'Bad Name', props: { bg: '#111' } }`, + [`bad-name${EXT}`]: `{ id: 'i1', name: 'Bad Name', props: { bg: '#222' } }`, + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(fs.files.get(`bad-name${EXT}`)).toContain('#222') + expect(items[0]?.fileBase).toBe('bad-name-2') + expect(fs.files.get(`bad-name-2${EXT}`)).toContain('#111') + expect(fs.files.has(`Bad Name${EXT}`)).toBe(false) + }) + + it('ID 不一致なら単純な占有として suffix する', async () => { + const fs = makeFakeFs({ + [`Bad Name${EXT}`]: file('i1', 'Bad Name'), + [`bad-name${EXT}`]: file('other', 'bad-name'), + }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + const migrated = items.find((i) => i.id === 'i1') + expect(migrated?.fileBase).toBe('bad-name-2') + expect(fs.files.get(`bad-name${EXT}`)).toContain('other') + }) + }) + + it('新旧名が casefold 一致なら中間名経由で移行する', async () => { + const fs = makeFakeFs({ [`Alpha${EXT}`]: file('i1', 'Alpha') }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('alpha') + expect(fs.files.has(`alpha${EXT}`)).toBe(true) + expect(fs.files.has(`Alpha${EXT}`)).toBe(false) + // 中間名の残骸が無い + expect(fs.files.size).toBe(1) + }) + + it('表示名が欠損なら種別 fallback で slug 化する', async () => { + const fs = makeFakeFs({ [`無題${EXT}`]: `{ id: 'i1', props: {} }` }) + const col = makeCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('theme') + expect(fs.files.has(`theme${EXT}`)).toBe(true) + }) +}) + +describe('sweepHistory', () => { + it('主ファイルと対応の取れない .history.json5 を削除する', async () => { + const fs = makeFakeFs({ + [`alive${EXT}`]: file('i1', 'alive'), + 'alive.history.json5': '{ entries: [] }', + 'dead.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('alive.history.json5')).toBe(true) + expect(fs.files.has('dead.history.json5')).toBe(false) + }) + + it('パース不能な主ファイルの履歴は保全する (読込採否を問わない)', async () => { + const fs = makeFakeFs({ + [`broken${EXT}`]: '{{{ not json5', + 'broken.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('broken.history.json5')).toBe(true) + }) + + it('照合は casefold で行う', async () => { + const fs = makeFakeFs({ + [`Alpha${EXT}`]: file('i1', 'Alpha'), + 'alpha.history.json5': '{ entries: [] }', + }) + const col = makeCollection(fs) + await col.sweepHistory() + expect(fs.files.has('alpha.history.json5')).toBe(true) + }) +}) + +// --- スキル相当 (frontmatter 付き .md) の構成でも同じ仕様が成立すること --- + +interface SkillItem extends SingleItemFile { + id: string + name: string + body: string +} + +function makeSkillCollection(fs: ReturnType) { + const config: SingleFileCollectionConfig< + SkillItem, + ReturnType + > = { + logTag: 'test-skill', + kindFallback: 'skill', + ext: '.md', + list: fs.list, + read: fs.read, + write: fs.write, + remove: fs.remove, + rename: fs.rename, + parse: (raw) => parseSkillFile(raw), + rawIdOf: (p) => p.meta.id, + // スキルの凍結実効値 = 拡張子を除いた basename (現行 fallbackId と同値) + effectiveIdOf: (_filename, base) => base, + injectId: (raw, id) => injectFrontmatterId(raw, id), + fromFile: (p, id, filename) => ({ + id, + name: + typeof p.meta.name === 'string' && p.meta.name + ? p.meta.name + : filename.replace(/\.md$/, ''), + body: p.body, + }), + displayNameOf: (p) => (typeof p.meta.name === 'string' ? p.meta.name : ''), + idOf: (s) => s.id, + nameOf: (s) => s.name, + serialize: (s) => `---\nid: ${s.id}\nname: ${s.name}\n---\n${s.body}`, + } + return createSingleFileCollection(config) +} + +describe('スキル相当構成 (.md + frontmatter)', () => { + it('frontmatter の id 欠損は拡張子なし basename を注入して凍結する', async () => { + const fs = makeFakeFs({ + 'My Skill.md': '---\nname: My Skill\n---\nbody text', + }) + const col = makeSkillCollection(fs) + const { items } = await col.loadAll() + expect(items[0]?.id).toBe('My Skill') + expect(items[0]?.body).toBe('body text') + expect(fs.files.get('My Skill.md')).toContain("id: 'My Skill'") + // 冪等 + const frozen = fs.files.get('My Skill.md') + await col.loadAll() + expect(fs.files.get('My Skill.md')).toBe(frozen) + }) + + it('copy-adopt は frontmatter の表示名から slug 化し本文を保持する', async () => { + const fs = makeFakeFs({ + 'マイスキル.md': '---\nid: my-skill\nname: My Skill\n---\nbody text', + }) + const col = makeSkillCollection(fs) + const { items } = await col.loadAll() + await col.migrateItems(items) + expect(items[0]?.fileBase).toBe('my-skill') + expect(fs.files.get('my-skill.md')).toBe( + '---\nid: my-skill\nname: My Skill\n---\nbody text', + ) + expect(fs.files.has('マイスキル.md')).toBe(false) + }) + + it('履歴 sweep は .md の basename と casefold 照合する', async () => { + const fs = makeFakeFs({ + 'keep.md': '---\nid: keep\n---\nbody', + 'keep.history.json5': '{ entries: [] }', + 'gone.history.json5': '{ entries: [] }', + }) + const col = makeSkillCollection(fs) + await col.sweepHistory() + expect(fs.files.has('keep.history.json5')).toBe(true) + expect(fs.files.has('gone.history.json5')).toBe(false) + }) +}) diff --git a/src/services/singleFileCollection.ts b/src/services/singleFileCollection.ts new file mode 100644 index 000000000..4c97ebe22 --- /dev/null +++ b/src/services/singleFileCollection.ts @@ -0,0 +1,428 @@ +import { + casefold, + isSlugConforming, + resolveAvailable, + slugifyName, +} from '@/services/settingsSlug' + +/** + * 「単一ファイルで 1 アイテム」を表すコレクションのファイル永続化サービス + * (#913 — テーマ `.ndtheme.json5` / スキル `.md` 共通)。 + * sidecarFileCollection の単一ファイル版で、不変条件は同じ: + * + * - ファイル basename は ASCII slug (slugify の不動点)。参照はファイル内 ID + * - ID → 実ファイル名の対応表が唯一の正。実体は各アイテムの runtime-only + * フィールド `fileBase` (ファイルへは書かない。localStorage ミラーには同乗) + * - ID 凍結は常設規則: ID 欠損のファイルを読んだら種別の実効値を書き戻す + * - 履歴サイドカー (`.history.json5`) の basename は主ファイルと同一 + * + * 状態は持たない。reactive state・localStorage・seed の方針は store 側が持ち、 + * ファイル I/O の手続きだけをここへ委譲する。 + * (同一ウィンドウ内の書込交錯を防ぐ直列化キューのみ内部に持つ) + */ + +/** 各アイテムが持つ runtime-only のファイル対応フィールド。 */ +export interface SingleItemFile { + /** + * 実ファイル基底名 (規定拡張子を除いた部分)。 + * 未割当 (undefined) = まだファイル化されていない。 + * ファイルへは書かない (serialize に含めないこと)。 + */ + fileBase?: string +} + +export interface SingleFileCollectionConfig { + /** console.warn の識別子 (例: 'theme' | 'skills') */ + logTag: string + /** slug が空になったときの種別 fallback ('theme' | 'skill') */ + kindFallback: string + /** 規定複合拡張子 ('.ndtheme.json5' | '.md') */ + ext: string + list(): Promise + read(filename: string): Promise + write(filename: string, content: string): Promise + remove(filename: string): Promise + rename(oldFilename: string, newFilename: string): Promise + /** 生内容のパース。throw = パース不能 (読込スキップ・正規化対象外) */ + parse(raw: string): P + /** パース成功でも採用しない個体 (テーマの props 欠損等)。false = 警告スキップ (凍結もしない) */ + accepts?(parsed: P): boolean + /** パース結果から生 ID 値 (欠損判定前) を取り出す */ + rawIdOf(parsed: P): unknown + /** ID 凍結の実効値 (テーマ = `custom-<完全ファイル名>` / スキル = 拡張子なし basename) */ + effectiveIdOf(filename: string, base: string): string + /** 生内容への最小変換で ID を注入する (idFreeze) */ + injectId(raw: string, id: string): string + /** パース結果 + 確定 ID → アイテム。fileBase はサービスが付与する */ + fromFile(parsed: P, id: string, filename: string): T + /** copy-adopt で slug の元にする表示名 (欠損なら '' → kindFallback に落ちる) */ + displayNameOf(parsed: P): string + idOf(item: T): string + /** 表示名 (新規割当・リネームの slug の元)。空なら kindFallback に落ちる */ + nameOf(item: T): string + /** 通常保存経路の再シリアライズ。fileBase を含めないこと */ + serialize(item: T): string +} + +const HISTORY_SUFFIX = '.history.json5' +/** ID 凍結の欠損判定に使う上限長。制御文字含有は欠損に含めない (#913) */ +const ID_MAX_LENGTH = 256 + +export interface LoadAllResult { + items: T[] + /** + * ディレクトリに存在した規定拡張子ファイル数。`items.length` と別に返すのは + * 「全ファイルがパース失敗」(entryFileCount > 0, items 空) と「ファイルなし」 + * (= localStorage → ファイルの片方向移行が必要) を呼び出し側が区別するため。 + */ + entryFileCount: number +} + +const encoder = new TextEncoder() + +/** ファイル名の辞書順 (UTF-8 バイト順)。OS 依存の列挙順に依存しない。 */ +function compareBytes(a: string, b: string): number { + const x = encoder.encode(a) + const y = encoder.encode(b) + const n = Math.min(x.length, y.length) + for (let i = 0; i < n; i++) { + const d = (x[i] as number) - (y[i] as number) + if (d !== 0) return d + } + return x.length - y.length +} + +/** ID 凍結の「欠損」判定: キー不在・空・非文字列・上限長超過。 */ +function isValidId(v: unknown): v is string { + return typeof v === 'string' && v.length > 0 && v.length <= ID_MAX_LENGTH +} + +export function createSingleFileCollection( + cfg: SingleFileCollectionConfig, +) { + // 同一ウィンドウ内の変更系操作を直列化する (空き名探索 → 書込の交錯防止)。 + let chain: Promise = Promise.resolve() + function enqueue(fn: () => Promise): Promise { + const next = chain.then(fn, fn) + chain = next.catch(() => undefined) + return next + } + + /** 規定拡張子 or 履歴を剥がした basename。どちらでもなければ null。 */ + function stripKnownExt(filename: string): string | null { + if (filename.endsWith(HISTORY_SUFFIX)) { + return filename.slice(0, -HISTORY_SUFFIX.length) + } + if (filename.endsWith(cfg.ext)) { + return filename.slice(0, -cfg.ext.length) + } + return null + } + + /** + * 占有集合 (casefold 済み) を構築する。 + * 探索空間 = 種別ディレクトリの実列挙 (規定拡張子 + 履歴の basename。 + * パース不能・スキップ個体も含む) ∪ 対応表 (各アイテムの fileBase) + * ∪ (ID を決める操作では) 種別内 ID 集合。 + * 操作対象アイテム自身 (とその実ファイル) は占有とみなさない。 + */ + async function buildTaken( + allItems: readonly T[], + opts: { excludeItem?: T; includeIds: boolean }, + ): Promise> { + const files = await cfg.list() + const excludeBase = opts.excludeItem?.fileBase + const taken = new Set() + for (const f of files) { + const base = stripKnownExt(f) + if (base === null) continue + if (excludeBase !== undefined && base === excludeBase) continue + taken.add(casefold(base)) + } + for (const it of allItems) { + if (it === opts.excludeItem) continue + if (it.fileBase !== undefined) taken.add(casefold(it.fileBase)) + if (opts.includeIds) taken.add(casefold(cfg.idOf(it))) + } + return taken + } + + async function loadAllImpl(): Promise> { + const allFiles = (await cfg.list()).slice().sort(compareBytes) + const mainFiles = allFiles.filter( + (f) => f.endsWith(cfg.ext) && !f.endsWith(HISTORY_SUFFIX), + ) + + const items: T[] = [] + const seenIds = new Set() + for (const filename of mainFiles) { + try { + const base = filename.slice(0, -cfg.ext.length) + let raw = await cfg.read(filename) + let parsed = cfg.parse(raw) + if (cfg.accepts && !cfg.accepts(parsed)) { + console.warn( + `[${cfg.logTag}] ${filename} is not a valid item — skipped (file kept)`, + ) + continue + } + if (!isValidId(cfg.rawIdOf(parsed))) { + // ID 凍結 (常設規則): 種別の実効値を最小変換で注入して書き戻す + raw = cfg.injectId(raw, cfg.effectiveIdOf(filename, base)) + await cfg.write(filename, raw) + parsed = cfg.parse(raw) + } + const id = cfg.rawIdOf(parsed) as string + if (seenIds.has(id)) { + console.warn( + `[${cfg.logTag}] duplicate id "${id}" in ${filename} — skipped (file kept)`, + ) + continue + } + seenIds.add(id) + + const item = cfg.fromFile(parsed, id, filename) + item.fileBase = base + items.push(item) + } catch (e) { + console.warn(`[${cfg.logTag}] failed to parse ${filename}:`, e) + } + } + return { items, entryFileCount: mainFiles.length } + } + + async function persistItemImpl( + item: T, + allItems: readonly T[], + ): Promise { + if (item.fileBase === undefined) { + // 新規割当 (ID を決める操作): ファイル名と ID 集合の両方に対して空きを探す + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: true, + }) + item.fileBase = resolveAvailable( + slugifyName(cfg.nameOf(item), cfg.kindFallback), + (c) => taken.has(casefold(c)), + ) + } + await cfg.write(item.fileBase + cfg.ext, cfg.serialize(item)) + } + + async function deleteItemFilesImpl(item: T): Promise { + const base = item.fileBase + if (base === undefined) return + // 履歴サイドカーも削除 (残すと同名の新規アイテムが削除済みアイテムの + // 履歴リングを継承する)。remove は missing = no-op 意味論。 + await cfg.remove(base + cfg.ext) + await cfg.remove(base + HISTORY_SUFFIX) + } + + /** 主ファイル → history の順で rename する。不在は skip (ensure-dest)。 */ + async function renameFileSet(from: string, to: string): Promise { + const files = new Set(await cfg.list()) + for (const ext of [cfg.ext, HISTORY_SUFFIX]) { + const oldFile = from + ext + const newFile = to + ext + if (!files.has(oldFile)) continue + if (files.has(newFile)) { + console.warn( + `[${cfg.logTag}] rename target already exists, skipped: ${newFile}`, + ) + continue + } + await cfg.rename(oldFile, newFile) + } + } + + /** + * 表示名リネームにファイルを追随させる (ID 不変・ファイル名側のみ占有解決)。 + * fileBase 未割当なら no-op (次の persist が割り当てる)。 + */ + async function renameItemFilesImpl( + item: T, + allItems: readonly T[], + ): Promise { + const oldBase = item.fileBase + if (oldBase === undefined) return + const newBase = slugifyName(cfg.nameOf(item), cfg.kindFallback) + if (newBase === oldBase) return + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: false, + }) + const resolved = resolveAvailable(newBase, (c) => taken.has(casefold(c))) + if (resolved === oldBase) return + if (casefold(resolved) === casefold(oldBase)) { + // 大文字小文字のみ違う自己リネーム: case-insensitive FS では同一ファイル + // へ解決するため、規定拡張子を維持した slug 中間名経由の 2 段で行う + const inter = resolveAvailable( + slugifyName(`${resolved} mv`, cfg.kindFallback), + (c) => taken.has(casefold(c)) || casefold(c) === casefold(resolved), + ) + await renameFileSet(oldBase, inter) + await renameFileSet(inter, resolved) + } else { + await renameFileSet(oldBase, resolved) + } + item.fileBase = resolved + } + + /** 読み戻し検証: 書いた内容と一致するか。 */ + async function verifyWritten(base: string, raw: string): Promise { + try { + return (await cfg.read(base + cfg.ext)) === raw + } catch { + return false + } + } + + /** + * 移行 (a): 規約外名 1 アイテムの copy-adopt。 + * 読む → (凍結済みの) 生内容を新 slug 名へ書込 → 検証 → 旧削除。 + */ + async function copyAdoptOne( + item: T, + oldBase: string, + allItems: readonly T[], + ): Promise { + const oldFile = oldBase + cfg.ext + const raw = await cfg.read(oldFile) + const parsed = cfg.parse(raw) + // slug の元はファイル内の表示名 (欠損なら種別 fallback) + const candidate = slugifyName(cfg.displayNameOf(parsed), cfg.kindFallback) + const taken = await buildTaken(allItems, { + excludeItem: item, + includeIds: false, + }) + const isTaken = (c: string) => taken.has(casefold(c)) + + if (!isTaken(candidate) && casefold(candidate) === casefold(oldBase)) { + // 大文字小文字のみ違い: 中間名経由の 2 段 (copy → 旧削除 → rename) + const inter = resolveAvailable( + slugifyName(`${candidate} mv`, cfg.kindFallback), + (c) => isTaken(c) || casefold(c) === casefold(candidate), + ) + await cfg.write(inter + cfg.ext, raw) + if (!(await verifyWritten(inter, raw))) { + console.warn(`[${cfg.logTag}] migration verify failed for ${inter}`) + await cfg.remove(inter + cfg.ext) + return + } + await cfg.remove(oldFile) + await cfg.rename(inter + cfg.ext, candidate + cfg.ext) + item.fileBase = candidate + return + } + + let final: string + if (!isTaken(candidate)) { + final = candidate + } else { + // 達成済み判定: 衝突先 (casefold 一致の実名。自身の旧名は除く) の + // 内部 ID と内容を照合する + const files = await cfg.list() + const occupantFile = files.find( + (f) => + f.endsWith(cfg.ext) && + !f.endsWith(HISTORY_SUFFIX) && + f.slice(0, -cfg.ext.length) !== oldBase && + casefold(f.slice(0, -cfg.ext.length)) === casefold(candidate), + ) + if (occupantFile !== undefined) { + const occupantBase = occupantFile.slice(0, -cfg.ext.length) + let occupantId: unknown + let occupantRaw: string | null = null + try { + occupantRaw = await cfg.read(occupantFile) + occupantId = cfg.rawIdOf(cfg.parse(occupantRaw)) + } catch { + // パース不能な衝突先は ID 不一致として扱う (単純占有 → suffix) + } + if (occupantId === cfg.idOf(item)) { + if (occupantRaw === raw) { + // 達成済み (クラッシュ残骸の回収): 旧削除のみ再実行 + await cfg.remove(oldFile) + item.fileBase = occupantBase + return + } + // ID 一致・内容不一致 (旧形式バックアップ復元等): 削除せず suffix へ。 + // 重複 ID 警告として可視化し手動解決に委ねる + console.warn( + `[${cfg.logTag}] duplicate id "${cfg.idOf(item)}" at ${occupantFile} with different content — kept both`, + ) + } + } + final = resolveAvailable(candidate, isTaken) + } + + await cfg.write(final + cfg.ext, raw) + if (!(await verifyWritten(final, raw))) { + console.warn(`[${cfg.logTag}] migration verify failed for ${final}`) + await cfg.remove(final + cfg.ext) + return + } + if (casefold(final) === casefold(oldBase)) { + // 保険: 旧削除前に新旧が同一ファイルへ解決しないことを確認 + console.warn( + `[${cfg.logTag}] migration target resolves to the same file as ${oldBase} — old file kept`, + ) + item.fileBase = final + return + } + await cfg.remove(oldFile) + item.fileBase = final + } + + /** + * 移行 (a): 対応表のうち規約外名 (slugify の不動点でない) のアイテムを + * copy-adopt で正規化する。冪等 (失敗分は次回起動でリトライされる)。 + */ + async function migrateItemsImpl(items: readonly T[]): Promise { + for (const item of items) { + const oldBase = item.fileBase + if (oldBase === undefined) continue + if (isSlugConforming(oldBase)) continue + try { + await copyAdoptOne(item, oldBase, items) + } catch (e) { + console.warn(`[${cfg.logTag}] migration failed for ${oldBase}:`, e) + } + } + } + + /** + * 履歴 sweep: sweep 時点のディレクトリ実列挙に存在する規定拡張子付き + * 全主ファイル (読込採否を問わない) の basename 集合と casefold で照合し、 + * 対応の取れない `.history.json5` を削除する。 + */ + async function sweepHistoryImpl(): Promise { + const files = await cfg.list() + const mainBases = new Set() + for (const f of files) { + if (f.endsWith(HISTORY_SUFFIX)) continue + if (f.endsWith(cfg.ext)) { + mainBases.add(casefold(f.slice(0, -cfg.ext.length))) + } + } + for (const f of files) { + if (!f.endsWith(HISTORY_SUFFIX)) continue + const base = casefold(f.slice(0, -HISTORY_SUFFIX.length)) + if (!mainBases.has(base)) { + await cfg.remove(f) + } + } + } + + return { + loadAll: () => enqueue(loadAllImpl), + persistItem: (item: T, allItems: readonly T[]) => + enqueue(() => persistItemImpl(item, allItems)), + deleteItemFiles: (item: T) => enqueue(() => deleteItemFilesImpl(item)), + renameItemFiles: (item: T, allItems: readonly T[]) => + enqueue(() => renameItemFilesImpl(item, allItems)), + migrateItems: (items: readonly T[]) => + enqueue(() => migrateItemsImpl(items)), + sweepHistory: () => enqueue(sweepHistoryImpl), + } +} From a4cd5dfac8eb67e24708a74f3232ff68c0097e4c Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:00:36 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat(stores):=20=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=82=92=E5=AF=BE=E5=BF=9C=E8=A1=A8=E5=8F=82=E7=85=A7?= =?UTF-8?q?=20+=20copy-adopt=20=E7=A7=BB=E8=A1=8C=E3=81=AB=E9=85=8D?= =?UTF-8?q?=E7=B7=9A=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - themeFileSync を singleFileCollection ベースに書き換え、theme オブジェクトの runtime-only な fileBase を対応表とする (ファイルへは projection で書かず、localStorage ミラーには同乗) - ID 凍結 (実効値 custom-<完全ファイル名>)・重複 ID skip・辞書順読込 - initFileStorage で (a) 規約外名の copy-adopt 正規化 → (b) ミラー在・ ファイル不在の新 slug 再作成 (旧 localStorage 片方向移行を統合) → 履歴 sweep をメインウィンドウのみで実行 - 変更系操作は ready ゲート (初回読込 + 移行完了) 待ちに直列化し、 保存・削除の直前にミラーの対応表を読み直す - renameTheme を「旧削除 + 新書込の並行発火」から rename コマンド追随 (主ファイル + 履歴、ID 不変・完了 await 後に persist) へ置換 - 手動テーマ貼り付けの同一 ID 更新で、貼り付けコードに $notedeck が 無ければ既存の $notedeck (storeId 等) を保持する - 履歴キー (pushSnapshot / theme.history / theme.revert) を theme.id から fileBase 参照に変更 - 外部エディタ起動 (ThemeEditorContent) を対応表参照に変更、 themeFilename / listThemes を廃止 (β 方針: 後方互換は残さない) Co-Authored-By: Claude Fable 5 --- src/capabilities/builtins/theme.ts | 20 ++- src/components/window/ThemeEditorContent.vue | 25 +-- src/stores/theme.dom.test.ts | 56 +++++++ src/stores/theme.ts | 160 +++++++++++++------ src/stores/themeFileSync.ts | 129 ++++++++------- src/theme/types.ts | 6 + src/utils/settingsFs.ts | 12 +- 7 files changed, 278 insertions(+), 130 deletions(-) diff --git a/src/capabilities/builtins/theme.ts b/src/capabilities/builtins/theme.ts index db3fc988d..6043701e0 100644 --- a/src/capabilities/builtins/theme.ts +++ b/src/capabilities/builtins/theme.ts @@ -363,6 +363,12 @@ function isStringRecord(v: unknown): v is Record { return true } +/** 履歴サイドカーのキーは対応表の fileBase (#913)。未割当なら id に落ちる。 */ +function themeHistoryBase(id: string): string { + const theme = useThemeStore().installedThemes.find((t) => t.id === id) + return theme?.fileBase ?? id +} + export const themeHistoryCapability: Command = { id: 'theme.history', label: 'テーマの編集履歴', @@ -387,7 +393,7 @@ export const themeHistoryCapability: Command = { execute: async (params) => { const id = typeof params?.id === 'string' ? params.id : '' if (!id) throw new Error('theme.history: id is required') - return await listSnapshots('theme', id) + return await listSnapshots('theme', themeHistoryBase(id)) }, } @@ -403,7 +409,11 @@ export const themeRevertCapability: Command = { const id = typeof params?.id === 'string' ? params.id : '' const index = typeof params?.index === 'number' ? params.index : -1 if (!id || index < 0) return null - const entry = await getSnapshotAt('theme', id, index) + const entry = await getSnapshotAt( + 'theme', + themeHistoryBase(id), + index, + ) if (!entry) return null const snap = entry.snapshot return { @@ -441,7 +451,11 @@ export const themeRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 if (!id) throw new Error('theme.revert: id is required') if (index < 0) throw new Error('theme.revert: index must be >= 0') - const entry = await getSnapshotAt('theme', id, index) + const entry = await getSnapshotAt( + 'theme', + themeHistoryBase(id), + index, + ) if (!entry) { throw new Error(`theme.revert: no snapshot at index ${index}`) } diff --git a/src/components/window/ThemeEditorContent.vue b/src/components/window/ThemeEditorContent.vue index 0354fa913..3ce30e9e7 100644 --- a/src/components/window/ThemeEditorContent.vue +++ b/src/components/window/ThemeEditorContent.vue @@ -29,7 +29,7 @@ import { parseColor } from '@/theme/colorUtils' import { compileMisskeyTheme } from '@/theme/compiler' import type { MisskeyTheme } from '@/theme/types' import { createJson5Linter } from '@/utils/json5Linter' -import { themeFilename } from '@/utils/settingsFs' +import { THEME_EXT } from '@/utils/settingsFs' const jsonLang = json() const jsonLinter = createJson5Linter() @@ -68,16 +68,19 @@ const baseMode = ref<'dark' | 'light'>('dark') // Track the ID of the theme being edited (null = new theme) const editingThemeId = ref(null) -// 外部エディタ起動用: 既存テーマのみ対象(未保存は disabled) -useWindowExternalFile(() => - tab.value === 'code' - ? { - name: themeFilename(themeName.value), - subdir: 'themes', - disabled: !editingThemeId.value, - } - : null, -) +// 外部エディタ起動用: 既存テーマのみ対象(未保存・対応表未確定は disabled)。 +// ファイル名は対応表 (fileBase) を参照する — 表示名から計算しない (#913) +useWindowExternalFile(() => { + if (tab.value !== 'code') return null + const current = themeStore.installedThemes.find( + (t) => t.id === editingThemeId.value, + ) + return { + name: current?.fileBase ? `${current.fileBase}${THEME_EXT}` : '', + subdir: 'themes', + disabled: !current?.fileBase, + } +}) // Primary color props that users typically want to edit directly const PRIMARY_PROPS: { key: string; label: string }[] = [ diff --git a/src/stores/theme.dom.test.ts b/src/stores/theme.dom.test.ts index 852c50bc9..5b07371ff 100644 --- a/src/stores/theme.dom.test.ts +++ b/src/stores/theme.dom.test.ts @@ -126,6 +126,62 @@ describe('useThemeStore.removeTheme (undo) — #988', () => { }) }) +describe('useThemeStore.installTheme — 同一 ID の貼り付けは更新扱い (#913)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('貼り付けコードに $notedeck が無ければ既存の $notedeck を保持する', async () => { + const store = useThemeStore() + store.installedThemes = [ + { ...RED, $notedeck: { storeId: 'red-store', installedFor: ['acc-1'] } }, + ] + + const ok = await store.installTheme( + JSON.stringify({ id: RED.id, name: 'Red v2', props: { bg: '#f00' } }), + ) + + expect(ok).toBe(true) + expect(store.installedThemes).toHaveLength(1) + expect(store.installedThemes[0]?.name).toBe('Red v2') + expect(store.installedThemes[0]?.$notedeck?.storeId).toBe('red-store') + expect(store.installedThemes[0]?.$notedeck?.installedFor).toEqual(['acc-1']) + }) + + it('貼り付けコードに $notedeck があればそちらを採用する', async () => { + const store = useThemeStore() + store.installedThemes = [{ ...RED, $notedeck: { storeId: 'old-store' } }] + + const ok = await store.installTheme( + JSON.stringify({ + id: RED.id, + name: 'Red v3', + props: { bg: '#f00' }, + $notedeck: { storeId: 'new-store' }, + }), + ) + + expect(ok).toBe(true) + expect(store.installedThemes[0]?.$notedeck?.storeId).toBe('new-store') + }) + + it('更新扱いでも既存の fileBase (対応表) を引き継ぐ', async () => { + const store = useThemeStore() + store.installedThemes = [{ ...RED, fileBase: 'red' }] + + await store.installTheme( + JSON.stringify({ id: RED.id, name: 'Red v2', props: { bg: '#f00' } }), + ) + + expect(store.installedThemes[0]?.fileBase).toBe('red') + }) +}) + describe('useThemeStore.unlinkAccountFromTheme — #988', () => { beforeEach(() => { setActivePinia(createPinia()) diff --git a/src/stores/theme.ts b/src/stores/theme.ts index a6bccce5b..a957e5104 100644 --- a/src/stores/theme.ts +++ b/src/stores/theme.ts @@ -122,6 +122,33 @@ export const useThemeStore = defineStore('theme', () => { const customCss = ref('') // Whether file-based storage has been initialized const initialized = ref(false) + // 変更系操作 (インストール・リネーム・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + + /** 保存・削除の直前にミラーの対応表を読み直す (別ウィンドウのリネーム追随)。 */ + function adoptMirrorFileBase(theme: MisskeyTheme) { + const mirrored = getStorageJson( + STORAGE_KEYS.themeInstalledThemes, + [], + ).find((t) => t.id === theme.id) + if (mirrored?.fileBase) theme.fileBase = mirrored.fileBase + } + + /** テーマ 1 件をファイルへ反映する (ready 待ち + fileBase 割当のミラー反映)。 */ + function persistThemeFile(theme: MisskeyTheme) { + if (!settingsFs.isTauri) return + void ready + .then(async () => { + adoptMirrorFileBase(theme) + await themeFileSync.themeFiles.persistItem(theme, installedThemes.value) + setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) + }) + .catch((e) => console.warn('[theme] failed to persist theme:', e)) + } function init(): void { // Restore compiled CSS from localStorage first (sync, FOUC prevention). @@ -183,11 +210,12 @@ export const useThemeStore = defineStore('theme', () => { // Kick off async file sync in background (Tauri only) if (settingsFs.isTauri) { - initFileStorage().catch((e) => - console.warn('[theme] file storage init failed:', e), - ) + initFileStorage() + .catch((e) => console.warn('[theme] file storage init failed:', e)) + .finally(() => resolveReady?.()) } else { initialized.value = true + resolveReady?.() } } @@ -281,6 +309,18 @@ export const useThemeStore = defineStore('theme', () => { if (parsed.$notedeck && typeof parsed.$notedeck === 'object') { theme.$notedeck = { ...parsed.$notedeck } } + + // 同一 ID は更新扱い: 既存対応表のファイルへ書く (#913)。貼り付け + // コードに $notedeck が無ければ既存の $notedeck (storeId 等) を保持 + // する — 落とすとストア同一性が消え、次のストアインストールが + // suffix 付き重複になり更新検知も恒久的に死ぬ + const existingTheme = installedThemes.value.find((t) => t.id === theme.id) + if (existingTheme) { + if (!theme.$notedeck && existingTheme.$notedeck) { + theme.$notedeck = { ...existingTheme.$notedeck } + } + theme.fileBase = existingTheme.fileBase + } // forAccountIds に指定された account 全てを installedFor に追加。 // per-account カラム経由なら [accountId]、全アカウントカラム経由なら // 全 logged-in account ids。 @@ -292,16 +332,17 @@ export const useThemeStore = defineStore('theme', () => { } } - // Avoid duplicates - const existingTheme = installedThemes.value.find((t) => t.id === theme.id) if (existingTheme) { - // 上書きケース: 既存テーマの編集前 snapshot を history に push - pushSnapshot('theme', existingTheme.id, { - id: existingTheme.id, - name: existingTheme.name, - base: existingTheme.base, - props: existingTheme.props, - }).catch((e) => console.warn('[theme] history push failed:', e)) + // 上書きケース: 既存テーマの編集前 snapshot を history に push。 + // 履歴キーは対応表の fileBase (未割当 = ファイル未作成なら履歴も無し) + if (existingTheme.fileBase) { + pushSnapshot('theme', existingTheme.fileBase, { + id: existingTheme.id, + name: existingTheme.name, + base: existingTheme.base, + props: existingTheme.props, + }).catch((e) => console.warn('[theme] history push failed:', e)) + } installedThemes.value = installedThemes.value.map((t) => t.id === theme.id ? theme : t, ) @@ -310,12 +351,8 @@ export const useThemeStore = defineStore('theme', () => { } // Sync: localStorage cache setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) - // Async: write only the changed theme to file - if (initialized.value) { - themeFileSync - .persistSingleTheme(theme) - .catch((e) => console.warn('[theme] failed to persist theme:', e)) - } + // Async: write only the changed theme to file (ready 待ち) + persistThemeFile(theme) return true } catch { return false @@ -330,6 +367,9 @@ export const useThemeStore = defineStore('theme', () => { function removeTheme(id: string): (() => void) | undefined { const idx = installedThemes.value.findIndex((t) => t.id === id) const removed = installedThemes.value[idx] + // ミラー上書き前に対応表を読み直す (別ウィンドウのリネーム後の削除が + // stale なファイル名で空振りしないように) + if (removed && settingsFs.isTauri) adoptMirrorFileBase(removed) installedThemes.value = installedThemes.value.filter((t) => t.id !== id) // Clear selection if removed (computed setter → settingsStore) const wasDark = selectedDarkThemeId.value === id @@ -343,10 +383,10 @@ export const useThemeStore = defineStore('theme', () => { // Sync: update localStorage cache only (no need to rewrite remaining theme files) setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) applyCurrentTheme() - // Async: delete the removed theme file - if (initialized.value && removed) { - themeFileSync - .deleteThemeFile(removed) + // Async: delete the removed theme file (+ 履歴サイドカー、ready 待ち) + if (settingsFs.isTauri && removed) { + void ready + .then(() => themeFileSync.themeFiles.deleteItemFiles(removed)) .catch((e) => console.warn('[theme] failed to delete theme file:', e)) } if (!removed) return undefined @@ -362,13 +402,7 @@ export const useThemeStore = defineStore('theme', () => { if (wasLight) selectedLightThemeId.value = id setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) applyCurrentTheme() - if (initialized.value) { - themeFileSync - .persistSingleTheme(removed) - .catch((e) => - console.warn('[theme] failed to restore theme file:', e), - ) - } + persistThemeFile(removed) } } @@ -404,13 +438,7 @@ export const useThemeStore = defineStore('theme', () => { t.id === themeId ? updated : t, ) setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) - if (initialized.value) { - themeFileSync - .persistSingleTheme(updated) - .catch((e) => - console.warn('[theme] failed to persist installedFor update:', e), - ) - } + persistThemeFile(updated) return undefined } @@ -418,19 +446,24 @@ export const useThemeStore = defineStore('theme', () => { const theme = installedThemes.value.find((t) => t.id === themeId) if (!theme) return - const oldFilename = settingsFs.themeFilename(theme.name || theme.id) theme.name = newName - const newFilename = settingsFs.themeFilename(newName) - setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) - if (initialized.value && oldFilename !== newFilename) { - // Delete old file and write new one (theme id stays the same, only name changes) - Promise.all([ - settingsFs.deleteTheme(oldFilename), - themeFileSync.persistSingleTheme(theme), - ]).catch((e) => console.warn('[theme] failed to rename theme file:', e)) - } + if (!settingsFs.isTauri) return + // ファイルは rename コマンドで追随させる (ID 不変・主ファイル + 履歴。 + // 旧削除 + 新書込の並行発火は旧ファイルを孤児化させるため禁止 #913)。 + // rename の完了を待ってから保存する + void ready + .then(async () => { + adoptMirrorFileBase(theme) + await themeFileSync.themeFiles.renameItemFiles( + theme, + installedThemes.value, + ) + await themeFileSync.themeFiles.persistItem(theme, installedThemes.value) + setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) + }) + .catch((e) => console.warn('[theme] failed to rename theme file:', e)) } function selectTheme(id: string | null, mode: 'dark' | 'light'): void { @@ -628,9 +661,14 @@ export const useThemeStore = defineStore('theme', () => { async function initFileStorage(): Promise { const data = await themeFileSync.loadFromFiles() + // 初期化 (この async 関数が走る間) にメモリ追加されたテーマと、 + // ミラーにだけ在るテーマ (過去の書込が黙って失敗した個体) の集合。 + const fileIds = new Set(data.themes.map((t) => t.id)) + const memoryOnly = installedThemes.value.filter((t) => !fileIds.has(t.id)) + if (data.themes.length > 0) { - installedThemes.value = data.themes - setStorageJson(STORAGE_KEYS.themeInstalledThemes, data.themes) + installedThemes.value = [...data.themes, ...memoryOnly] + setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) applyCurrentTheme() } @@ -642,12 +680,28 @@ export const useThemeStore = defineStore('theme', () => { initialized.value = true - // Migrate: if localStorage has themes but no files exist, write them - if (data.needsMigrateThemes && installedThemes.value.length > 0) { - themeFileSync - .persistAllThemes(installedThemes.value) - .catch((e) => console.warn('[theme] migration to files failed:', e)) + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等 + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化 + await themeFileSync.themeFiles.migrateItems(installedThemes.value) + // (b) ミラー在・ファイル不在 → 新 slug 名で再作成 + // (localStorage → ファイルの旧片方向移行もこの経路に統合) + for (const t of memoryOnly) { + t.fileBase = undefined // ミラー由来の旧 fileBase は無効 (ファイル不在) + await themeFileSync.themeFiles + .persistItem(t, installedThemes.value) + .catch((e) => + console.warn('[theme] failed to persist mirror-only theme:', e), + ) + } + // 履歴 sweep: 主ファイルと対応の取れない .history.json5 を削除 + await themeFileSync.themeFiles + .sweepHistory() + .catch((e) => console.warn('[theme] history sweep failed:', e)) } + // fileBase 割当をミラーへ反映 + setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value) + // Migrate custom CSS to file if not yet written if (data.needsMigrateCss && customCss.value) { themeFileSync diff --git a/src/stores/themeFileSync.ts b/src/stores/themeFileSync.ts index c5c16b96c..86dd6ba2c 100644 --- a/src/stores/themeFileSync.ts +++ b/src/stores/themeFileSync.ts @@ -1,77 +1,96 @@ import JSON5 from 'json5' -import type { MisskeyTheme } from '@/theme/types' + +import { injectJson5Id } from '@/services/idFreeze' +import { createSingleFileCollection } from '@/services/singleFileCollection' +import type { MisskeyTheme, NotedeckThemeMeta } from '@/theme/types' import * as settingsFs from '@/utils/settingsFs' +/** + * テーマ (`themes/.ndtheme.json5` 単一ファイル) の永続化 + * (#913 で ID → ファイル名対応表化)。 + * + * - 対応表の実体は theme オブジェクトの runtime-only な `fileBase`。 + * ファイルへは書かない (serializeTheme が projection で strip する) + * - ID 凍結の実効値 = `custom-` + 完全ファイル名 (現行フォールバックと同値) + * - themes/ の素の `.json5` (規定拡張子でないもの) は従来どおり無視 + * (drop-in は #1041 スコープ外) + */ + +type ParsedTheme = Record + +/** テーマ 1 件のファイル projection。runtime-only の fileBase は含めない。 */ +function serializeTheme(theme: MisskeyTheme): string { + const out: Record = { + id: theme.id, + name: theme.name, + base: theme.base === 'light' ? 'light' : 'dark', + props: theme.props, + } + // NoteDeck 独自メタ ($notedeck) は従来どおりファイルに書く + if (theme.$notedeck) out.$notedeck = theme.$notedeck + return JSON5.stringify(out, null, 2) +} + +export const themeFiles = createSingleFileCollection( + { + logTag: 'theme', + kindFallback: 'theme', + ext: settingsFs.THEME_EXT, + // 占有判定・sweep には .history.json5 を含む実列挙が要る + // (規定拡張子の filter はコレクション側が行う) + list: () => settingsFs.listThemeDirFiles(), + read: (filename) => settingsFs.readTheme(filename), + write: (filename, content) => settingsFs.writeTheme(filename, content), + remove: (filename) => settingsFs.deleteTheme(filename), + rename: (oldFilename, newFilename) => + settingsFs.renameTheme(oldFilename, newFilename), + parse: (raw) => JSON5.parse(raw) as ParsedTheme, + accepts: (p) => !!p && typeof p === 'object' && !!p.props, + rawIdOf: (p) => p.id, + effectiveIdOf: (filename) => `custom-${filename}`, + injectId: (raw, id) => injectJson5Id(raw, 'id', id), + fromFile: (p, id, filename) => { + const theme: MisskeyTheme = { + id, + name: typeof p.name === 'string' && p.name ? p.name : filename, + base: p.base === 'light' ? 'light' : 'dark', + props: p.props as Record, + } + // NoteDeck 独自メタ ($notedeck.storeId / installedFor 等) を保持 + // しないと再起動時にストア紐付き / per-account 紐付きが消える + if (p.$notedeck && typeof p.$notedeck === 'object') { + theme.$notedeck = { ...(p.$notedeck as NotedeckThemeMeta) } + } + return theme + }, + displayNameOf: (p) => (typeof p.name === 'string' ? p.name : ''), + idOf: (t) => t.id, + nameOf: (t) => t.name, + serialize: serializeTheme, + }, +) + export interface FileStorageData { themes: MisskeyTheme[] + /** ディレクトリに存在した .ndtheme.json5 の数 (パース失敗分を含む) */ + entryFileCount: number customCss: string | null - /** True when localStorage has themes but no files exist (needs migration) */ - needsMigrateThemes: boolean /** True when localStorage has custom CSS but no file exists */ needsMigrateCss: boolean } /** Load installed themes and custom CSS from the file system. */ export async function loadFromFiles(): Promise { - const filenames = await settingsFs.listThemes() - let themes: MisskeyTheme[] = [] - - if (filenames.length > 0) { - const results = await Promise.all( - filenames.map(async (filename) => { - try { - const content = await settingsFs.readTheme(filename) - const parsed = JSON5.parse(content) - if (parsed?.props) { - return { - id: parsed.id || `custom-${filename}`, - name: parsed.name || filename, - base: parsed.base === 'light' ? 'light' : 'dark', - props: parsed.props, - // NoteDeck 独自メタ ($notedeck.storeId / installedFor 等) を保持 - // しないと再起動時にストア紐付き / per-account 紐付きが消える - ...(parsed.$notedeck && typeof parsed.$notedeck === 'object' - ? { $notedeck: parsed.$notedeck } - : {}), - } as MisskeyTheme - } - } catch (e) { - console.warn(`[theme] failed to parse ${filename}:`, e) - } - return null - }), - ) - themes = results.filter((t): t is MisskeyTheme => t !== null) - } - + const { items, entryFileCount } = await themeFiles.loadAll() const customCss = await settingsFs.readCustomCss() - return { - themes, + themes: items, + entryFileCount, customCss: customCss || null, - needsMigrateThemes: filenames.length === 0, needsMigrateCss: !customCss, } } -/** Write a single theme to its individual file. */ -export async function persistSingleTheme(theme: MisskeyTheme): Promise { - const filename = settingsFs.themeFilename(theme.name || theme.id) - const content = JSON5.stringify(theme, null, 2) - await settingsFs.writeTheme(filename, content) -} - -/** Write all themes to individual files. */ -export async function persistAllThemes(themes: MisskeyTheme[]): Promise { - await Promise.all(themes.map((theme) => persistSingleTheme(theme))) -} - -/** Delete a theme file by name/id. */ -export async function deleteThemeFile(theme: MisskeyTheme): Promise { - const filename = settingsFs.themeFilename(theme.name || theme.id) - await settingsFs.deleteTheme(filename) -} - /** Write custom CSS to file. */ export async function writeCustomCssFile(css: string): Promise { await settingsFs.writeCustomCss(css) diff --git a/src/theme/types.ts b/src/theme/types.ts index 3c467687c..236cce500 100644 --- a/src/theme/types.ts +++ b/src/theme/types.ts @@ -19,6 +19,12 @@ export interface MisskeyTheme { props: Record /** NoteDeck 独自拡張 (本家は無視、registry sync では保持される) */ $notedeck?: NotedeckThemeMeta + /** + * 実ファイル basename (#913 の ID → ファイル名対応表)。runtime-only — + * ファイルへは書かず (themeFileSync が書込前に strip)、localStorage + * ミラーには同乗する。 + */ + fileBase?: string } export type CompiledProps = Record diff --git a/src/utils/settingsFs.ts b/src/utils/settingsFs.ts index 7084fdc8e..e95de48b6 100644 --- a/src/utils/settingsFs.ts +++ b/src/utils/settingsFs.ts @@ -146,15 +146,11 @@ async function writeRootSettingsFile( // --- Theme-specific helpers --- const THEMES_DIR = 'themes' -const THEME_EXT = '.ndtheme.json5' +export const THEME_EXT = '.ndtheme.json5' -export function themeFilename(name: string): string { - return sanitizeFilename(name) + THEME_EXT -} - -export async function listThemes(): Promise { - const files = await listSettingsFiles(THEMES_DIR) - return files.filter((f) => f.endsWith(THEME_EXT)) +/** themes/ の実列挙 (履歴 .history.json5 を含む。#913 の占有判定・sweep 用)。 */ +export async function listThemeDirFiles(): Promise { + return listSettingsFiles(THEMES_DIR) } export async function readTheme(filename: string): Promise { From 0da14c1dafa6bb4f2b1624a291fdaab7681c0f4b Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:08:05 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat(stores):=20=E3=82=B9=E3=82=AD?= =?UTF-8?q?=E3=83=AB=E3=82=92=E5=AF=BE=E5=BF=9C=E8=A1=A8=E5=8F=82=E7=85=A7?= =?UTF-8?q?=20+=20copy-adopt=20=E7=A7=BB=E8=A1=8C=E3=81=AB=E9=85=8D?= =?UTF-8?q?=E7=B7=9A=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills store を singleFileCollection ベースに書き換え、SkillMeta の runtime-only な fileBase を対応表とする (frontmatter には書かない) - ID 凍結の実効値 = 拡張子を除いた basename (現行 fallbackId と同値)、 重複 ID は 2 件目以降を skip (ファイルは削除しない) - initFileStorage で (a) 規約外名の copy-adopt 正規化 → 履歴 sweep を メインウィンドウのみで実行。スキルは本文ミラーが無いため (b) 再作成は 非適用 (外部削除 = 削除確定)。builtin seed / storeMoved migrate は 既存挙動を維持 - 変更系操作 (add / update / remove) を ready ゲート (初回読込 + 移行 完了) 待ちに直列化。新規作成の fileBase は表示名 slug から割当 (ファイル名は ID からでなく対応表から) - 表示名変更はファイル rename で追随 (ID 不変・主ファイル + 履歴、 完了 await 後に persist)。削除は主ファイル + 履歴を消す - 履歴キー (pushSnapshot / skills.history / skills.revert) を name || id から fileBase 参照に変更 - 外部エディタ起動 (SkillEditContent) を対応表参照に変更、 skillFilename / listSkillFiles を廃止 (β 方針: 後方互換は残さない) Co-Authored-By: Claude Fable 5 --- src/capabilities/builtins/skills.ts | 7 +- src/components/window/SkillEditContent.vue | 15 +- src/stores/skills.test.ts | 1 + src/stores/skills.ts | 194 ++++++++++++++------- src/stores/skillsFileSync.test.ts | 183 +++++++++++++++++++ src/utils/settingsFs.ts | 12 +- 6 files changed, 335 insertions(+), 77 deletions(-) create mode 100644 src/stores/skillsFileSync.test.ts diff --git a/src/capabilities/builtins/skills.ts b/src/capabilities/builtins/skills.ts index a8c20a9eb..f04a2f532 100644 --- a/src/capabilities/builtins/skills.ts +++ b/src/capabilities/builtins/skills.ts @@ -528,7 +528,8 @@ export const skillsHistoryCapability: Command = { const store = useSkillsStore() const skill = store.skills.find((s) => s.id === id) if (!skill) throw new Error(`skills.history: skill "${id}" not found`) - const basename = skill.name || skill.id + // 履歴キーは対応表の fileBase (#913)。未割当なら旧キーに落ちる + const basename = skill.fileBase ?? (skill.name || skill.id) return await listSnapshots('skill', basename) }, } @@ -546,7 +547,7 @@ export const skillsRevertCapability: Command = { const index = typeof params?.index === 'number' ? params.index : -1 const cur = useSkillsStore().skills.find((s) => s.id === id) if (!cur || index < 0) return null - const basename = cur.name || cur.id + const basename = cur.fileBase ?? (cur.name || cur.id) const entry = await getSnapshotAt('skill', basename, index) if (!entry) return null return { @@ -592,7 +593,7 @@ export const skillsRevertCapability: Command = { const store = useSkillsStore() const skill = store.skills.find((s) => s.id === id) if (!skill) throw new Error(`skills.revert: skill "${id}" not found`) - const basename = skill.name || skill.id + const basename = skill.fileBase ?? (skill.name || skill.id) const entry = await getSnapshotAt('skill', basename, index) if (!entry) { throw new Error(`skills.revert: no snapshot at index ${index}`) diff --git a/src/components/window/SkillEditContent.vue b/src/components/window/SkillEditContent.vue index 536266615..da77c474f 100644 --- a/src/components/window/SkillEditContent.vue +++ b/src/components/window/SkillEditContent.vue @@ -8,7 +8,7 @@ import EditorItemHeader from '@/components/window/EditorItemHeader.vue' import { useEditorTabs } from '@/composables/useEditorTabs' import { useWindowExternalFile } from '@/composables/useWindowExternalFile' import { type SkillMode, useSkillsStore } from '@/stores/skills' -import { skillFilename } from '@/utils/settingsFs' +import { SKILL_EXT } from '@/utils/settingsFs' const CodeEditor = defineAsyncComponent( () => import('@/components/deck/widgets/CodeEditor.vue'), @@ -31,9 +31,16 @@ skillsStore.ensureLoaded() const skill = computed(() => skillsStore.get(props.skillId)) -useWindowExternalFile(() => - skill.value ? { name: skillFilename(props.skillId), subdir: 'skills' } : null, -) +// 外部エディタ起動はファイル名を対応表 (fileBase) から引く — ID から計算しない (#913) +useWindowExternalFile(() => { + const s = skill.value + if (!s) return null + return { + name: s.fileBase ? `${s.fileBase}${SKILL_EXT}` : '', + subdir: 'skills', + disabled: !s.fileBase, + } +}) const name = ref('') const description = ref('') diff --git a/src/stores/skills.test.ts b/src/stores/skills.test.ts index 9c370fd30..9642c79d3 100644 --- a/src/stores/skills.test.ts +++ b/src/stores/skills.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/utils/settingsFs', () => ({ isTauri: false, + SKILL_EXT: '.md', })) import type { SkillMeta } from '@/stores/skills' diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 1112dfc35..457521849 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -3,10 +3,16 @@ import { computed, ref } from 'vue' import { emitNoteDeckEvent } from '@/aiscript/events' import { planBuiltInSeed } from '@/services/builtInSeed' +import { injectFrontmatterId } from '@/services/idFreeze' +import { createSingleFileCollection } from '@/services/singleFileCollection' import { planStoreMovedMigration } from '@/services/storeMovedSkills' import { pushSnapshot } from '@/utils/historyFs' import * as settingsFs from '@/utils/settingsFs' -import { parseSkillFile, serializeSkillFile } from '@/utils/skillFrontmatter' +import { + type ParsedSkillFile, + parseSkillFile, + serializeSkillFile, +} from '@/utils/skillFrontmatter' import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' /** @@ -69,6 +75,11 @@ export interface SkillMeta { * 未指定 = false。 */ isPersona?: boolean + /** + * 実ファイル basename (#913 の ID → ファイル名対応表)。runtime-only — + * frontmatter には書かない (frontmatterFromMeta に含めないこと)。 + */ + fileBase?: string } export function generateSkillId(name: string): string { @@ -195,6 +206,47 @@ export const _internal = { frontmatterFromMeta, } +function serializeSkill(skill: SkillMeta): string { + const fm = frontmatterFromMeta(skill) + return serializeSkillFile( + fm as Record, + skill.body, + ) +} + +/** + * skill (`skills/.md` 単一ファイル・frontmatter が meta) の永続化 + * (#913 で ID → ファイル名対応表化)。 + * ID 凍結の実効値 = 拡張子を除いた basename (現行 fallbackId と同値)。 + */ +const skillFiles = createSingleFileCollection({ + logTag: 'skills', + kindFallback: 'skill', + ext: settingsFs.SKILL_EXT, + // 占有判定・sweep には .history.json5 を含む実列挙が要る + // (規定拡張子の filter はコレクション側が行う) + list: () => settingsFs.listSkillDirFiles(), + read: (filename) => settingsFs.readSkillFile(filename), + write: (filename, content) => settingsFs.writeSkillFile(filename, content), + remove: (filename) => settingsFs.deleteSkillFile(filename), + rename: (oldFilename, newFilename) => + settingsFs.renameSkillFile(oldFilename, newFilename), + parse: (raw) => parseSkillFile(raw), + rawIdOf: (p) => p.meta.id, + effectiveIdOf: (_filename, base) => base, + injectId: (raw, id) => injectFrontmatterId(raw, id), + fromFile: (p, id, filename) => + metaFromFrontmatter( + { ...(p.meta as SkillFrontmatter), id }, + p.body, + filename.replace(/\.md$/, ''), + ), + displayNameOf: (p) => (typeof p.meta.name === 'string' ? p.meta.name : ''), + idOf: (s) => s.id, + nameOf: (s) => s.name, + serialize: serializeSkill, +}) + interface BuiltInTemplate { id: string filename: string @@ -227,16 +279,23 @@ export const useSkillsStore = defineStore('skills', () => { ) const initialized = ref(false) let loaded = false + // 変更系操作 (新規作成・リネーム・保存・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) function ensureLoaded() { if (loaded) return loaded = true if (settingsFs.isTauri) { - initFileStorage().catch((e) => - console.warn('[skills] file storage init failed:', e), - ) + initFileStorage() + .catch((e) => console.warn('[skills] file storage init failed:', e)) + .finally(() => resolveReady?.()) } else { initialized.value = true + resolveReady?.() } } @@ -297,59 +356,47 @@ export const useSkillsStore = defineStore('skills', () => { .join('\n\n') } + /** ファイルへの直接反映 (初期化・seed 用。通常経路は ready ゲート越し)。 */ async function persist(skill: SkillMeta): Promise { if (!settingsFs.isTauri) return - const fm = frontmatterFromMeta(skill) - const content = serializeSkillFile( - fm as Record, - skill.body, - ) - const filename = settingsFs.skillFilename(skill.id) - await settingsFs.writeSkillFile(filename, content) - } - - async function deleteFile(skill: SkillMeta): Promise { - if (!settingsFs.isTauri) return - const filename = settingsFs.skillFilename(skill.id) - try { - await settingsFs.deleteSkillFile(filename) - } catch (e) { - console.warn('[skills] failed to delete skill file:', e) - } + await skillFiles.persistItem(skill, skills.value) } async function initFileStorage(): Promise { - const files = await settingsFs.listSkillFiles() - const fileSkills: SkillMeta[] = [] - for (const filename of files) { - try { - const raw = await settingsFs.readSkillFile(filename) - const { meta, body } = parseSkillFile(raw) - const fallbackId = filename.replace(/\.md$/, '') - fileSkills.push( - metaFromFrontmatter(meta as SkillFrontmatter, body, fallbackId), - ) - } catch (e) { - console.warn(`[skills] failed to parse ${filename}:`, e) - } - } + const { items: fileSkills } = await skillFiles.loadAll() fileSkills.sort((a, b) => a.createdAt - b.createdAt) if (fileSkills.length === 0) { await seedBuiltIns() initialized.value = true - } else { - skills.value = fileSkills - await migrateLegacyAizu() - await migrateStoreMovedBuiltIns() - await seedMissingBuiltIns() - // initialized=true を立てた **後** に sync する。update() 内の persist は - // `if (initialized.value)` ガード越しなので、立てる前に呼ぶと in-memory - // だけ反映され disk に書かれず、次回起動も古い frontmatter を読んでしまう。 - initialized.value = true - const templates = await loadBuiltInTemplates() - syncBuiltInsMetadata(templates) + return } + + // 初期化 (この async 関数が走る間) にメモリ追加された skill は残す + const fileIds = new Set(fileSkills.map((s) => s.id)) + const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id)) + skills.value = [...fileSkills, ...memoryOnly] + + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等。 + // スキルは本文の localStorage ミラーが無いため (b) 再作成は非適用 + // (外部削除 = 削除確定) + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化 + await skillFiles.migrateItems(skills.value) + // 履歴 sweep: 主ファイルと対応の取れない .history.json5 を削除 + await skillFiles + .sweepHistory() + .catch((e) => console.warn('[skills] history sweep failed:', e)) + } + + await migrateLegacyAizu() + await migrateStoreMovedBuiltIns() + await seedMissingBuiltIns() + // initialized=true を立てた **後** に sync する (update() のファイル反映は + // ready ゲート越しなので、この関数の完了後に順に flush される)。 + initialized.value = true + const templates = await loadBuiltInTemplates() + syncBuiltInsMetadata(templates) } async function seedBuiltIns(): Promise { @@ -453,10 +500,12 @@ export const useSkillsStore = defineStore('skills', () => { const now = Date.now() const skill: SkillMeta = { ...input, createdAt: now, updatedAt: now } skills.value = [...skills.value, skill] - if (initialized.value) { - persist(skill).catch((e) => - console.warn('[skills] failed to persist new skill:', e), - ) + if (settingsFs.isTauri) { + // 新規作成の fileBase は表示名 slug から persistItem が割り当てる + // (ファイル名は ID からでなく対応表から #913)。ready 待ちで移行と直列化 + void ready + .then(() => skillFiles.persistItem(skill, skills.value)) + .catch((e) => console.warn('[skills] failed to persist new skill:', e)) } return skill } @@ -467,13 +516,14 @@ export const useSkillsStore = defineStore('skills', () => { if (idx < 0) return const current = skills.value[idx] if (!current) return - // 編集前 snapshot を history sidecar に push (fire-and-forget) - pushSnapshot('skill', current.name || current.id, { + const prevSnapshot = { body: current.body, name: current.name, version: current.version, mode: current.mode, - }).catch((e) => console.warn('[skills] history push failed:', e)) + } + const renamed = + typeof patch.name === 'string' && patch.name !== current.name const updated: SkillMeta = { ...current, ...patch, @@ -485,10 +535,23 @@ export const useSkillsStore = defineStore('skills', () => { updated, ...skills.value.slice(idx + 1), ] - if (initialized.value) { - persist(updated).catch((e) => - console.warn('[skills] failed to persist update:', e), - ) + if (settingsFs.isTauri) { + void ready + .then(async () => { + // 直近の状態を参照する (連続 update では最後の閉包が最新を書く) + const live = skills.value.find((s) => s.id === id) + if (!live) return // 既に削除された + // 編集前 snapshot を history sidecar に push。履歴キーは対応表の + // fileBase (未割当 = ファイル未作成なら履歴も無し) + if (live.fileBase) { + await pushSnapshot('skill', live.fileBase, prevSnapshot) + } + // 表示名の変更はファイル rename で追随 (ID 不変・主ファイル + 履歴)。 + // rename の完了を待ってから保存する (#913) + if (renamed) await skillFiles.renameItemFiles(live, skills.value) + await skillFiles.persistItem(live, skills.value) + }) + .catch((e) => console.warn('[skills] failed to persist update:', e)) } emitNoteDeckEvent('skill:edited', { id }) } @@ -500,7 +563,12 @@ export const useSkillsStore = defineStore('skills', () => { const target = skills.value[idx] if (!target) return undefined skills.value = skills.value.filter((s) => s.id !== id) - if (initialized.value) deleteFile(target) + if (settingsFs.isTauri) { + // 主ファイル + 履歴サイドカーを削除 (ready 待ち) + void ready + .then(() => skillFiles.deleteItemFiles(target)) + .catch((e) => console.warn('[skills] failed to delete skill file:', e)) + } return () => { if (skills.value.some((s) => s.id === id)) return const at = Math.min(idx, skills.value.length) @@ -509,10 +577,12 @@ export const useSkillsStore = defineStore('skills', () => { target, ...skills.value.slice(at), ] - if (initialized.value) { - persist(target).catch((e) => - console.warn('[skills] failed to restore skill file:', e), - ) + if (settingsFs.isTauri) { + void ready + .then(() => skillFiles.persistItem(target, skills.value)) + .catch((e) => + console.warn('[skills] failed to restore skill file:', e), + ) } } } diff --git a/src/stores/skillsFileSync.test.ts b/src/stores/skillsFileSync.test.ts new file mode 100644 index 000000000..a65f3fa46 --- /dev/null +++ b/src/stores/skillsFileSync.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** インメモリ疑似 FS (skills/ ディレクトリ相当)。 */ +const files = new Map() + +vi.mock('@/utils/settingsFs', () => ({ + isTauri: true, + isMainDeckWindow: () => true, + SKILL_EXT: '.md', + listSkillDirFiles: async () => Array.from(files.keys()), + readSkillFile: async (f: string) => { + const c = files.get(f) + if (c === undefined) throw new Error(`not found: ${f}`) + return c + }, + writeSkillFile: async (f: string, c: string) => { + files.set(f, c) + }, + deleteSkillFile: async (f: string) => { + files.delete(f) + }, + renameSkillFile: async (a: string, b: string) => { + if (!files.has(a)) throw new Error(`not found: ${a}`) + if (files.has(b)) throw new Error(`already exists: ${b}`) + files.set(b, files.get(a) as string) + files.delete(a) + }, + // historyFs (pushSnapshot) 用 + readHistorySidecar: async (_k: string, basename: string) => + files.get(`${basename}.history.json5`) ?? null, + writeHistorySidecar: async ( + _k: string, + basename: string, + content: string, + ) => { + files.set(`${basename}.history.json5`, content) + }, + deleteHistorySidecar: async (_k: string, basename: string) => { + files.delete(`${basename}.history.json5`) + }, +})) + +import { type SkillMeta, useSkillsStore } from '@/stores/skills' +import { STORAGE_KEYS, setStorageJson } from '@/utils/storage' + +// builtin seed が試験対象のファイル群へ混入しないよう、テンプレ id を +// 「seed 済み」として localStorage に前置きする (テンプレ追加に追従) +const tplIds = Object.keys( + import.meta.glob('@/defaults/skills/*.md', { + query: '?raw', + import: 'default', + eager: true, + }), +).map((p) => p.split('/').pop()?.replace(/\.md$/, '') ?? '') + +const skillFile = (id: string, name: string, body = 'body') => + `---\nid: ${id}\nname: ${name}\nversion: 0.1.0\nmode: manual\nscope: global\ncreatedAt: 1\nupdatedAt: 1\n---\n${body}` + +async function initStore() { + const store = useSkillsStore() + store.ensureLoaded() + await vi.waitFor(() => { + expect(store.initialized).toBe(true) + }) + return store +} + +function makeSkill( + id: string, + name: string, +): Omit { + return { + id, + name, + version: '0.1.0', + mode: 'manual', + triggers: [], + scope: 'global', + body: 'b', + cheapCheckCapabilities: [], + } +} + +describe('useSkillsStore — ファイル対応表配線 (#913)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + files.clear() + setStorageJson(STORAGE_KEYS.skillsSeededBuiltins, tplIds) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + it('ファイルから読み込み fileBase (対応表) を保持する', async () => { + files.set('alpha.md', skillFile('alpha', 'Alpha')) + const store = await initStore() + expect(store.get('alpha')?.fileBase).toBe('alpha') + }) + + it('frontmatter の id 欠損は拡張子なし basename を凍結する', async () => { + files.set('my-skill.md', '---\nname: My Skill\n---\nbody') + const store = await initStore() + expect(store.get('my-skill')).toBeDefined() + expect(files.get('my-skill.md')).toContain("id: 'my-skill'") + }) + + it('規約外名は起動時に copy-adopt で正規化される', async () => { + files.set('マイスキル.md', skillFile('my-id', 'My Skill')) + const store = await initStore() + expect(files.has('my-skill.md')).toBe(true) + expect(files.has('マイスキル.md')).toBe(false) + expect(store.get('my-id')?.fileBase).toBe('my-skill') + }) + + it('孤児履歴は起動時 sweep で削除される', async () => { + files.set('alpha.md', skillFile('alpha', 'Alpha')) + files.set('alpha.history.json5', '{ entries: [] }') + files.set('dead.history.json5', '{ entries: [] }') + await initStore() + expect(files.has('alpha.history.json5')).toBe(true) + expect(files.has('dead.history.json5')).toBe(false) + }) + + it('新規作成は表示名 slug のファイル名で保存する (ID からではない)', async () => { + files.set('seed.md', skillFile('seed', 'Seed')) + const store = await initStore() + store.add(makeSkill('x1-abcd', 'My New Skill')) + await vi.waitFor(() => { + expect(files.has('my-new-skill.md')).toBe(true) + }) + expect(files.get('my-new-skill.md')).toContain('x1-abcd') + expect(files.has('x1-abcd.md')).toBe(false) + }) + + it('表示名の変更はファイル rename で追随する (ID 不変・履歴も追随)', async () => { + files.set('alpha.md', skillFile('a1', 'Alpha')) + files.set('alpha.history.json5', '{ entries: [] }') + const store = await initStore() + store.update('a1', { name: 'Beta' }) + await vi.waitFor(() => { + expect(files.has('beta.md')).toBe(true) + }) + expect(files.has('alpha.md')).toBe(false) + expect(files.has('beta.history.json5')).toBe(true) + expect(files.has('alpha.history.json5')).toBe(false) + expect(store.get('a1')?.fileBase).toBe('beta') + expect(files.get('beta.md')).toContain('a1') + }) + + it('本文更新は編集前 snapshot を fileBase キーで残す', async () => { + files.set('alpha.md', skillFile('a1', 'Alpha', 'old body')) + const store = await initStore() + store.update('a1', { body: 'new body' }) + await vi.waitFor(() => { + expect(files.has('alpha.history.json5')).toBe(true) + }) + expect(files.get('alpha.history.json5')).toContain('old body') + await vi.waitFor(() => { + expect(files.get('alpha.md')).toContain('new body') + }) + }) + + it('削除は主ファイルと履歴サイドカーを消す', async () => { + files.set('alpha.md', skillFile('a1', 'Alpha')) + files.set('alpha.history.json5', '{ entries: [] }') + const store = await initStore() + store.remove('a1') + await vi.waitFor(() => { + expect(files.has('alpha.md')).toBe(false) + }) + expect(files.has('alpha.history.json5')).toBe(false) + }) + + it('同一 ID を主張する 2 件目のファイルは skip され削除されない', async () => { + files.set('a.md', skillFile('dup', 'A')) + files.set('b.md', skillFile('dup', 'B')) + const store = await initStore() + expect(store.skills.filter((s) => s.id === 'dup')).toHaveLength(1) + expect(store.get('dup')?.fileBase).toBe('a') + expect(files.has('b.md')).toBe(true) + }) +}) diff --git a/src/utils/settingsFs.ts b/src/utils/settingsFs.ts index e95de48b6..ce399fce6 100644 --- a/src/utils/settingsFs.ts +++ b/src/utils/settingsFs.ts @@ -352,15 +352,11 @@ export async function renamePluginFile( // --- Skill helpers --- const SKILLS_DIR = 'skills' -const SKILL_EXT = '.md' +export const SKILL_EXT = '.md' -export function skillFilename(name: string): string { - return sanitizeFilename(name) + SKILL_EXT -} - -export async function listSkillFiles(): Promise { - const files = await listSettingsFiles(SKILLS_DIR) - return files.filter((f) => f.endsWith(SKILL_EXT)) +/** skills/ の実列挙 (履歴 .history.json5 を含む。#913 の占有判定・sweep 用)。 */ +export async function listSkillDirFiles(): Promise { + return listSettingsFiles(SKILLS_DIR) } export async function readSkillFile(filename: string): Promise { From 8b89a07c415d47056bd58dca81a1ab2c5633da9e Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:20:09 +0900 Subject: [PATCH 08/30] =?UTF-8?q?feat(settings):=20=E6=96=B0=E8=A6=8F?= =?UTF-8?q?=E5=89=B2=E5=BD=93=E3=81=AB=20preferredBase=20=E3=83=95?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=92=E8=BF=BD=E5=8A=A0=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit builtin seed のスキルはテンプレ id (slug 適合) をそのままファイル名に する。表示名 slug だとフレッシュインストールで notedeck.md / notedeck-2.md / skill.md に化けて意味が消えるため。占有時は連番、 規約不適合なら表示名 slug に落ちる。sidecar 側にも同じフックを足し、 PR-3 のストアインストール (ファイル名 = storeId) が同じ経路に乗れる ようにする。 Co-Authored-By: Claude Fable 5 --- src/services/sidecarFileCollection.ts | 15 +++++++++--- src/services/singleFileCollection.test.ts | 30 +++++++++++++++++++++++ src/services/singleFileCollection.ts | 16 +++++++++--- src/stores/skills.ts | 3 +++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/services/sidecarFileCollection.ts b/src/services/sidecarFileCollection.ts index c2520d155..7738da9e4 100644 --- a/src/services/sidecarFileCollection.ts +++ b/src/services/sidecarFileCollection.ts @@ -64,6 +64,11 @@ export interface SidecarCollectionConfig { * 「メタあり・ソースなし」のソース再作成 (読込規則) に使う。 */ mirrorSrcById?(id: string): string | undefined + /** + * 新規割当時に優先するファイル基底名 (ストアインストールの storeId 等)。 + * 規約不適合なら無視して表示名 slug に落ち、占有時は連番 suffix で回避する。 + */ + preferredBase?(item: T): string | undefined } const META_SUFFIX = '.meta.json5' @@ -225,10 +230,12 @@ export function createSidecarCollection( excludeItem: item, includeIds: true, }) - item.fileBase = resolveAvailable( - slugifyName(cfg.nameOf(item), cfg.kindFallback), - (c) => taken.has(casefold(c)), - ) + const preferred = cfg.preferredBase?.(item) + const base = + preferred !== undefined && isSlugConforming(preferred) + ? preferred + : slugifyName(cfg.nameOf(item), cfg.kindFallback) + item.fileBase = resolveAvailable(base, (c) => taken.has(casefold(c))) } const base = item.fileBase // 書込順は src → meta (メタを存在マーカーとする) diff --git a/src/services/singleFileCollection.test.ts b/src/services/singleFileCollection.test.ts index 754c9c300..8354b10da 100644 --- a/src/services/singleFileCollection.test.ts +++ b/src/services/singleFileCollection.test.ts @@ -292,6 +292,36 @@ describe('persistItem', () => { expect(t.fileBase).toBe('alpha-2') }) + it('preferredBase が規約適合で空いていればそれを使う (builtin seed / store install)', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs, { + preferredBase: (t) => (t.id === 't1' ? 'notedeck-guide' : undefined), + }) + const t = item({ name: 'NoteDeck ガイド' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('notedeck-guide') + }) + + it('preferredBase が占有済みなら連番 suffix で回避する', async () => { + const fs = makeFakeFs({ [`notedeck-guide${EXT}`]: file('other', 'x') }) + const col = makeCollection(fs, { + preferredBase: () => 'notedeck-guide', + }) + const t = item({ name: 'guide' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('notedeck-guide-2') + }) + + it('preferredBase が規約不適合なら無視して表示名 slug に落ちる', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs, { + preferredBase: () => '日本語ID', + }) + const t = item({ name: 'Alpha' }) + await col.persistItem(t, [t]) + expect(t.fileBase).toBe('alpha') + }) + it('fileBase 割当済みなら name が変わっても再計算しない', async () => { const fs = makeFakeFs() const col = makeCollection(fs) diff --git a/src/services/singleFileCollection.ts b/src/services/singleFileCollection.ts index 4c97ebe22..900c5a987 100644 --- a/src/services/singleFileCollection.ts +++ b/src/services/singleFileCollection.ts @@ -62,6 +62,12 @@ export interface SingleFileCollectionConfig { nameOf(item: T): string /** 通常保存経路の再シリアライズ。fileBase を含めないこと */ serialize(item: T): string + /** + * 新規割当時に優先するファイル基底名 (builtin seed のテンプレ id、 + * ストアインストールの storeId 等)。規約不適合なら無視して表示名 slug に + * 落ち、占有時は連番 suffix で回避する。 + */ + preferredBase?(item: T): string | undefined } const HISTORY_SUFFIX = '.history.json5' @@ -201,10 +207,12 @@ export function createSingleFileCollection( excludeItem: item, includeIds: true, }) - item.fileBase = resolveAvailable( - slugifyName(cfg.nameOf(item), cfg.kindFallback), - (c) => taken.has(casefold(c)), - ) + const preferred = cfg.preferredBase?.(item) + const base = + preferred !== undefined && isSlugConforming(preferred) + ? preferred + : slugifyName(cfg.nameOf(item), cfg.kindFallback) + item.fileBase = resolveAvailable(base, (c) => taken.has(casefold(c))) } await cfg.write(item.fileBase + cfg.ext, cfg.serialize(item)) } diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 457521849..a53ba17d6 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -223,6 +223,9 @@ const skillFiles = createSingleFileCollection({ logTag: 'skills', kindFallback: 'skill', ext: settingsFs.SKILL_EXT, + // builtin seed はテンプレ id (slug 適合) をそのままファイル名にする + // (表示名 slug だと `notedeck.md` / `notedeck-2.md` に化けて意味が消える) + preferredBase: (s) => (s.builtIn ? s.id : undefined), // 占有判定・sweep には .history.json5 を含む実列挙が要る // (規定拡張子の filter はコレクション側が行う) list: () => settingsFs.listSkillDirFiles(), From ba49d59ebc91698de464cb89822675ba68679a98 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:43:12 +0900 Subject: [PATCH 09/30] =?UTF-8?q?feat(deck-profile):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=20ID=20=E3=82=92=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E5=90=8D=E3=81=8B=E3=82=89=E5=88=86?= =?UTF-8?q?=E9=9B=A2=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 唯一「ID = ファイル名」で結合していたプロファイルを singleFileCollection の対応表 (fileBase) 方式へ移す。 - codec: id をファイルに書き、parse 済み raw を土台に既知フィールドを 上書きして未知フィールドを保持する (ダウングレード往復の剥がれ防止)。 fileBase は runtime-only でファイルへ書かない - ID 凍結: id 欠損は拡張子込みの旧完全ファイル名を実効値として注入。 既存の nd-deck-active-profile / ?profile= / windowProfileId は無追随で 生き続ける - リネーム = 表示名変更のみ (ID 不変)。ファイルは rename 追随し、完了を await してから保存する (旧 rename/persist 並行発火の順序バグ根絶) - 移行 (a) 規約外名の copy-adopt / (b) ミラー在・ファイル不在の再作成を メインウィンドウのみで実行。memOnly マージ同定を「ID 一致 or 名前+作成日時一致」に拡大 - 新規作成は slug 形式の ID + slug ファイル名。変更系操作は ready ゲート (初回読込 + 移行完了) を待つ。保存・削除の直前にミラーの対応表を読み直す - 外部エディタ起動は対応表のファイル名で開く Co-Authored-By: Claude Fable 5 --- .../window/ProfileEditorContent.vue | 14 +- src/services/deckProfileCodec.test.ts | 42 ++- src/services/deckProfileCodec.ts | 38 ++- src/services/deckProfileFiles.ts | 99 ++++--- src/stores/deck.ts | 5 + src/stores/deckProfile.ts | 272 ++++++++++++------ src/stores/plugins.test.ts | 1 + src/stores/skills.test.ts | 1 + src/stores/skillsFileSync.test.ts | 1 + src/utils/settingsFs.ts | 12 +- 10 files changed, 329 insertions(+), 156 deletions(-) diff --git a/src/components/window/ProfileEditorContent.vue b/src/components/window/ProfileEditorContent.vue index 7d427ade8..ab0b82a3e 100644 --- a/src/components/window/ProfileEditorContent.vue +++ b/src/components/window/ProfileEditorContent.vue @@ -24,7 +24,7 @@ import { useServersStore } from '@/stores/servers' import { useIsCompactLayout } from '@/stores/ui' import { createJson5Linter } from '@/utils/json5Linter' import { proxyThumbUrl } from '@/utils/mediaProxy' -import { profileFilename } from '@/utils/settingsFs' +import { PROFILE_EXT } from '@/utils/settingsFs' const AddColumnDialog = defineAsyncComponent( () => import('@/components/deck/AddColumnDialog.vue'), @@ -109,12 +109,20 @@ const { tab, containerRef: editorRef } = useEditorTabs<'visual' | 'code'>( : ((props.initialTab as 'visual' | 'code') ?? 'visual'), ) +// 外部エディタで開く実ファイル名は対応表 (fileBase) から引く (#913: +// 表示名から計算しない)。fileBase 未割当 = ファイル未作成なら無効 +const editingProfileFilename = computed(() => { + void profileStore.profileVersion + const fileBase = editingProfile.value?.fileBase + return fileBase ? fileBase + PROFILE_EXT : null +}) + useWindowExternalFile(() => tab.value === 'code' ? { - name: profileFilename(editingName.value), + name: editingProfileFilename.value ?? '', subdir: 'profiles', - disabled: !editingName.value, + disabled: !editingProfileFilename.value, } : null, ) diff --git a/src/services/deckProfileCodec.test.ts b/src/services/deckProfileCodec.test.ts index 6330ac20c..770928b5c 100644 --- a/src/services/deckProfileCodec.test.ts +++ b/src/services/deckProfileCodec.test.ts @@ -74,19 +74,31 @@ describe('migrateWidgetColumns', () => { }) describe('parseProfileFile', () => { - it('ファイル名を id に割り当て、欠損フィールドをデフォルト補完する', () => { - const { profile } = parseProfileFile('main.json5', {}) - expect(profile.id).toBe('main.json5') - expect(profile.name).toBe('main.json5') + it('確定済み ID を採用し、欠損フィールドをデフォルト補完する', () => { + const { profile } = parseProfileFile({}, 'my-id', 'main.ndprofile.json5') + expect(profile.id).toBe('my-id') + expect(profile.name).toBe('main.ndprofile.json5') expect(profile.columns).toEqual([]) expect(profile.layout).toEqual([]) expect(profile.createdAt).toBeGreaterThan(0) }) + it('未知フィールドを保持する (ダウングレード往復でフィールドが剥がれない)', () => { + const { profile } = parseProfileFile( + { name: 'メイン', futureField: 'keep' }, + 'my-id', + 'main.ndprofile.json5', + ) + expect((profile as unknown as { futureField?: string }).futureField).toBe( + 'keep', + ) + }) + it('columns にマイグレーションを適用し、副産物を返す', () => { const { profile, droppedConsoleCount, extractedWidgets } = parseProfileFile( - 'main.json5', { name: 'メイン', columns: [legacyWidgetColumn()] }, + 'my-id', + 'main.ndprofile.json5', ) expect(profile.name).toBe('メイン') expect( @@ -98,15 +110,29 @@ describe('parseProfileFile', () => { }) describe('toFileFormat', () => { - it('内部フィールド id をファイルに書かない', () => { + it('id をファイルに書き、runtime-only の fileBase は書かない', () => { const out = toFileFormat({ - id: 'main.json5', + id: 'my-id', name: 'メイン', columns: [], layout: [], createdAt: 1, + fileBase: 'main', }) - expect(out).not.toHaveProperty('id') + expect(out.id).toBe('my-id') + expect(out).not.toHaveProperty('fileBase') expect(out.name).toBe('メイン') }) + + it('未知フィールドを往復で保持する', () => { + const { profile } = parseProfileFile( + { name: 'メイン', futureField: 'keep' }, + 'my-id', + 'main.ndprofile.json5', + ) + profile.fileBase = 'main' + const out = toFileFormat(profile) + expect(out.futureField).toBe('keep') + expect(out).not.toHaveProperty('fileBase') + }) }) diff --git a/src/services/deckProfileCodec.ts b/src/services/deckProfileCodec.ts index b3794736d..d55cfae2e 100644 --- a/src/services/deckProfileCodec.ts +++ b/src/services/deckProfileCodec.ts @@ -7,12 +7,16 @@ * 戻り値の副産物を見て適用する。 */ -import type { DeckColumn, DeckProfile, DeckWindowLayout } from '@/stores/deck' +import type { DeckColumn, DeckProfile } from '@/stores/deck' import type { WidgetMeta } from '@/stores/widgets' -/** Strip internal-only fields before writing to file. */ +/** + * ファイルへ書く形にする (#913: id はファイルに書く。参照はファイル内 ID)。 + * runtime-only の fileBase (ID → ファイル名対応表) だけを strip し、 + * 未知フィールドはそのまま書き戻す (ダウングレード往復での剥がれ防止)。 + */ export function toFileFormat(profile: DeckProfile): Record { - const { id: _id, ...rest } = profile + const { fileBase: _fileBase, ...rest } = profile return rest } @@ -82,23 +86,31 @@ export interface ParsedProfileFile profile: DeckProfile } -/** Parse a profile file and assign an ID based on filename. */ +/** + * プロファイルファイルのパース結果をアイテム化する (#913: id はファイル内が正。 + * 欠損時の凍結は singleFileCollection が済ませてから渡す)。 + * パース済み raw オブジェクトを土台に既知フィールドを上書きする方式で、 + * 未知フィールドを保持する (createdAt の Date.now() 補完等の揮発デフォルトは + * 通常保存経路のみに現れ、凍結・copy-adopt の書込内容には混入しない)。 + */ export function parseProfileFile( - filename: string, data: Record, + id: string, + filename: string, ): ParsedProfileFile { const rawColumns = (data.columns as DeckColumn[]) || [] const { columns, droppedConsoleCount, extractedWidgets, sidebarSeed } = migrateWidgetColumns(rawColumns) + const profile = { + ...data, + id, + name: (data.name as string) || filename, + columns, + layout: (data.layout as string[][]) || [], + createdAt: (data.createdAt as number) || Date.now(), + } as DeckProfile return { - profile: { - id: filename, - name: (data.name as string) || filename, - columns, - layout: (data.layout as string[][]) || [], - createdAt: (data.createdAt as number) || Date.now(), - windows: data.windows as DeckWindowLayout[] | undefined, - }, + profile, droppedConsoleCount, extractedWidgets, sidebarSeed, diff --git a/src/services/deckProfileFiles.ts b/src/services/deckProfileFiles.ts index 4a25e6b6e..fc936ae32 100644 --- a/src/services/deckProfileFiles.ts +++ b/src/services/deckProfileFiles.ts @@ -1,51 +1,74 @@ /** - * デッキプロファイルのファイル I/O 層 (#782)。themeFileSync と同役割。 - * 形式変換は deckProfileCodec、読込時のマイグレーション副作用の適用は - * store 側の責務。 + * デッキプロファイル (`profiles/.ndprofile.json5` 単一ファイル) の永続化 + * (#913 で ID → ファイル名対応表化)。themeFileSync と同役割。 + * + * - 対応表の実体は profile の runtime-only な `fileBase`。 + * ファイルへは書かない (toFileFormat が strip する) + * - ID 凍結の実効値 = 拡張子込みの完全ファイル名 (現行「ID = ファイル名」の + * フォールバックと同値。既存の nd-deck-active-profile / `?profile=` / + * windowProfileId 参照が無追随で生き続ける) + * - widget マイグレーションの副産物 (Console 削除数・抽出 widget) は + * fromFile から返せないため蓄積し、store が drainProfileLoadByproducts で + * 回収する */ import JSON5 from 'json5' -import { toFileFormat } from '@/services/deckProfileCodec' +import { parseProfileFile, toFileFormat } from '@/services/deckProfileCodec' +import { injectJson5Id } from '@/services/idFreeze' +import { createSingleFileCollection } from '@/services/singleFileCollection' import type { DeckProfile } from '@/stores/deck' +import type { WidgetMeta } from '@/stores/widgets' import * as settingsFs from '@/utils/settingsFs' -export interface ProfileFileEntry { - filename: string - data: Record +export interface ProfileLoadByproducts { + droppedConsoleCount: number + extractedWidgets: WidgetMeta[] + sidebarSeed: string[] } -/** 全プロファイルファイルを読み込む。パース失敗ファイルは warn してスキップ。 */ -export async function loadProfileFileEntries(): Promise { - const filenames = await settingsFs.listProfiles() - if (filenames.length === 0) return [] - - const results = await Promise.all( - filenames.map(async (filename) => { - try { - const content = await settingsFs.readProfile(filename) - return { - filename, - data: JSON5.parse(content) as Record, - } - } catch (e) { - console.warn(`[deckProfile] failed to parse ${filename}:`, e) - return null - } - }), - ) - return results.filter((entry): entry is ProfileFileEntry => entry !== null) +function emptyByproducts(): ProfileLoadByproducts { + return { droppedConsoleCount: 0, extractedWidgets: [], sidebarSeed: [] } } -/** Write only the given profile to its file. */ -export async function persistProfileFile(profile: DeckProfile): Promise { - const filename = settingsFs.profileFilename(profile.name) - const content = JSON5.stringify(toFileFormat(profile), null, 2) - await settingsFs.writeProfile(filename, content) -} +let pendingByproducts = emptyByproducts() -/** Write all profiles to files. */ -export async function persistAllProfileFiles( - profiles: readonly DeckProfile[], -): Promise { - await Promise.all(profiles.map((p) => persistProfileFile(p))) +/** loadAll 中に蓄積した widget マイグレーション副産物を回収する。 */ +export function drainProfileLoadByproducts(): ProfileLoadByproducts { + const out = pendingByproducts + pendingByproducts = emptyByproducts() + return out } + +export const profileFiles = createSingleFileCollection< + DeckProfile, + Record +>({ + logTag: 'deckProfile', + kindFallback: 'profile', + ext: settingsFs.PROFILE_EXT, + // 占有判定・sweep には .history.json5 を含む実列挙が要る + // (規定拡張子の filter はコレクション側が行う) + list: () => settingsFs.listProfileDirFiles(), + read: (filename) => settingsFs.readProfile(filename), + write: (filename, content) => settingsFs.writeProfile(filename, content), + remove: (filename) => settingsFs.deleteProfile(filename), + rename: (oldFilename, newFilename) => + settingsFs.renameProfile(oldFilename, newFilename), + parse: (raw) => JSON5.parse(raw) as Record, + accepts: (p) => !!p && typeof p === 'object' && !Array.isArray(p), + rawIdOf: (p) => p.id, + effectiveIdOf: (filename) => filename, + injectId: (raw, id) => injectJson5Id(raw, 'id', id), + fromFile: (p, id, filename) => { + const { profile, droppedConsoleCount, extractedWidgets, sidebarSeed } = + parseProfileFile(p, id, filename) + pendingByproducts.droppedConsoleCount += droppedConsoleCount + pendingByproducts.extractedWidgets.push(...extractedWidgets) + pendingByproducts.sidebarSeed.push(...sidebarSeed) + return profile + }, + displayNameOf: (p) => (typeof p.name === 'string' ? p.name : ''), + idOf: (p) => p.id, + nameOf: (p) => p.name, + serialize: (p) => JSON5.stringify(toFileFormat(p), null, 2), +}) diff --git a/src/stores/deck.ts b/src/stores/deck.ts index f3e37cd40..c7194a100 100644 --- a/src/stores/deck.ts +++ b/src/stores/deck.ts @@ -126,6 +126,11 @@ export interface DeckProfile { createdAt: number /** Window positions/sizes for multi-window layouts */ windows?: DeckWindowLayout[] + /** + * 実ファイル basename (#913 の ID → ファイル名対応表)。runtime-only — + * ファイルへは書かない (toFileFormat が strip する)。localStorage ミラーには同乗する + */ + fileBase?: string } /** sidebar チャットカラムで開く DM 会話のターゲット (永続化しない transient 値)。 */ diff --git a/src/stores/deckProfile.ts b/src/stores/deckProfile.ts index e0c3bc175..6118d60b0 100644 --- a/src/stores/deckProfile.ts +++ b/src/stores/deckProfile.ts @@ -1,14 +1,15 @@ import { defineStore } from 'pinia' import { computed, ref, shallowRef } from 'vue' +import { migrateWidgetColumns } from '@/services/deckProfileCodec' import { - migrateWidgetColumns, - parseProfileFile, -} from '@/services/deckProfileCodec' -import { - loadProfileFileEntries, - persistAllProfileFiles, - persistProfileFile, + drainProfileLoadByproducts, + profileFiles, } from '@/services/deckProfileFiles' +import { + casefold, + resolveAvailable, + slugifyName, +} from '@/services/settingsSlug' import type { DeckColumn, DeckProfile, DeckWindowLayout } from '@/stores/deck' import { useWidgetsStore, type WidgetMeta } from '@/stores/widgets' import { createDebouncedPersist } from '@/utils/debouncedPersist' @@ -61,6 +62,13 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { /** Whether file-based storage has been initialized */ const initialized = ref(false) + // 変更系操作 (作成・リネーム・保存・削除) のファイル反映は + // 「初回読込 (対応表確定) + 初回移行」の完了を待つゲート (#913) + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + /** Cached profile name, kept in sync imperatively to avoid localStorage dependency. */ const currentProfileName = ref(null) @@ -134,6 +142,58 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { // --- Persistence (debounced) --- + /** + * 保存・削除の直前にミラーの対応表 (fileBase) を読み直す (#913)。 + * 自分のミラー書込が別ウィンドウのリネーム結果を潰す前に、必ず先に + * 取り込む (stale なファイル名での書込・削除の空振り防止)。 + */ + function adoptMirrorFileBases(profiles: readonly DeckProfile[]) { + const mirror = getStorageJson(STORAGE_KEYS.deckProfiles, []) + const byId = new Map(mirror.map((p) => [p.id, p.fileBase])) + for (const p of profiles) { + const fileBase = byId.get(p.id) + if (fileBase) p.fileBase = fileBase + } + } + + /** ミラーへ書く直前に対応表を読み直して合流させてから書く。 */ + function writeProfilesMirror() { + if (settingsFs.isTauri) adoptMirrorFileBases(profilesData.value) + setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + } + + /** プロファイル 1 件をファイルへ反映する (ready 待ち + 対応表のミラー反映)。 */ + function persistProfileToFile(profileId: string) { + if (!settingsFs.isTauri) return + void ready + .then(async () => { + // 直近の状態を参照する (初期化中のマージでオブジェクトが入れ替わる) + const live = profilesData.value.find((p) => p.id === profileId) + if (!live) return // 既に削除された + adoptMirrorFileBases([live]) + await profileFiles.persistItem(live, profilesData.value) + setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + profileVersion.value++ + }) + .catch((e) => console.warn('[deckProfile] failed to persist profile:', e)) + } + + /** 全プロファイルをファイルへ反映する (ready 待ち)。 */ + function persistAllProfilesToFiles() { + if (!settingsFs.isTauri) return + void ready + .then(async () => { + for (const p of profilesData.value) { + await profileFiles.persistItem(p, profilesData.value) + } + setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + profileVersion.value++ + }) + .catch((e) => + console.warn('[deckProfile] failed to persist to files:', e), + ) + } + const { schedule: schedulePersist, cancel: cancelPersist } = createDebouncedPersist(persistNow) @@ -145,16 +205,11 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { function persistNow() { try { - const profiles = profilesData.value // Sync: localStorage + bump version - setStorageJson(STORAGE_KEYS.deckProfiles, profiles) + writeProfilesMirror() const profile = currentProfile.value // Async: write changed profile to file - if (initialized.value && profile) { - persistProfileFile(profile).catch((e) => - console.warn('[deckProfile] failed to persist profile:', e), - ) - } + if (profile) persistProfileToFile(profile.id) // Notify other windows if (windowProfileId.value) { emitTauri('deck:profile-updated', { @@ -226,13 +281,9 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { /** Persist profiles: write profilesData to localStorage + files + notify other windows. */ function saveProfiles(profiles: DeckProfile[]) { profilesData.value = profiles - setStorageJson(STORAGE_KEYS.deckProfiles, profiles) + writeProfilesMirror() profileVersion.value++ - if (initialized.value) { - persistAllProfileFiles(profiles).catch((e) => - console.warn('[deckProfile] failed to persist to files:', e), - ) - } + persistAllProfilesToFiles() // Notify all windows that the profile list changed emitTauri('deck:profiles-changed').catch(() => { // Not running in Tauri (browser dev mode) @@ -257,6 +308,18 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { } } + /** + * 新規プロファイルの ID を slug 形式で生成する (#913: ファイル名由来をやめる)。 + * ファイル basename は persistItem が対応表・実列挙に対して別途解決するため、 + * ここでは種別内 ID の一意性のみ見る。 + */ + function generateProfileId(name: string): string { + const taken = new Set(profilesData.value.map((p) => casefold(p.id))) + return resolveAvailable(slugifyName(name, 'profile'), (c) => + taken.has(casefold(c)), + ) + } + // --- Profile CRUD --- function syncColumnsToProfile( @@ -268,13 +331,9 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { if (!profile) return profile.columns = deepClone(cols) profile.layout = deepClone(lay) - setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + writeProfilesMirror() profileVersion.value++ - if (initialized.value) { - persistProfileFile(profile).catch((e) => - console.warn('[deckProfile] failed to persist profile:', e), - ) - } + persistProfileToFile(profileId) } function switchProfile( @@ -285,7 +344,7 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { if (!newProfile) return null // Single localStorage write - setStorageJson(STORAGE_KEYS.deckProfiles, profiles) + writeProfilesMirror() profileVersion.value++ const oldProfileId = windowProfileId.value @@ -295,15 +354,10 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { refreshProfileName() // Async: persist only changed profiles - if (initialized.value) { - const toWrite = oldProfileId - ? profiles.filter((p) => p.id === oldProfileId || p.id === newProfileId) - : [newProfile] - const unique = [...new Map(toWrite.map((p) => [p.id, p])).values()] - persistAllProfileFiles(unique).catch((e) => - console.warn('[deckProfile] failed to persist profiles:', e), - ) + if (oldProfileId && oldProfileId !== newProfileId) { + persistProfileToFile(oldProfileId) } + persistProfileToFile(newProfileId) return { columns: newProfile.columns, @@ -316,7 +370,7 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { const autoName = name || nextProfileName(profiles) const profile: DeckProfile = { - id: settingsFs.profileFilename(autoName), + id: generateProfileId(autoName), name: autoName, columns: [], layout: [], @@ -335,7 +389,7 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { const profiles = profilesData.value const autoName = name || nextProfileName(profiles) const profile: DeckProfile = { - id: settingsFs.profileFilename(autoName), + id: generateProfileId(autoName), name: autoName, columns: [], layout: [], @@ -368,18 +422,21 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { function deleteProfile(profileId: string): (() => void) | undefined { const removedIndex = profilesData.value.findIndex((p) => p.id === profileId) const removed = profilesData.value[removedIndex] + // 削除対象の対応表 (fileBase) は、ミラーを絞り込みで上書きする前に読み直す + // (別ウィンドウのリネーム後の削除が stale 名で空振りしないように #913) + if (removed && settingsFs.isTauri) adoptMirrorFileBases([removed]) const profiles = profilesData.value.filter((p) => p.id !== profileId) profilesData.value = profiles - setStorageJson(STORAGE_KEYS.deckProfiles, profiles) + writeProfilesMirror() profileVersion.value++ if (activeProfileId.value === profileId) { saveActiveProfileId(profiles[0]?.id ?? null) } - if (initialized.value) { - settingsFs - .deleteProfile(profileId) + if (removed && settingsFs.isTauri) { + void ready + .then(() => profileFiles.deleteItemFiles(removed)) .catch((e) => console.warn('[deckProfile] failed to delete file:', e)) } @@ -393,31 +450,36 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { } } + /** + * 表示名を変更する (#913: ID 不変。activeProfileId / windowProfileId / + * `?profile=` の追随は不要)。ファイルは rename コマンドで追随させる + * (旧削除 + 新書込の並行発火は旧ファイルを孤児化させるため禁止)。 + */ function renameProfile(profileId: string, newName: string) { const profile = profilesData.value.find((p) => p.id === profileId) if (!profile) return - const oldFilename = profile.id - const newFilename = settingsFs.profileFilename(newName) - profile.name = newName - profile.id = newFilename - - if (activeProfileId.value === oldFilename) { - saveActiveProfileId(newFilename) - } - if (windowProfileId.value === oldFilename) { - windowProfileId.value = newFilename - } - - saveProfiles(profilesData.value) + writeProfilesMirror() + profileVersion.value++ refreshProfileName() + emitTauri('deck:profiles-changed').catch(() => { + // Not running in Tauri (browser dev mode) + }) - if (initialized.value && oldFilename !== newFilename) { - settingsFs - .renameProfile(oldFilename, newFilename) - .catch((e) => console.warn('[deckProfile] failed to rename file:', e)) - } + if (!settingsFs.isTauri) return + // rename の完了を待ってから保存する (並行発火の順序バグ根絶 #913) + void ready + .then(async () => { + const live = profilesData.value.find((p) => p.id === profileId) + if (!live) return // 既に削除された + adoptMirrorFileBases([live]) + await profileFiles.renameItemFiles(live, profilesData.value) + await profileFiles.persistItem(live, profilesData.value) + setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + profileVersion.value++ + }) + .catch((e) => console.warn('[deckProfile] failed to rename file:', e)) } /** Initialize this window with a profile */ @@ -468,17 +530,6 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { // --- File-based initialization --- - async function loadProfilesFromFiles(): Promise { - const entries = await loadProfileFileEntries() - return entries.map(({ filename, data }) => { - const { profile, droppedConsoleCount, extractedWidgets, sidebarSeed } = - parseProfileFile(filename, data) - pendingConsoleMigrationCount += droppedConsoleCount - pushExtractedWidgets(extractedWidgets, sidebarSeed) - return profile - }) - } - /** Ensure profiles exist on first load. Discards legacy format profiles. */ function ensureDefaults( fallbackColumns: DeckColumn[], @@ -500,7 +551,7 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { if (profiles.length === 0) { const profile: DeckProfile = { - id: settingsFs.profileFilename('プロファイル 1'), + id: generateProfileId('プロファイル 1'), name: 'プロファイル 1', columns: deepClone(fallbackColumns), layout: deepClone(fallbackLayout), @@ -519,28 +570,79 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { // Kick off async file sync in background (Tauri only) if (settingsFs.isTauri) { - initFileStorage().catch((e) => - console.warn('[deckProfile] file storage init failed:', e), - ) + initFileStorage() + .catch((e) => + console.warn('[deckProfile] file storage init failed:', e), + ) + .finally(() => resolveReady?.()) } else { initialized.value = true flushConsoleMigrationNotice() + resolveReady?.() } } async function initFileStorage(): Promise { - const fileProfiles = await loadProfilesFromFiles() - + const { items: fileProfiles } = await profileFiles.loadAll() + const byproducts = drainProfileLoadByproducts() + pendingConsoleMigrationCount += byproducts.droppedConsoleCount + pushExtractedWidgets(byproducts.extractedWidgets, byproducts.sidebarSeed) + + // Merge: file profiles are authoritative, but keep in-memory-only + // profiles that were created before file I/O completed. + // 同定は「ID 一致 or 名前 + 作成日時一致」(#913 決定録 — ダウングレード + // 往復でファイル内 ID が剥がれた場合の複製緩和) + const memOnly = profilesData.value.filter( + (p) => + !fileProfiles.some( + (f) => + f.id === p.id || (f.name === p.name && f.createdAt === p.createdAt), + ), + ) if (fileProfiles.length > 0) { - // Merge: file profiles are authoritative, but keep in-memory-only - // profiles that were created before file I/O completed. - const fileIds = new Set(fileProfiles.map((p) => p.id)) - const memOnly = profilesData.value.filter((p) => !fileIds.has(p.id)) - const merged = [...fileProfiles, ...memOnly] - profilesData.value = merged - setStorageJson(STORAGE_KEYS.deckProfiles, merged) + profilesData.value = [...fileProfiles, ...memOnly] profileVersion.value++ + refreshProfileName() + } + + // マイグレーション (#913) はメインウィンドウのみが実行する。冪等 + if (settingsFs.isMainDeckWindow()) { + // (a) 規約外名の copy-adopt 正規化。凍結済み ID (= 旧完全ファイル名) は + // 不変なので、activeProfileId / `?profile=` はファイル名が変わっても + // 無追随で整合する + await profileFiles.migrateItems(profilesData.value) + // (b) ミラーに在りファイル不在 → 新 slug 名で再作成 + // (localStorage → ファイルの旧片方向移行もこの経路に統合) + for (const p of memOnly) { + p.fileBase = undefined // ミラー由来の旧 fileBase は無効 (ファイル不在) + await profileFiles + .persistItem(p, profilesData.value) + .catch((e) => + console.warn( + '[deckProfile] failed to persist mirror-only profile:', + e, + ), + ) + } + // マージでミラー複製が落ちた場合のアクティブ参照の修復 + if ( + activeProfileId.value && + !profilesData.value.some((p) => p.id === activeProfileId.value) + ) { + saveActiveProfileId(profilesData.value[0]?.id ?? null) + } + } + // このウィンドウの表示対象がマージで消えた場合はアクティブへ退避する + if ( + windowProfileId.value && + !profilesData.value.some((p) => p.id === windowProfileId.value) + ) { + windowProfileId.value = activeProfileId.value + refreshProfileName() } + // fileBase 割当 (対応表) をミラーへ同乗させる + setStorageJson(STORAGE_KEYS.deckProfiles, profilesData.value) + initialized.value = true flushConsoleMigrationNotice() @@ -548,9 +650,7 @@ export const useDeckProfileStore = defineStore('deckProfile', () => { if (pendingConsoleMigrationFilesDirty || pendingWidgetExtractionDirty) { pendingConsoleMigrationFilesDirty = false pendingWidgetExtractionDirty = false - persistAllProfileFiles(profilesData.value).catch((e) => - console.warn('[deckProfile] migration rewrite failed:', e), - ) + persistAllProfilesToFiles() } } diff --git a/src/stores/plugins.test.ts b/src/stores/plugins.test.ts index d4894ffc4..8500f6d59 100644 --- a/src/stores/plugins.test.ts +++ b/src/stores/plugins.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/utils/settingsFs', () => ({ isTauri: false, isMainDeckWindow: () => true, + PROFILE_EXT: '.ndprofile.json5', writePluginFile: vi.fn(async () => undefined), deletePluginFile: vi.fn(async () => undefined), listPluginFiles: vi.fn(async () => []), diff --git a/src/stores/skills.test.ts b/src/stores/skills.test.ts index 9642c79d3..cb7e6d57a 100644 --- a/src/stores/skills.test.ts +++ b/src/stores/skills.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/utils/settingsFs', () => ({ isTauri: false, SKILL_EXT: '.md', + PROFILE_EXT: '.ndprofile.json5', })) import type { SkillMeta } from '@/stores/skills' diff --git a/src/stores/skillsFileSync.test.ts b/src/stores/skillsFileSync.test.ts index a65f3fa46..54e51167c 100644 --- a/src/stores/skillsFileSync.test.ts +++ b/src/stores/skillsFileSync.test.ts @@ -9,6 +9,7 @@ vi.mock('@/utils/settingsFs', () => ({ isTauri: true, isMainDeckWindow: () => true, SKILL_EXT: '.md', + PROFILE_EXT: '.ndprofile.json5', listSkillDirFiles: async () => Array.from(files.keys()), readSkillFile: async (f: string) => { const c = files.get(f) diff --git a/src/utils/settingsFs.ts b/src/utils/settingsFs.ts index ce399fce6..3f0f6fc5a 100644 --- a/src/utils/settingsFs.ts +++ b/src/utils/settingsFs.ts @@ -95,15 +95,11 @@ export async function openSettingsFileInEditor( // --- Profile-specific helpers --- const PROFILES_DIR = 'profiles' -const PROFILE_EXT = '.ndprofile.json5' +export const PROFILE_EXT = '.ndprofile.json5' -export function profileFilename(name: string): string { - return sanitizeFilename(name) + PROFILE_EXT -} - -export async function listProfiles(): Promise { - const files = await listSettingsFiles(PROFILES_DIR) - return files.filter((f) => f.endsWith(PROFILE_EXT)) +/** profiles/ の実列挙 (履歴 .history.json5 を含む。#913 の占有判定・sweep 用)。 */ +export async function listProfileDirFiles(): Promise { + return listSettingsFiles(PROFILES_DIR) } export async function readProfile(filename: string): Promise { From 6b904407567cead83e71390beb3133ef067f69e8 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:43:35 +0900 Subject: [PATCH 10/30] =?UTF-8?q?test(deck-profile):=20ID=20=E5=88=86?= =?UTF-8?q?=E9=9B=A2=E3=83=BB=E5=87=8D=E7=B5=90=E3=83=BB=E7=A7=BB=E8=A1=8C?= =?UTF-8?q?=E3=81=AE=E9=85=8D=E7=B7=9A=E3=83=86=E3=82=B9=E3=83=88=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skillsFileSync.test の先例に倣い、settingsFs をインメモリ疑似 FS で モックして store 経由の不変条件を保証する: - ファイル内 id の採用と fileBase 対応表の保持 - id 欠損の凍結 (= 旧完全ファイル名) と規約外名の copy-adopt 正規化 - 凍結による activeProfileId / windowProfileId / デッキ内容の無追随整合 - リネームの ID 不変 + ファイル rename 追随 - 新規作成・デフォルトプロファイルの slug ファイル名/ID - 移行 (b) ミラー再作成と memOnly マージ同定の拡大 - 保存・削除直前のミラー対応表読み直し (別ウィンドウのリネーム追随) Co-Authored-By: Claude Fable 5 --- src/stores/deckProfileFileSync.test.ts | 338 +++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 src/stores/deckProfileFileSync.test.ts diff --git a/src/stores/deckProfileFileSync.test.ts b/src/stores/deckProfileFileSync.test.ts new file mode 100644 index 000000000..f39bba487 --- /dev/null +++ b/src/stores/deckProfileFileSync.test.ts @@ -0,0 +1,338 @@ +// @vitest-environment happy-dom +import JSON5 from 'json5' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** インメモリ疑似 FS (profiles/ ディレクトリ相当)。 */ +const files = new Map() + +vi.mock('@/utils/settingsFs', () => ({ + isTauri: true, + isMainDeckWindow: () => true, + PROFILE_EXT: '.ndprofile.json5', + listProfileDirFiles: async () => Array.from(files.keys()), + readProfile: async (f: string) => { + const c = files.get(f) + if (c === undefined) throw new Error(`not found: ${f}`) + return c + }, + writeProfile: async (f: string, c: string) => { + files.set(f, c) + }, + deleteProfile: async (f: string) => { + files.delete(f) + }, + renameProfile: async (a: string, b: string) => { + if (!files.has(a)) throw new Error(`not found: ${a}`) + if (files.has(b)) throw new Error(`already exists: ${b}`) + files.set(b, files.get(a) as string) + files.delete(a) + }, +})) + +import type { DeckColumn } from '@/stores/deck' +import { useDeckProfileStore } from '@/stores/deckProfile' +import { STORAGE_KEYS, setStorageJson, setStorageString } from '@/utils/storage' + +const EXT = '.ndprofile.json5' + +const profileFile = (data: Record) => + JSON5.stringify(data, null, 2) + +const homeColumn = (id: string) => + ({ id, type: 'home' }) as unknown as DeckColumn + +async function initStore() { + const store = useDeckProfileStore() + store.ensureDefaults([], []) + await vi.waitFor(() => { + expect(store.initialized).toBe(true) + }) + return store +} + +describe('useDeckProfileStore — ファイル対応表配線 (#913)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + files.clear() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + it('ファイル内の id を採用し fileBase (対応表) を保持する', async () => { + files.set( + `main${EXT}`, + profileFile({ + id: 'my-id', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + }), + ) + const store = await initStore() + const p = store.getProfiles().find((x) => x.id === 'my-id') + expect(p).toBeDefined() + expect(p?.fileBase).toBe('main') + expect(p?.name).toBe('メイン') + }) + + it('id 欠損は拡張子込みの完全ファイル名で凍結し、規約外名は slug へ正規化する', async () => { + files.set( + `プロファイル 1${EXT}`, + profileFile({ + name: 'プロファイル 1', + columns: [], + layout: [], + createdAt: 1, + }), + ) + const store = await initStore() + // 凍結: 実効値 = 拡張子込みの旧完全ファイル名 (= 現行フォールバックと同値) + const p = store.getProfiles().find((x) => x.id === `プロファイル 1${EXT}`) + expect(p).toBeDefined() + // 移行 (a): ファイルだけ slug 化 (表示名 'プロファイル 1' → '1') + expect(p?.fileBase).toBe('1') + expect(files.has(`1${EXT}`)).toBe(true) + expect(files.has(`プロファイル 1${EXT}`)).toBe(false) + // 凍結 ID は生内容への最小変換で注入されている + expect(files.get(`1${EXT}`)).toContain(`id: 'プロファイル 1${EXT}'`) + }) + + it('凍結により activeProfileId / windowProfileId / デッキ内容が無追随で生き続ける', async () => { + const legacyId = `プロファイル 1${EXT}` + const col = homeColumn('col-1') + files.set( + legacyId, + profileFile({ + name: 'プロファイル 1', + columns: [col], + layout: [['col-1']], + createdAt: 1, + }), + ) + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { + id: legacyId, + name: 'プロファイル 1', + columns: [col], + layout: [['col-1']], + createdAt: 1, + }, + ]) + setStorageString(STORAGE_KEYS.deckActiveProfile, legacyId) + + const store = await initStore() + store.initWindowProfile(legacyId) + + expect(store.activeProfileId).toBe(legacyId) + expect(store.windowProfileId).toBe(legacyId) + expect(store.currentProfile?.name).toBe('プロファイル 1') + expect(store.currentProfile?.columns).toEqual([col]) + expect(store.currentProfile?.layout).toEqual([['col-1']]) + }) + + it('リネームは表示名のみ変更し (ID 不変)、ファイルは rename で追随する', async () => { + files.set( + `work${EXT}`, + profileFile({ + id: 'work', + name: 'Work', + columns: [], + layout: [], + createdAt: 1, + }), + ) + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { id: 'work', name: 'Work', columns: [], layout: [], createdAt: 1 }, + ]) + setStorageString(STORAGE_KEYS.deckActiveProfile, 'work') + const store = await initStore() + store.initWindowProfile('work') + + store.renameProfile('work', 'Play') + + await vi.waitFor(() => { + expect(files.has(`play${EXT}`)).toBe(true) + }) + expect(files.has(`work${EXT}`)).toBe(false) + const p = store.getProfiles().find((x) => x.id === 'work') + expect(p?.name).toBe('Play') + expect(p?.fileBase).toBe('play') + // ID が不変なので参照の追随は不要 + expect(store.activeProfileId).toBe('work') + expect(store.windowProfileId).toBe('work') + expect(files.get(`play${EXT}`)).toContain("id: 'work'") + }) + + it('新規作成はファイル名・ID とも slug 形式になる', async () => { + const store = await initStore() + const created = store.createEmptyProfile('My Deck') + expect(created.id).toBe('my-deck') + await vi.waitFor(() => { + expect(files.has(`my-deck${EXT}`)).toBe(true) + }) + expect(files.get(`my-deck${EXT}`)).toContain("id: 'my-deck'") + }) + + it('デフォルトプロファイルも ASCII slug ファイル名で作られる (日本語ファイル名を生まない)', async () => { + const store = await initStore() + // slugifyName('プロファイル 1') は数字だけ残して '1' になる + await vi.waitFor(() => { + expect(files.has(`1${EXT}`)).toBe(true) + }) + expect(store.activeProfileId).toBe('1') + expect(store.getProfiles()[0]?.name).toBe('プロファイル 1') + for (const name of files.keys()) { + expect(name).toMatch(/^[a-z0-9-]+\.ndprofile\.json5$/) + } + }) + + it('移行 (b): ミラーに在りファイル不在のプロファイルを slug 名で再作成する', async () => { + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { id: 'lost-id', name: 'Lost', columns: [], layout: [], createdAt: 5 }, + ]) + setStorageString(STORAGE_KEYS.deckActiveProfile, 'lost-id') + const store = await initStore() + await vi.waitFor(() => { + expect(files.has(`lost${EXT}`)).toBe(true) + }) + expect(files.get(`lost${EXT}`)).toContain("id: 'lost-id'") + expect(store.activeProfileId).toBe('lost-id') + }) + + it('memOnly マージは「ID 一致 or 名前+作成日時一致」で複製を落とす', async () => { + files.set( + `main${EXT}`, + profileFile({ name: 'メイン', columns: [], layout: [], createdAt: 42 }), + ) + // ダウングレード往復でファイル内 id が剥がれ、ミラーだけ旧 ID を持つ状況 + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { + id: 'stale-mirror-id', + name: 'メイン', + columns: [], + layout: [], + createdAt: 42, + }, + ]) + const store = await initStore() + const withName = store.getProfiles().filter((p) => p.name === 'メイン') + expect(withName).toHaveLength(1) + expect(withName[0]?.id).toBe(`main${EXT}`) + // ミラー複製が移行 (b) でファイル再作成されない + expect(files.size).toBe(1) + // マージで落ちた ID を指していたアクティブ参照は修復される + expect(store.activeProfileId).toBe(`main${EXT}`) + }) + + it('削除は対応表のファイルを消す', async () => { + files.set( + `work${EXT}`, + profileFile({ + id: 'w1', + name: 'Work', + columns: [], + layout: [], + createdAt: 1, + }), + ) + const store = await initStore() + store.deleteProfile('w1') + await vi.waitFor(() => { + expect(files.has(`work${EXT}`)).toBe(false) + }) + }) + + it('保存はファイル内の未知フィールドを保持し fileBase を書かない', async () => { + files.set( + `main${EXT}`, + profileFile({ + id: 'm', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + futureField: 'keep', + }), + ) + const store = await initStore() + store.initWindowProfile('m') + store.setColumns([homeColumn('c-new')]) + store.flushPersist() + await vi.waitFor(() => { + expect(files.get(`main${EXT}`)).toContain('c-new') + }) + expect(files.get(`main${EXT}`)).toContain('futureField') + expect(files.get(`main${EXT}`)).toContain("id: 'm'") + expect(files.get(`main${EXT}`)).not.toContain('fileBase') + }) + + it('保存の直前にミラーの対応表を読み直す (別ウィンドウのリネーム追随)', async () => { + files.set( + `main${EXT}`, + profileFile({ + id: 'm', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + }), + ) + const store = await initStore() + store.initWindowProfile('m') + // 別ウィンドウのリネームをシミュレート (ファイルとミラーだけが動き、 + // このウィンドウのメモリは古いまま) + files.set(`renamed${EXT}`, files.get(`main${EXT}`) as string) + files.delete(`main${EXT}`) + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { + id: 'm', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + fileBase: 'renamed', + }, + ]) + + store.setColumns([homeColumn('c-new')]) + store.flushPersist() + await vi.waitFor(() => { + expect(files.get(`renamed${EXT}`)).toContain('c-new') + }) + expect(files.has(`main${EXT}`)).toBe(false) + }) + + it('削除の直前にミラーの対応表を読み直す (stale 名の空振りで復活させない)', async () => { + files.set( + `main${EXT}`, + profileFile({ + id: 'm', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + }), + ) + const store = await initStore() + // 別ウィンドウのリネームをシミュレート + files.set(`renamed${EXT}`, files.get(`main${EXT}`) as string) + files.delete(`main${EXT}`) + setStorageJson(STORAGE_KEYS.deckProfiles, [ + { + id: 'm', + name: 'メイン', + columns: [], + layout: [], + createdAt: 1, + fileBase: 'renamed', + }, + ]) + + store.deleteProfile('m') + await vi.waitFor(() => { + expect(files.has(`renamed${EXT}`)).toBe(false) + }) + }) +}) From 872320c997a98f7092be319ec4f8b92c0fca700e Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:59:39 +0900 Subject: [PATCH 11/30] =?UTF-8?q?fix(settings):=20custom.css=20=E3=81=AE?= =?UTF-8?q?=E7=B7=A8=E9=9B=86=E5=B1=A5=E6=AD=B4=E3=82=92=20ALLOWED=5FROOT?= =?UTF-8?q?=5FFILES=20=E3=81=AB=E8=BF=BD=E5=8A=A0=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit custom.css.history.json5 が root allowlist から漏れており、フロントの settingsFs 履歴系がこの名前で read/write を試みて常に reject されていた。 回帰テストは次コミットの import_bundle 強化テスト群と同居。 Co-Authored-By: Claude Fable 5 --- src-tauri/src/settings_store.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/settings_store.rs b/src-tauri/src/settings_store.rs index a9876c58b..3549636ff 100644 --- a/src-tauri/src/settings_store.rs +++ b/src-tauri/src/settings_store.rs @@ -40,6 +40,9 @@ pub const ALLOWED_ROOT_FILES: &[&str] = &[ // 公開しない制約はここではなく capability registry 側で担保している // (settingsFs の固定名ラッパーのみが本コマンドに到達する) "permissions.json5", + // custom.css の編集履歴サイドカー (#913 付随修正)。allowlist から漏れて + // いたため、フロントの履歴 read/write が一度も成功していなかった + "custom.css.history.json5", ]; /// Validate a subdirectory name against the whitelist. From 5d83330c519e2fbb8a1f5a7b24c87ed696c3891f Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 07:59:54 +0900 Subject: [PATCH 12/30] =?UTF-8?q?feat(settings):=20=E3=83=90=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=A2=E3=83=83=E3=83=97=E5=BE=A9=E5=85=83=E3=81=AE?= =?UTF-8?q?=20atomic=20=E5=8C=96=E3=81=A8=E3=82=AD=E3=83=BC=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=83=BBcasefold=20=E8=A1=9D=E7=AA=81=E6=A4=9C?= =?UTF-8?q?=E6=9F=BB=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import_bundle を強化する: - キー構造検証: 「許可サブディレクトリ + ファイル名」の 2 要素、または 許可ルートファイルの 1 要素のみ。3 要素以上のネストは拒否。サブディレクトリ 側のファイル名には強化検証 (制御文字・Windows 予約デバイス名 (stem 判定)・ 先頭ドット・末尾ドット/空白) を適用。文字種 (日本語等) は寛容のまま — 旧バックアップの復元を拒否しない。validate_filename 本体は強化しない (規約外名の既存ファイルの読取・移行が成立しなくなるため) - casefold 衝突検査 + 排他書込: 書込先ディレクトリの実列挙 + ASCII casefold の事前照合の上で、create_new 予約 → atomic_write (tmp + rename)。FS 自身の 同名解決 (非 ASCII casefold・NFC/NFD) を衝突検出の正とし、case-insensitive FS で旧バックアップが正規化済みファイルを truncate 置換するのを防ぐ - 衝突判定・skip/suffix は basename グループ (ソース + メタ + 履歴) 単位。 全構成が内容バイト一致ならグループ skip、不一致なら全構成を同一の -2 昇順 suffix (複合拡張子の前に挿入) へ退避し、ファイル内 ID は変えない - sessions/ 配下は 0o600 を維持。素の fs::write を全廃 - 許可ルートファイルは固定名の単一ファイルなので復元 = atomic 置換 (suffix 退避先は allowlist 外で二度と読まれないため衝突分岐は不適用) - import_settings_json がスキップ / 別名退避の警告リストを返し、 BackupContent.vue が再起動前に件数 + 内容を開示する Co-Authored-By: Claude Fable 5 --- src-tauri/src/commands/settings.rs | 25 +- src-tauri/src/settings_store.rs | 537 +++++++++++++++++++++++- src/bindings.ts | 8 +- src/components/window/BackupContent.vue | 19 +- 4 files changed, 562 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e26b2879d..d1f2d91ce 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -221,10 +221,20 @@ pub async fn export_settings_json(app: tauri::AppHandle) -> Result { Ok(true) } +/// import_settings_json の結果。`imported: false` はダイアログのキャンセル。 +/// `warnings` はスキップ / 別名退避したエントリの説明 (#913 付随修正 — フロントは +/// 復元完了メッセージに件数 + 内容を表示する)。 +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ImportSettingsResult { + pub imported: bool, + pub warnings: Vec, +} + /// Import settings from a JSON bundle via open dialog. #[tauri::command] #[specta::specta] -pub async fn import_settings_json(app: tauri::AppHandle) -> Result { +pub async fn import_settings_json(app: tauri::AppHandle) -> Result { use std::collections::BTreeMap; use tauri_plugin_dialog::DialogExt; @@ -237,7 +247,11 @@ pub async fn import_settings_json(app: tauri::AppHandle) -> Result { .blocking_pick_file(); let Some(src) = src else { - return Ok(false); // user cancelled + // user cancelled + return Ok(ImportSettingsResult { + imported: false, + warnings: vec![], + }); }; let src_path = src @@ -249,7 +263,10 @@ pub async fn import_settings_json(app: tauri::AppHandle) -> Result { let bundle: BTreeMap = serde_json::from_str(&raw) .map_err(|e| NoteDeckError::InvalidInput(format!("Invalid JSON: {e}")))?; - store::import_bundle(&base_dir, &bundle)?; + let warnings = store::import_bundle(&base_dir, &bundle)?; - Ok(true) + Ok(ImportSettingsResult { + imported: true, + warnings, + }) } diff --git a/src-tauri/src/settings_store.rs b/src-tauri/src/settings_store.rs index 3549636ff..03037e072 100644 --- a/src-tauri/src/settings_store.rs +++ b/src-tauri/src/settings_store.rs @@ -303,36 +303,280 @@ pub fn export_bundle(base_dir: &Path) -> Result> { Ok(bundle) } -/// バックアップバンドルを検証しながら書き戻す。 -/// path traversal と allowlist 外のエントリは warn してスキップする。 -pub fn import_bundle(base_dir: &Path, bundle: &BTreeMap) -> Result<()> { - for (key, content) in bundle { - // Path traversal prevention - if key.contains("..") || key.starts_with('/') || key.starts_with('\\') { - tracing::warn!("Skipping suspicious entry: {key}"); - continue; +/// import キー内ファイル名の強化検証 (#913)。「新しく置くファイル」にのみ適用し、 +/// `validate_filename` 本体は強化しない (規約外名の既存ファイルへの読取・削除・ +/// リネーム元指定を拒否すると移行が成立しないため)。 +/// 文字種 (日本語等) は寛容のまま受ける — 旧バックアップの復元を拒否しない。 +fn validate_import_filename(name: &str) -> Result<()> { + validate_filename(name)?; + if name.chars().any(|c| c.is_control()) { + return Err(NoteDeckError::InvalidInput(format!( + "Filename contains control characters: {name:?}" + ))); + } + if name.starts_with('.') { + return Err(NoteDeckError::InvalidInput(format!( + "Filename must not start with a dot: {name}" + ))); + } + if name.ends_with('.') || name.ends_with(' ') { + return Err(NoteDeckError::InvalidInput(format!( + "Filename must not end with a dot or space: {name}" + ))); + } + // Windows 予約デバイス名 (stem = 最初のドットより前で判定) + let stem = name.split('.').next().unwrap_or(name); + if is_windows_reserved_stem(stem) { + return Err(NoteDeckError::InvalidInput(format!( + "Filename uses a Windows reserved device name: {name}" + ))); + } + Ok(()) +} + +fn is_windows_reserved_stem(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9')) +} + +/// 種別の規定複合拡張子。suffix は「複合拡張子の前」に挿入するので、長い順に +/// 剥がして basename を得る (`.json5` は複合拡張子の suffix なので必ず最後)。 +const COMPOUND_EXTS: &[&str] = &[ + ".meta.json5", + ".history.json5", + ".ndprofile.json5", + ".ndtheme.json5", + ".is", + ".md", + ".json5", +]; + +/// ファイル名を (basename, 複合拡張子) に分割する。既知の拡張子がなければ +/// 全体を basename とする (suffix は末尾付与になる)。 +fn split_compound_ext(name: &str) -> (&str, &str) { + for ext in COMPOUND_EXTS { + if let Some(base) = name.strip_suffix(ext) { + if !base.is_empty() { + return (base, ext); + } + } + } + (name, "") +} + +/// グループ全構成の宛先を `create_new` で排他予約する。1 つでも既存衝突したら +/// 予約済み分を削除して `Ok(false)` を返す (FS 自身の同名解決 — 非 ASCII +/// casefold・NFC/NFD — を衝突検出の正とするため、事前照合では拾えない衝突も +/// ここで検出される)。 +fn reserve_all(dir: &Path, names: &[String]) -> Result { + let mut reserved: Vec = Vec::new(); + for name in names { + let path = dir.join(name); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(_) => reserved.push(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + for p in &reserved { + let _ = fs::remove_file(p); + } + return Ok(false); + } + Err(e) => { + for p in &reserved { + let _ = fs::remove_file(p); + } + return Err(NoteDeckError::InvalidInput(format!( + "Failed to write {}: {e}", + path.display() + ))); + } } + } + Ok(true) +} - // Validate: must be in allowed subdirs or allowed root files - let allowed = ALLOWED_SUBDIRS +/// 同一 basename のグループ (ソース + メタ + 履歴。単一ファイル種別は 1 ファイル +/// = 1 グループ) を書き込む。衝突判定・skip/suffix はグループ単位 — エントリ +/// 単位でばらすとペア種別のソースとメタが別名に分裂し、既存の孤児ソースと +/// 誤ペアリングしたり履歴だけが別アイテムのリングに結合する。 +fn import_group( + base_dir: &Path, + subdir: &str, + members: &[(String, String)], + warnings: &mut Vec, +) -> Result<()> { + let dir = base_dir.join(subdir); + fs::create_dir_all(&dir).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + // sessions/ は AI 会話内容のため 0o600 を維持 (write_file と同じ流儀) + let mode = if subdir == "sessions" { + Some(0o600) + } else { + None + }; + + // 書込直前の実列挙 + ASCII casefold の事前スクリーニング (case-sensitive FS + // でも「大文字小文字のみ異なる既存名は占有」の規則を守る) + let existing = list_files(base_dir, subdir)?; + let collides = |name: &str| -> bool { + let folded = name.to_ascii_lowercase(); + existing.iter().any(|e| e.to_ascii_lowercase() == folded) + }; + + if !members.iter().any(|(n, _)| collides(n)) { + let names: Vec = members.iter().map(|(n, _)| n.clone()).collect(); + if reserve_all(&dir, &names)? { + for (name, content) in members { + atomic_write(&dir.join(name), content, mode)?; + } + return Ok(()); + } + // 排他予約が失敗 = 事前照合で拾えない FS の同名解決 (非 ASCII casefold・ + // NFC/NFD)。予約済み分は削除済みなので、グループごと衝突分岐へ回す + } + + // --- 衝突分岐 (グループ単位) --- + // 衝突した構成が全て内容バイト一致ならグループ全体 skip (再 import の no-op + // 収束)。いずれか不一致ならグループ全体を同一 suffix の basename へ退避する + // (ファイル内 ID は変えず、次回起動の重複 ID 警告で手動解決に乗せる)。 + let mut any_collision = false; + let mut all_identical = true; + for (name, content) in members { + // fs::read は FS の同名解決を通るので、排他失敗の実体も拾える + match fs::read(dir.join(name)) { + Ok(bytes) => { + any_collision = true; + if bytes != content.as_bytes() { + all_identical = false; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(NoteDeckError::InvalidInput(format!( + "Failed to read {subdir}/{name}: {e}" + ))); + } + } + // casefold 一致の別表記 (case-sensitive FS では上の read に映らない) + let folded = name.to_ascii_lowercase(); + for e in existing.iter().filter(|e| e.to_ascii_lowercase() == folded) { + any_collision = true; + match fs::read(dir.join(e)) { + Ok(bytes) => { + if bytes != content.as_bytes() { + all_identical = false; + } + } + Err(_) => all_identical = false, + } + } + } + + let member_keys = || -> String { + members + .iter() + .map(|(n, _)| format!("{subdir}/{n}")) + .collect::>() + .join(", ") + }; + + if any_collision && all_identical { + tracing::warn!("Import: skipping identical group: {}", member_keys()); + warnings.push(format!("スキップ (既存と内容同一): {}", member_keys())); + return Ok(()); + } + + // suffix 退避: 全構成が同時に空く番号を create_new プローブで探索する + for n in 2..10_000u32 { + let candidates: Vec = members .iter() - .any(|d| key.starts_with(&format!("{d}/"))) - || ALLOWED_ROOT_FILES.contains(&key.as_str()); - if !allowed { - tracing::warn!("Skipping unknown entry: {key}"); + .map(|(name, _)| { + let (base, ext) = split_compound_ext(name); + format!("{base}-{n}{ext}") + }) + .collect(); + if candidates.iter().any(|c| collides(c)) { + continue; + } + if !reserve_all(&dir, &candidates)? { continue; } + for ((_, content), cand) in members.iter().zip(&candidates) { + atomic_write(&dir.join(cand), content, mode)?; + } + let renamed = candidates.join(", "); + tracing::warn!( + "Import: group collides, restored with suffix: {} -> {renamed}", + member_keys() + ); + warnings.push(format!( + "別名で復元 (既存と衝突): {} → {renamed}", + member_keys() + )); + return Ok(()); + } + Err(NoteDeckError::InvalidInput(format!( + "No free suffix found for import group: {}", + member_keys() + ))) +} - let dest_path = base_dir.join(key); +/// バックアップバンドルを検証しながら書き戻す。 +/// +/// - キー構造は「許可サブディレクトリ + ファイル名」の 2 要素、または許可 +/// ルートファイル名そのものの 1 要素のみ。3 要素以上のネストは拒否 +/// - サブディレクトリ側のファイル名には強化検証 (`validate_import_filename`) を +/// 適用。拒否したエントリはスキップし警告として収集する +/// - 許可ルートファイルは固定名の単一ファイルなので復元 = atomic 置換 +/// (suffix 退避先の名前は allowlist 外で二度と読まれないため衝突分岐は不適用) +/// - サブディレクトリのアイテムは basename グループ単位で casefold 衝突検査 + +/// 排他書込 (詳細は `import_group`) +/// +/// 戻り値はスキップ / 別名退避したエントリの警告リスト。 +pub fn import_bundle(base_dir: &Path, bundle: &BTreeMap) -> Result> { + let mut warnings: Vec = Vec::new(); + // (subdir, basename) → [(filename, content)]。BTreeMap なので処理順は決定的 + let mut groups: BTreeMap<(String, String), Vec<(String, String)>> = BTreeMap::new(); + + if !bundle.is_empty() { + fs::create_dir_all(base_dir).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + } - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).map_err(|e| NoteDeckError::InvalidInput(e.to_string()))?; + for (key, content) in bundle { + let parts: Vec<&str> = key.split('/').collect(); + match parts.as_slice() { + [name] if ALLOWED_ROOT_FILES.contains(name) => { + atomic_write(&base_dir.join(name), content, None)?; + } + [subdir, name] if ALLOWED_SUBDIRS.contains(subdir) => { + if let Err(e) = validate_import_filename(name) { + tracing::warn!("Import: skipping invalid filename: {key}: {e}"); + warnings.push(format!("スキップ (不正なファイル名): {key}")); + continue; + } + let (base, _) = split_compound_ext(name); + groups + .entry((subdir.to_string(), base.to_string())) + .or_default() + .push((name.to_string(), content.clone())); + } + _ => { + tracing::warn!("Import: skipping unknown entry: {key}"); + warnings.push(format!("スキップ (不正なキー): {key}")); + } } + } - fs::write(&dest_path, content) - .map_err(|e| NoteDeckError::InvalidInput(format!("Failed to write {key}: {e}")))?; + for ((subdir, _), members) in &groups { + import_group(base_dir, subdir, members, &mut warnings)?; } - Ok(()) + + Ok(warnings) } #[cfg(test)] @@ -575,10 +819,261 @@ mod tests { bundle.insert("secret.txt".to_string(), "secret".to_string()); bundle.insert("config/bad.json".to_string(), "bad".to_string()); - import_bundle(&base, &bundle).unwrap(); + let warnings = import_bundle(&base, &bundle).unwrap(); assert!(base.join("custom.css").exists()); assert!(!base.join("secret.txt").exists()); assert!(!base.join("config/bad.json").exists()); + // 拒否 2 件が警告として収集される + assert_eq!(warnings.len(), 2); + } + + #[test] + fn custom_css_history_is_allowed_root_file() { + // #913 付随修正: allowlist から漏れていて履歴の read/write が常に + // reject されていた (フロントは settingsFs の history 系でこの名前を使う) + assert!(ALLOWED_ROOT_FILES.contains(&"custom.css.history.json5")); + let dir = tempfile::tempdir().unwrap(); + write_root_file(dir.path(), "custom.css.history.json5", "{ entries: [] }").unwrap(); + assert_eq!( + read_root_file(dir.path(), "custom.css.history.json5").unwrap(), + "{ entries: [] }" + ); + } + + #[test] + fn import_bundle_rejects_nested_keys() { + // キー構造は 2 要素 (subdir/name) か許可ルートファイルの 1 要素のみ。 + // 3 要素以上のネストは拒否 + 警告 + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert("themes/deep/evil.json5".to_string(), "x".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(!base.join("themes/deep/evil.json5").exists()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("themes/deep/evil.json5")); + } + + #[test] + fn import_bundle_rejects_reserved_and_malformed_filenames() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + // Windows 予約デバイス名 (stem 判定) + bundle.insert("themes/aux.ndtheme.json5".to_string(), "x".to_string()); + bundle.insert("skills/COM1.md".to_string(), "x".to_string()); + // 制御文字 / 先頭ドット / 末尾ドット・空白 + bundle.insert("skills/bad\u{1}name.md".to_string(), "x".to_string()); + bundle.insert("skills/.hidden.md".to_string(), "x".to_string()); + bundle.insert("skills/trail .md ".to_string(), "x".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 5); + assert_eq!(list_files(base, "themes").unwrap(), Vec::::new()); + assert_eq!(list_files(base, "skills").unwrap(), Vec::::new()); + } + + #[test] + fn import_bundle_accepts_lenient_charset() { + // 文字種 (日本語等) は寛容のまま — 旧バックアップの復元を拒否しない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert( + "themes/天気テーマ.ndtheme.json5".to_string(), + "{ id: 't1' }".to_string(), + ); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(warnings.is_empty()); + assert_eq!( + read_file(base, "themes", "天気テーマ.ndtheme.json5").unwrap(), + "{ id: 't1' }" + ); + } + + #[test] + fn import_bundle_skips_identical_group() { + // 衝突した構成が全て内容バイト一致 → グループ全体 skip (再 import の + // no-op 収束) + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "# weather").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "# weather".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("skills/weather.md")); + assert_eq!(list_files(base, "skills").unwrap(), vec!["weather.md"]); + } + + #[test] + fn import_bundle_diverts_conflicting_group_with_suffix() { + // 内容不一致の衝突はグループ全体を同一 suffix の basename へ退避する。 + // ペア種別 (ソース + メタ) はメタが非衝突でも分裂させない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "widgets", "clock.is", "local code").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("widgets/clock.is".to_string(), "backup code".to_string()); + bundle.insert( + "widgets/clock.meta.json5".to_string(), + "{ id: 'w1' }".to_string(), + ); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + + // 既存はそのまま、バックアップ側は -2 で並置 (suffix は複合拡張子の前) + assert_eq!( + read_file(base, "widgets", "clock.is").unwrap(), + "local code" + ); + assert_eq!( + read_file(base, "widgets", "clock-2.is").unwrap(), + "backup code" + ); + assert_eq!( + read_file(base, "widgets", "clock-2.meta.json5").unwrap(), + "{ id: 'w1' }" + ); + assert!(!base.join("widgets/clock.meta.json5").exists()); + } + + #[test] + fn import_bundle_casefold_collision_diverts_on_case_sensitive_fs() { + // 大文字小文字のみ異なる既存名は占有 (ASCII casefold の事前照合)。 + // case-sensitive FS でも Windows/macOS と挙動を揃える + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "Weather.md", "local").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert_eq!(read_file(base, "skills", "Weather.md").unwrap(), "local"); + assert_eq!(read_file(base, "skills", "weather-2.md").unwrap(), "backup"); + } + + #[test] + fn import_bundle_suffix_probes_next_free_number() { + // -2 が占有済みなら全構成が同時に空く次の番号へ + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "local").unwrap(); + write_file(base, "skills", "weather-2.md", "taken").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert_eq!(warnings.len(), 1); + assert_eq!(read_file(base, "skills", "weather-3.md").unwrap(), "backup"); + assert_eq!(read_file(base, "skills", "weather-2.md").unwrap(), "taken"); + } + + #[test] + fn import_bundle_overwrites_root_files_atomically() { + // 許可ルートファイルは固定名の単一ファイル。復元 = 置換 (skip/suffix の + // 衝突分岐は適用しない — suffix 先は allowlist 外で二度と読まれない) + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_root_file(base, "custom.css", "old {}").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("custom.css".to_string(), "new {}".to_string()); + + let warnings = import_bundle(base, &bundle).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(read_root_file(base, "custom.css").unwrap(), "new {}"); + } + + #[cfg(unix)] + #[test] + fn import_bundle_applies_sessions_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + + let mut bundle = BTreeMap::new(); + bundle.insert("sessions/20260722.json5".to_string(), "{}".to_string()); + + import_bundle(base, &bundle).unwrap(); + let mode = fs::metadata(base.join("sessions/20260722.json5")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn import_bundle_leaves_no_temp_or_reservation_files() { + // 排他予約 (create_new) + atomic_write 後に空ファイルや .tmp が残らない + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_file(base, "skills", "weather.md", "local").unwrap(); + + let mut bundle = BTreeMap::new(); + bundle.insert("skills/weather.md".to_string(), "backup".to_string()); + bundle.insert("skills/fresh.md".to_string(), "fresh".to_string()); + + import_bundle(base, &bundle).unwrap(); + assert_eq!( + list_files(base, "skills").unwrap(), + vec!["fresh.md", "weather-2.md", "weather.md"] + ); + assert_eq!(read_file(base, "skills", "fresh.md").unwrap(), "fresh"); + } + + #[test] + fn split_compound_ext_longest_first() { + assert_eq!( + split_compound_ext("clock.meta.json5"), + ("clock", ".meta.json5") + ); + assert_eq!( + split_compound_ext("clock.history.json5"), + ("clock", ".history.json5") + ); + assert_eq!( + split_compound_ext("p.ndprofile.json5"), + ("p", ".ndprofile.json5") + ); + assert_eq!( + split_compound_ext("t.ndtheme.json5"), + ("t", ".ndtheme.json5") + ); + assert_eq!(split_compound_ext("w.is"), ("w", ".is")); + assert_eq!(split_compound_ext("s.md"), ("s", ".md")); + assert_eq!(split_compound_ext("q.json5"), ("q", ".json5")); + assert_eq!(split_compound_ext("noext"), ("noext", "")); + } + + #[test] + fn validate_import_filename_rules() { + assert!(validate_import_filename("weather.md").is_ok()); + assert!(validate_import_filename("日本語.json5").is_ok()); + // 予約デバイス名は stem 判定・case-insensitive + assert!(validate_import_filename("CON").is_err()); + assert!(validate_import_filename("nul.json5").is_err()); + assert!(validate_import_filename("Lpt9.is").is_err()); + // COM0 / COM10 / 部分一致は予約名ではない + assert!(validate_import_filename("com0.md").is_ok()); + assert!(validate_import_filename("com10.md").is_ok()); + assert!(validate_import_filename("console.md").is_ok()); + assert!(validate_import_filename("\u{7f}x.md").is_err()); + assert!(validate_import_filename(".dotfile").is_err()); + assert!(validate_import_filename("dot.").is_err()); + assert!(validate_import_filename("space ").is_err()); } } diff --git a/src/bindings.ts b/src/bindings.ts index d69755242..fe068e45e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2329,7 +2329,7 @@ async exportSettingsJson() : Promise> { +async importSettingsJson() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("import_settings_json") }; } catch (e) { @@ -3223,6 +3223,12 @@ export type HttpFetchResponse = { status: number; headers: Partial<{ [key in str * 画像ディスクキャッシュの使用量 (#815)。設定のキャッシュ画面で表示する */ export type ImageCacheStats = { bytes: number; files: number } +/** + * import_settings_json の結果。`imported: false` はダイアログのキャンセル。 + * `warnings` はスキップ / 別名退避したエントリの説明 (#913 付随修正 — フロントは + * 復元完了メッセージに件数 + 内容を表示する)。 + */ +export type ImportSettingsResult = { imported: boolean; warnings: string[] } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> /** * Misskey の `mutedWords` / `hardMutedWords` の 1 要素。 diff --git a/src/components/window/BackupContent.vue b/src/components/window/BackupContent.vue index 2a20a0615..02c78e20f 100644 --- a/src/components/window/BackupContent.vue +++ b/src/components/window/BackupContent.vue @@ -44,7 +44,24 @@ const exportSettings = () => const importSettings = () => backupAction( isImportingSettings, - () => commands.importSettingsJson().then((r) => unwrap(r)), + async () => { + const result = unwrap(await commands.importSettingsJson()) + if (!result.imported) return false // dialog cancelled + // スキップ / 別名退避したエントリ (#913) を再起動前に開示する。 + // 再起動後では警告が失われるのでここで見せるしかない + if (result.warnings.length > 0) { + await confirm({ + title: '設定インポート完了', + message: `${result.warnings.length} 件のエントリをスキップまたは別名で復元しました。アプリを再起動します。`, + code: result.warnings.join('\n'), + codeLanguage: 'text', + type: 'warning', + hideCancel: true, + okLabel: '再起動', + }) + } + return true + }, { confirmOpts: { title: '設定インポート', From 8777d2d3caf08176397f9fd4a04014f446aefa48 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:27:33 +0900 Subject: [PATCH 13/30] =?UTF-8?q?feat(stores):=20storeSha512=20=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=A8=20applyStoreUpdate=E3=83=BBstoreId=20?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=90=8D=E3=81=AE=E5=9F=BA?= =?UTF-8?q?=E7=9B=A4=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ストアインストール経路の同一性統一 (misstore 側は次コミット) の store 基盤: - widget/plugin/query のメタと skill frontmatter、テーマ $notedeck に storeSha512 / storeVersion を追加 (更新検知 #1040 の baseline。 既存インストール品の未記録はそのまま — ここでは記録し始めるだけ) - 各コレクション config に preferredBase: storeId を配線し、 新規ストアインストールのファイル名 = storeId にする (規約不適合は 自動で表示名 slug に落ち、占有時は連番 suffix) - applyStoreUpdate を widgets/plugins/columnQueries に追加: 本体 (src) と ストア由来メタを上書きし、ローカル値 (改名 name・active・スコープ・ configData・autoRun) は維持。ソース欠損の readOnly 個体は検証済み 配布ソースで復旧する - persist 経路で live 要素 (reactive proxy) を渡すよう修正: raw 参照だと 占有判定の「自分自身は占有とみなさない」が崩れ、preferredBase = id/storeId のファイル名に無意味な -2 suffix が付く (builtin seed にも 効く潜在バグ) Co-Authored-By: Claude Fable 5 --- src/services/sidecarFileCollection.test.ts | 25 ++++++++ src/stores/columnQueries.test.ts | 46 ++++++++++++++ src/stores/columnQueries.ts | 57 ++++++++++++++++- src/stores/plugins.test.ts | 60 ++++++++++++++++++ src/stores/plugins.ts | 74 +++++++++++++++++++++- src/stores/skills.ts | 27 ++++++-- src/stores/skillsFileSync.test.ts | 28 ++++++++ src/stores/widgets.test.ts | 56 ++++++++++++++++ src/stores/widgets.ts | 58 ++++++++++++++++- src/theme/types.ts | 4 ++ 10 files changed, 423 insertions(+), 12 deletions(-) diff --git a/src/services/sidecarFileCollection.test.ts b/src/services/sidecarFileCollection.test.ts index 211d127b8..83bce84a4 100644 --- a/src/services/sidecarFileCollection.test.ts +++ b/src/services/sidecarFileCollection.test.ts @@ -367,6 +367,31 @@ describe('persistItem', () => { await col.persistItem(w, [w]) expect(fs.files.size).toBe(0) }) + + it('preferredBase が規約適合で空いていればそれを使う (ストアインストール #913)', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs, { preferredBase: () => 'store-item' }) + const w = item({ name: 'とても日本語な表示名' }) + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('store-item') + expect(fs.files.has('store-item.meta.json5')).toBe(true) + }) + + it('preferredBase が占有済みなら連番 suffix で回避する', async () => { + const fs = makeFakeFs({ 'store-item.meta.json5': '{}' }) + const col = makeCollection(fs, { preferredBase: () => 'store-item' }) + const w = item({ name: 'alpha' }) + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('store-item-2') + }) + + it('preferredBase が規約不適合なら無視して表示名 slug に落ちる', async () => { + const fs = makeFakeFs() + const col = makeCollection(fs, { preferredBase: () => '日本語ID' }) + const w = item({ name: 'alpha' }) + await col.persistItem(w, [w]) + expect(w.fileBase).toBe('alpha') + }) }) describe('persistAll', () => { diff --git a/src/stores/columnQueries.test.ts b/src/stores/columnQueries.test.ts index 799f18c20..1c1bc9fa1 100644 --- a/src/stores/columnQueries.test.ts +++ b/src/stores/columnQueries.test.ts @@ -50,3 +50,49 @@ describe('useColumnQueriesStore.removeQuery (undo) — #988', () => { expect(store.getQuery(a.id)?.name).toBe('readded') }) }) + +describe('applyStoreUpdate (#913 ストア再インストール)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('src / description / sha を上書きし name (ローカル改名) は維持する', async () => { + const store = useColumnQueriesStore() + const q = await store.createQuery({ + name: 'My Renamed', + src: 'old', + storeId: 'ent-query', + }) + await store.applyStoreUpdate(q.id, { + src: 'new', + description: 'd2', + storeSha512: 'abc', + storeVersion: '2.0.0', + }) + expect(store.getQuery(q.id)).toMatchObject({ + name: 'My Renamed', + src: 'new', + description: 'd2', + storeSha512: 'abc', + storeVersion: '2.0.0', + }) + }) + + it('ソース欠損の readOnly 個体は検証済み配布ソースで復旧する', async () => { + const store = useColumnQueriesStore() + const q = await store.createQuery({ + name: 'a', + src: '', + storeId: 'ent-query', + }) + store.queries = [{ ...q, readOnly: true }] + await store.applyStoreUpdate(q.id, { + src: 'recovered', + storeSha512: 'abc', + storeVersion: '1.0.0', + }) + expect(store.getQuery(q.id)?.src).toBe('recovered') + expect(store.getQuery(q.id)?.readOnly).toBeFalsy() + }) +}) diff --git a/src/stores/columnQueries.ts b/src/stores/columnQueries.ts index c16a834c4..91af6a318 100644 --- a/src/stores/columnQueries.ts +++ b/src/stores/columnQueries.ts @@ -25,6 +25,10 @@ export interface NamedQueryMeta extends SidecarItemFile { description?: string src: string storeId?: string + /** インストール/更新時に照合済みの配布ソース SHA-512 (#913。更新検知 #1040 の baseline) */ + storeSha512?: string + /** インストール/更新時の registry バージョン (#913) */ + storeVersion?: string /** ストア配布物のアイコン (任意)。他の配布物カードと表示を揃える */ iconUrl?: string /** 組込シードのクエリ (スキルの builtIn と同じ分類軸) */ @@ -38,6 +42,8 @@ interface QueryFileMeta { name: string description?: string storeId?: string + storeSha512?: string + storeVersion?: string iconUrl?: string builtIn?: boolean createdAt: number @@ -57,6 +63,8 @@ const queryFiles = createSidecarCollection({ idOf: (q) => q.id, nameOf: (q) => q.name, srcOf: (q) => q.src, + // ストアインストールはファイル名 = storeId (#913。占有時は連番 suffix) + preferredBase: (q) => q.storeId, mirrorSrcById: (id) => getStorageJson(STORAGE_KEYS.columnQueries, []).find( (q) => q.id === id, @@ -66,6 +74,8 @@ const queryFiles = createSidecarCollection({ name: q.name, ...(q.description ? { description: q.description } : {}), ...(q.storeId ? { storeId: q.storeId } : {}), + ...(q.storeSha512 ? { storeSha512: q.storeSha512 } : {}), + ...(q.storeVersion ? { storeVersion: q.storeVersion } : {}), ...(q.iconUrl ? { iconUrl: q.iconUrl } : {}), ...(q.builtIn ? { builtIn: true } : {}), createdAt: q.createdAt, @@ -77,6 +87,8 @@ const queryFiles = createSidecarCollection({ description: meta.description, src, storeId: meta.storeId, + storeSha512: meta.storeSha512, + storeVersion: meta.storeVersion, iconUrl: meta.iconUrl, builtIn: meta.builtIn, createdAt: meta.createdAt ?? Date.now(), @@ -175,8 +187,12 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { if (settingsFs.isTauri) { await ready try { - adoptMirrorFileBase(query) - await queryFiles.persistItem(query, queries.value) + // ref の深い reactivity で queries.value の要素は proxy になるため、 + // 占有判定の「操作対象自身は占有とみなさない」参照一致が崩れない + // よう live 要素 (proxy) を渡す + const live = queries.value.find((q) => q.id === query.id) ?? query + adoptMirrorFileBase(live) + await queryFiles.persistItem(live, queries.value) // fileBase 割当をミラーへ反映 persistMirror() } catch (e) { @@ -192,7 +208,12 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { async function createQuery( input: Pick & - Partial>, + Partial< + Pick< + NamedQueryMeta, + 'description' | 'storeId' | 'iconUrl' | 'storeSha512' | 'storeVersion' + > + >, ): Promise { ensureLoaded() const now = Date.now() @@ -236,6 +257,35 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { await persist(next) } + /** + * ストア再インストール (#913): 本体 (src) とストア由来メタを上書き更新する。 + * ローカル値 (name の改名) は維持。ソース欠損の readOnly 個体は + * 検証済み配布ソースで復旧する (persist 抑止を解除)。 + */ + async function applyStoreUpdate( + id: string, + patch: { + src: string + description?: string + iconUrl?: string + storeSha512: string + storeVersion: string + }, + ): Promise { + ensureLoaded() + const idx = queries.value.findIndex((q) => q.id === id) + const prev = queries.value[idx] + if (!prev) return + const next: NamedQueryMeta = { + ...prev, + ...patch, + readOnly: undefined, + updatedAt: Date.now(), + } + queries.value = queries.value.map((q) => (q.id === id ? next : q)) + await persist(next) + } + /** * クエリを削除する。削除を取り消す undo を返す (#988 — skill / widget と同じ * 「confirm → 削除 → 元に戻すトースト」に揃えるため)。未知の id なら undefined。 @@ -290,6 +340,7 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { getQuery, createQuery, updateQuery, + applyStoreUpdate, removeQuery, refCountByQueryId, } diff --git a/src/stores/plugins.test.ts b/src/stores/plugins.test.ts index 8500f6d59..43236a98b 100644 --- a/src/stores/plugins.test.ts +++ b/src/stores/plugins.test.ts @@ -332,3 +332,63 @@ describe('removePlugin (undo) — #988', () => { expect(store.getPlugin('p1')?.name).toBe('readded') }) }) + +describe('applyStoreUpdate (#913 ストア再インストール)', () => { + it('本体とストア由来メタを上書きし、ローカル値 (name/active/scope/configData) を維持する', () => { + const store = usePluginsStore() + store.addPlugin( + makePlugin({ + installId: 'p1', + name: 'My Renamed', + active: false, + global: true, + storeId: 'ent-plugin', + config: { + greet: { type: 'string', label: 'greet', default: 'hi' }, + }, + configData: { greet: 'こんにちは' }, + }), + ) + store.applyStoreUpdate('p1', { + src: 'new src', + version: '2.0.0', + description: 'new desc', + config: { + greet: { type: 'string', label: 'greet', default: 'hi' }, + extra: { type: 'string', label: 'extra', default: 'def' }, + }, + iconUrl: 'https://example.com/icon.svg', + storeSha512: 'abc', + storeVersion: '2.0.0', + }) + const p = store.getPlugin('p1') + expect(p).toMatchObject({ + src: 'new src', + version: '2.0.0', + description: 'new desc', + iconUrl: 'https://example.com/icon.svg', + storeSha512: 'abc', + storeVersion: '2.0.0', + // ローカル値は維持 + name: 'My Renamed', + active: false, + global: true, + }) + // 既存の設定値は維持し、新キーのみデフォルト補完 + expect(p?.configData).toEqual({ greet: 'こんにちは', extra: 'def' }) + }) + + it('ソース欠損の readOnly 個体は検証済み配布ソースで復旧する', () => { + const store = usePluginsStore() + store.addPlugin(makePlugin({ installId: 'p1', readOnly: true, src: '' })) + store.applyStoreUpdate('p1', { + src: 'recovered', + version: '1.0.0', + storeSha512: 'abc', + storeVersion: '1.0.0', + }) + const p = store.getPlugin('p1') + expect(p?.src).toBe('recovered') + expect(p?.readOnly).toBeFalsy() + }) +}) diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts index b1695f357..3a6b6d120 100644 --- a/src/stores/plugins.ts +++ b/src/stores/plugins.ts @@ -110,6 +110,10 @@ export interface PluginMeta extends SidecarItemFile { installedFor?: string[] /** misstore 由来の追跡 ID (将来の自動更新用) */ storeId?: string + /** インストール/更新時に照合済みの配布ソース SHA-512 (#913。更新検知 #1040 の baseline) */ + storeSha512?: string + /** インストール/更新時の registry バージョン (#913) */ + storeVersion?: string /** 個別アイコン URL (MisStore registry の iconUrl 互換) */ iconUrl?: string } @@ -156,6 +160,8 @@ interface PluginFileMeta { global?: boolean installedFor?: string[] storeId?: string + storeSha512?: string + storeVersion?: string iconUrl?: string } @@ -174,6 +180,8 @@ const pluginFiles = createSidecarCollection({ idOf: (p) => p.installId, nameOf: (p) => p.name, srcOf: (p) => p.src, + // ストアインストールはファイル名 = storeId (#913。占有時は連番 suffix) + preferredBase: (p) => p.storeId, mirrorSrcById: (id) => loadPluginsFromStorage().find((p) => p.installId === id)?.src, toFileMeta: (p) => ({ @@ -189,6 +197,8 @@ const pluginFiles = createSidecarCollection({ ...(p.global ? { global: true } : {}), ...(p.installedFor?.length ? { installedFor: p.installedFor } : {}), ...(p.storeId ? { storeId: p.storeId } : {}), + ...(p.storeSha512 ? { storeSha512: p.storeSha512 } : {}), + ...(p.storeVersion ? { storeVersion: p.storeVersion } : {}), ...(p.iconUrl ? { iconUrl: p.iconUrl } : {}), }), fromFile: (meta, src, metaFile) => ({ @@ -205,6 +215,8 @@ const pluginFiles = createSidecarCollection({ global: meta.global, installedFor: meta.installedFor, storeId: meta.storeId, + storeSha512: meta.storeSha512, + storeVersion: meta.storeVersion, iconUrl: meta.iconUrl, }), }) @@ -268,8 +280,14 @@ export const usePluginsStore = defineStore('plugins', () => { void ready .then(async () => { if (plugin) { - adoptMirrorFileBase(plugin) - await pluginFiles.persistItem(plugin, plugins.value) + // ref の深い reactivity で plugins.value の要素は proxy になるため、 + // 占有判定の「操作対象自身は占有とみなさない」参照一致が崩れない + // よう live 要素 (proxy) を渡す + const live = + plugins.value.find((p) => p.installId === plugin.installId) ?? + plugin + adoptMirrorFileBase(live) + await pluginFiles.persistItem(live, plugins.value) } else { await pluginFiles.persistAll(plugins.value, plugins.value) } @@ -583,6 +601,57 @@ export const usePluginsStore = defineStore('plugins', () => { } } + /** + * ストア再インストール (#913): 本体 (src) とストア由来メタを上書き更新する。 + * ローカル値 (name の改名・active・スコープ・configData の設定値) は維持し、 + * 新しい config キーのみデフォルト値を補完する。ソース欠損の readOnly 個体は + * 検証済み配布ソースで復旧する (persist 抑止を解除)。 + */ + function applyStoreUpdate( + installId: string, + patch: { + src: string + version: string + author?: string + description?: string + permissions?: string[] + config?: Record + iconUrl?: string + storeSha512: string + storeVersion: string + }, + ): void { + ensureLoaded() + const plugin = plugins.value.find((p) => p.installId === installId) + if (!plugin) return + // 編集前 src を history sidecar に push (updateSrc と同じ undo リング) + if (plugin.fileBase && !plugin.readOnly) { + pushSnapshot('plugin', plugin.fileBase, { + src: plugin.src, + name: plugin.name, + version: plugin.version, + permissions: plugin.permissions, + active: plugin.active, + }).catch((e) => console.warn('[plugins] history push failed:', e)) + } + plugin.src = patch.src + plugin.version = patch.version + plugin.author = patch.author + plugin.description = patch.description + plugin.permissions = patch.permissions + plugin.config = patch.config + plugin.iconUrl = patch.iconUrl + plugin.storeSha512 = patch.storeSha512 + plugin.storeVersion = patch.storeVersion + if (patch.config) { + for (const [key, def] of Object.entries(patch.config)) { + if (!(key in plugin.configData)) plugin.configData[key] = def.default + } + } + plugin.readOnly = undefined + persist(plugin) + } + function renamePlugin(installId: string, newName: string) { ensureLoaded() const plugin = plugins.value.find((p) => p.installId === installId) @@ -626,6 +695,7 @@ export const usePluginsStore = defineStore('plugins', () => { linkScope, unlinkScope, migrateScopes, + applyStoreUpdate, renamePlugin, setActive, updateConfigData, diff --git a/src/stores/skills.ts b/src/stores/skills.ts index a53ba17d6..76d142904 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -40,6 +40,10 @@ export interface SkillMeta { scope: SkillScope installedFor?: string[] storeId?: string + /** インストール/更新時に照合済みの配布ソース SHA-512 (#913。更新検知 #1040 の baseline) */ + storeSha512?: string + /** インストール/更新時の registry バージョン (#913) */ + storeVersion?: string /** Markdown 本文 (frontmatter を除いた指示文) */ body: string createdAt: number @@ -103,6 +107,8 @@ interface SkillFrontmatter { scope?: string installedFor?: string[] storeId?: string + storeSha512?: string + storeVersion?: string builtIn?: boolean createdAt?: number updatedAt?: number @@ -150,6 +156,8 @@ function frontmatterFromMeta(skill: SkillMeta): Record { out.installedFor = skill.installedFor } if (skill.storeId) out.storeId = skill.storeId + if (skill.storeSha512) out.storeSha512 = skill.storeSha512 + if (skill.storeVersion) out.storeVersion = skill.storeVersion if (skill.builtIn) out.builtIn = true if (skill.iconUrl) out.iconUrl = skill.iconUrl if (skill.cheapCheckCapabilities && skill.cheapCheckCapabilities.length > 0) { @@ -187,6 +195,8 @@ function metaFromFrontmatter( installedFor: scope === 'per-account' ? asArray(fm.installedFor) : undefined, storeId: fm.storeId, + storeSha512: fm.storeSha512, + storeVersion: fm.storeVersion, body, createdAt: fm.createdAt ?? now, updatedAt: fm.updatedAt ?? now, @@ -223,9 +233,10 @@ const skillFiles = createSingleFileCollection({ logTag: 'skills', kindFallback: 'skill', ext: settingsFs.SKILL_EXT, + // ストアインストールはファイル名 = storeId (#913。占有時は連番 suffix)。 // builtin seed はテンプレ id (slug 適合) をそのままファイル名にする // (表示名 slug だと `notedeck.md` / `notedeck-2.md` に化けて意味が消える) - preferredBase: (s) => (s.builtIn ? s.id : undefined), + preferredBase: (s) => s.storeId ?? (s.builtIn ? s.id : undefined), // 占有判定・sweep には .history.json5 を含む実列挙が要る // (規定拡張子の filter はコレクション側が行う) list: () => settingsFs.listSkillDirFiles(), @@ -362,7 +373,12 @@ export const useSkillsStore = defineStore('skills', () => { /** ファイルへの直接反映 (初期化・seed 用。通常経路は ready ゲート越し)。 */ async function persist(skill: SkillMeta): Promise { if (!settingsFs.isTauri) return - await skillFiles.persistItem(skill, skills.value) + // ref の深い reactivity で skills.value の要素は proxy になるため、 + // 占有判定の「操作対象自身は占有とみなさない」参照一致が崩れないよう + // live 要素 (proxy) を渡す (raw を渡すと自分の ID/fileBase が占有扱いに + // なり preferredBase = id/storeId のファイル名へ無意味な suffix が付く) + const live = skills.value.find((s) => s.id === skill.id) ?? skill + await skillFiles.persistItem(live, skills.value) } async function initFileStorage(): Promise { @@ -504,10 +520,11 @@ export const useSkillsStore = defineStore('skills', () => { const skill: SkillMeta = { ...input, createdAt: now, updatedAt: now } skills.value = [...skills.value, skill] if (settingsFs.isTauri) { - // 新規作成の fileBase は表示名 slug から persistItem が割り当てる - // (ファイル名は ID からでなく対応表から #913)。ready 待ちで移行と直列化 + // 新規作成の fileBase は persistItem が割り当てる (storeId 優先 → + // 表示名 slug。ファイル名は ID からでなく対応表から #913)。 + // ready 待ちで移行と直列化 void ready - .then(() => skillFiles.persistItem(skill, skills.value)) + .then(() => persist(skill)) .catch((e) => console.warn('[skills] failed to persist new skill:', e)) } return skill diff --git a/src/stores/skillsFileSync.test.ts b/src/stores/skillsFileSync.test.ts index 54e51167c..ab09d5e45 100644 --- a/src/stores/skillsFileSync.test.ts +++ b/src/stores/skillsFileSync.test.ts @@ -181,4 +181,32 @@ describe('useSkillsStore — ファイル対応表配線 (#913)', () => { expect(store.get('dup')?.fileBase).toBe('a') expect(files.has('b.md')).toBe(true) }) + + it('storeId 付きの新規 (ストアインストール) は storeId をファイル名にする (#913)', async () => { + files.set('seed.md', skillFile('seed', 'Seed')) + const store = await initStore() + store.add({ + ...makeSkill('ent-skill', '日本語の表示名'), + storeId: 'ent-skill', + }) + await vi.waitFor(() => { + expect(files.has('ent-skill.md')).toBe(true) + }) + }) + + it('storeSha512 / storeVersion は frontmatter に永続化され読み戻せる (#913)', async () => { + files.set( + 'greeter.md', + '---\nid: g1\nname: Greeter\nversion: 1.0.0\nmode: manual\nscope: global\nstoreId: ent\nstoreSha512: abc123\nstoreVersion: 1.0.0\ncreatedAt: 1\nupdatedAt: 1\n---\nbody', + ) + const store = await initStore() + expect(store.get('g1')?.storeSha512).toBe('abc123') + expect(store.get('g1')?.storeVersion).toBe('1.0.0') + store.update('g1', { body: 'new body' }) + await vi.waitFor(() => { + expect(files.get('greeter.md')).toContain('new body') + }) + expect(files.get('greeter.md')).toContain('storeSha512: abc123') + expect(files.get('greeter.md')).toContain('storeVersion: 1.0.0') + }) }) diff --git a/src/stores/widgets.test.ts b/src/stores/widgets.test.ts index e1de64606..ddbb2094d 100644 --- a/src/stores/widgets.test.ts +++ b/src/stores/widgets.test.ts @@ -93,3 +93,59 @@ describe('useWidgetsStore.removeWidget (undo)', () => { expect(localStorage.getItem('nd-aiscript-app-w1:key')).toBe('"v"') }) }) + +describe('applyStoreUpdate (#913 ストア再インストール)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('src とストア由来メタを上書きし name / autoRun は維持する', () => { + const store = useWidgetsStore() + store.addWidget({ + ...makeWidget('w1', 'My Renamed'), + autoRun: true, + storeId: 'ent-widget', + iconUrl: 'https://example.com/old.svg', + }) + const updated = store.applyStoreUpdate('w1', { + src: 'new src', + iconUrl: 'https://example.com/new.svg', + storeSha512: 'abc', + storeVersion: '2.0.0', + }) + expect(updated).toBe(store.getWidget('w1')) + expect(store.getWidget('w1')).toMatchObject({ + src: 'new src', + iconUrl: 'https://example.com/new.svg', + storeSha512: 'abc', + storeVersion: '2.0.0', + // ローカル値は維持 + name: 'My Renamed', + autoRun: true, + }) + }) + + it('ソース欠損の readOnly 個体は検証済み配布ソースで復旧する', () => { + const store = useWidgetsStore() + store.addWidget({ ...makeWidget('w1'), readOnly: true, src: '' }) + store.applyStoreUpdate('w1', { + src: 'recovered', + storeSha512: 'abc', + storeVersion: '1.0.0', + }) + expect(store.getWidget('w1')?.src).toBe('recovered') + expect(store.getWidget('w1')?.readOnly).toBeFalsy() + }) + + it('未知の installId には undefined を返す', () => { + const store = useWidgetsStore() + expect( + store.applyStoreUpdate('nope', { + src: 's', + storeSha512: 'a', + storeVersion: '1', + }), + ).toBeUndefined() + }) +}) diff --git a/src/stores/widgets.ts b/src/stores/widgets.ts index 6f2ba4eaf..bf1e6b76c 100644 --- a/src/stores/widgets.ts +++ b/src/stores/widgets.ts @@ -22,6 +22,10 @@ export interface WidgetMeta extends SidecarItemFile { src: string autoRun: boolean storeId?: string + /** インストール/更新時に照合済みの配布ソース SHA-512 (#913。更新検知 #1040 の baseline) */ + storeSha512?: string + /** インストール/更新時の registry バージョン (#913) */ + storeVersion?: string createdAt: number updatedAt: number /** 個別アイコン URL (MisStore registry の iconUrl 互換) */ @@ -34,6 +38,8 @@ interface WidgetFileMeta { name: string autoRun: boolean storeId?: string + storeSha512?: string + storeVersion?: string createdAt: number updatedAt: number iconUrl?: string @@ -54,6 +60,8 @@ const widgetFiles = createSidecarCollection({ idOf: (w) => w.installId, nameOf: (w) => w.name, srcOf: (w) => w.src, + // ストアインストールはファイル名 = storeId (#913。占有時は連番 suffix) + preferredBase: (w) => w.storeId, mirrorSrcById: (id) => loadWidgetsFromStorage().find((w) => w.installId === id)?.src, toFileMeta: (w) => ({ @@ -61,6 +69,8 @@ const widgetFiles = createSidecarCollection({ name: w.name, autoRun: w.autoRun, ...(w.storeId ? { storeId: w.storeId } : {}), + ...(w.storeSha512 ? { storeSha512: w.storeSha512 } : {}), + ...(w.storeVersion ? { storeVersion: w.storeVersion } : {}), ...(w.iconUrl ? { iconUrl: w.iconUrl } : {}), createdAt: w.createdAt, updatedAt: w.updatedAt, @@ -71,6 +81,8 @@ const widgetFiles = createSidecarCollection({ src, autoRun: meta.autoRun ?? false, storeId: meta.storeId, + storeSha512: meta.storeSha512, + storeVersion: meta.storeVersion, iconUrl: meta.iconUrl, createdAt: meta.createdAt ?? Date.now(), updatedAt: meta.updatedAt ?? Date.now(), @@ -154,8 +166,14 @@ export const useWidgetsStore = defineStore('widgets', () => { void ready .then(async () => { if (widget) { - adoptMirrorFileBase(widget) - await widgetFiles.persistItem(widget, widgets.value) + // ref の深い reactivity で widgets.value の要素は proxy になるため、 + // 占有判定の「操作対象自身は占有とみなさない」参照一致が崩れない + // よう live 要素 (proxy) を渡す + const live = + widgets.value.find((w) => w.installId === widget.installId) ?? + widget + adoptMirrorFileBase(live) + await widgetFiles.persistItem(live, widgets.value) } else { await widgetFiles.persistAll(widgets.value, widgets.value) } @@ -373,6 +391,41 @@ export const useWidgetsStore = defineStore('widgets', () => { } } + /** + * ストア再インストール (#913): 本体 (src) とストア由来メタを上書き更新する。 + * ローカル値 (name の改名・autoRun) は維持。ソース欠損の readOnly 個体は + * 検証済み配布ソースで復旧する (persist 抑止を解除)。 + */ + function applyStoreUpdate( + installId: string, + patch: { + src: string + iconUrl?: string + storeSha512: string + storeVersion: string + }, + ): WidgetMeta | undefined { + ensureLoaded() + const widget = widgets.value.find((w) => w.installId === installId) + if (!widget) return undefined + // 編集前 src を history sidecar に push (updateSrc と同じ undo リング) + if (widget.fileBase && !widget.readOnly) { + pushSnapshot('widget', widget.fileBase, { + src: widget.src, + name: widget.name, + autoRun: widget.autoRun, + }).catch((e) => console.warn('[widgets] history push failed:', e)) + } + widget.src = patch.src + widget.iconUrl = patch.iconUrl + widget.storeSha512 = patch.storeSha512 + widget.storeVersion = patch.storeVersion + widget.updatedAt = Date.now() + widget.readOnly = undefined + persist(widget) + return widget + } + function setStoreId(installId: string, storeId: string | undefined) { ensureLoaded() const widget = widgets.value.find((w) => w.installId === installId) @@ -418,6 +471,7 @@ export const useWidgetsStore = defineStore('widgets', () => { removeWidget, updateSrc, setAutoRun, + applyStoreUpdate, setStoreId, renameWidget, getWidget, diff --git a/src/theme/types.ts b/src/theme/types.ts index 236cce500..337c6cb90 100644 --- a/src/theme/types.ts +++ b/src/theme/types.ts @@ -6,6 +6,10 @@ export interface NotedeckThemeMeta { /** misstore からインストールされた場合の追跡 ID (将来の自動更新用) */ storeId?: string + /** インストール/更新時に照合済みの配布ソース SHA-512 (#913。更新検知 #1040 の baseline) */ + storeSha512?: string + /** インストール/更新時の registry バージョン (#913) */ + storeVersion?: string /** どの account の per-account テーマカラム「ストアのテーマ」セクションに * 表示するか。空 / undefined なら誰も使わない (Global ローカルセクション * にのみ出る)。複数 account に紐付けられる (重複インストール不要)。 */ From 6ab584da532553c134fc52114a27d26abcbfd0a2 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:28:05 +0900 Subject: [PATCH 14/30] =?UTF-8?q?feat(misstore):=20=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB=E7=B5=8C=E8=B7=AF=E3=81=AE?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=80=A7=E3=82=92=20storeId=20=E7=85=A7?= =?UTF-8?q?=E5=90=88=E3=81=AB=E7=B5=B1=E4=B8=80=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存判定・更新判定の照合キーを全種別で storeId のみに統一し、 「storeId 一致の既存あり → 上書き更新 / なし → 新規作成」に揃える: - installSkill: frontmatter の id 宣言優先を廃止。新規のローカル ID = storeId (レジストリのディレクトリ名が正)、既存への更新はローカル ID 維持。storeId 不一致の既存 ID に占有されていたら上書きせず連番 suffix - installTheme: t.id === entry.id フォールバックを廃止。更新は配布内 UUID が変わってもローカル ID・ローカル改名を維持して既存を置換 (既存 fileBase のファイルへ書く) - installPlugin: isDuplicate(name) の名前重複拒否を廃止 (同名自作が あってもインストール可能 — ファイル名は suffix が捌く)。linkScope-only も廃止し、本体とストア由来メタを上書き + scope 追加 - installWidget: 既存ありの early return を廃止し上書き更新に - installQuery: 更新で name を渡さない (ローカル改名維持) - isInstalled / isThemeInstalled / isSkillInstalled: name・内部 ID 照合の残りを全廃 (deep link の同定もこの経路で storeId に統一) - 全種別でインストール/更新時に storeSha512 (computeSha512 で照合済みの 値) と storeVersion を記録 更新で維持されるローカル値: 有効/無効・実行モード・スコープ・設定値・ autoRun・ローカル改名 (name は新規時のみ entry 値を採用) Co-Authored-By: Claude Fable 5 --- src/stores/misstore.test.ts | 255 ++++++++++++++++++++++++++++++------ src/stores/misstore.ts | 136 +++++++++++++------ 2 files changed, 312 insertions(+), 79 deletions(-) diff --git a/src/stores/misstore.test.ts b/src/stores/misstore.test.ts index 4f09c8766..f0ec7d399 100644 --- a/src/stores/misstore.test.ts +++ b/src/stores/misstore.test.ts @@ -8,19 +8,25 @@ import type { WidgetMeta } from '@/stores/widgets' const h = vi.hoisted(() => ({ skillsStore: { skills: [] as unknown[], - get: vi.fn(), add: vi.fn(), update: vi.fn(), }, pluginsStore: { plugins: [] as unknown[], linkScope: vi.fn(), - isDuplicate: vi.fn(() => false), addPlugin: vi.fn(), + applyStoreUpdate: vi.fn(), }, widgetsStore: { widgets: [] as unknown[], addWidget: vi.fn(), + applyStoreUpdate: vi.fn(), + }, + queriesStore: { + queries: [] as unknown[], + ensureLoaded: vi.fn(), + createQuery: vi.fn(async () => undefined), + applyStoreUpdate: vi.fn(async () => undefined), }, themeStore: { installedThemes: [] as unknown[], @@ -44,6 +50,9 @@ vi.mock('@/stores/widgets', () => ({ useWidgetsStore: () => h.widgetsStore, generateWidgetId: () => 'w-test-1', })) +vi.mock('@/stores/columnQueries', () => ({ + useColumnQueriesStore: () => h.queriesStore, +})) vi.mock('@/stores/theme', () => ({ useThemeStore: () => h.themeStore, })) @@ -51,6 +60,7 @@ vi.mock('@/stores/theme', () => ({ import { getPluginDetailUrl, type StorePluginEntry, + type StoreQueryEntry, type StoreSkillEntry, type StoreThemeEntry, type StoreWidgetEntry, @@ -127,16 +137,25 @@ function skillEntry(over: Partial = {}): StoreSkillEntry { } } +function queryEntry(over: Partial = {}): StoreQueryEntry { + return { + ...baseEntry, + id: 'ent-query', + name: 'Test Query', + category: 'hide', + ...over, + } +} + const fetchMock = vi.fn() beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() h.skillsStore.skills = [] - h.skillsStore.get.mockReturnValue(undefined) h.pluginsStore.plugins = [] - h.pluginsStore.isDuplicate.mockReturnValue(false) h.widgetsStore.widgets = [] + h.queriesStore.queries = [] h.themeStore.installedThemes = [] h.themeStore.installTheme.mockResolvedValue(true) vi.stubGlobal('fetch', fetchMock) @@ -255,10 +274,33 @@ describe('installSkill', () => { storeId: 'ent-skill', body: 'Greet the user', builtIn: false, + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', }) expect(store.installingSkill).toBeNull() }) + it('新規は frontmatter の id 宣言より storeId を正としてローカル ID にする (#913)', async () => { + const store = useMisStoreStore() + const declared = '---\nid: distributed-own-id\nname: Greeter\n---\nbody' + fetchMock.mockResolvedValue(okText(declared)) + await store.installSkill(skillEntry({ sha512: sha512Hex(declared) })) + const added = h.skillsStore.add.mock.calls[0]?.[0] as SkillMeta + expect(added.id).toBe('ent-skill') + expect(added.storeId).toBe('ent-skill') + }) + + it('storeId 不一致の既存 ID が storeId を占有していたら suffix で回避する (#913)', async () => { + const store = useMisStoreStore() + h.skillsStore.skills = [{ id: 'ent-skill', name: '自作' }] + fetchMock.mockResolvedValue(okText(source)) + await store.installSkill(skillEntry({ sha512: sha512Hex(source) })) + expect(h.skillsStore.update).not.toHaveBeenCalled() + const added = h.skillsStore.add.mock.calls[0]?.[0] as SkillMeta + expect(added.id).toBe('ent-skill-2') + expect(added.storeId).toBe('ent-skill') + }) + it('falls back to mode=manual and entry metadata when frontmatter is absent', async () => { const store = useMisStoreStore() const bare = 'just a body' @@ -270,16 +312,38 @@ describe('installSkill', () => { expect(added.body).toBe(bare) }) - it('updates an existing skill and preserves createdAt', async () => { + it('既存は storeId で照合しローカル ID・name・mode を維持して上書き更新する (#913)', async () => { const store = useMisStoreStore() - h.skillsStore.get.mockReturnValue({ createdAt: 111 }) + h.skillsStore.skills = [ + { + id: 'local-1', + storeId: 'ent-skill', + name: 'My Renamed', + mode: 'heartbeat', + }, + ] fetchMock.mockResolvedValue(okText(source)) await store.installSkill(skillEntry({ sha512: sha512Hex(source) })) expect(h.skillsStore.add).not.toHaveBeenCalled() - expect(h.skillsStore.update).toHaveBeenCalledWith( - 'ent-skill', - expect.objectContaining({ createdAt: 111 }), - ) + expect(h.skillsStore.update).toHaveBeenCalledTimes(1) + const [id, patch] = h.skillsStore.update.mock.calls[0] as [ + string, + Partial, + ] + expect(id).toBe('local-1') + expect(patch).toMatchObject({ + body: 'Greet the user', + triggers: ['hi', 'hello'], + storeId: 'ent-skill', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }) + // ローカル値 (改名・実行モード・スコープ・ID) は更新で上書きしない + expect(patch).not.toHaveProperty('name') + expect(patch).not.toHaveProperty('mode') + expect(patch).not.toHaveProperty('scope') + expect(patch).not.toHaveProperty('installedFor') + expect(patch).not.toHaveProperty('id') }) it('keeps mode: heartbeat instead of falling back to manual (#967)', async () => { @@ -318,17 +382,68 @@ describe('installSkill', () => { expect(store.installingSkill).toBeNull() }) - it('isSkillInstalled matches by storeId or id', () => { + it('isSkillInstalled は storeId のみで照合する (内部 ID 一致では真にならない #913)', () => { const store = useMisStoreStore() h.skillsStore.skills = [{ id: 'local-id', storeId: 'ent-skill' }] expect(store.isSkillInstalled(skillEntry())).toBe(true) h.skillsStore.skills = [{ id: 'ent-skill' }] - expect(store.isSkillInstalled(skillEntry())).toBe(true) + expect(store.isSkillInstalled(skillEntry())).toBe(false) h.skillsStore.skills = [{ id: 'other' }] expect(store.isSkillInstalled(skillEntry())).toBe(false) }) }) +describe('installQuery', () => { + const source = 'note.text != null' + + it('新規は createQuery に storeId と storeSha512 / storeVersion を渡す (#913)', async () => { + const store = useMisStoreStore() + fetchMock.mockResolvedValue(okText(source)) + await store.installQuery(queryEntry({ sha512: sha512Hex(source) })) + expect(h.queriesStore.createQuery).toHaveBeenCalledTimes(1) + expect(h.queriesStore.createQuery).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Test Query', + description: 'desc', + src: source, + storeId: 'ent-query', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }), + ) + expect(store.installingQuery).toBeNull() + }) + + it('既存 (storeId 一致) は applyStoreUpdate で更新し name を渡さない (#913)', async () => { + const store = useMisStoreStore() + h.queriesStore.queries = [ + { id: 'q-local', storeId: 'ent-query', name: 'My Renamed' }, + ] + fetchMock.mockResolvedValue(okText(source)) + await store.installQuery(queryEntry({ sha512: sha512Hex(source) })) + expect(h.queriesStore.createQuery).not.toHaveBeenCalled() + expect(h.queriesStore.applyStoreUpdate).toHaveBeenCalledTimes(1) + const [id, patch] = h.queriesStore.applyStoreUpdate.mock + .calls[0] as unknown as [string, Record] + expect(id).toBe('q-local') + expect(patch).toMatchObject({ + src: source, + description: 'desc', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }) + expect(patch).not.toHaveProperty('name') + }) + + it('isQueryInstalled は storeId で照合する', () => { + const store = useMisStoreStore() + h.queriesStore.queries = [{ id: 'q1', storeId: 'ent-query' }] + expect(store.isQueryInstalled(queryEntry())).toBe(true) + h.queriesStore.queries = [{ id: 'ent-query' }] + expect(store.isQueryInstalled(queryEntry())).toBe(false) + }) +}) + describe('installPlugin', () => { const source = '/* plugin source */' const meta = { @@ -340,15 +455,29 @@ describe('installPlugin', () => { config: { greet: { type: 'string', default: 'hi' } }, } - it('only links the scope when the same storeId is already installed', async () => { + it('既存 (storeId 一致) は本体とストア由来メタを上書き更新して scope を追加する (#913)', async () => { const store = useMisStoreStore() h.pluginsStore.plugins = [{ installId: 'p1', storeId: 'ent-plugin' }] - await store.installPlugin(pluginEntry(), { kind: 'global' }) + fetchMock.mockResolvedValue(okText(source)) + h.parsePluginMeta.mockReturnValue(meta) + await store.installPlugin(pluginEntry({ sha512: sha512Hex(source) }), { + kind: 'global', + }) + expect(h.pluginsStore.applyStoreUpdate).toHaveBeenCalledWith( + 'p1', + expect.objectContaining({ + src: source, + version: '1.0.0', + config: meta.config, + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }), + ) expect(h.pluginsStore.linkScope).toHaveBeenCalledWith('p1', { kind: 'global', }) - expect(fetchMock).not.toHaveBeenCalled() expect(h.pluginsStore.addPlugin).not.toHaveBeenCalled() + expect(store.installing).toBeNull() }) it('installs a new plugin with default configData and launches it (global scope)', async () => { @@ -367,12 +496,25 @@ describe('installPlugin', () => { storeId: 'ent-plugin', global: true, configData: { greet: 'hi' }, + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', }) expect(added.installedFor).toBeUndefined() expect(h.launchPlugin).toHaveBeenCalledWith(added) expect(store.installing).toBeNull() }) + it('同名の自作プラグインがあってもストア経路のインストールは拒否しない (#913)', async () => { + const store = useMisStoreStore() + h.pluginsStore.plugins = [{ installId: 'p9', name: 'MyPlugin' }] + fetchMock.mockResolvedValue(okText(source)) + h.parsePluginMeta.mockReturnValue(meta) + await store.installPlugin(pluginEntry({ sha512: sha512Hex(source) }), { + kind: 'global', + }) + expect(h.pluginsStore.addPlugin).toHaveBeenCalledTimes(1) + }) + it('installs into installedFor for an account scope', async () => { const store = useMisStoreStore() fetchMock.mockResolvedValue(okText(source)) @@ -386,19 +528,6 @@ describe('installPlugin', () => { expect(added.global).toBeUndefined() }) - it('rejects when the plugin name is already installed', async () => { - const store = useMisStoreStore() - fetchMock.mockResolvedValue(okText(source)) - h.parsePluginMeta.mockReturnValue(meta) - h.pluginsStore.isDuplicate.mockReturnValue(true) - await expect( - store.installPlugin(pluginEntry({ sha512: sha512Hex(source) }), { - kind: 'global', - }), - ).rejects.toThrow(/既にインストールされています/) - expect(store.installing).toBeNull() - }) - it('rejects when plugin metadata cannot be parsed', async () => { const store = useMisStoreStore() fetchMock.mockResolvedValue(okText(source)) @@ -410,22 +539,37 @@ describe('installPlugin', () => { ).rejects.toThrow(/メタデータの解析に失敗/) }) - it('isInstalled matches by plugin name', () => { + it('isInstalled は storeId のみで照合する (名前一致では真にならない #913)', () => { const store = useMisStoreStore() - h.pluginsStore.plugins = [{ name: 'Test Plugin' }] + h.pluginsStore.plugins = [{ installId: 'p1', storeId: 'ent-plugin' }] expect(store.isInstalled(pluginEntry())).toBe(true) - expect(store.isInstalled(pluginEntry({ name: 'Other' }))).toBe(false) + h.pluginsStore.plugins = [{ installId: 'p1', name: 'Test Plugin' }] + expect(store.isInstalled(pluginEntry())).toBe(false) }) }) describe('installWidget', () => { - it('returns the existing widget without refetching', async () => { + it('既存 (storeId 一致) は early return せず本体とメタを上書き更新する (#913)', async () => { const store = useMisStoreStore() + const source = '<: "widget v2"' const existing = { installId: 'w0', storeId: 'ent-widget' } as WidgetMeta + const updated = { ...existing, src: source } as WidgetMeta h.widgetsStore.widgets = [existing] - await expect(store.installWidget(widgetEntry())).resolves.toBe(existing) - expect(fetchMock).not.toHaveBeenCalled() + h.widgetsStore.applyStoreUpdate.mockReturnValue(updated) + fetchMock.mockResolvedValue(okText(source)) + await expect( + store.installWidget(widgetEntry({ sha512: sha512Hex(source) })), + ).resolves.toBe(updated) + expect(h.widgetsStore.applyStoreUpdate).toHaveBeenCalledWith( + 'w0', + expect.objectContaining({ + src: source, + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }), + ) expect(h.widgetsStore.addWidget).not.toHaveBeenCalled() + expect(store.installingWidget).toBeNull() }) it('installs a new widget from the verified source', async () => { @@ -441,6 +585,8 @@ describe('installWidget', () => { src: source, autoRun: true, storeId: 'ent-widget', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', }) expect(h.widgetsStore.addWidget).toHaveBeenCalledWith(widget) expect(store.installingWidget).toBeNull() @@ -457,10 +603,14 @@ describe('installWidget', () => { describe('installTheme', () => { const source = "{ id: 'ent-theme', props: { accent: '#f00' } }" - it('injects $notedeck.storeId and unions installedFor with the existing install', async () => { + it('injects $notedeck (storeId / storeSha512) and unions installedFor with the existing install', async () => { const store = useMisStoreStore() h.themeStore.installedThemes = [ - { id: 'ent-theme', $notedeck: { installedFor: ['acc1'] } }, + { + id: 'ent-theme', + name: 'Test Theme', + $notedeck: { storeId: 'ent-theme', installedFor: ['acc1'] }, + }, ] fetchMock.mockResolvedValue(okText(source)) await store.installTheme(themeEntry({ sha512: sha512Hex(source) }), [ @@ -473,17 +623,42 @@ describe('installTheme', () => { expect(parsed.id).toBe('ent-theme') expect(parsed.$notedeck).toEqual({ storeId: 'ent-theme', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', installedFor: ['acc1', 'acc2'], }) expect(store.installingTheme).toBeNull() }) + it('既存 (storeId 一致) は配布内 UUID が変わってもローカル ID と name を維持する (#913)', async () => { + const store = useMisStoreStore() + h.themeStore.installedThemes = [ + { + id: 'custom-999', + name: 'My Renamed', + $notedeck: { storeId: 'ent-theme' }, + }, + ] + const changed = "{ id: 'new-uuid', name: 'Distributed', props: { a: '1' } }" + fetchMock.mockResolvedValue(okText(changed)) + await store.installTheme(themeEntry({ sha512: sha512Hex(changed) })) + const json = h.themeStore.installTheme.mock.calls[0]?.[0] as string + const parsed = JSON.parse(json) + expect(parsed.id).toBe('custom-999') + expect(parsed.name).toBe('My Renamed') + expect(parsed.$notedeck.storeId).toBe('ent-theme') + }) + it('omits installedFor when no account context is given', async () => { const store = useMisStoreStore() fetchMock.mockResolvedValue(okText(source)) await store.installTheme(themeEntry({ sha512: sha512Hex(source) })) const json = h.themeStore.installTheme.mock.calls[0]?.[0] as string - expect(JSON.parse(json).$notedeck).toEqual({ storeId: 'ent-theme' }) + expect(JSON.parse(json).$notedeck).toEqual({ + storeId: 'ent-theme', + storeSha512: sha512Hex(source), + storeVersion: '1.0.0', + }) }) it('rejects when the theme store reports failure', async () => { @@ -496,14 +671,14 @@ describe('installTheme', () => { expect(store.installingTheme).toBeNull() }) - it('isThemeInstalled matches by id or $notedeck.storeId', () => { + it('isThemeInstalled は $notedeck.storeId のみで照合する (内部 ID 一致では真にならない #913)', () => { const store = useMisStoreStore() - h.themeStore.installedThemes = [{ id: 'ent-theme' }] - expect(store.isThemeInstalled(themeEntry())).toBe(true) h.themeStore.installedThemes = [ { id: 'custom-123', $notedeck: { storeId: 'ent-theme' } }, ] expect(store.isThemeInstalled(themeEntry())).toBe(true) + h.themeStore.installedThemes = [{ id: 'ent-theme' }] + expect(store.isThemeInstalled(themeEntry())).toBe(false) h.themeStore.installedThemes = [{ id: 'other' }] expect(store.isThemeInstalled(themeEntry())).toBe(false) }) diff --git a/src/stores/misstore.ts b/src/stores/misstore.ts index b2986b89c..dc2c9633e 100644 --- a/src/stores/misstore.ts +++ b/src/stores/misstore.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, shallowRef } from 'vue' import { launchPlugin, parsePluginMeta } from '@/aiscript/plugin-api' +import { casefold, resolveAvailable } from '@/services/settingsSlug' import { useColumnQueriesStore } from '@/stores/columnQueries' import { type PluginMeta, @@ -353,7 +354,9 @@ export const useMisStoreStore = defineStore('misstore', () => { /** * MisStore からスキル (.md + frontmatter) を取得して skills/ に保存する。 - * 既存の同 storeId/同 id は上書き更新 (再インストール = アップデート)。 + * 照合キーは storeId のみ (#913 — 表示名・frontmatter の id 宣言は使わない)。 + * 既存の同 storeId は上書き更新 (再インストール = アップデート)。 + * ローカル値 (改名 name / 実行モード mode / スコープ) は更新で維持する。 * インストール直後に有効化はしない (mode=always のスキルは常時有効)。 */ async function installSkill(entry: StoreSkillEntry): Promise { @@ -372,9 +375,38 @@ export const useMisStoreStore = defineStore('misstore', () => { const { meta, body } = parseSkillFile(source) const skillsStore = useSkillsStore() - const id = (meta.id as string | undefined) || entry.id - const existing = skillsStore.get(id) + const existing = skillsStore.skills.find((s) => s.storeId === entry.id) const now = Date.now() + + if (existing) { + // 上書き更新: 本体とストア由来メタのみ。ローカル ID・name・mode・ + // scope/installedFor・有効/無効 (activeIds) は維持する + skillsStore.update(existing.id, { + version: (meta.version as string | undefined) || entry.version, + description: + (meta.description as string | undefined) || entry.description, + author: (meta.author as string | undefined) || entry.author, + triggers: Array.isArray(meta.triggers) + ? (meta.triggers as string[]) + : [], + body, + iconUrl: (meta.iconUrl as string | undefined) || entry.iconUrl, + cheapCheckCapabilities: Array.isArray(meta.cheapCheckCapabilities) + ? (meta.cheapCheckCapabilities as string[]) + : [], + isPersona: meta.isPersona === true, + storeId: entry.id, + storeSha512: hash, + storeVersion: entry.version, + }) + return + } + + // 新規: ローカル ID = storeId (配布物内の ID 宣言よりレジストリの + // ディレクトリ名を正とする)。storeId 不一致の既存 ID に占有されて + // いたら上書きせず連番 suffix で回避する + const takenIds = new Set(skillsStore.skills.map((s) => casefold(s.id))) + const id = resolveAvailable(entry.id, (c) => takenIds.has(casefold(c))) const newSkill: SkillMeta = { id, name: (meta.name as string | undefined) || entry.name, @@ -397,8 +429,10 @@ export const useMisStoreStore = defineStore('misstore', () => { ? (meta.installedFor as string[]) : undefined, storeId: entry.id, + storeSha512: hash, + storeVersion: entry.version, body, - createdAt: existing?.createdAt ?? now, + createdAt: now, updatedAt: now, builtIn: false, iconUrl: (meta.iconUrl as string | undefined) || entry.iconUrl, @@ -407,22 +441,16 @@ export const useMisStoreStore = defineStore('misstore', () => { : [], isPersona: meta.isPersona === true, } - - if (existing) { - skillsStore.update(id, newSkill) - } else { - skillsStore.add(newSkill) - } + skillsStore.add(newSkill) } finally { installingSkill.value = null } } function isSkillInstalled(entry: StoreSkillEntry): boolean { + // 照合キーは storeId のみ (#913 — 内部 ID 一致では真にしない) const skillsStore = useSkillsStore() - return skillsStore.skills.some( - (s) => s.storeId === entry.id || s.id === entry.id, - ) + return skillsStore.skills.some((s) => s.storeId === entry.id) } // --- Queries (#783 カラムクエリ) --- @@ -473,10 +501,13 @@ export const useMisStoreStore = defineStore('misstore', () => { queriesStore.ensureLoaded() const existing = queriesStore.queries.find((q) => q.storeId === entry.id) if (existing) { - await queriesStore.updateQuery(existing.id, { - name: entry.name, - description: entry.description, + // 上書き更新: 本体とストア由来メタのみ。ローカル改名 (name) は維持 + await queriesStore.applyStoreUpdate(existing.id, { src: source, + description: entry.description, + iconUrl: entry.iconUrl, + storeSha512: hash, + storeVersion: entry.version, }) } else { await queriesStore.createQuery({ @@ -485,6 +516,8 @@ export const useMisStoreStore = defineStore('misstore', () => { src: source, storeId: entry.id, iconUrl: entry.iconUrl, + storeSha512: hash, + storeVersion: entry.version, }) } } finally { @@ -501,9 +534,11 @@ export const useMisStoreStore = defineStore('misstore', () => { // --- Install --- /** - * MisStore からプラグインをインストール / スコープ追加する (#771)。 - * - 同じ storeId の既存プラグインがあればライブラリ本体は再取得せず、 - * 指定 scope への参照を追加するだけ (重複インストールは避ける) + * MisStore からプラグインをインストール / 更新する (#771, #913)。 + * 照合キーは storeId のみ (同名の自作があってもインストール可能 — + * ファイル名は suffix が捌く)。 + * - 同じ storeId の既存プラグインがあれば本体とストア由来メタを上書き更新し、 + * 指定 scope への参照を追加する (active/configData/scope/name は維持) * - 無ければ新規追加 (指定 scope で有効化、storeId 紐付け) */ async function installPlugin( @@ -513,12 +548,7 @@ export const useMisStoreStore = defineStore('misstore', () => { installing.value = entry.id try { const pluginsStore = usePluginsStore() - // 既に同 storeId でインストール済みなら scope 参照を追加するだけ const existing = pluginsStore.plugins.find((p) => p.storeId === entry.id) - if (existing) { - pluginsStore.linkScope(existing.installId, scope) - return - } const res = await fetch(entry.sourceUrl) if (!res.ok) throw new Error(`HTTP ${res.status}`) @@ -536,8 +566,20 @@ export const useMisStoreStore = defineStore('misstore', () => { throw new Error('プラグインメタデータの解析に失敗しました') } - if (pluginsStore.isDuplicate(meta.name)) { - throw new Error(`"${meta.name}" は既にインストールされています`) + if (existing) { + pluginsStore.applyStoreUpdate(existing.installId, { + src: source, + version: meta.version, + author: meta.author, + description: meta.description, + permissions: meta.permissions, + config: meta.config, + iconUrl: entry.iconUrl, + storeSha512: hash, + storeVersion: entry.version, + }) + pluginsStore.linkScope(existing.installId, scope) + return } const configData: Record = {} @@ -559,6 +601,8 @@ export const useMisStoreStore = defineStore('misstore', () => { src: source, active: true, storeId: entry.id, + storeSha512: hash, + storeVersion: entry.version, ...(entry.iconUrl ? { iconUrl: entry.iconUrl } : {}), ...(scope.kind === 'global' ? { global: true } @@ -575,9 +619,9 @@ export const useMisStoreStore = defineStore('misstore', () => { // --- Install widget --- /** - * MisStore からウィジェットを取得して widgetsStore に追加する。 - * 同 storeId のウィジェットが既にあれば再インストールせず early return - * (UI 側の DeckWidgetColumn ライブラリインストール挙動と整合)。 + * MisStore からウィジェットを取得して widgetsStore に追加 / 更新する。 + * 照合キーは storeId のみ (#913)。同 storeId の既存があれば本体とストア由来 + * メタを上書き更新する (ローカル値 name/autoRun は維持)。 * * AI capability (`widgets.install`) はカラム文脈を持たないので、 * deckStore.addWidget ではなく widgetsStore.addWidget を直接呼んで @@ -589,9 +633,19 @@ export const useMisStoreStore = defineStore('misstore', () => { try { const widgetsStore = useWidgetsStore() const existing = widgetsStore.widgets.find((w) => w.storeId === entry.id) - if (existing) return existing + // fetchWidgetSource が sha512 照合済み → entry.sha512 = 検証済みの値 const src = await fetchWidgetSource(entry) + if (existing) { + const updated = widgetsStore.applyStoreUpdate(existing.installId, { + src, + iconUrl: entry.iconUrl, + storeSha512: entry.sha512, + storeVersion: entry.version, + }) + return updated ?? existing + } + const now = Date.now() const widget: WidgetMeta = { installId: generateWidgetId(), @@ -599,6 +653,8 @@ export const useMisStoreStore = defineStore('misstore', () => { src, autoRun: entry.autoRun, storeId: entry.id, + storeSha512: entry.sha512, + storeVersion: entry.version, createdAt: now, updatedAt: now, ...(entry.iconUrl ? { iconUrl: entry.iconUrl } : {}), @@ -643,10 +699,11 @@ export const useMisStoreStore = defineStore('misstore', () => { const JSON5 = (await import('json5')).default const parsed = JSON5.parse(source) - // 既存インストールがあれば installedFor を引き継ぎ、新規 ID と union + // 照合キーは storeId のみ (#913)。既存インストールがあれば + // installedFor を引き継ぎ、新規 ID と union const themeStore = useThemeStore() const existing = themeStore.installedThemes.find( - (t) => t.id === entry.id || t.$notedeck?.storeId === entry.id, + (t) => t.$notedeck?.storeId === entry.id, ) const installedForBase = existing?.$notedeck?.installedFor ?? [] const installedFor = Array.from( @@ -654,9 +711,14 @@ export const useMisStoreStore = defineStore('misstore', () => { ) const withMeta = { ...parsed, + // 更新は配布内 UUID が変わってもローカル ID・ローカル改名を維持する + // (themeStore.installTheme が同 ID を既存対応表のファイルへ書く) + ...(existing ? { id: existing.id, name: existing.name } : {}), $notedeck: { ...(parsed.$notedeck ?? {}), storeId: entry.id, + storeSha512: hash, + storeVersion: entry.version, ...(installedFor.length > 0 ? { installedFor } : {}), }, } @@ -671,21 +733,17 @@ export const useMisStoreStore = defineStore('misstore', () => { } // --- Installed check --- + // 照合キーは storeId のみ (#913)。表示名・ファイル内 ID では照合しない function isInstalled(entry: StorePluginEntry): boolean { const pluginsStore = usePluginsStore() - return pluginsStore.plugins.some((p) => p.name === entry.name) + return pluginsStore.plugins.some((p) => p.storeId === entry.id) } function isThemeInstalled(entry: StoreThemeEntry): boolean { - // id 比較が一次基準: MisStore は id ユニーク (ame / dark-amethyst 等) なので - // 同 id があれば確実にこの store entry のインストール済みコピー。 - // storeId 比較を fallback にするのは、テーマインストール時 themeStore は - // theme.id を尊重するが、parsed の id が衝突した時 themeStore 側で - // `custom-${Date.now()}` に置換される可能性があるため (theme.ts L236)。 const themeStore = useThemeStore() return themeStore.installedThemes.some( - (t) => t.id === entry.id || t.$notedeck?.storeId === entry.id, + (t) => t.$notedeck?.storeId === entry.id, ) } From 6f1a46671c6e87fbecf1c88e4ec4004a1cafd746 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:34:39 +0900 Subject: [PATCH 15/30] =?UTF-8?q?feat(misstore):=20=E6=96=B0=E8=A6=8F?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB=20ID=20=E3=82=92=20storeId?= =?UTF-8?q?=20=E3=81=AB=E7=B5=B1=E4=B8=80=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit widget/plugin/query も新規インストール時のローカル ID = storeId とする (仕様確定版の「スキル含む全種別」)。アンインストール → 再インストール でカラム参照 (widgetIds / noteQueryRefs) が生き残る。既存 ID との衝突 時のみ連番 suffix (skills と同じガード)。既存インストール品の ID は 凍結どおり不変。 Co-Authored-By: Claude Fable 5 --- src/stores/columnQueries.ts | 7 ++++++- src/stores/misstore.test.ts | 3 ++- src/stores/misstore.ts | 21 ++++++++++++++------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/stores/columnQueries.ts b/src/stores/columnQueries.ts index 91af6a318..97a228a31 100644 --- a/src/stores/columnQueries.ts +++ b/src/stores/columnQueries.ts @@ -211,7 +211,12 @@ export const useColumnQueriesStore = defineStore('columnQueries', () => { Partial< Pick< NamedQueryMeta, - 'description' | 'storeId' | 'iconUrl' | 'storeSha512' | 'storeVersion' + | 'id' + | 'description' + | 'storeId' + | 'iconUrl' + | 'storeSha512' + | 'storeVersion' > >, ): Promise { diff --git a/src/stores/misstore.test.ts b/src/stores/misstore.test.ts index f0ec7d399..5b883bf8c 100644 --- a/src/stores/misstore.test.ts +++ b/src/stores/misstore.test.ts @@ -580,7 +580,8 @@ describe('installWidget', () => { widgetEntry({ sha512: sha512Hex(source) }), ) expect(widget).toMatchObject({ - installId: 'w-test-1', + // 新規インストールのローカル ID = storeId (#913) + installId: 'ent-widget', name: 'Test Widget', src: source, autoRun: true, diff --git a/src/stores/misstore.ts b/src/stores/misstore.ts index dc2c9633e..eb98d1353 100644 --- a/src/stores/misstore.ts +++ b/src/stores/misstore.ts @@ -10,11 +10,7 @@ import { } from '@/stores/plugins' import { type SkillMeta, useSkillsStore } from '@/stores/skills' import { useThemeStore } from '@/stores/theme' -import { - generateWidgetId, - useWidgetsStore, - type WidgetMeta, -} from '@/stores/widgets' +import { useWidgetsStore, type WidgetMeta } from '@/stores/widgets' import { parseSkillFile } from '@/utils/skillFrontmatter' const STORE_BASE_URL = 'https://store.notedeck.io' @@ -511,6 +507,10 @@ export const useMisStoreStore = defineStore('misstore', () => { }) } else { await queriesStore.createQuery({ + // 新規インストールのローカル ID = storeId (#913)。衝突時のみ suffix + id: resolveAvailable(entry.id, (c) => + queriesStore.queries.some((q) => casefold(q.id) === c), + ), name: entry.name, description: entry.description, src: source, @@ -590,7 +590,11 @@ export const useMisStoreStore = defineStore('misstore', () => { } const newPlugin: PluginMeta = { - installId: `p-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + // 新規インストールのローカル ID = storeId (#913。再インストールで + // 参照が生き残る)。衝突時のみ連番 suffix + installId: resolveAvailable(entry.id, (c) => + pluginsStore.plugins.some((p) => casefold(p.installId) === c), + ), name: meta.name, version: meta.version, author: meta.author, @@ -648,7 +652,10 @@ export const useMisStoreStore = defineStore('misstore', () => { const now = Date.now() const widget: WidgetMeta = { - installId: generateWidgetId(), + // 新規インストールのローカル ID = storeId (#913)。衝突時のみ suffix + installId: resolveAvailable(entry.id, (c) => + widgetsStore.widgets.some((w) => casefold(w.installId) === c), + ), name: entry.name, src, autoRun: entry.autoRun, From dbc17861da91861dbcaa55183494add3560adf1c Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:43:56 +0900 Subject: [PATCH 16/30] =?UTF-8?q?feat(settings):=20=E9=87=8D=E8=A4=87=20ID?= =?UTF-8?q?=20=E3=81=AE=E8=AA=AD=E8=BE=BC=E3=82=B9=E3=82=AD=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=82=92=E3=83=88=E3=83=BC=E3=82=B9=E3=83=88=E3=81=A7?= =?UTF-8?q?=E5=8F=AF=E8=A6=96=E5=8C=96=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仕様の「警告 + UI 通知」のうち UI 通知が console.warn 止まりだった 残件。コレクションに notify フックを足し、全 6 種別の設定を 警告トーストへ配線する (ユーザー操作なしには解消しない状態のため)。 Co-Authored-By: Claude Fable 5 --- src/services/deckProfileFiles.ts | 2 ++ src/services/sidecarFileCollection.test.ts | 15 +++++++++++++++ src/services/sidecarFileCollection.ts | 9 +++++++++ src/services/singleFileCollection.test.ts | 13 +++++++++++++ src/services/singleFileCollection.ts | 9 +++++++++ src/stores/columnQueries.ts | 2 ++ src/stores/plugins.ts | 3 ++- src/stores/skills.ts | 3 ++- src/stores/themeFileSync.ts | 3 ++- src/stores/widgets.ts | 3 ++- src/utils/toastNotify.ts | 13 +++++++++++++ 11 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/utils/toastNotify.ts diff --git a/src/services/deckProfileFiles.ts b/src/services/deckProfileFiles.ts index fc936ae32..5386e5ae3 100644 --- a/src/services/deckProfileFiles.ts +++ b/src/services/deckProfileFiles.ts @@ -19,6 +19,7 @@ import { createSingleFileCollection } from '@/services/singleFileCollection' import type { DeckProfile } from '@/stores/deck' import type { WidgetMeta } from '@/stores/widgets' import * as settingsFs from '@/utils/settingsFs' +import { notifyWarningToast } from '@/utils/toastNotify' export interface ProfileLoadByproducts { droppedConsoleCount: number @@ -44,6 +45,7 @@ export const profileFiles = createSingleFileCollection< Record >({ logTag: 'deckProfile', + notify: notifyWarningToast, kindFallback: 'profile', ext: settingsFs.PROFILE_EXT, // 占有判定・sweep には .history.json5 を含む実列挙が要る diff --git a/src/services/sidecarFileCollection.test.ts b/src/services/sidecarFileCollection.test.ts index 83bce84a4..4d129aa8e 100644 --- a/src/services/sidecarFileCollection.test.ts +++ b/src/services/sidecarFileCollection.test.ts @@ -218,6 +218,21 @@ describe('loadAll', () => { expect(fs.files.has('b.meta.json5')).toBe(true) expect(fs.files.has('b.is')).toBe(true) }) + + it('notify フックで UI 通知を出す', async () => { + const fs = makeFakeFs({ + 'a.meta.json5': '{ installId: "dup", name: "a" }', + 'a.is': 'src-a', + 'b.meta.json5': '{ installId: "dup", name: "b" }', + 'b.is': 'src-b', + }) + const notify = vi.fn() + const col = makeCollection(fs, { notify }) + await col.loadAll() + expect(notify).toHaveBeenCalledTimes(1) + expect(notify.mock.calls[0]?.[0]).toContain('dup') + expect(notify.mock.calls[0]?.[0]).toContain('b.meta.json5') + }) }) describe('メタあり・ソースなし', () => { diff --git a/src/services/sidecarFileCollection.ts b/src/services/sidecarFileCollection.ts index 7738da9e4..86f80cfcf 100644 --- a/src/services/sidecarFileCollection.ts +++ b/src/services/sidecarFileCollection.ts @@ -69,6 +69,12 @@ export interface SidecarCollectionConfig { * 規約不適合なら無視して表示名 slug に落ち、占有時は連番 suffix で回避する。 */ preferredBase?(item: T): string | undefined + /** + * ユーザーへの UI 通知 (トースト等)。重複 ID skip のような + * 「ユーザー操作なしには解消しない状態」の可視化に使う。 + * console.warn は常に別途出る。 + */ + notify?(message: string): void } const META_SUFFIX = '.meta.json5' @@ -176,6 +182,9 @@ export function createSidecarCollection( console.warn( `[${cfg.logTag}] duplicate id "${id}" in ${metaFile} — skipped (file kept)`, ) + cfg.notify?.( + `同じ ID「${id}」の設定ファイルが複数あります。${metaFile} は読み込まれていません (ファイルは残っています — 不要なら手動で削除してください)`, + ) continue } seenIds.add(id) diff --git a/src/services/singleFileCollection.test.ts b/src/services/singleFileCollection.test.ts index 8354b10da..fa17d3645 100644 --- a/src/services/singleFileCollection.test.ts +++ b/src/services/singleFileCollection.test.ts @@ -217,6 +217,19 @@ describe('loadAll', () => { expect(fs.files.has(`b${EXT}`)).toBe(true) }) + it('同一 ID の 2 件目は notify フックで UI 通知を出す', async () => { + const fs = makeFakeFs({ + [`a${EXT}`]: file('dup', 'a'), + [`b${EXT}`]: file('dup', 'b'), + }) + const notify = vi.fn() + const col = makeCollection(fs, { notify }) + await col.loadAll() + expect(notify).toHaveBeenCalledTimes(1) + expect(notify.mock.calls[0]?.[0]).toContain('dup') + expect(notify.mock.calls[0]?.[0]).toContain(`b${EXT}`) + }) + it('パースに失敗したファイルはスキップし、他は復元する', async () => { const fs = makeFakeFs({ [`broken${EXT}`]: '{{{ not json5', diff --git a/src/services/singleFileCollection.ts b/src/services/singleFileCollection.ts index 900c5a987..9e66ff82c 100644 --- a/src/services/singleFileCollection.ts +++ b/src/services/singleFileCollection.ts @@ -68,6 +68,12 @@ export interface SingleFileCollectionConfig { * 落ち、占有時は連番 suffix で回避する。 */ preferredBase?(item: T): string | undefined + /** + * ユーザーへの UI 通知 (トースト等)。重複 ID skip のような + * 「ユーザー操作なしには解消しない状態」の可視化に使う。 + * console.warn は常に別途出る。 + */ + notify?(message: string): void } const HISTORY_SUFFIX = '.history.json5' @@ -183,6 +189,9 @@ export function createSingleFileCollection( console.warn( `[${cfg.logTag}] duplicate id "${id}" in ${filename} — skipped (file kept)`, ) + cfg.notify?.( + `同じ ID「${id}」の設定ファイルが複数あります。${filename} は読み込まれていません (ファイルは残っています — 不要なら手動で削除してください)`, + ) continue } seenIds.add(id) diff --git a/src/stores/columnQueries.ts b/src/stores/columnQueries.ts index 97a228a31..6b111c6a5 100644 --- a/src/stores/columnQueries.ts +++ b/src/stores/columnQueries.ts @@ -7,6 +7,7 @@ import { import { useDeckStore } from '@/stores/deck' import * as settingsFs from '@/utils/settingsFs' import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' +import { notifyWarningToast } from '@/utils/toastNotify' /** * 名前付きカラムクエリのプール (#783 Phase 1.5、仕様追補 A)。 @@ -52,6 +53,7 @@ interface QueryFileMeta { const queryFiles = createSidecarCollection({ logTag: 'columnQueries', + notify: notifyWarningToast, kindFallback: 'query', idKey: 'id', list: () => settingsFs.listQueryFiles(), diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts index 3a6b6d120..ed298fac0 100644 --- a/src/stores/plugins.ts +++ b/src/stores/plugins.ts @@ -1,7 +1,6 @@ import JSON5 from 'json5' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' - import { planBuiltInSeed } from '@/services/builtInSeed' import { createSidecarCollection, @@ -18,6 +17,7 @@ import { setStorageJson, setStorageString, } from '@/utils/storage' +import { notifyWarningToast } from '@/utils/toastNotify' interface BuiltInPluginTemplate { installId: string @@ -168,6 +168,7 @@ interface PluginFileMeta { /** .is + .meta.json5 ペアのファイル永続化 (#782 Phase 2、widgets と共通) */ const pluginFiles = createSidecarCollection({ logTag: 'plugins', + notify: notifyWarningToast, kindFallback: 'plugin', idKey: 'installId', // 直接参照ではなくアロー包みで遅延参照する (テストの部分モックと相性を保つ) diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 76d142904..13a406752 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' - import { emitNoteDeckEvent } from '@/aiscript/events' import { planBuiltInSeed } from '@/services/builtInSeed' import { injectFrontmatterId } from '@/services/idFreeze' @@ -14,6 +13,7 @@ import { serializeSkillFile, } from '@/utils/skillFrontmatter' import { getStorageJson, STORAGE_KEYS, setStorageJson } from '@/utils/storage' +import { notifyWarningToast } from '@/utils/toastNotify' /** * Skill 実行モード: @@ -231,6 +231,7 @@ function serializeSkill(skill: SkillMeta): string { */ const skillFiles = createSingleFileCollection({ logTag: 'skills', + notify: notifyWarningToast, kindFallback: 'skill', ext: settingsFs.SKILL_EXT, // ストアインストールはファイル名 = storeId (#913。占有時は連番 suffix)。 diff --git a/src/stores/themeFileSync.ts b/src/stores/themeFileSync.ts index 86dd6ba2c..b462df485 100644 --- a/src/stores/themeFileSync.ts +++ b/src/stores/themeFileSync.ts @@ -1,9 +1,9 @@ import JSON5 from 'json5' - import { injectJson5Id } from '@/services/idFreeze' import { createSingleFileCollection } from '@/services/singleFileCollection' import type { MisskeyTheme, NotedeckThemeMeta } from '@/theme/types' import * as settingsFs from '@/utils/settingsFs' +import { notifyWarningToast } from '@/utils/toastNotify' /** * テーマ (`themes/.ndtheme.json5` 単一ファイル) の永続化 @@ -34,6 +34,7 @@ function serializeTheme(theme: MisskeyTheme): string { export const themeFiles = createSingleFileCollection( { logTag: 'theme', + notify: notifyWarningToast, kindFallback: 'theme', ext: settingsFs.THEME_EXT, // 占有判定・sweep には .history.json5 を含む実列挙が要る diff --git a/src/stores/widgets.ts b/src/stores/widgets.ts index bf1e6b76c..cd63f22ed 100644 --- a/src/stores/widgets.ts +++ b/src/stores/widgets.ts @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import { ref } from 'vue' - import { createSidecarCollection, type SidecarItemFile, @@ -15,6 +14,7 @@ import { setStorageJson, setStorageString, } from '@/utils/storage' +import { notifyWarningToast } from '@/utils/toastNotify' export interface WidgetMeta extends SidecarItemFile { installId: string @@ -48,6 +48,7 @@ interface WidgetFileMeta { /** .is + .meta.json5 ペアのファイル永続化 (#782 Phase 2、plugins と共通) */ const widgetFiles = createSidecarCollection({ logTag: 'widgets', + notify: notifyWarningToast, kindFallback: 'widget', idKey: 'installId', // 直接参照ではなくアロー包みで遅延参照する (テストの部分モックと相性を保つ) diff --git a/src/utils/toastNotify.ts b/src/utils/toastNotify.ts new file mode 100644 index 000000000..874e77c69 --- /dev/null +++ b/src/utils/toastNotify.ts @@ -0,0 +1,13 @@ +/** + * setup コンテキスト外 (module スコープのサービス設定等) から + * トーストを出すための遅延ヘルパ。pinia 未初期化なら黙って捨てる。 + */ +export function notifyWarningToast(message: string): void { + import('@/stores/toast') + .then(({ useToast }) => { + useToast().show(message, 'warning') + }) + .catch(() => { + /* toast unavailable — skip */ + }) +} From 7d210940339a931e0f7e2c39ac19f4b19f516861 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:52:45 +0900 Subject: [PATCH 17/30] =?UTF-8?q?feat(common):=20CodeDiffView=20=E5=85=B1?= =?UTF-8?q?=E9=80=9A=20diff=20=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=8D=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0=20(#981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @codemirror/merge の unified ビューで oldText/newText の全文 diff を 読取専用表示する。言語モードは既存基盤 (AiScript 自前モード / lang-json / lang-markdown / lang-css) から解決し、挿入/削除色は グローバル CSS 変数で開放する。未変更領域は既定で折りたたむ。 Co-Authored-By: Claude Fable 5 --- package.json | 1 + pnpm-lock.yaml | 14 ++ .../common/CodeDiffView.dom.test.ts | 74 ++++++++++ src/components/common/CodeDiffView.vue | 136 ++++++++++++++++++ src/styles/global.css | 7 + 5 files changed, 232 insertions(+) create mode 100644 src/components/common/CodeDiffView.dom.test.ts create mode 100644 src/components/common/CodeDiffView.vue diff --git a/package.json b/package.json index 2492c78b4..7ee9efa78 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/lint": "^6.9.7", "@codemirror/lsp-client": "^6.2.5", + "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.1", "@lezer/highlight": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef85de256..154f66106 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@codemirror/lsp-client': specifier: ^6.2.5 version: 6.2.5 + '@codemirror/merge': + specifier: ^6.12.2 + version: 6.12.2 '@codemirror/state': specifier: ^6.6.0 version: 6.6.0 @@ -533,6 +536,9 @@ packages: '@codemirror/lsp-client@6.2.5': resolution: {integrity: sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ==} + '@codemirror/merge@6.12.2': + resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==} + '@codemirror/state@6.6.0': resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} @@ -5005,6 +5011,14 @@ snapshots: marked: 15.0.12 vscode-languageserver-protocol: 3.18.0 + '@codemirror/merge@6.12.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.1 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + '@codemirror/state@6.6.0': dependencies: '@marijn/find-cluster-break': 1.0.2 diff --git a/src/components/common/CodeDiffView.dom.test.ts b/src/components/common/CodeDiffView.dom.test.ts new file mode 100644 index 000000000..18104cb26 --- /dev/null +++ b/src/components/common/CodeDiffView.dom.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { type App, createApp } from 'vue' +import CodeDiffView from './CodeDiffView.vue' + +let app: App | null = null +let container: HTMLElement | null = null + +function mountDiff(props: { + oldText: string + newText: string + language?: 'aiscript' | 'json5' | 'markdown' | 'css' | 'text' + collapseUnchanged?: boolean +}) { + container = document.createElement('div') + document.body.appendChild(container) + app = createApp(CodeDiffView, props) + app.mount(container) +} + +afterEach(() => { + app?.unmount() + container?.remove() + app = null + container = null +}) + +describe('CodeDiffView', () => { + it('old/new の差分から挿入 (changedLine) と削除 (deletedChunk) の装飾を描画する', () => { + mountDiff({ + oldText: 'line1\nold line\nline3', + newText: 'line1\nnew line\nline3', + collapseUnchanged: false, + }) + // 変更行 (挿入側) の装飾 + expect(container?.querySelector('.cm-changedLine')).toBeTruthy() + // 削除チャンク (旧テキスト) の装飾と内容 + const deleted = container?.querySelector('.cm-deletedChunk') + expect(deleted).toBeTruthy() + expect(deleted?.textContent).toContain('old line') + // 新テキストが本文として描画される + expect(container?.textContent).toContain('new line') + }) + + it('差分が無ければ変更装飾を描画しない', () => { + mountDiff({ + oldText: 'same\ntext', + newText: 'same\ntext', + collapseUnchanged: false, + }) + expect(container?.querySelector('.cm-changedLine')).toBeNull() + expect(container?.querySelector('.cm-deletedChunk')).toBeNull() + }) + + it('oldText が空 (新規作成) は全行挿入の diff として描画される', () => { + mountDiff({ oldText: '', newText: 'a\nb', collapseUnchanged: false }) + expect(container?.querySelector('.cm-changedLine')).toBeTruthy() + }) + + it('チャンク承認/拒否の mergeControls は表示しない (読取専用ビュー)', () => { + mountDiff({ + oldText: 'a', + newText: 'b', + collapseUnchanged: false, + }) + expect(container?.querySelector('.cm-chunkButtons')).toBeNull() + }) + + it('ルート要素に DOM フック用の data 属性と言語を出す', () => { + mountDiff({ oldText: 'a', newText: 'b', language: 'json5' }) + const root = container?.querySelector('[data-nd-code-diff]') + expect(root).toBeTruthy() + expect(root?.getAttribute('data-language')).toBe('json5') + }) +}) diff --git a/src/components/common/CodeDiffView.vue b/src/components/common/CodeDiffView.vue new file mode 100644 index 000000000..762cb0f44 --- /dev/null +++ b/src/components/common/CodeDiffView.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/styles/global.css b/src/styles/global.css index 08f4199db..677da885e 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -105,6 +105,13 @@ --nd-accent-subtle: color-mix(in srgb, var(--nd-accent) 10%, transparent); --nd-love-hover: color-mix(in srgb, var(--nd-love) 15%, transparent); --nd-love-subtle: color-mix(in srgb, var(--nd-love) 10%, transparent); + /* Diff 表示 (CodeDiffView #981/#1040) の挿入/削除色。エディタ面はダーク固定 + (#1e1e1e) なのでテーマ非依存の値だが、カスタム CSS から上書きできるよう + 変数で開放する */ + --nd-diffInsertBg: rgba(87, 171, 90, 0.16); + --nd-diffInsertTextBg: rgba(87, 171, 90, 0.35); + --nd-diffDeleteBg: rgba(221, 46, 68, 0.14); + --nd-diffDeleteTextBg: rgba(221, 46, 68, 0.35); /* Shadow depth — Apple dual-layer: near contact + distant ambient */ --nd-shadow-contact: rgba(0, 0, 0, 0.08); --nd-shadow-ambient: rgba(0, 0, 0, 0.15); From b5749c70142eb92411d268ca4f0d3510bb954781 Mon Sep 17 00:00:00 2001 From: hitalin Date: Wed, 12 Aug 2026 08:53:44 +0900 Subject: [PATCH 18/30] =?UTF-8?q?feat(confirm):=20ConfirmOptions.diff=20?= =?UTF-8?q?=E3=81=A7=E9=81=A9=E7=94=A8=E5=89=8D=E3=81=AE=E5=85=A8=E6=96=87?= =?UTF-8?q?=20diff=20=E3=82=92=E8=A1=A8=E7=A4=BA=20(#981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff 指定時は code ブロックの代わりに CodeDiffView を描画する (併用時は diff 優先)。attribution・remember 等の既存挙動は不変。 ダイアログ内は最大高さを制限して diff 側にスクロールを持たせる。 Co-Authored-By: Claude Fable 5 --- src/components/common/AppConfirm.vue | 24 +++++++++++++++++++++++- src/stores/confirm.ts | 12 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/common/AppConfirm.vue b/src/components/common/AppConfirm.vue index 33ec708cd..dc55d3a57 100644 --- a/src/components/common/AppConfirm.vue +++ b/src/components/common/AppConfirm.vue @@ -1,6 +1,7 @@