diff --git a/manual/ja/storage-and-sync.md b/manual/ja/storage-and-sync.md index 05efb72ed..8439f4d27 100644 --- a/manual/ja/storage-and-sync.md +++ b/manual/ja/storage-and-sync.md @@ -99,6 +99,8 @@ Fork したページが入るのは、ノートではなく自分のナレッジ 共有フォルダを設定すると、サイドバーに **ライブラリ** セクションと **共有** の項目が現れます。チーム全員が共有したもの(ノート・ナレッジページ・文献・データファイル)が、種類ごとのタブに並びます。他人のエントリは読み取り専用です。**Fork** を押すと、自分のストレージにコピーして自由に編集できます。持っているメディアは自動でローカルのライブラリに実体化され、fork にはその出どころが記録されます。 +共有されたノートには、共有した時点のフォルダも付いてきます。**ノート** のタブに **フォルダ** 列が並び、そこで絞り込みや検索ができます。Fork ではこのフォルダは引き継ぎません。共有した人の整理であって、こちらの分類ではないからです(fork したノートは未分類から始まります)。 + ### 検索と AI チャットでの共有エントリ {#shared-entries-in-search-and-ai-chat} 共有されたノート・ナレッジページ・文献・データファイルは、fork しなくても現れます。`⌘K` 検索パレットに **共有** のセクションが増え、AI チャットも自分のノートと同じように共有エントリを横断検索の対象にします(Internal のグラウンディングスコープ)。索引(と、共有ナレッジページについては埋め込み)は自分の端末上だけに作られます。共有フォルダには何も書き戻さず、AI が共有エントリを根拠にしても、あなた自身が引用カードを挿入しない限り PROV には記録されません。 diff --git a/manual/storage-and-sync.md b/manual/storage-and-sync.md index 63a32ba0a..b5effefa8 100644 --- a/manual/storage-and-sync.md +++ b/manual/storage-and-sync.md @@ -99,6 +99,8 @@ The button appears in the desktop app once a shared folder and an identity are s Once a shared folder is configured, a **Library** section with a **Shared** entry appears in the sidebar. It lists what everyone on the team has shared — notes, knowledge pages, references, and data files — on a tab each. Entries from others are read-only — press **Fork** to copy one into your own storage, where you can edit it freely. Any media it carries is materialized into your local library automatically, and the fork records where it came from. +A shared note also carries the folder it was in when it was shared, so the **Notes** tab has a **Folder** column you can filter and search on. Forking does not bring that folder along: it is the author's own filing, so your copy starts unfiled. + ### Shared entries in search and AI chat {#shared-entries-in-search-and-ai-chat} Shared notes, knowledge pages, references, and data files also show up without forking them: the `⌘K` search palette gets a **Shared** section, and AI chat can cross-search them the same way it cross-searches your own notes (Internal grounding scope). Graphium builds a search index and, for shared knowledge pages, an embedding — both on your own device only. Nothing is written back to the shared folder, and the AI never records a shared entry as a source in provenance unless you insert a citation card yourself. diff --git a/src/features/composer/Composer.tsx b/src/features/composer/Composer.tsx index ecd4008c5..ee66ad6af 100644 --- a/src/features/composer/Composer.tsx +++ b/src/features/composer/Composer.tsx @@ -52,7 +52,7 @@ import { import { collectLexicalHits } from "./lexical-hits"; import { useLexicalStatus } from "../lexical-search"; // 共有ライブラリは重い SharedLibraryView を巻き込まないよう、バレルではなくストアを直接引く -import { useSharedLibrary } from "../sharing/shared-library-store"; +import { getSharedNoteContexts, useSharedLibrary } from "../sharing/shared-library-store"; import { getSharedRoot, getSharedAiEnabled } from "../../lib/storage/shared/config"; import { isTauri } from "../../lib/platform"; import type { SharedEntry } from "../../lib/storage/shared"; @@ -193,8 +193,10 @@ export function Composer(props: ComposerProps) { return searchShared(prompt, sharedLibrary.entries, { limit: MAX_SHARED_RESULTS, sharedHits: sharedTextHits, + // 共有時点のフォルダ名でも当てる(手元のノートと同じ理由 "folder") + noteContextsOf: (entry) => getSharedNoteContexts(entry, sharedLibrary), }); - }, [prompt, sharedEnabled, sharedLibrary.entries, sharedTextHits]); + }, [prompt, sharedEnabled, sharedLibrary, sharedTextHits]); // 素材(ファイル名 + OCR で読み取った画像内の文字 + 索引済みテキスト) const mediaHits = useMemo(() => { @@ -691,6 +693,28 @@ type NoteRowProps = { onClick: () => void; }; +/** + * 「なぜこの行が出たか」の理由バッジ。今はフォルダ名で当たったときだけ出す + * (題名にも本文にも見えない場所で当たっているので、印が無いと結果が不気味になる)。 + */ +function FolderReasonBadge() { + const t = useT(); + return ( + + {t("nav.noteContexts")} + + ); +} + function NoteRow({ hit, active, onMouseEnter, onClick }: NoteRowProps) { const { entry, titleMatches, bodySnippet } = hit; const isWiki = entry.source === "ai"; @@ -753,6 +777,7 @@ function NoteRow({ hit, active, onMouseEnter, onClick }: NoteRowProps) { )} + {hit.reasons.includes("folder") && } {entry.author && ( )} + {hit.reasons.includes("folder") && } {authorName && ( & { noteId: string; title: strin wikiKind: partial.wikiKind, author: partial.author, model: partial.model, + noteContexts: partial.noteContexts, }; } @@ -443,3 +444,47 @@ describe("searchShared()", () => { expect(searchShared("焼結", [])).toEqual([]); }); }); + +describe("フォルダ名でのヒット(reason: folder)", () => { + const notes: NoteIndexEntry[] = [ + entry({ noteId: "n1", title: "無題のメモ", noteContexts: ["卒論/焼結"] }), + entry({ noteId: "n2", title: "焼結の記録", noteContexts: ["共通/装置"] }), + entry({ noteId: "n3", title: "関係ないノート" }), + ]; + + it("ノート: タイトルに無くてもフォルダ名で当たる", () => { + const hits = searchNotes("卒論", notes); + expect(hits.map((h) => h.entry.noteId)).toEqual(["n1"]); + expect(hits[0].reasons).toContain("folder"); + }); + + it("ノート: 大文字小文字を無視する", () => { + const upper = [entry({ noteId: "n4", title: "x", noteContexts: ["Thesis/Sintering"] })]; + expect(searchNotes("thesis", upper).map((h) => h.entry.noteId)).toEqual(["n4"]); + }); + + it("ノート: タイトル一致にもフォルダ一致にも当たれば両方が理由に残る", () => { + const hits = searchNotes("焼結", notes); + const n1 = hits.find((h) => h.entry.noteId === "n1"); + expect(n1?.reasons).toEqual(expect.arrayContaining(["folder"])); + const n2 = hits.find((h) => h.entry.noteId === "n2"); + expect(n2?.reasons).toContain("title-prefix"); + expect(n2?.reasons).not.toContain("folder"); + }); + + const sharedEntries: SharedEntry[] = [ + shared({ id: "s1", title: "無題の共有ノート" }), + shared({ id: "s2", title: "関係ない共有ノート" }), + ]; + const noteContextsOf = (e: SharedEntry) => (e.id === "s1" ? ["卒論/焼結"] : []); + + it("共有: noteContextsOf で渡したフォルダ名で当たる", () => { + const hits = searchShared("卒論", sharedEntries, { noteContextsOf }); + expect(hits.map((h) => h.entry.id)).toEqual(["s1"]); + expect(hits[0].reasons).toContain("folder"); + }); + + it("共有: noteContextsOf を渡さなければフォルダでは当たらない", () => { + expect(searchShared("卒論", sharedEntries)).toEqual([]); + }); +}); diff --git a/src/features/composer/search.ts b/src/features/composer/search.ts index 0e4a63a0e..b546f2955 100644 --- a/src/features/composer/search.ts +++ b/src/features/composer/search.ts @@ -33,7 +33,14 @@ export type SearchHit = { bodySnippet?: TextSnippet; }; -export type SearchReason = "title-prefix" | "title-contains" | "heading" | "label" | "author" | "body"; +export type SearchReason = + | "title-prefix" + | "title-contains" + | "heading" + | "label" + | "author" + | "folder" + | "body"; /** 抜粋(複数範囲を強調できる) */ export type TextSnippet = { @@ -52,6 +59,8 @@ export type TextHit = { /** 本文ヒットの加点: 見出し一致(25)より下、ラベル/作者フィルタ(30/20)とは独立 */ const BODY_BASE_SCORE = 12; +/** フォルダ名一致の加点。ラベル一致と同じ重み(どちらも人が付けた分類軸なので揃える) */ +const FOLDER_SCORE = 30; /** 本文ヒットの相対スコアによる上乗せの最大値 */ const BODY_RELATIVE_SCORE_MAX = 10; @@ -204,6 +213,8 @@ export function searchNotes( // フリーテキスト if (textLower) { + // フォルダ名一致(人が付けた分類軸。一覧の「フォルダ」列と同じ値で当てる) + const folderHit = (entry.noteContexts ?? []).some((c) => c.toLowerCase().includes(textLower)); const occurrences = findAllOccurrences(entry.title, textLower); if (occurrences.length > 0) { titleMatches = occurrences; @@ -222,11 +233,21 @@ export function searchNotes( if (headingHit) { score += 25; reasons.push("heading"); - } else if (!bodyHit && parsed.labelTokens.length === 0 && parsed.authorTokens.length === 0) { - // フィルタもタイトル/見出し/本文も当たっていない → 落とす + } else if ( + !bodyHit && + !folderHit && + parsed.labelTokens.length === 0 && + parsed.authorTokens.length === 0 + ) { + // フィルタもタイトル/見出し/フォルダ/本文も当たっていない → 落とす continue; } } + // フォルダ名は題名・見出しに当たっていても理由として残す(なぜ出たかが分かるように) + if (folderHit) { + score += FOLDER_SCORE; + reasons.push("folder"); + } // 本文(語彙インデックス)ヒット。タイトル・見出しに当たっていても抜粋は添える if (bodyHit) { score += BODY_BASE_SCORE + relativeBoost(bodyHit.score, bodyMax, BODY_RELATIVE_SCORE_MAX); @@ -412,7 +433,7 @@ export function searchMedia( // ── 共有ライブラリ検索 ── -export type SharedSearchReason = "title-prefix" | "title-contains" | "author" | "body"; +export type SharedSearchReason = "title-prefix" | "title-contains" | "author" | "folder" | "body"; export type SharedHit = { entry: SharedEntry; @@ -429,6 +450,12 @@ export type SharedSearchOptions = { limit?: number; /** 共有エントリの本文ヒット。entry.id → ヒット。無ければ題名・作者だけで当てる */ sharedHits?: ReadonlyMap; + /** + * 共有エントリのフォルダ(共有時点の noteContexts)を引く関数。 + * 値は共有ストアのスナップショット由来なので、ここを純関数に保つために注入で受ける。 + * 未指定ならフォルダでは当てない。 + */ + noteContextsOf?: (entry: SharedEntry) => string[]; }; /** @@ -499,6 +526,10 @@ export function searchShared( let bodySnippet: TextSnippet | undefined; if (textLower) { + // 共有時点のフォルダ名一致(手元のノートの folder 理由と揃える) + const folderHit = (options.noteContextsOf?.(entry) ?? []).some((c) => + c.toLowerCase().includes(textLower), + ); const occurrences = findAllOccurrences(title, textLower); if (occurrences.length > 0) { titleMatches = occurrences; @@ -509,10 +540,14 @@ export function searchShared( score += 50; reasons.push("title-contains"); } - } else if (!bodyHit && parsed.authorTokens.length === 0) { - // 題名にも本文にも当たらず、作者フィルタも無い → 落とす + } else if (!bodyHit && !folderHit && parsed.authorTokens.length === 0) { + // 題名・本文・フォルダのどれにも当たらず、作者フィルタも無い → 落とす continue; } + if (folderHit) { + score += FOLDER_SCORE; + reasons.push("folder"); + } // 本文ヒットは題名に当たっていても抜粋として添える(ノート行と同じ扱い) if (bodyHit) { score += BODY_BASE_SCORE + relativeBoost(bodyHit.score, bodyMax, BODY_RELATIVE_SCORE_MAX); diff --git a/src/features/sharing/SharedLibraryTable.test.tsx b/src/features/sharing/SharedLibraryTable.test.tsx new file mode 100644 index 000000000..a83eef526 --- /dev/null +++ b/src/features/sharing/SharedLibraryTable.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +// 共有ライブラリの表「フォルダ列」が、ノート一覧のフォルダ列と同じ見せ方になっているかのテスト。 +// +// 対象の不変条件(鏡の原則 — 同じ意味のものは同じ見た目で出す): +// - 列ヘッダにノート一覧と同じ説明ツールチップ(nav.noteContextsTooltip)が付く +// - フォルダ無しの行のダッシュは、ノート一覧と同じ薄さ(text-muted-foreground/30) +// - 絞り込みポップアップの選択肢に、表のピル(ContextBadge)と同じ色のドットが付く +// (未分類は実在するフォルダではないので色を持たない = 中空のドット) + +import { describe, it, expect, afterEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/react"; +import { SharedLibraryTable } from "./SharedLibraryTable"; +import { LocaleProvider, t } from "../../i18n"; +import { ContextBadge } from "../note-context/ContextBadge"; +import type { SharedEntry } from "../../lib/storage/shared"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const AUTHOR = { name: "Ada", email: "ada@example.com" }; + +const entry = (id: string, title: string, noteContexts?: string[]): SharedEntry => ({ + id, + type: "note", + author: AUTHOR, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + hash: `sha256:${id}`, + prov: { derived_from: [] }, + version: 1, + extra: noteContexts ? { title, noteContexts } : { title }, +}); + +const ENTRIES = [ + entry("n1", "焼結の記録", ["卒論/焼結"]), + entry("n2", "装置メモ", ["共通/装置"]), + entry("n3", "フォルダ無し"), +]; + +function renderTable() { + return render( + + {}} + onVerifyHash={() => {}} + onCopyCitation={() => {}} + onUnshare={() => {}} + /> + , + ); +} + +/** + * 表のピル(ContextBadge)が使う色。jsdom は inline style の hsl() を rgb() に + * 変換するので、期待値も同じ経路(実際の ContextBadge を描画)から取って比べる。 + */ +function badgeColor(value: string): string { + const { container, unmount } = render( + + + , + ); + const color = (container.firstElementChild as HTMLElement).style.color; + unmount(); + return color; +} + +afterEach(() => cleanup()); + +describe("SharedLibraryTable のフォルダ列", () => { + it("列ヘッダにノート一覧と同じ説明ツールチップが付く", () => { + const { container } = renderTable(); + const headers = Array.from(container.querySelectorAll("th")); + const folderTh = headers.find((th) => th.textContent?.includes(t("nav.noteContexts"))); + expect(folderTh?.getAttribute("title")).toBe(t("nav.noteContextsTooltip")); + }); + + it("フォルダ無しの行のダッシュはノート一覧と同じ薄さで出る", () => { + const { container } = renderTable(); + const dash = Array.from(container.querySelectorAll("span")).find( + (el) => el.textContent === "—", + ); + expect(dash?.className).toContain("text-muted-foreground/30"); + }); + + it("絞り込みの選択肢に表のピルと同じ色のドットが付く(未分類は色無し)", () => { + const { container } = renderTable(); + const filterBtn = container.querySelector( + `button[aria-label="${t("library.filterFolder")}"]`, + ) as HTMLButtonElement; + fireEvent.click(filterBtn); + + // ポップアップは portal 経由で body 直下に出る + const options = Array.from( + document.body.querySelectorAll('button[role="menuitemcheckbox"]'), + ) as HTMLElement[]; + for (const folder of ["卒論/焼結", "共通/装置"]) { + const opt = options.find((o) => o.textContent?.includes(folder)); + const dot = opt?.querySelector("span.rounded-full") as HTMLElement | null; + expect(dot, `${folder} のドット`).toBeTruthy(); + expect(dot?.style.backgroundColor).toBe(badgeColor(folder)); + } + + // 未分類の選択肢は色を持たず、境界線だけのドットになる + const unfiled = options.find((o) => o.textContent?.includes(t("nav.unfiled"))); + expect(unfiled, "未分類の選択肢").toBeTruthy(); + const unfiledDot = unfiled?.querySelector("span.rounded-full") as HTMLElement | null; + expect(unfiledDot?.style.backgroundColor).toBe(""); + expect(unfiledDot?.className).toContain("border-border"); + }); +}); diff --git a/src/features/sharing/SharedLibraryTable.tsx b/src/features/sharing/SharedLibraryTable.tsx index 91c43b59f..3ffdb67b0 100644 --- a/src/features/sharing/SharedLibraryTable.tsx +++ b/src/features/sharing/SharedLibraryTable.tsx @@ -5,11 +5,15 @@ // 呼び出し側(SharedLibraryView)が type タブ(note / knowledge / asset)に // 応じて entries を絞ってから渡す。ここでは検索・並び替え・絞り込みのみを担う。 -import { useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { Check, Filter, GitFork, Link2, Trash2 } from "lucide-react"; import type { AuthorIdentity } from "../document-provenance/types"; import type { SharedEntry } from "../../lib/storage/shared"; import { NoteListToolbar } from "../navigation/NoteListToolbar"; +import { ContextBadge } from "../note-context/ContextBadge"; +import { aggregateNoteContexts, noteContextHue } from "../note-context/context-tags"; +import { UNFILED_PATH } from "../note-context/folder-tree-model"; +import { getSharedNoteContexts, useSharedLibrary } from "./shared-library-store"; import { FilterPopup, type FilterOption } from "../../ui/filter-popup"; import { formatDate } from "../../lib/format-datetime"; import { cn } from "../../lib/utils"; @@ -67,6 +71,18 @@ function entryKind(entry: SharedEntry, t: (k: string) => string): { value: strin return { value: entry.type, label: entry.type }; } +// FilterPopup の左側に置く小さな色チップ(NoteListView の LabelDot と同じ見た目)。 +// 表本体の ContextBadge と同じ HSL を使い、絞り込みの選択肢と行のピルが同じ色で対応する。 +// color 無しは「色を持たない選択肢」(未分類)用の中空チップ — 行の左端が揃うようにする。 +function LabelDot({ color }: { color?: string }) { + return ( + + ); +} + function isForkable(type: SharedEntry["type"]): boolean { return type === "note" || type === "knowledge"; } @@ -87,13 +103,22 @@ export function SharedLibraryTable({ }: SharedLibraryTableProps) { const t = useT(); const showKindColumn = tab === "asset" || tab === "knowledge"; + // フォルダはノートだけが持つ概念(ナレッジ・素材には無い)ので note タブ限定 + const showFolderColumn = tab === "note"; + // 表は表示専用なので、フォルダの値はストアのスナップショットから引く + // (共有時に書かれた extra を優先し、無ければ本文から拾った控えで補う) + const sharedSnapshot = useSharedLibrary(); const [sortKey, setSortKey] = useState("updatedAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [searchQuery, setSearchQuery] = useState(""); const [kindFilter, setKindFilter] = useState([]); const [authorFilter, setAuthorFilter] = useState([]); + const [folderFilter, setFolderFilter] = useState([]); + const [folderFilterOpen, setFolderFilterOpen] = useState(false); + const [folderFilterPos, setFolderFilterPos] = useState({ top: 0, left: 0 }); + const folderFilterBtnRef = useRef(null); const [kindFilterOpen, setKindFilterOpen] = useState(false); const [kindFilterPos, setKindFilterPos] = useState({ top: 0, left: 0 }); const kindFilterBtnRef = useRef(null); @@ -124,6 +149,11 @@ export function SharedLibraryTable({ .sort((a, b) => a.label.localeCompare(b.label, "ja")); }, [entries, showKindColumn, t]); + const contextsOf = useCallback( + (entry: SharedEntry) => getSharedNoteContexts(entry, sharedSnapshot), + [sharedSnapshot], + ); + const authorFilterOptions = useMemo(() => { const counts = new Map(); for (const entry of entries) { @@ -140,6 +170,27 @@ export function SharedLibraryTable({ .sort((a, b) => a.label.localeCompare(b.label, "ja")); }, [entries]); + // フォルダの選択肢(表示中のエントリから集計)。「未分類」は該当が 1 件以上あるときだけ出す + const folderFilterOptions = useMemo(() => { + if (!showFolderColumn) return []; + const options: FilterOption[] = aggregateNoteContexts( + entries.map((e) => ({ noteContexts: contextsOf(e) })), + ) + // 色チップはノート一覧のフォルダ列フィルタと同じ(表のピルと同色で対応が付く) + .map(({ value, count }) => ({ + value, + label: value, + count, + icon: , + })) + .sort((a, b) => a.label.localeCompare(b.label, "ja")); + const unfiled = entries.filter((e) => contextsOf(e).length === 0).length; + // 未分類は実在するフォルダではないので色を持たない(中空チップで左端だけ揃える) + if (unfiled > 0) + options.push({ value: UNFILED_PATH, label: t("nav.unfiled"), count: unfiled, icon: }); + return options; + }, [entries, showFolderColumn, contextsOf, t]); + const filtered = useMemo(() => { let result = entries; @@ -148,7 +199,25 @@ export function SharedLibraryTable({ result = result.filter((entry) => { const title = entryTitle(entry, t).toLowerCase(); const authorName = (entry.author?.name ?? "").toLowerCase(); - return title.includes(q) || authorName.includes(q); + // フォルダ名も検索対象(列に出ている値はどれも同じ部分一致で当たる) + const folderHit = + showFolderColumn && contextsOf(entry).some((c) => c.toLowerCase().includes(q)); + return title.includes(q) || authorName.includes(q) || folderHit; + }); + } + + // フォルダフィルタ(OR) — 小文字比較で名寄せする。 + // UNFILED_PATH は「フォルダ無し」を意味する特殊値(ノート一覧と同じ扱い) + if (folderFilter.length > 0) { + const hasUnfiled = folderFilter.includes(UNFILED_PATH); + const set = new Set( + folderFilter.filter((c) => c !== UNFILED_PATH).map((c) => c.toLowerCase()), + ); + result = result.filter((entry) => { + const contexts = contextsOf(entry); + return ( + (hasUnfiled && contexts.length === 0) || contexts.some((c) => set.has(c.toLowerCase())) + ); }); } @@ -182,7 +251,7 @@ export function SharedLibraryTable({ }); return sorted; - }, [entries, searchQuery, kindFilter, authorFilter, sortKey, sortDir, t]); + }, [entries, searchQuery, kindFilter, authorFilter, folderFilter, contextsOf, showFolderColumn, sortKey, sortDir, t]); const emptyKey = tab === "note" ? "library.empty.note" : tab === "knowledge" ? "library.empty.knowledge" : "library.empty.asset"; @@ -215,6 +284,40 @@ export function SharedLibraryTable({ {t("library.col.title")} {sortKey === "title" && (sortDir === "desc" ? " ↓" : " ↑")} + {/* フォルダ列(共有した時点のフォルダ)。ノート一覧の同名列と同じ見せ方 */} + {showFolderColumn && ( + +
+ {t("nav.noteContexts")} + {folderFilterOptions.length > 0 && ( + + )} + {folderFilter.length > 0 && ( + ({folderFilter.length}) + )} +
+ + )} {showKindColumn && (
@@ -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": "検証エラー",