|
@@ -310,6 +413,7 @@ export function SharedLibraryTable({
const status = hashStatus[entry.id] ?? "unknown";
const isBusy = busyId === entry.id;
const kind = showKindColumn ? entryKind(entry, t) : null;
+ const contexts = showFolderColumn ? contextsOf(entry) : [];
return (
|
{entryTitle(entry, t)}
+ {showFolderColumn && (
+ |
+ {contexts.length > 0 ? (
+
+ {contexts.slice(0, 2).map((c) => (
+
+ ))}
+ {contexts.length > 2 && (
+
+ +{contexts.length - 2}
+
+ )}
+
+ ) : (
+ // 空欄のダッシュはノート一覧と同じ薄さ(/30)にする
+ —
+ )}
+ |
+ )}
{showKindColumn && (
{kind?.label} |
)}
@@ -394,6 +520,20 @@ export function SharedLibraryTable({
)}
+ {folderFilterOpen && (
+ setFolderFilterOpen(false)}
+ title={t("library.filterFolder")}
+ options={folderFilterOptions}
+ selected={folderFilter}
+ onChange={setFolderFilter}
+ searchPlaceholder={t("common.search")}
+ clearLabel={t("nav.clearFilter")}
+ noMatchText={t("library.noMatch")}
+ minWidth={220}
+ />
+ )}
{kindFilterOpen && (
= {
docs: {
description: {
component:
- "共有ライブラリの表形式ビュー(カードグリッドからの置き換え)。詳細パネルは Tauri の invoke に依存するため、Storybook 上では本文を読み込めない(読み込み中のまま止まる)。",
+ "共有ライブラリの表形式ビュー(カードグリッドからの置き換え)。ノートタブには共有した時点のフォルダ列が並ぶ(1 件はフォルダ無し = 空欄)。詳細パネルは Tauri の invoke に依存するため、Storybook 上では本文を読み込めない(読み込み中のまま止まる)。",
},
},
},
diff --git a/src/features/sharing/fork-note.test.ts b/src/features/sharing/fork-note.test.ts
new file mode 100644
index 000000000..b05e2afd6
--- /dev/null
+++ b/src/features/sharing/fork-note.test.ts
@@ -0,0 +1,73 @@
+// forkSharedNote のテスト。共有ノートの読み出しは Tauri invoke をモックする。
+// 見るのは 2 点だけ:
+// - forkedFrom に出どころが記録される
+// - noteContexts(共有元のフォルダ)は引き継がない
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+const invokeMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: invokeMock,
+}));
+
+import { forkSharedNote } from "./fork-note";
+import { computeSharedEntryHash } from "../../lib/storage/shared/hash";
+import type { SharedEntry } from "../../lib/storage/shared";
+import type { GraphiumDocument } from "../../lib/document-types";
+
+const ROOT = "/tmp/shared";
+const SHARED_ID = "0195e000-0000-7000-8000-000000000001";
+
+const sharedDoc = {
+ version: 5,
+ title: "焼結の手順",
+ pages: [{ id: "p1", title: "焼結の手順", blocks: [], labels: {}, provLinks: [], knowledgeLinks: [] }],
+ noteContexts: ["卒論/焼結"],
+ createdAt: "2026-05-04T00:00:00Z",
+ modifiedAt: "2026-05-04T00:00:00Z",
+} as unknown as GraphiumDocument;
+
+/** provider.read が返す形(Rust 側の shared_read の戻り値と同じ JSON) */
+async function installEntry(doc: GraphiumDocument): Promise {
+ const body = new TextEncoder().encode(JSON.stringify(doc));
+ const base: SharedEntry = {
+ id: SHARED_ID,
+ type: "note",
+ author: { name: "Tanaka", email: "tanaka@example.com" },
+ created_at: "2026-05-01T00:00:00Z",
+ updated_at: "2026-05-04T00:00:00Z",
+ hash: "",
+ prov: { derived_from: [] },
+ extra: { title: doc.title },
+ };
+ const entry = { ...base, hash: await computeSharedEntryHash(base, body) };
+ // provider が読む形(Rust の shared_read が返す JSON)
+ const stored = JSON.stringify({ entry, body_base64: btoa(String.fromCharCode(...body)) });
+ invokeMock.mockReset();
+ invokeMock.mockImplementation(async (cmd: string) => {
+ if (cmd === "shared_read") return stored;
+ throw new Error(`unmocked: ${cmd}`);
+ });
+}
+
+beforeEach(async () => {
+ await installEntry(sharedDoc);
+});
+
+describe("forkSharedNote", () => {
+ it("forkedFrom に共有元が記録される", async () => {
+ const r = await forkSharedNote(SHARED_ID, { root: ROOT });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.doc.forkedFrom?.sharedId).toBe(SHARED_ID);
+ expect(r.doc.forkedFrom?.authorEmail).toBe("tanaka@example.com");
+ });
+
+ it("共有元のフォルダ(noteContexts)は引き継がない", async () => {
+ const r = await forkSharedNote(SHARED_ID, { root: ROOT });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.doc.noteContexts).toBeUndefined();
+ });
+});
diff --git a/src/features/sharing/fork-note.ts b/src/features/sharing/fork-note.ts
index ce40a1312..03b723f65 100644
--- a/src/features/sharing/fork-note.ts
+++ b/src/features/sharing/fork-note.ts
@@ -76,6 +76,11 @@ export async function forkSharedNote(
authorEmail: entry.author?.email ?? "",
forkedAt: now,
},
+ // 共有元のフォルダ(noteContexts)は引き継がない。
+ // なぜ: フォルダは共有した人の個人的な整理であって、fork した側の分類ではない。
+ // 引き継ぐと自分のフォルダ一覧に他人の分類が勝手に増える。
+ // 共有時点のフォルダは共有ライブラリの表の「フォルダ」列で参照できる。
+ noteContexts: undefined,
// ローカル間派生関係は保持しない(ローカル ID は別空間)
derivedFromNoteId: undefined,
derivedFromBlockId: undefined,
diff --git a/src/features/sharing/share-note.test.ts b/src/features/sharing/share-note.test.ts
index 7922f5356..4db74a5af 100644
--- a/src/features/sharing/share-note.test.ts
+++ b/src/features/sharing/share-note.test.ts
@@ -100,6 +100,22 @@ describe("shareNote — first share", () => {
await shareNote(original, { root: "/tmp/shared", author });
expect(original.sharedRef).toBeUndefined();
});
+
+ it("共有した時点のフォルダが extra.noteContexts に載る", async () => {
+ const r = await shareNote(makeDoc({ noteContexts: ["卒論/焼結", "共通/装置"] }), {
+ root: "/tmp/shared",
+ author,
+ });
+ expect(r.ok).toBe(true);
+ const stored = JSON.parse([...fs.entries.values()][0]);
+ expect(stored.entry.extra.noteContexts).toEqual(["卒論/焼結", "共通/装置"]);
+ });
+
+ it("フォルダ未設定のノートでも空配列が入る(列は「—」表示になる)", async () => {
+ await shareNote(makeDoc(), { root: "/tmp/shared", author });
+ const stored = JSON.parse([...fs.entries.values()][0]);
+ expect(stored.entry.extra.noteContexts).toEqual([]);
+ });
});
describe("shareNote — re-share (update)", () => {
@@ -120,6 +136,22 @@ describe("shareNote — re-share (update)", () => {
// hash は内容変更により変わる
expect(updated.doc.sharedRef!.hash).not.toBe(first.doc.sharedRef!.hash);
});
+
+ it("再共有でフォルダが上書きされる", async () => {
+ const first = await shareNote(makeDoc({ noteContexts: ["旧フォルダ"] }), {
+ root: "/tmp/shared",
+ author,
+ });
+ expect(first.ok).toBe(true);
+ if (!first.ok) return;
+ const updated = await shareNote(
+ { ...first.doc, noteContexts: ["新フォルダ"] },
+ { root: "/tmp/shared", author },
+ );
+ expect(updated.ok).toBe(true);
+ const stored = JSON.parse([...fs.entries.values()][0]);
+ expect(stored.entry.extra.noteContexts).toEqual(["新フォルダ"]);
+ });
});
describe("shareNote — failure paths", () => {
diff --git a/src/features/sharing/share-note.ts b/src/features/sharing/share-note.ts
index dd9b83f6d..a9f6345c5 100644
--- a/src/features/sharing/share-note.ts
+++ b/src/features/sharing/share-note.ts
@@ -82,7 +82,12 @@ export async function shareNote(
doc: GraphiumDocument,
options: ShareNoteOptions,
): Promise {
- return shareGraphiumDocument(doc, "note", {}, options);
+ // 共有した時点のフォルダ(noteContexts)を extra に載せる。
+ // なぜ: 共有ライブラリの表でも手元のノート一覧と同じ「フォルダ」列で絞り込める
+ // ようにするため(鏡の原則)。本文にも noteContexts は入っているが、
+ // 一覧は本文を読まずに描くのでメタデータ側にも持たせる。
+ // 再共有(共有コピーの更新)も同じ経路を通るので、共有側のフォルダは常に上書きされる。
+ return shareGraphiumDocument(doc, "note", { noteContexts: doc.noteContexts ?? [] }, options);
}
/**
diff --git a/src/features/sharing/shared-entry-source.test.ts b/src/features/sharing/shared-entry-source.test.ts
index 2a7a976a9..93f160895 100644
--- a/src/features/sharing/shared-entry-source.test.ts
+++ b/src/features/sharing/shared-entry-source.test.ts
@@ -7,7 +7,11 @@
import { describe, expect, it } from "vitest";
import type { SharedEntry } from "../../lib/storage/shared";
import type { GraphiumDocument } from "../../lib/document-types";
-import { sharedEntryToSourceInput, sharedEntryFingerprint } from "./shared-entry-source";
+import {
+ sharedEntryToSourceInput,
+ sharedEntryFingerprint,
+ extractSharedDerivedMeta,
+} from "./shared-entry-source";
const entry = (over: Partial): SharedEntry =>
({
@@ -127,3 +131,33 @@ describe("sharedEntryToSourceInput", () => {
expect(a).not.toBe(c);
});
});
+
+describe("extractSharedDerivedMeta", () => {
+ const noteWithContexts = { ...noteDoc, noteContexts: ["卒論/焼結", " 共通/装置 "] };
+
+ it("note かつ verified なら本文のフォルダを正規化して返す", () => {
+ const meta = extractSharedDerivedMeta(entry({}), encode(noteWithContexts), true);
+ expect(meta).toEqual({ noteContexts: ["卒論/焼結", "共通/装置"] });
+ });
+
+ it("フォルダ未設定のノートは空配列", () => {
+ expect(extractSharedDerivedMeta(entry({}), encode(noteDoc), true)).toEqual({ noteContexts: [] });
+ });
+
+ it("hash 不一致(未検証)の本文からは何も取らない", () => {
+ expect(extractSharedDerivedMeta(entry({}), encode(noteWithContexts), false)).toBeNull();
+ });
+
+ it("note 以外(knowledge)は対象外", () => {
+ const meta = extractSharedDerivedMeta(
+ entry({ type: "knowledge" }),
+ encode(noteWithContexts),
+ true,
+ );
+ expect(meta).toBeNull();
+ });
+
+ it("本文が壊れていれば null", () => {
+ expect(extractSharedDerivedMeta(entry({}), new TextEncoder().encode("{"), true)).toBeNull();
+ });
+});
diff --git a/src/features/sharing/shared-entry-source.ts b/src/features/sharing/shared-entry-source.ts
index 0dd6e7669..1c0538907 100644
--- a/src/features/sharing/shared-entry-source.ts
+++ b/src/features/sharing/shared-entry-source.ts
@@ -17,6 +17,7 @@ import type { GraphiumDocument } from "../../lib/document-types";
import type { LexicalSourceInput } from "../lexical-search/lexical-index";
import { chunkNoteDocument, chunkPlainText } from "../lexical-search/chunk";
import { extractWikiSections } from "../wiki/section-extract";
+import { normalizeNoteContexts } from "../note-context/context-tags";
import type { SharedEntry } from "../../lib/storage/shared";
/** 索引対象にする共有エントリの type(template / report は共有導線が無いので対象外) */
@@ -87,3 +88,34 @@ export function sharedEntryToSourceInput(
const text = [metaTitle, extraString(entry, "description"), filename].filter(Boolean).join("\n");
return { ...base, title: metaTitle || filename, chunks: chunkPlainText(text) };
}
+
+// ── 本文由来の派生メタ ──
+
+/**
+ * 共有エントリの本文からしか取れない、一覧・検索が使うメタデータ。
+ *
+ * なぜ本文から拾うか: `extra.noteContexts` を書くようになったのは途中からで、
+ * それ以前に共有されたエントリのメタデータにはフォルダが入っていない。本文
+ * (共有ノート JSON)には元々 `noteContexts` が入っているので、そこから補える。
+ */
+export type SharedDerivedMeta = {
+ /** 共有した時点のノートのフォルダ(正規化済み。無ければ空配列) */
+ noteContexts: string[];
+};
+
+/**
+ * 読み出した本文から派生メタを取り出す(純関数)。
+ * 対象は type=note かつ hash 照合済み(verified)のときだけ。
+ * それ以外は null を返し、呼び出し側は何も記録しない
+ * (改ざん・書き換え中の本文から一覧の表示値を作らないため)。
+ */
+export function extractSharedDerivedMeta(
+ entry: SharedEntry,
+ body: Uint8Array,
+ verified: boolean,
+): SharedDerivedMeta | null {
+ if (entry.type !== "note" || !verified) return null;
+ const doc = parseDocument(body);
+ if (!doc) return null;
+ return { noteContexts: normalizeNoteContexts(doc.noteContexts) ?? [] };
+}
diff --git a/src/features/sharing/shared-library-store.test.ts b/src/features/sharing/shared-library-store.test.ts
index 81bc3ab66..7f04a5b71 100644
--- a/src/features/sharing/shared-library-store.test.ts
+++ b/src/features/sharing/shared-library-store.test.ts
@@ -3,6 +3,7 @@
// - 読み出し中の変更通知は取りこぼさない(終わってからもう一度読む)
// - readSharedEntryBody は hash を照合し、不一致を mismatched に残す
// - 本文は id|hash でキャッシュされ、二度読まない
+// - 本文から拾った派生メタ(フォルダ)は hash 付きで残り、消えた id は落ちる
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SharedEntry, SharedEntryContent, SharedEntryType } from "../../lib/storage/shared";
@@ -16,6 +17,8 @@ import {
refreshSharedLibrary,
subscribeSharedLibrary,
groupSharedEntriesByType,
+ getSharedNoteContexts,
+ parseDerivedMetaStore,
} from "./shared-library-store";
const ROOT = "/tmp/shared-root";
@@ -166,3 +169,107 @@ describe("readSharedEntryBody", () => {
await expect(readSharedEntryBody(entry({}))).rejects.toThrow(/shared root/);
});
});
+
+describe("派生メタ(本文由来のフォルダ)", () => {
+ const NOTE_ID = "0195e000-0000-7000-8000-00000000000c";
+ const docBody = (contexts: string[]) =>
+ new TextEncoder().encode(JSON.stringify({ title: "x", pages: [], noteContexts: contexts }));
+
+ it("本文を読むと派生メタが入る", async () => {
+ const body = docBody(["卒論/焼結"]);
+ const e = await signedEntry(body, { id: NOTE_ID });
+ __setSharedLibraryLoaderForTest(async () => result([e]), {
+ root: ROOT,
+ reader: async () => ({ entry: e, body }),
+ });
+ await refreshSharedLibrary();
+ await readSharedEntryBody(e);
+
+ expect(getSharedLibrarySnapshot().derived[NOTE_ID]).toEqual({
+ hash: e.hash,
+ noteContexts: ["卒論/焼結"],
+ });
+ });
+
+ it("共有から消えた id の派生メタは refresh で落ちる", async () => {
+ const body = docBody(["共通/装置"]);
+ const e = await signedEntry(body, { id: NOTE_ID });
+ let listing: SharedEntry[] = [e];
+ __setSharedLibraryLoaderForTest(async () => result(listing), {
+ root: ROOT,
+ reader: async () => ({ entry: e, body }),
+ });
+ await refreshSharedLibrary();
+ await readSharedEntryBody(e);
+ expect(getSharedLibrarySnapshot().derived[NOTE_ID]).toBeDefined();
+
+ listing = [];
+ await refreshSharedLibrary();
+ expect(getSharedLibrarySnapshot().derived[NOTE_ID]).toBeUndefined();
+ });
+
+ it("hash が合わない本文からは派生メタを作らない", async () => {
+ const body = docBody(["卒論/焼結"]);
+ const stored = await signedEntry(body, { id: NOTE_ID });
+ const listed = { ...stored, hash: "sha256:stale" };
+ __setSharedLibraryLoaderForTest(async () => result([listed]), {
+ root: ROOT,
+ reader: async () => ({ entry: stored, body }),
+ });
+ await refreshSharedLibrary();
+ await readSharedEntryBody(listed);
+ expect(getSharedLibrarySnapshot().derived[NOTE_ID]).toBeUndefined();
+ });
+});
+
+describe("getSharedNoteContexts", () => {
+ const snapshot = (derived: Record) =>
+ ({ ...getSharedLibrarySnapshot(), derived }) as ReturnType;
+
+ it("extra.noteContexts があればそれを使う(派生メタより優先)", () => {
+ const e = entry({ id: "x", hash: "sha256:h", extra: { noteContexts: ["extra 側"] } });
+ const contexts = getSharedNoteContexts(e, snapshot({ x: { hash: "sha256:h", noteContexts: ["派生側"] } }));
+ expect(contexts).toEqual(["extra 側"]);
+ });
+
+ it("extra が無ければ hash 一致の派生メタで補う", () => {
+ const e = entry({ id: "x", hash: "sha256:h" });
+ expect(getSharedNoteContexts(e, snapshot({ x: { hash: "sha256:h", noteContexts: ["派生側"] } }))).toEqual([
+ "派生側",
+ ]);
+ });
+
+ it("派生メタの hash が違えば使わない(別の版の値だから)", () => {
+ const e = entry({ id: "x", hash: "sha256:new" });
+ expect(getSharedNoteContexts(e, snapshot({ x: { hash: "sha256:old", noteContexts: ["古い"] } }))).toEqual([]);
+ });
+
+ it("どちらも無ければ空配列", () => {
+ expect(getSharedNoteContexts(entry({ id: "x" }), snapshot({}))).toEqual([]);
+ });
+
+ it("extra.noteContexts が配列でなければ無視する(古い / 壊れたエントリ)", () => {
+ const e = entry({ id: "x", hash: "sha256:h", extra: { noteContexts: "卒論" } });
+ expect(getSharedNoteContexts(e, snapshot({ x: { hash: "sha256:h", noteContexts: ["派生側"] } }))).toEqual([
+ "派生側",
+ ]);
+ });
+});
+
+describe("parseDerivedMetaStore(localStorage からの復元)", () => {
+ it("正しい形だけを取り込む", () => {
+ const raw = JSON.stringify({
+ ok: { hash: "sha256:h", noteContexts: ["卒論", 1] },
+ hashなし: { noteContexts: ["x"] },
+ 配列でない: { hash: "sha256:h", noteContexts: "x" },
+ objectでない: 3,
+ });
+ expect(parseDerivedMetaStore(raw)).toEqual({ ok: { hash: "sha256:h", noteContexts: ["卒論"] } });
+ });
+
+ it("壊れた JSON・未保存では空", () => {
+ expect(parseDerivedMetaStore("{")).toEqual({});
+ expect(parseDerivedMetaStore(null)).toEqual({});
+ expect(parseDerivedMetaStore("[1,2]")).toEqual({});
+ });
+});
diff --git a/src/features/sharing/shared-library-store.ts b/src/features/sharing/shared-library-store.ts
index ec5dc8daf..4d1576ddf 100644
--- a/src/features/sharing/shared-library-store.ts
+++ b/src/features/sharing/shared-library-store.ts
@@ -15,6 +15,9 @@
// - readSharedEntryBody は `id|hash` をキーに小さな LRU で本文を持つ。
// 語彙索引・埋め込み・プレビューが同じ本文を続けて読むため
// - hash が合わなかった id は mismatched に貯める(設定の索引カードで見せる)
+// - 本文を読んだついでに拾える値(フォルダ)は derived に貯め、localStorage に
+// 残す。一覧は本文を読まずに描くので、読めた分だけ表に出せるようにする
+// (新しい読み取りは足さない — 語彙索引レーンが読む経路に相乗りする)
import { useSyncExternalStore } from "react";
import {
@@ -27,6 +30,10 @@ import {
import { computeSharedEntryHash } from "../../lib/storage/shared/hash";
import { isTauri } from "../../lib/platform";
import { loadAllSharedEntries, type SharedLibraryLoadResult } from "./shared-library-loader";
+import { extractSharedDerivedMeta, type SharedDerivedMeta } from "./shared-entry-source";
+
+/** 本文由来のメタを id ごとに覚えたもの。hash はそれを読んだときのエントリの hash */
+export type SharedDerivedMetaMap = Record;
export type SharedLibrarySnapshot = {
/** 読み出しに使った共有ルート(未設定 / 非デスクトップなら null) */
@@ -40,6 +47,12 @@ export type SharedLibrarySnapshot = {
loading: boolean;
/** 本文の hash が entry.hash と合わなかったエントリ id */
mismatched: string[];
+ /**
+ * 本文を読んだときに拾えた派生メタ(id → 値)。
+ * `extra` に情報が無い古い共有エントリを補うための控えで、hash が変わったら
+ * 古い値は使わない(読み直せば入れ替わる)。
+ */
+ derived: SharedDerivedMetaMap;
};
export type SharedLibraryLoader = (root: string) => Promise;
@@ -49,6 +62,9 @@ export type SharedEntryReader = (root: string, id: string) => Promise)) {
+ if (!value || typeof value !== "object") continue;
+ const { hash, noteContexts } = value as { hash?: unknown; noteContexts?: unknown };
+ if (typeof hash !== "string" || !hash) continue;
+ if (!Array.isArray(noteContexts)) continue;
+ out[id] = { hash, noteContexts: noteContexts.filter((c): c is string => typeof c === "string") };
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
/**
* 全 SharedEntryType。satisfies で Record を要求しているので、type が増えたら
* ここが型エラーになって気づける。
@@ -71,6 +112,23 @@ const ALL_ENTRY_TYPES = Object.keys({
report: 0,
} satisfies Record) as SharedEntryType[];
+function readPersistedDerivedMeta(): SharedDerivedMetaMap {
+ try {
+ return parseDerivedMetaStore(localStorage.getItem(DERIVED_META_KEY));
+ } catch {
+ // localStorage が使えない環境(プライベートモード等)では控えを持たないだけ
+ return {};
+ }
+}
+
+function persistDerivedMeta(map: SharedDerivedMetaMap): void {
+ try {
+ localStorage.setItem(DERIVED_META_KEY, JSON.stringify(map));
+ } catch {
+ /* 容量超過・無効化。控えなので落とさない */
+ }
+}
+
/** type ごとの配列を「note → reference → data-manifest → template → knowledge → report」の順に平坦化する */
function flatten(result: SharedLibraryLoadResult): SharedEntry[] {
return Object.values(result.entries).flat();
@@ -88,6 +146,11 @@ class SharedLibraryStore {
/** `${id}|${hash}` → 本文(LRU: Map の挿入順を使う) */
private bodyCache = new Map();
+ constructor() {
+ // 起動時に前回までに読めた派生メタを復元する(一覧を開いた直後から列が埋まる)
+ this.snapshot = { ...EMPTY, derived: readPersistedDerivedMeta() };
+ }
+
getSnapshot = (): SharedLibrarySnapshot => this.snapshot;
subscribe = (listener: () => void): (() => void) => {
@@ -143,6 +206,8 @@ class SharedLibraryStore {
loading: false,
// 消えたエントリの不一致記録は持ち越さない
mismatched: this.snapshot.mismatched.filter((id) => alive.has(id)),
+ // 共有から消えた id の派生メタも同様に落とす(控えが無限に育たないように)
+ derived: this.pruneDerived(alive),
});
} catch (e) {
// ここに来るのは load(root) 自体が落ちたとき(= どの type も読めていない)。
@@ -202,9 +267,43 @@ class SharedLibraryStore {
this.bodyCache.delete(oldest);
}
this.setMismatched(entry.id, !verified);
+ // 本文を読んだこの経路にだけ相乗りして派生メタを更新する(読み取りは増やさない)
+ this.recordDerivedMeta(entry, content.body, verified);
return value;
}
+ /** 共有から消えた id を落とした派生メタを返す(変化が無ければ同じ参照) */
+ private pruneDerived(alive: Set): SharedDerivedMetaMap {
+ const current = this.snapshot.derived;
+ const ids = Object.keys(current);
+ const kept = ids.filter((id) => alive.has(id));
+ if (kept.length === ids.length) return current;
+ const next: SharedDerivedMetaMap = {};
+ for (const id of kept) next[id] = current[id];
+ persistDerivedMeta(next);
+ return next;
+ }
+
+ private recordDerivedMeta(entry: SharedEntry, body: Uint8Array, verified: boolean): void {
+ const meta = extractSharedDerivedMeta(entry, body, verified);
+ if (!meta) return;
+ const prev = this.snapshot.derived[entry.id];
+ if (
+ prev &&
+ prev.hash === entry.hash &&
+ prev.noteContexts.length === meta.noteContexts.length &&
+ prev.noteContexts.every((c, i) => c === meta.noteContexts[i])
+ ) {
+ return; // 同じ値なら再描画を起こさない
+ }
+ const next: SharedDerivedMetaMap = {
+ ...this.snapshot.derived,
+ [entry.id]: { hash: entry.hash, ...meta },
+ };
+ persistDerivedMeta(next);
+ this.emit({ derived: next });
+ }
+
private setMismatched(id: string, bad: boolean): void {
const has = this.snapshot.mismatched.includes(id);
if (bad === has) return;
@@ -225,6 +324,12 @@ class SharedLibraryStore {
this.bodyCache.clear();
this.snapshot = EMPTY;
this.listeners.clear();
+ // 派生メタの控えもテストごとに捨てる(前のテストの値が次に漏れないように)
+ try {
+ localStorage.removeItem(DERIVED_META_KEY);
+ } catch {
+ /* localStorage が無い環境 */
+ }
}
}
@@ -264,6 +369,22 @@ export function readSharedEntryBody(entry: SharedEntry): Promise<{ body: Uint8Ar
return store.readBody(entry);
}
+/**
+ * 共有エントリのフォルダ(共有した時点の noteContexts)を引く唯一のヘルパー。
+ * 表・検索・詳細のどこから見ても同じ値になるよう、優先順位をここ 1 か所に閉じる:
+ * 1. `extra.noteContexts`(共有時に書かれたもの。文字列配列のときだけ採る)
+ * 2. 本文から拾った派生メタ(hash が一致するときだけ。古い共有エントリの補完)
+ * 3. 空配列(=未分類)
+ */
+export function getSharedNoteContexts(entry: SharedEntry, snapshot: SharedLibrarySnapshot): string[] {
+ const raw = (entry.extra as Record | undefined)?.noteContexts;
+ if (Array.isArray(raw)) return raw.filter((c): c is string => typeof c === "string" && c.trim() !== "");
+ const derived = snapshot.derived[entry.id];
+ // hash が違う=別の版を読んだときの値。表示に使うと嘘になるので捨てる
+ if (derived && derived.hash === entry.hash) return derived.noteContexts;
+ return [];
+}
+
/** 共有ルート(デスクトップかつ設定済みのときだけ非 null) */
export function getSharedLibraryRoot(): string | null {
return store.currentRoot();
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index ad603be96..7f6217f37 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -2390,6 +2390,7 @@ export const en: Record = {
"library.sort.author": "Author",
"library.sort.version": "Version",
"library.filterKind": "Filter by kind",
+ "library.filterFolder": "Filter by folder",
"library.hash.ok": "Verified",
"library.hash.mismatch": "Mismatch",
"library.hash.error": "Verify error",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 3b0f3b47f..842526788 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -2387,6 +2387,7 @@ export const ja: Record = {
"library.sort.author": "作者",
"library.sort.version": "版",
"library.filterKind": "種別で絞り込む",
+ "library.filterFolder": "フォルダで絞り込む",
"library.hash.ok": "検証済み",
"library.hash.mismatch": "不一致",
"library.hash.error": "検証エラー",