diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 433d7884b..21e9fe88d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1316,6 +1316,18 @@ Key pieces: media is uploaded as `shared-blob:` references; on Fork, those blobs are re-materialized into the personal copy +The Library mirrors the personal side's left navigation: alongside +Notes / Knowledge / Assets it has **Labels** and **Processes** tabs +that reuse the personal `LabelGalleryView` / `ProcessGalleryView` +components as-is. Since a shared entry is a manifest, not a body, +these tabs are backed by a small local-only cache +(`src/features/sharing/shared-projection.ts`, DATA_MODEL.md §7.6) built +by riding the existing body-read path (the lexical sync lane) instead +of fetching notes just to list them. The Assets tab similarly surfaces +images and files embedded in shared notes (`SharedEntry.extra.blobs`) +as read-only rows — open the parent note, or copy the file into your +own materials. + Today the shared backend is a local folder. Other backends (cloud buckets, S3, IPFS-style) can be added by implementing the same blob interface. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index ff284df7c..1176c8b80 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -1700,7 +1700,8 @@ Graphium/ └── appdata/ ├── note-index.json # the GraphiumIndex ├── graph-layouts.json # saved manual graph arrangements (§5.4) - └── asset-chats:.json # AI chats started from a material (§2.5) + ├── asset-chats:.json # AI chats started from a material (§2.5) + └── shared-projection.json # labels/process extracted from shared notes (§7.6) ``` Media binaries keep their original file extension (derived from the @@ -1923,6 +1924,54 @@ PROV-JSON-LD export resolves to a typed external entity. SidePeek saves bypass revision recording by design, so citations inserted there are not attributed to an edit activity. +### 7.6 Shared projection cache (`SharedProjection`) + +The Library's **Labels** and **Processes** tabs mirror the personal +side's navigation, but a shared entry only carries a manifest — the +body is fetched on demand and kept in an LRU (§7.3). To list labels and +processes without opening every shared note, `src/features/sharing/shared-projection.ts` +keeps a small side cache, written only to the local appdata channel +(`shared-projection.json`, desktop only — never to the shared root): + +```ts +const SHARED_PROJECTION_VERSION = 1; + +type SharedProjectionEntry = { + hash: string; // SharedEntry.hash when projected; skips re-projection if unchanged + title: string; + updatedAt: string; + createdAt: string; + author: string; + headings: NoteIndexEntry["headings"]; + steps?: NoteIndexEntry["steps"]; + labels: NoteIndexEntry["labels"]; + inlineLabels?: NoteIndexEntry["inlineLabels"]; + process: ProcessIndexEntry | null; // null for notes without steps +}; + +type SharedProjection = { + version: number; + logic: { index: number; process: number }; // INDEX_SCHEMA_VERSION / PROCESS_INDEX_VERSION + updatedAt: string; + entries: Record; // keyed by SharedEntry.id +}; +``` + +**No new reads.** Projection rides the lexical sync lane (§17 of the +internal shared-storage design) that already fetches shared note bodies +for the vocabulary index; when that lane reads a body it also calls +`projectSharedNote`, which reuses `buildIndexEntry` (labels/headings/ +steps — the same extraction the personal note index uses) and +`buildProcessEntry` (the process graph). The projected `process` is the +return value of `buildProcessEntry` unmodified, except `crossNoteLinks` +is always cleared: those links point at the *sharer's* local note ids, +which are meaningless on the receiving side. + +**Reconstructible cache.** A `version` or `logic` mismatch discards the +whole file and starts empty; entries missing from a subsequent list +refresh are pruned. Losing this file costs nothing but a few +re-projections next time the affected notes are read. + ## 8. Compatibility rules Hard rules for changing any schema in this document. diff --git a/manual/ja/storage-and-sync.md b/manual/ja/storage-and-sync.md index e9d958d9f..392db8564 100644 --- a/manual/ja/storage-and-sync.md +++ b/manual/ja/storage-and-sync.md @@ -105,6 +105,10 @@ Fork したページが入るのは、ノートではなく自分のナレッジ ![共有ライブラリをフォルダで絞り込む](/screenshots/shared-library-folder-filter.png) +**素材** タブにも同じくフォルダ列があり、共有ノートに貼られている画像・ファイルが、素材として直接共有されたものと並んで一覧に出ます。ノートに貼られたものは単独で fork や検証ができるエントリではないため、**ノートを開く** か **自分の素材に取り込む** かのどちらかを行います。同じファイルが複数の共有ノートに貼られている場合は 1 行にまとめられ、何件のノートに含まれるかが件数で示されます。 + +自分の左ナビゲーションと同じ構成をもう二つ、**ラベル** と **プロセス** のタブが鏡になっています。共有ノートから見つかったラベルと PROV-DM の手順を、自分のノート一覧・プロセス一覧と同じやり方で抽出して表示します。これは後述する共有検索・AI チャットを支えているバックグラウンドの読み込みに相乗りしているため、Graphium がまだ本文を読んでいない共有ノートは、これらのタブにはまだ反映されません。プロセスタブから自分のノートへ 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 066ac427b..7be14451f 100644 --- a/manual/storage-and-sync.md +++ b/manual/storage-and-sync.md @@ -105,6 +105,10 @@ A shared note also carries the folder it was in when it was shared, so the **Not ![Filtering the shared library by folder](/screenshots/shared-library-folder-filter.png) +The **Assets** tab also has a Folder column, and lists every image or file embedded in a shared note alongside items shared directly as a material. A note's images and files are not entries you can fork or verify on their own — you can **open the parent note** or **add the file to your own materials**, and if the same file appears in several shared notes it is shown once with a count of how many notes carry it. + +Two more tabs mirror your own left-hand navigation: **Labels** and **Processes**. They show labels and PROV-DM procedures found in shared notes, extracted the same way your own note list and process list are. This piggybacks on the background read that also powers shared search and AI chat (see below), so a shared note whose content Graphium has not read yet will not contribute to these tabs until it does. Forking a process into your own notes works the same way it does for your own procedures. + ### 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/asset-browser/LabelGalleryView.tsx b/src/features/asset-browser/LabelGalleryView.tsx index 0643880d8..75cb64be3 100644 --- a/src/features/asset-browser/LabelGalleryView.tsx +++ b/src/features/asset-browser/LabelGalleryView.tsx @@ -58,6 +58,11 @@ export type LabelGalleryViewProps = { label: string; onBack: () => void; onNavigateNote: (noteId: string) => void; + /** + * 戻るボタンを出さない。呼び出し側が種別の切り替え導線を自前で持つとき + * (共有ライブラリのラベルタブ)に使う。既定は従来どおり表示する。 + */ + hideBack?: boolean; }; // ── ネットワークモーダル ── @@ -282,6 +287,7 @@ export function LabelGalleryView({ label, onBack, onNavigateNote, + hideBack = false, }: LabelGalleryViewProps) { const t = useT(); const [searchQuery, setSearchQuery] = useState(""); @@ -414,12 +420,14 @@ export function LabelGalleryView({
{/* ヘッダー */}
- + {!hideBack && ( + + )} void; onCreateProvNote?: (entry: MediaIndexEntry) => void; /** PDF / URL を原文構成のまま UI 言語へ全文翻訳して 1 ノート化する */ @@ -50,6 +60,7 @@ export type MaterialActionsMenuProps = { export function MaterialActionsMenu({ entry, + noteFolderLookup, onIngest, onCreateProvNote, onTranslatePdf, @@ -120,9 +131,11 @@ export function MaterialActionsMenu({ setShareBusy(true); setShareError(null); try { + // 素材ギャラリーの「フォルダ」と同じ値を共有側にも載せる(AssetGalleryView と同じ引き方) + const noteContexts = assetFolderValues(entry, noteFolderLookup ?? EMPTY_NOTE_FOLDER_LOOKUP); const result = isUrlEntry - ? await shareReference(entry, { sharedRoot, author: sharedAuthor, title: entry.name, description: "" }) - : await shareMedia(entry, { sharedRoot, blobRoot: blobRoot!, author: sharedAuthor, title: entry.name, description: "" }); + ? await shareReference(entry, { sharedRoot, author: sharedAuthor, title: entry.name, description: "", noteContexts }) + : await shareMedia(entry, { sharedRoot, blobRoot: blobRoot!, author: sharedAuthor, title: entry.name, description: "", noteContexts }); if (!result.ok) { setShareError(result.error); return; @@ -133,7 +146,7 @@ export function MaterialActionsMenu({ } finally { setShareBusy(false); } - }, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, onSharedRefUpdated]); + }, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, noteFolderLookup, onSharedRefUpdated]); const itemClass = "w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-foreground rounded hover:bg-muted transition-colors disabled:text-muted-foreground disabled:cursor-not-allowed"; diff --git a/src/features/asset-browser/material-detail-header.tsx b/src/features/asset-browser/material-detail-header.tsx index 9328105c1..50ef2d692 100644 --- a/src/features/asset-browser/material-detail-header.tsx +++ b/src/features/asset-browser/material-detail-header.tsx @@ -268,6 +268,7 @@ export function MaterialDetailHeader({ const actionsMenu = ( Promise | void; /** トリガーボタンの className を上書き(既定はパネルアクション色) */ buttonClassName?: string; @@ -24,6 +33,7 @@ export type ShareMediaDialogProps = { */ export function ShareMediaDialog({ entry, + noteFolderLookup, onSharedRefUpdated, buttonClassName, }: ShareMediaDialogProps) { @@ -71,12 +81,15 @@ export function ShareMediaDialog({ const entryWithRef: MediaIndexEntry = sharedRefState ? { ...entry, sharedRef: sharedRefState } : entry; + // 素材ギャラリーの「フォルダ」と同じ値を共有側にも載せる(AssetGalleryView と同じ引き方) + const noteContexts = assetFolderValues(entryWithRef, noteFolderLookup ?? EMPTY_NOTE_FOLDER_LOOKUP); const result = isUrlEntry ? await shareReference(entryWithRef, { sharedRoot, author: sharedAuthor, title, description, + noteContexts, }) : await shareMedia(entryWithRef, { sharedRoot, @@ -84,6 +97,7 @@ export function ShareMediaDialog({ author: sharedAuthor, title, description, + noteContexts, }); if (!result.ok) { setError(result.error); @@ -99,7 +113,7 @@ export function ShareMediaDialog({ } finally { setBusy(false); } - }, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, sharedRefState, title, description, onSharedRefUpdated]); + }, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, noteFolderLookup, sharedRefState, title, description, onSharedRefUpdated]); return ( <> diff --git a/src/features/network-graph/ProcessGalleryView.tsx b/src/features/network-graph/ProcessGalleryView.tsx index 9eb005855..d5744473e 100644 --- a/src/features/network-graph/ProcessGalleryView.tsx +++ b/src/features/network-graph/ProcessGalleryView.tsx @@ -28,6 +28,16 @@ export type ProcessGalleryViewProps = { onBack: () => void; onNavigateNote: (noteId: string) => void; onForkProcess: (noteId: string) => Promise; + /** + * 戻るボタンを出さない。呼び出し側が別の切り替え導線を持つとき + * (共有ライブラリのプロセスタブ)に使う。既定は従来どおり表示する。 + */ + hideBack?: boolean; + /** + * fork ボタンの文言。共有ライブラリでは「自分のノートに派生」と言い方が変わるので + * 差し替えられるようにする。未指定なら従来どおり process.fork。 + */ + forkLabel?: string; }; type SortKey = "stepCount" | "modifiedAt" | "title"; @@ -46,6 +56,8 @@ export function ProcessGalleryView({ onBack, onNavigateNote, onForkProcess, + hideBack = false, + forkLabel, }: ProcessGalleryViewProps) { const t = useT(); const [searchQuery, setSearchQuery] = useState(""); @@ -139,12 +151,14 @@ export function ProcessGalleryView({ {/* 左: 一覧 */}
- + {!hideBack && ( + + )} {t("process.title")} {t("process.count", { n: String(filtered.length) })} @@ -205,7 +219,9 @@ export function ProcessGalleryView({ className="shrink-0 inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded border border-border text-foreground hover:bg-surface-hover disabled:opacity-50 disabled:cursor-wait transition-colors" > - {forkingNoteId === selected.noteId ? t("process.forking") : t("process.fork")} + {forkingNoteId === selected.noteId + ? t("process.forking") + : (forkLabel ?? t("process.fork"))}
{forkError && ( diff --git a/src/features/sharing/SharedLabelsTab.test.tsx b/src/features/sharing/SharedLabelsTab.test.tsx new file mode 100644 index 000000000..e877fc316 --- /dev/null +++ b/src/features/sharing/SharedLabelsTab.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +// Library「ラベル」タブのテスト。 +// +// 対象の不変条件: +// - チップの件数がギャラリーの行数と一致する(FileSidebar と同じ数え方 = +// block preview / インラインの文字列 / 工程名のユニーク数。数字と行数がずれると +// どちらが本当か分からなくなる) +// - チップで種別を切り替えるとギャラリーの中身も切り替わる +// - 戻るボタンは出さない(切り替え導線はチップが担う) +// - 投影がまだ無いノートは数にも一覧にも出ない(読めた分だけ増える) + +import { describe, it, expect, afterEach } from "vitest"; +import { render, fireEvent, cleanup, within } from "@testing-library/react"; +import { SharedLabelsTab } from "./SharedLabelsTab"; +import { + createEmptySharedProjection, + projectSharedNote, + type SharedProjection, +} from "./shared-projection"; +import { LocaleProvider, t, getDisplayLabelName } from "../../i18n"; +import type { GraphiumDocument } from "../../lib/document-types"; +import type { SharedEntry } from "../../lib/storage/shared"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const styled = (text: string, styles: Record = {}) => ({ + type: "text", + text, + styles, +}); +const para = (id: string, content: any[]) => ({ id, type: "paragraph", content, children: [] }); +const step = (id: string, title: string, children: any[] = []) => ({ + id, + type: "step", + content: [styled(title)], + children, +}); +const doc = (blocks: any[], title: string): GraphiumDocument => + ({ + version: 6, + title, + pages: [{ id: "p1", title, blocks, labels: {}, provLinks: [], knowledgeLinks: [] }], + }) as any; + +function sharedNote(id: string, title: string): SharedEntry { + return { + id, + type: "note", + author: { name: "Ada", email: "ada@example.com" }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + hash: `sha256:${id}`, + prov: { derived_from: [] }, + version: 1, + extra: { title }, + }; +} + +const NOTE_A = sharedNote("s-a", "焼成の記録"); +const NOTE_B = sharedNote("s-b", "粉砕の記録"); + +// procedure: 焼成 / 粉砕 = 2、material: 前駆体粉末 / アルミナ = 2、tool: 電気炉 = 1 +const DOC_A = doc( + [ + step("a-s1", "焼成", [ + para("a-b1", [styled("前駆体粉末", { inlineMaterial: "m1" })]), + para("a-b2", [styled("電気炉", { inlineTool: "t1" })]), + ]), + ], + "焼成の記録", +); +const DOC_B = doc( + [ + step("b-s1", "粉砕", [ + para("b-b1", [styled("前駆体粉末", { inlineMaterial: "m1" })]), + para("b-b2", [styled("アルミナ", { inlineMaterial: "m2" })]), + ]), + ], + "粉砕の記録", +); + +function projectionOf(...pairs: [SharedEntry, GraphiumDocument][]): SharedProjection { + const projection = createEmptySharedProjection(); + for (const [entry, d] of pairs) projection.entries[entry.id] = projectSharedNote(entry, d); + return projection; +} + +function renderTab(projection: SharedProjection, entries: SharedEntry[] = [NOTE_A, NOTE_B]) { + return render( + + {}} /> + , + ); +} + +/** 種別チップ(aria-pressed を持つボタン)を表示名で引く */ +function chip(container: HTMLElement, label: string): HTMLElement { + const hit = [...container.querySelectorAll("button[aria-pressed]")].find((b) => + b.textContent?.includes(getDisplayLabelName(label)), + ); + if (!hit) throw new Error(`chip not found: ${label}`); + return hit as HTMLElement; +} + +afterEach(cleanup); + +describe("SharedLabelsTab", () => { + it("種別チップの件数がギャラリーの行数と一致する", () => { + const { container } = renderTab(projectionOf([NOTE_A, DOC_A], [NOTE_B, DOC_B])); + + expect(chip(container, "material").textContent).toContain("2"); + expect(chip(container, "procedure").textContent).toContain("2"); + expect(chip(container, "tool").textContent).toContain("1"); + + // 初期表示は件数が最も多い種別(同数なら名前順 = material) + const rows = container.querySelectorAll("tbody tr"); + expect(rows.length).toBe(2); + const previews = [...rows].map((r) => r.querySelector("td")?.textContent); + expect(previews).toEqual(expect.arrayContaining(["前駆体粉末", "アルミナ"])); + }); + + it("チップを切り替えるとその種別の一覧になる", () => { + const { container } = renderTab(projectionOf([NOTE_A, DOC_A], [NOTE_B, DOC_B])); + + fireEvent.click(chip(container, "procedure")); + + const previews = [...container.querySelectorAll("tbody tr")].map( + (r) => r.querySelector("td")?.textContent, + ); + expect(previews).toEqual(expect.arrayContaining(["焼成", "粉砕"])); + expect(previews).not.toContain("アルミナ"); + }); + + it("戻るボタンは出さない(切り替えはチップが担う)", () => { + const { queryByText } = renderTab(projectionOf([NOTE_A, DOC_A])); + expect(queryByText(t("common.back"))).toBeNull(); + }); + + it("まだ投影されていない共有ノートは数にも一覧にも出ない", () => { + // NOTE_B は共有されているが本文をまだ読めていない状態 + const { container } = renderTab(projectionOf([NOTE_A, DOC_A])); + + expect(chip(container, "procedure").textContent).toContain("1"); + const table = container.querySelector("table"); + expect(table && within(table).queryByText("粉砕")).toBeNull(); + }); + + it("投影が空なら空表示を出す", () => { + const { getByText, container } = renderTab(createEmptySharedProjection()); + expect(getByText(t("library.empty.labels"))).toBeTruthy(); + expect(container.querySelector("button[aria-pressed]")).toBeNull(); + }); +}); diff --git a/src/features/sharing/SharedLabelsTab.tsx b/src/features/sharing/SharedLabelsTab.tsx new file mode 100644 index 000000000..d80d4cf63 --- /dev/null +++ b/src/features/sharing/SharedLabelsTab.tsx @@ -0,0 +1,135 @@ +// Library「ラベル」タブ — 共有ノートの投影から個人側と同じラベル一覧を描く。 +// +// なぜこの形か: +// 個人側は「左ナビのラベル種別 → LabelGalleryView」という 2 段構えになっている。 +// Library には左ナビが無いので、種別の選択をタブ上部のチップに置き換えるだけにして、 +// 一覧そのものは LabelGalleryView をそのまま使う(§16 の鏡の原則。別コンポーネントを +// 書くと同じものが 2 つの見た目を持つことになる)。 +// +// 集計は FileSidebar のラベルセクションと同じ数え方にそろえる。チップの数字と +// ギャラリーの行数がずれると「どちらが本当か」が分からなくなるため。 +// +// 設計詳細: docs/internal/team-shared-storage-design.md §19 + +import { useMemo, useState } from "react"; +import type { SharedEntry } from "../../lib/storage/shared"; +import { getDisplayLabelName, useT } from "../../i18n"; +import { LabelGalleryView } from "../asset-browser/LabelGalleryView"; +import { buildSharedPseudoIndex, type SharedProjection } from "./shared-projection"; + +// ラベル色マッピング(NoteListView / FileSidebar / LabelGalleryView と同じ値)。 +// LabelGalleryView 側の定数は非公開で、共有タブのためだけに export を足すのは +// 個人側ビューへの変更になるので、ここでは同じ表を持つ。 +const LABEL_HEX: Record = { + procedure: "#5b8fb9", + material: "#4B7A52", + tool: "#c08b3e", + attribute: "#c08b3e", + // Output Entity は v3→v4 で "result" から改名。新キーが無いと色を失う + output: "#c26356", + result: "#c26356", +}; + +export type SharedLabelsTabProps = { + /** 共有ノートの投影キャッシュ */ + projection: SharedProjection; + /** 共有ノート(type === "note")。まだ投影されていないものは自然に除かれる */ + entries: SharedEntry[]; + /** ラベルの行から辿ったノートを開く(共有 id を渡す) */ + onNavigateNote: (sharedId: string) => void; +}; + +export function SharedLabelsTab({ projection, entries, onNavigateNote }: SharedLabelsTabProps) { + const uiT = useT(); + const [pickedLabel, setPickedLabel] = useState(null); + + // LabelGalleryView に渡す擬似 index。共有 id をノート id として使う + const noteIndex = useMemo( + () => buildSharedPseudoIndex(projection, entries), + [projection, entries], + ); + + // 種別ごとの行数 = ギャラリーで 1 行になる単位(preview / ハイライト文字列 / 工程名)の + // ユニーク数。FileSidebar の labelCounts と同じ数え方にそろえてある。 + const labelCounts = useMemo(() => { + const keySets = new Map>(); + const ensure = (label: string): Set => { + let set = keySets.get(label); + if (!set) { + set = new Set(); + keySets.set(label, set); + } + return set; + }; + for (const note of noteIndex.notes) { + for (const l of note.labels) ensure(l.label).add(`block::${l.preview}`); + for (const il of note.inlineLabels ?? []) ensure(il.label).add(`inline::${il.text}`); + // step コンテナも「ステップ」ラベルとして数える(個人側と同じ扱い) + for (const s of note.steps ?? []) ensure("procedure").add(`step::${s.text}`); + } + return [...keySets.entries()] + .map(([label, keys]) => ({ label, count: keys.size })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); + }, [noteIndex]); + + // 選んだ種別が投影の更新で消えることがある(共有解除など)。 + // そのときは黙って先頭に戻す(空白のギャラリーを出さない) + const activeLabel = + labelCounts.find((c) => c.label === pickedLabel)?.label ?? labelCounts[0]?.label ?? null; + + if (!activeLabel) { + return ( +
+ {uiT("library.empty.labels")} +
+ ); + } + + return ( +
+ {/* 種別チップ(個人側の左ナビ「ラベル」セクションに相当する) */} +
+ {labelCounts.map(({ label, count }) => { + const color = LABEL_HEX[label] ?? "#8fa394"; + const isActive = label === activeLabel; + return ( + + ); + })} +
+ + {/* + 種別を変えたら検索語・並び替えを持ち越さないよう作り直す(タブ切替と同じ作法)。 + 戻る導線はチップが担うので hideBack にする(onBack は呼ばれない) + */} + {}} + onNavigateNote={onNavigateNote} + /> +
+ ); +} diff --git a/src/features/sharing/SharedLibraryTable.test.tsx b/src/features/sharing/SharedLibraryTable.test.tsx index a83eef526..d2cf06e6d 100644 --- a/src/features/sharing/SharedLibraryTable.test.tsx +++ b/src/features/sharing/SharedLibraryTable.test.tsx @@ -115,3 +115,170 @@ describe("SharedLibraryTable のフォルダ列", () => { expect(unfiledDot?.className).toContain("border-border"); }); }); + +// ── 素材タブの blob 行(共有ノート内の画像・ファイル) ── +// SharedEntry ではない仮想行なので、版・検証は空欄・操作は「ノートを開く」と「取り込む」だけ。 +// 検索 / 絞り込み / 並び替えは共有エントリの行と同じ経路で効く必要がある。 + +const assetEntry = (id: string, title: string, mediaType: string): SharedEntry => ({ + id, + type: "data-manifest", + author: AUTHOR, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + hash: `sha256:${id}`, + prov: { derived_from: [] }, + version: 1, + extra: { title, media_type: mediaType }, +}); + +const blobRef = (hash: string, filename?: string) => ({ + provider: "local-folder", + uri: `file:///blobs/${hash}`, + hash, + size: 10, + ...(filename ? { filename } : {}), +}); + +/** 同じ画像を 2 ノートが持ち、片方だけが別の PDF も持つ構成 */ +const BLOB_PARENTS: SharedEntry[] = [ + { + ...entry("p1", "焼結ノート", ["卒論/焼結"]), + extra: { + title: "焼結ノート", + noteContexts: ["卒論/焼結"], + blobs: [blobRef("sha256:aaa", "spectrum.png"), blobRef("sha256:bbb", "paper.pdf")], + }, + }, + { + ...entry("p2", "装置ノート", ["共通/装置"]), + extra: { + title: "装置ノート", + noteContexts: ["共通/装置"], + blobs: [blobRef("sha256:aaa", "spectrum.png")], + }, + }, +]; + +function renderAssetTable( + overrides: Partial> = {}, +) { + return render( + + {}} + onVerifyHash={() => {}} + onCopyCitation={() => {}} + onUnshare={() => {}} + blobParents={BLOB_PARENTS} + {...overrides} + /> + , + ); +} + +function rowTexts(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("tbody tr")).map((tr) => tr.textContent ?? ""); +} + +describe("SharedLibraryTable の素材タブ(blob 行)", () => { + it("同じ hash の blob は 1 行に集約され、出どころ列に件数が出る", () => { + const { container } = renderAssetTable(); + const rows = rowTexts(container); + // 共有エントリ 1 + blob 2 種(spectrum.png は 2 ノートで 1 行に畳まれる) + expect(rows).toHaveLength(3); + const spectrum = rows.find((r) => r.includes("spectrum.png")); + expect(spectrum).toContain(t("library.blobOrigins", { count: "2" })); + // 1 ノートだけの blob は、そのノートの題名が出どころになる + expect(rows.find((r) => r.includes("paper.pdf"))).toContain("焼結ノート"); + }); + + it("種別は拡張子から推定し、版・検証は空欄になる", () => { + const { container } = renderAssetTable(); + const pdfRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) => + tr.textContent?.includes("paper.pdf"), + )!; + const cells = Array.from(pdfRow.querySelectorAll("td")).map((td) => td.textContent); + expect(cells).toContain(t("asset.type.pdf")); + // 版(v1 など)は付かず、ダッシュのまま + expect(pdfRow.textContent).not.toContain("v1"); + }); + + it("素材タブでもフォルダ列が出て、blob 行は親ノートのフォルダを表示する", () => { + const { container } = renderAssetTable(); + const headers = Array.from(container.querySelectorAll("th")); + expect(headers.some((th) => th.textContent?.includes(t("nav.noteContexts")))).toBe(true); + const spectrumRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) => + tr.textContent?.includes("spectrum.png"), + )!; + expect(spectrumRow.textContent).toContain("卒論/焼結"); + }); + + it("検索は blob 行にも効く", () => { + const { container } = renderAssetTable(); + const search = container.querySelector("input") as HTMLInputElement; + fireEvent.change(search, { target: { value: "spectrum" } }); + const rows = rowTexts(container); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("spectrum.png"); + }); + + it("種別フィルタは blob 行にも効く", () => { + const { container } = renderAssetTable(); + const filterBtn = container.querySelector( + `button[aria-label="${t("library.filterKind")}"]`, + ) as HTMLButtonElement; + fireEvent.click(filterBtn); + const options = Array.from( + document.body.querySelectorAll('button[role="menuitemcheckbox"]'), + ) as HTMLElement[]; + const pdfOption = options.find((o) => o.textContent?.includes(t("asset.type.pdf")))!; + expect(pdfOption).toBeTruthy(); + fireEvent.click(pdfOption); + const rows = rowTexts(container); + expect(rows).toHaveLength(1); + expect(rows[0]).toContain("paper.pdf"); + }); + + it("取り込みは onImportBlob に親ノートと BlobRef を渡す", () => { + const calls: { parent: string; hash: string }[] = []; + const { container } = renderAssetTable({ + onImportBlob: async (parent, blob) => { + calls.push({ parent: parent.id, hash: blob.hash }); + }, + }); + const spectrumRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) => + tr.textContent?.includes("spectrum.png"), + )!; + const importBtn = spectrumRow.querySelector( + `button[aria-label="${t("library.importBlob")}"]`, + ) as HTMLButtonElement; + expect(importBtn.disabled).toBe(false); + fireEvent.click(importBtn); + expect(calls).toEqual([{ parent: "p1", hash: "sha256:aaa" }]); + }); + + it("blob root 未設定(onImportBlob 無し)なら取り込みは無効で理由を出す", () => { + const { container } = renderAssetTable(); + const importBtn = container.querySelector( + `button[aria-label="${t("library.importBlob")}"]`, + ) as HTMLButtonElement; + expect(importBtn.disabled).toBe(true); + expect(importBtn.getAttribute("title")).toBe(t("share.noBlobRootPreview")); + }); + + it("blobParents が無ければ従来どおり共有エントリだけの表になる", () => { + const { container } = renderAssetTable({ blobParents: undefined }); + expect(rowTexts(container)).toHaveLength(1); + // 出どころ列は blob 行があるときだけ出す + const headers = Array.from(container.querySelectorAll("th")); + expect(headers.some((th) => th.textContent?.includes(t("library.col.origins")))).toBe(false); + }); +}); diff --git a/src/features/sharing/SharedLibraryTable.tsx b/src/features/sharing/SharedLibraryTable.tsx index 3ffdb67b0..dc02030ae 100644 --- a/src/features/sharing/SharedLibraryTable.tsx +++ b/src/features/sharing/SharedLibraryTable.tsx @@ -6,21 +6,29 @@ // 応じて entries を絞ってから渡す。ここでは検索・並び替え・絞り込みのみを担う。 import { useCallback, useMemo, useRef, useState } from "react"; -import { Check, Filter, GitFork, Link2, Trash2 } from "lucide-react"; +import { Check, Download, FileText, Filter, GitFork, Link2, Paperclip, Trash2 } from "lucide-react"; import type { AuthorIdentity } from "../document-provenance/types"; -import type { SharedEntry } from "../../lib/storage/shared"; +import type { BlobRef, 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 { + blobKindLabelKey, + blobRowTitle, + buildSharedBlobRows, + type SharedAssetItem, +} from "./shared-blob-rows"; import { FilterPopup, type FilterOption } from "../../ui/filter-popup"; import { formatDate } from "../../lib/format-datetime"; import { cn } from "../../lib/utils"; import { useT } from "../../i18n"; import { HashBadge, type HashStatus } from "./hash-badge"; -export type SharedLibraryTab = "note" | "knowledge" | "asset"; +// ラベル / プロセスは共有ノートの投影から描くタブで、この表は使わない。 +// タブの識別子だけ共通の型に載せる(呼び出し側のタブ状態が 1 つで済む)。 +export type SharedLibraryTab = "note" | "knowledge" | "asset" | "labels" | "process"; export type SharedLibrarySortKey = "updatedAt" | "title" | "author" | "version"; export type SharedLibraryTableProps = { tab: SharedLibraryTab; @@ -38,6 +46,16 @@ export type SharedLibraryTableProps = { onFork?: (entry: SharedEntry) => void; /** 自分作の行だけに出す */ onUnshare: (entry: SharedEntry) => void; + /** + * 素材タブに仮想行として並べる「共有ノート内の画像・ファイル」の親。 + * 呼び出し側が extra.blobs を持つ note エントリだけを絞って渡す。 + */ + blobParents?: SharedEntry[]; + /** + * blob 行の「自分の素材に取り込む」。blob root 未設定などで取り込めないときは + * 未指定にする(操作は無効化して理由をツールチップで出す)。 + */ + onImportBlob?: (parent: SharedEntry, blob: BlobRef) => Promise; }; const SORT_OPTIONS: { key: SharedLibrarySortKey; labelKey: string }[] = [ @@ -87,6 +105,37 @@ function isForkable(type: SharedEntry["type"]): boolean { return type === "note" || type === "knowledge"; } +// ── 行(SharedAssetItem)へのアクセサ ── +// blob 行は SharedEntry ではないので、作者・共有日・フォルダは親ノートから引く。 +// 検索 / 絞り込み / 並び替えを 1 本の経路で回すために、値の取り出しをここに集める。 + +/** 作者・共有日・フォルダ・行クリックの起点になるエントリ(blob 行は親ノート)。 */ +function itemSourceEntry(item: SharedAssetItem): SharedEntry { + return item.kind === "entry" ? item.entry : item.parent; +} + +function itemKey(item: SharedAssetItem): string { + return item.kind === "entry" ? `entry:${item.entry.id}` : item.key; +} + +function itemTitle(item: SharedAssetItem, t: (k: string) => string): string { + return item.kind === "entry" ? entryTitle(item.entry, t) : blobRowTitle(item); +} + +function itemKind( + item: SharedAssetItem, + t: (k: string) => string, +): { value: string; label: string } { + if (item.kind === "entry") return entryKind(item.entry, t); + const key = blobKindLabelKey(item.blob); + return { value: key, label: t(key) }; +} + +/** 版の並び替え用。blob 行は版を持たないので 0(共有エントリより後ろに寄る)。 */ +function itemVersion(item: SharedAssetItem): number { + return item.kind === "entry" ? (item.entry.version ?? 1) : 0; +} + export function SharedLibraryTable({ tab, entries, @@ -100,11 +149,14 @@ export function SharedLibraryTable({ onCopyCitation, onFork, onUnshare, + blobParents, + onImportBlob, }: SharedLibraryTableProps) { const t = useT(); const showKindColumn = tab === "asset" || tab === "knowledge"; - // フォルダはノートだけが持つ概念(ナレッジ・素材には無い)ので note タブ限定 - const showFolderColumn = tab === "note"; + // 素材のフォルダは共有時に書かれた extra.noteContexts(自分で付けた分 ∪ 貼ったノート由来)。 + // ノートと同じ意味の列なので、ノート一覧と同じ見せ方で出す(鏡の原則) + const showFolderColumn = tab === "note" || tab === "asset"; // 表は表示専用なので、フォルダの値はストアのスナップショットから引く // (共有時に書かれた extra を優先し、無ければ本文から拾った控えで補う) const sharedSnapshot = useSharedLibrary(); @@ -116,6 +168,9 @@ export function SharedLibraryTable({ const [authorFilter, setAuthorFilter] = useState([]); const [folderFilter, setFolderFilter] = useState([]); + // blob 行の取り込み中表示。busyId は共有エントリ id なので blob 行には使えない + const [importingKey, setImportingKey] = useState(null); + const [folderFilterOpen, setFolderFilterOpen] = useState(false); const [folderFilterPos, setFolderFilterPos] = useState({ top: 0, left: 0 }); const folderFilterBtnRef = useRef(null); @@ -135,11 +190,27 @@ export function SharedLibraryTable({ setSortDir(key === "title" ? "asc" : "desc"); }; + // 共有ノート内の画像・ファイルは SharedEntry ではないので、行として合流させる。 + // blobParents は素材タブのときだけ渡ってくる(他タブは kind: "entry" だけになる) + const blobRows = useMemo( + () => (blobParents?.length ? buildSharedBlobRows(blobParents) : []), + [blobParents], + ); + const items = useMemo( + () => [ + ...entries.map((entry) => ({ kind: "entry" as const, entry })), + ...blobRows.map((row) => ({ kind: "blob" as const, ...row })), + ], + [entries, blobRows], + ); + // 出どころ列は blob 行があるときだけ意味を持つ(無ければ全行ダッシュになる) + const showOriginColumn = blobRows.length > 0; + const kindFilterOptions = useMemo(() => { if (!showKindColumn) return []; const counts = new Map(); - for (const entry of entries) { - const kind = entryKind(entry, t); + for (const item of items) { + const kind = itemKind(item, t); if (!kind.value) continue; const prev = counts.get(kind.value); counts.set(kind.value, { label: kind.label, count: (prev?.count ?? 0) + 1 }); @@ -147,7 +218,7 @@ export function SharedLibraryTable({ return Array.from(counts.entries()) .map(([value, { label, count }]) => ({ value, label, count })) .sort((a, b) => a.label.localeCompare(b.label, "ja")); - }, [entries, showKindColumn, t]); + }, [items, showKindColumn, t]); const contextsOf = useCallback( (entry: SharedEntry) => getSharedNoteContexts(entry, sharedSnapshot), @@ -156,7 +227,8 @@ export function SharedLibraryTable({ const authorFilterOptions = useMemo(() => { const counts = new Map(); - for (const entry of entries) { + for (const item of items) { + const entry = itemSourceEntry(item); const email = entry.author?.email; if (!email) continue; const prev = counts.get(email); @@ -168,13 +240,13 @@ export function SharedLibraryTable({ return Array.from(counts.entries()) .map(([value, { label, count }]) => ({ value, label, count })) .sort((a, b) => a.label.localeCompare(b.label, "ja")); - }, [entries]); + }, [items]); - // フォルダの選択肢(表示中のエントリから集計)。「未分類」は該当が 1 件以上あるときだけ出す + // フォルダの選択肢(表示中の行から集計)。「未分類」は該当が 1 件以上あるときだけ出す const folderFilterOptions = useMemo(() => { if (!showFolderColumn) return []; const options: FilterOption[] = aggregateNoteContexts( - entries.map((e) => ({ noteContexts: contextsOf(e) })), + items.map((item) => ({ noteContexts: contextsOf(itemSourceEntry(item)) })), ) // 色チップはノート一覧のフォルダ列フィルタと同じ(表のピルと同色で対応が付く) .map(({ value, count }) => ({ @@ -184,20 +256,21 @@ export function SharedLibraryTable({ icon: , })) .sort((a, b) => a.label.localeCompare(b.label, "ja")); - const unfiled = entries.filter((e) => contextsOf(e).length === 0).length; + const unfiled = items.filter((item) => contextsOf(itemSourceEntry(item)).length === 0).length; // 未分類は実在するフォルダではないので色を持たない(中空チップで左端だけ揃える) if (unfiled > 0) options.push({ value: UNFILED_PATH, label: t("nav.unfiled"), count: unfiled, icon: }); return options; - }, [entries, showFolderColumn, contextsOf, t]); + }, [items, showFolderColumn, contextsOf, t]); const filtered = useMemo(() => { - let result = entries; + let result = items; if (searchQuery.trim()) { const q = searchQuery.trim().toLowerCase(); - result = result.filter((entry) => { - const title = entryTitle(entry, t).toLowerCase(); + result = result.filter((item) => { + const entry = itemSourceEntry(item); + const title = itemTitle(item, t).toLowerCase(); const authorName = (entry.author?.name ?? "").toLowerCase(); // フォルダ名も検索対象(列に出ている値はどれも同じ部分一致で当たる) const folderHit = @@ -213,8 +286,8 @@ export function SharedLibraryTable({ const set = new Set( folderFilter.filter((c) => c !== UNFILED_PATH).map((c) => c.toLowerCase()), ); - result = result.filter((entry) => { - const contexts = contextsOf(entry); + result = result.filter((item) => { + const contexts = contextsOf(itemSourceEntry(item)); return ( (hasUnfiled && contexts.length === 0) || contexts.some((c) => set.has(c.toLowerCase())) ); @@ -223,39 +296,54 @@ export function SharedLibraryTable({ if (kindFilter.length > 0) { const set = new Set(kindFilter); - result = result.filter((entry) => set.has(entryKind(entry, t).value)); + result = result.filter((item) => set.has(itemKind(item, t).value)); } if (authorFilter.length > 0) { const set = new Set(authorFilter); - result = result.filter((entry) => set.has(entry.author?.email ?? "")); + result = result.filter((item) => set.has(itemSourceEntry(item).author?.email ?? "")); } - const sorted = [...result].sort((a, b) => { + const sorted = [...result].sort((x, y) => { + const a = itemSourceEntry(x); + const b = itemSourceEntry(y); let cmp = 0; switch (sortKey) { case "updatedAt": cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime(); break; case "title": - cmp = entryTitle(a, t).localeCompare(entryTitle(b, t), "ja"); + cmp = itemTitle(x, t).localeCompare(itemTitle(y, t), "ja"); break; case "author": cmp = (a.author?.name ?? "").localeCompare(b.author?.name ?? "", "ja"); break; case "version": - cmp = (a.version ?? 1) - (b.version ?? 1); + cmp = itemVersion(x) - itemVersion(y); break; } return sortDir === "desc" ? -cmp : cmp; }); return sorted; - }, [entries, searchQuery, kindFilter, authorFilter, folderFilter, contextsOf, showFolderColumn, sortKey, sortDir, t]); + }, [items, searchQuery, kindFilter, authorFilter, folderFilter, contextsOf, showFolderColumn, sortKey, sortDir, t]); + + const handleImportBlob = useCallback( + async (parent: SharedEntry, item: SharedAssetItem & { kind: "blob" }) => { + if (!onImportBlob) return; + setImportingKey(item.key); + try { + await onImportBlob(parent, item.blob); + } finally { + setImportingKey(null); + } + }, + [onImportBlob], + ); const emptyKey = tab === "note" ? "library.empty.note" : tab === "knowledge" ? "library.empty.knowledge" : "library.empty.asset"; - const isFilteredEmpty = entries.length > 0 && filtered.length === 0; + const isFilteredEmpty = items.length > 0 && filtered.length === 0; return (
@@ -269,7 +357,7 @@ export function SharedLibraryTable({ />
- {entries.length === 0 ? ( + {items.length === 0 ? (
{t(emptyKey)}
) : isFilteredEmpty ? (
{t("library.noMatch")}
@@ -351,6 +439,11 @@ export function SharedLibraryTable({
)} + {/* 出どころ(blob 行がどの共有ノートに貼られているか)。 + 共有エントリ自身は「出どころ」を持たないので空欄になる */} + {showOriginColumn && ( + {t("library.col.origins")} + )}
- {!isMine && onFork && isForkable(entry.type) && ( - + {item.kind === "blob" && ( + <> + + + )} - {isMine && ( - + {entry && ( + <> + + {!isMine && onFork && isForkable(entry.type) && ( + + )} + {isMine && ( + + )} + )}
diff --git a/src/features/sharing/SharedLibraryView.fork.test.tsx b/src/features/sharing/SharedLibraryView.fork.test.tsx new file mode 100644 index 000000000..b2a1feef6 --- /dev/null +++ b/src/features/sharing/SharedLibraryView.fork.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +// Library「プロセス」タブの派生(fork)の成否表示のテスト。 +// +// 対象の不変条件: +// - onForkNote が失敗(reject)したら、プロセスタブに失敗表示が出る。 +// 共有ノートの fork は新ノート id を返さないので、成否は例外でしか伝わらない。 +// 呼び出し側(note-app)が黙って return すると「何も起きていないのに成功に見える」 +// - 成功したときは失敗表示を出さない + +import { describe, it, expect, afterEach, vi } from "vitest"; + +// SharedLibraryView は詳細パネル経由でブロック registry を読み込む。 +// pdf ビューアは jsdom に無い API(DOMMatrix)を要求するので、他のテストと同じく差し替える +vi.mock("react-pdf", () => ({ + Document: () => null, + Page: () => null, + pdfjs: { GlobalWorkerOptions: {} }, +})); +vi.mock("../../lib/pdfjs-config", () => ({})); +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { LocaleProvider, t } from "../../i18n"; +import { SharedLibraryView } from "./SharedLibraryView"; +import { + __resetSharedProjectionForTest, + recordSharedProjectionFromBody, +} from "./shared-projection"; +import type { SharedLibraryLoadResult } from "./shared-library-loader"; +import type { GraphiumDocument } from "../../lib/document-types"; +import type { SharedEntry } from "../../lib/storage/shared"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// 手順フロー(@xyflow/react)が大きさを測るのに使う。jsdom には無いので何もしない実装を置く +class NoopResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +(globalThis as { ResizeObserver?: unknown }).ResizeObserver ??= NoopResizeObserver; + +const NOTE: SharedEntry = { + id: "shared-1", + type: "note", + author: { name: "Ada", email: "a@b.co" }, + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-20T00:00:00.000Z", + hash: "sha256:aaa", + prov: { derived_from: [] }, + version: 1, + extra: { title: "焼成の記録" }, +} as SharedEntry; + +const PROCEDURE_DOC: GraphiumDocument = { + version: 6, + title: "焼成の記録", + pages: [ + { + id: "p1", + title: "焼成の記録", + blocks: [ + { + id: "s1", + type: "step", + content: [{ type: "text", text: "焼成", styles: {} }], + children: [ + { + id: "b1", + type: "paragraph", + content: [{ type: "text", text: "前駆体粉末", styles: { inlineMaterial: "m1" } }], + children: [], + }, + ], + }, + ], + labels: {}, + provLinks: [], + knowledgeLinks: [], + }, + ], +} as any; + +const loadEntries = async (): Promise => ({ + entries: { + note: [NOTE], + knowledge: [], + reference: [], + "data-manifest": [], + template: [], + report: [], + }, + errors: {}, +}); + +function renderProcessTab(onForkNote: (sharedId: string) => Promise) { + __resetSharedProjectionForTest(); + recordSharedProjectionFromBody(NOTE, new TextEncoder().encode(JSON.stringify(PROCEDURE_DOC)), true); + return render( + + {}} + onUnshare={async () => {}} + onBack={() => {}} + initialTab="process" + loadEntries={loadEntries} + /> + , + ); +} + +afterEach(() => { + cleanup(); + __resetSharedProjectionForTest(); +}); + +describe("プロセスタブの派生", () => { + it("fork が失敗したら失敗表示を出す(成功したように見せない)", async () => { + renderProcessTab(async () => { + throw new Error("shared root is not configured"); + }); + + fireEvent.click(await screen.findByText(t("library.forkToNotes"))); + + expect(await screen.findByText(t("process.forkFailed"))).toBeTruthy(); + }); + + it("fork が成功したら失敗表示は出ない", async () => { + renderProcessTab(async () => {}); + + fireEvent.click(await screen.findByText(t("library.forkToNotes"))); + // 派生中の表示が消える = 一連の処理が終わったところまで待つ + await screen.findByText(t("library.forkToNotes")); + + expect(screen.queryByText(t("process.forkFailed"))).toBeNull(); + }); +}); diff --git a/src/features/sharing/SharedLibraryView.stories.tsx b/src/features/sharing/SharedLibraryView.stories.tsx index 0600ef543..5791fcc49 100644 --- a/src/features/sharing/SharedLibraryView.stories.tsx +++ b/src/features/sharing/SharedLibraryView.stories.tsx @@ -3,6 +3,8 @@ // 研究室の「先生と学生」の場面を想定したモックデータ: // - currentIdentity は先生(山田先生) // - ノート 4 件(学生 2 人 + 先生 1 人、うち 1 件は version 2)。3 件は共有時点のフォルダ付き、1 件は無し +// うち 2 件はノート内に貼られた画像・ファイル(extra.blobs)を持つ。素材タブに仮想行として並ぶ +// (1 つは 2 件のノートに貼られた同じ画像= hash が同じなので 1 行に畳まれる) // - ナレッジ 2 件(wikiKind "summary" / "atom") // - reference 2 件(URL ブックマーク) // - data-manifest 3 件(image / pdf / data) @@ -14,6 +16,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { LocaleProvider, syncLocale } from "../../i18n"; import type { SharedEntry } from "../../lib/storage/shared"; import { SharedLibraryView } from "./SharedLibraryView"; +import { + __resetSharedProjectionForTest, + recordSharedProjectionFromBody, +} from "./shared-projection"; +import type { GraphiumDocument } from "../../lib/document-types"; import "../../app.css"; const now = new Date(); @@ -34,13 +41,34 @@ function makeEntry(partial: Partial & Pick ({ + provider: "local-folder", + uri: `file:///Users/yamada/shared-blobs/${hash}`, + hash: `sha256:${hash}`, + size, + ...(filename ? { filename } : {}), +}); + +// note-1 と note-3 に同じ SEM 画像が貼られている(同じ hash = 素材タブでは 1 行) +const SEM_BLOB = blob("b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8", "sem_grain.png", 842_000); + const NOTES: SharedEntry[] = [ makeEntry({ id: "note-1", type: "note", author: STUDENT_A, updated_at: daysAgo(0.2), - extra: { title: "Cu粉末の焼結実験(第1回)", noteContexts: ["卒論/焼結"] }, + extra: { + title: "Cu粉末の焼結実験(第1回)", + noteContexts: ["卒論/焼結"], + // 画像 + 表計算 + 題名を持たない古い共有(hash 先頭 12 桁で出る) + blobs: [ + SEM_BLOB, + blob("0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5061728394a5b", "焼結条件.xlsx", 21_000), + blob("ff00112233445566778899aabbccddeeff00112233445566778899aa", undefined, 5_400), + ], + }, }), makeEntry({ id: "note-2", @@ -55,7 +83,12 @@ const NOTES: SharedEntry[] = [ type: "note", author: STUDENT_B, updated_at: daysAgo(3), - extra: { title: "XRD 分析結果まとめ", noteContexts: ["卒論/焼結", "共通/装置"] }, + extra: { + title: "XRD 分析結果まとめ", + noteContexts: ["卒論/焼結", "共通/装置"], + // note-1 と同じ画像(出どころ列が「2 件のノート」になる)+ 音声メモ + blobs: [SEM_BLOB, blob("aabbccddeeff00112233445566778899aabbccddeeff001122334455", "討議メモ.m4a", 1_200_000)], + }, }), makeEntry({ id: "note-4", @@ -197,6 +230,7 @@ const baseArgs = { onForkNote: NOOP_ASYNC, onForkKnowledge: NOOP_ASYNC, onUnshare: NOOP_ASYNC, + onImportBlob: NOOP_ASYNC, onBack: () => console.log("back"), loadEntries: async () => ALL_ENTRIES, }; @@ -222,6 +256,21 @@ export const ProposedAssets: Story = { name: "提案(素材タブ)", args: { ...baseArgs, initialTab: "asset" }, decorators: Proposed.decorators, + parameters: { + docs: { + description: { + story: + "共有した素材(reference / data-manifest)に加えて、共有ノートに貼られた画像・ファイルが 📎 付きの仮想行として並ぶ。blob 行は SharedEntry ではないので版・検証・fork が無く、操作は「ノートを開く」と「自分の素材に取り込む」だけ。同じ hash の画像は 1 行に畳まれ、出どころ列が「2 件のノート」になる。", + }, + }, + }, +}; + +export const ProposedAssetsNoBlobRoot: Story = { + name: "素材タブ(blob 保管先 未設定)", + // onImportBlob が無い = blob root 未設定。取り込みボタンは無効のまま行だけ出る + args: { ...baseArgs, initialTab: "asset", onImportBlob: undefined }, + decorators: Proposed.decorators, }; export const ProposedEmpty: Story = { @@ -247,6 +296,142 @@ export const ProposedEnglish: Story = { ], }; +// ── ラベル / プロセスタブ ── +// +// この 2 つのタブは共有エントリの一覧ではなく、共有ノートの本文から投影した結果を見る。 +// 投影はモジュールスコープのストアに載るので、ストーリーでは実際の投影経路 +// (語彙索引レーンが本文を読んだときに呼ぶ関数)に本文を流し込んで作る。 +// 別経路でストアを組み立てると、実物と違う形のデータで見た目を確認することになる。 + +const styled = (text: string, styles: Record = {}) => ({ + type: "text", + text, + styles, +}); +const para = (id: string, content: any[]) => ({ id, type: "paragraph", content, children: [] }); +const stepBlock = (id: string, title: string, children: any[] = []) => ({ + id, + type: "step", + content: [styled(title)], + children, +}); +const makeDoc = (title: string, blocks: any[]): GraphiumDocument => + ({ + version: 6, + title, + pages: [{ id: "p1", title, blocks, labels: {}, provLinks: [], knowledgeLinks: [] }], + }) as any; + +const SINTERING_DOC = makeDoc("Cu粉末の焼結実験(第1回)", [ + stepBlock("s1", "秤量", [ + para("b1", [styled("Cu 粉末", { inlineMaterial: "mat-cu" })]), + para("b2", [styled("電子天秤", { inlineTool: "tool-balance" })]), + ]), + stepBlock("s2", "成形", [ + para("b3", [styled("一軸プレス", { inlineTool: "tool-press" })]), + para("b4", [styled("圧粉体", { inlineOutput: "out-green" })]), + ]), + stepBlock("s3", "焼結", [ + para("b5", [styled("管状炉", { inlineTool: "tool-furnace" })]), + para("b6", [styled("焼結体", { inlineOutput: "out-sintered" })]), + ]), +]); + +const PRETREAT_DOC = makeDoc("シリカ管の前処理手順", [ + stepBlock("s1", "洗浄", [ + para("b1", [styled("シリカ管", { inlineMaterial: "mat-silica" })]), + para("b2", [styled("超音波洗浄機", { inlineTool: "tool-sonic" })]), + ]), + stepBlock("s2", "乾燥", [para("b3", [styled("乾燥管", { inlineOutput: "out-dry" })])]), +]); + +const XRD_DOC = makeDoc("XRD 分析結果まとめ", [ + stepBlock("s1", "XRD 測定", [ + para("b1", [styled("焼結体", { inlineMaterial: "mat-sintered" })]), + para("b2", [styled("X 線回折装置", { inlineTool: "tool-xrd" })]), + para("b3", [styled("回折パターン", { inlineOutput: "out-pattern" })]), + ]), +]); + +const encodeDoc = (doc: GraphiumDocument) => new TextEncoder().encode(JSON.stringify(doc)); + +/** 3 件のノートを投影済みにする(残り 1 件は「まだ本文を読めていない」状態のまま) */ +function seedProjection() { + __resetSharedProjectionForTest(); + recordSharedProjectionFromBody(NOTES[0], encodeDoc(SINTERING_DOC), true); + recordSharedProjectionFromBody(NOTES[1], encodeDoc(PRETREAT_DOC), true); + recordSharedProjectionFromBody(NOTES[2], encodeDoc(XRD_DOC), true); +} + +const projectionDecorators = [ + (Story: () => React.JSX.Element) => { + syncLocale("ja"); + seedProjection(); + return ( + +
+ +
+
+ ); + }, +]; + +const emptyProjectionDecorators = [ + (Story: () => React.JSX.Element) => { + syncLocale("ja"); + // 起動直後 = まだどのノートの本文も読めていない状態 + __resetSharedProjectionForTest(); + return ( + +
+ +
+
+ ); + }, +]; + +export const ProposedLabels: Story = { + name: "提案(ラベルタブ)", + args: { ...baseArgs, initialTab: "labels" }, + decorators: projectionDecorators, + parameters: { + docs: { + description: { + story: + "共有ノートから投影したラベル。上のチップで種別を選び、一覧は個人側の LabelGalleryView をそのまま使う(戻るボタンだけ隠す)。件数は本文を読めたノートの分だけ増える。", + }, + }, + }, +}; + +export const ProposedProcess: Story = { + name: "提案(プロセスタブ)", + args: { ...baseArgs, initialTab: "process" }, + decorators: projectionDecorators, + parameters: { + docs: { + description: { + story: + "共有ノートから投影した手順。個人側の ProcessGalleryView をそのまま使い、fork の文言だけ「自分のノートに派生」に差し替える。", + }, + }, + }, +}; + +export const ProposedLabelsEmpty: Story = { + name: "ラベルタブ(投影前)", + args: { ...baseArgs, initialTab: "labels" }, + decorators: emptyProjectionDecorators, +}; + +export const ProposedProcessEmpty: Story = { + name: "プロセスタブ(投影前)", + args: { ...baseArgs, initialTab: "process" }, + decorators: emptyProjectionDecorators, +}; + // マニュアル用スクショ撮影ストーリー(英語 UI・パン作りの世界観) // currentIdentity は指導役 Mia Tanaka、ノートは学生役 Ken Sato / Hana Ito と Mia の混在 diff --git a/src/features/sharing/SharedLibraryView.tsx b/src/features/sharing/SharedLibraryView.tsx index cb3b3ef52..5efe13b8b 100644 --- a/src/features/sharing/SharedLibraryView.tsx +++ b/src/features/sharing/SharedLibraryView.tsx @@ -42,6 +42,14 @@ import { useSharedLibrary, } from "./shared-library-store"; import { buildSharedCitationLink } from "./citation-link"; +import { + buildSharedProcessIndex, + countProjectedLabelNotes, + countProjectedProcessNotes, + useSharedProjection, +} from "./shared-projection"; +import { SharedLabelsTab } from "./SharedLabelsTab"; +import { ProcessGalleryView } from "../network-graph/ProcessGalleryView"; import { collectSharedBlobHashes, rewriteSharedBlobUrls, @@ -89,6 +97,11 @@ type Props = { loadEntries?: (root: string) => Promise; /** 初期表示タブ(既定 "note") */ initialTab?: SharedLibraryTab; + /** + * 共有ノート内の画像・ファイル(extra.blobs)を自分の素材として取り込む。 + * blob root 未設定などで取り込めない環境では未指定にする(操作を出さない)。 + */ + onImportBlob?: (parent: SharedEntry, blob: BlobRef) => Promise; }; // 共有導線(Share ボタン)が実装されている type のみ表示タブに出す。 @@ -99,6 +112,11 @@ const TAB_ORDER: { tab: SharedLibraryTab; labelKey: string; types: SharedEntryTy { tab: "note", labelKey: "library.tab.note", types: ["note"] }, { tab: "knowledge", labelKey: "library.tab.knowledge", types: ["knowledge"] }, { tab: "asset", labelKey: "library.tab.asset", types: ["reference", "data-manifest"] }, + // ラベル / プロセスは共有ノートの投影から描くタブ。エントリ種別を持たないので + // types は空にする(entriesByTab / activeErrors の対象外になる)。 + // 並びは個人側の左ナビ(素材 → ラベル → プロセス)の鏡にする。 + { tab: "labels", labelKey: "library.tab.labels", types: [] }, + { tab: "process", labelKey: "library.tab.process", types: [] }, ]; function typeToTab(type: SharedEntryType): SharedLibraryTab | null { @@ -140,6 +158,7 @@ export function SharedLibraryView({ onFocusConsumed, loadEntries, initialTab = "note", + onImportBlob, }: Props) { const uiT = useT(); const [activeTab, setActiveTab] = useState(initialTab); @@ -233,13 +252,59 @@ export function SharedLibraryView({ return out; }, [entriesByType]); + // ラベル / プロセスは共有ノートの本文から投影した結果を見る(表の行ではない) + const projection = useSharedProjection(); + const sharedNoteIds = useMemo(() => entriesByTab.note.map((e) => e.id), [entriesByTab]); + + // プロセスタブに渡す ProcessIndex。毎レンダーで作り直すと ProcessGalleryView 内の + // フローが作り直されて重いので、投影が変わったときだけ組み立てる + const sharedProcessIndex = useMemo(() => buildSharedProcessIndex(projection), [projection]); + + // ラベル / プロセスの行から「ノートを開く」= 一覧の詳細パネルで開く。 + // 共有ノートは個人のノートとして開けないので、遷移先はここになる + const openSharedNote = useCallback( + (sharedId: string) => { + const hit = entriesByTab.note.find((e) => e.id === sharedId); + if (hit) setSelected(hit); + }, + [entriesByTab], + ); + + // プロセスの「派生」は共有ノートの fork に倒す(fork 先は自分のノート)。 + // onForkNote は新ノート id を返さないが、null を返すと ProcessGalleryView が + // 失敗表示になってしまうため、成功の印として共有 id を返す + // (戻り値は成否判定にしか使われない。失敗は onForkNote が throw して伝える + // = ProcessGalleryView 側の catch が失敗表示にする) + const forkProcessNote = useCallback( + async (sharedId: string): Promise => { + await onForkNote(sharedId); + return sharedId; + }, + [onForkNote], + ); + const counts = useMemo(() => { const out = {} as Record; for (const { tab } of TAB_ORDER) { out[tab] = entriesByTab[tab].length; } + // 件数の意味が他タブと違う: ラベルを持つ共有ノート数 / 手順を持つ共有ノート数。 + // まだ本文を読めていないノートは 0 に見える(読めた分だけ増えていく) + out.labels = countProjectedLabelNotes(projection, sharedNoteIds); + out.process = countProjectedProcessNotes(projection, sharedNoteIds); return out; - }, [entriesByTab]); + }, [entriesByTab, projection, sharedNoteIds]); + + // 素材タブに仮想行として並べる「共有ノート内の画像・ファイル」の親ノート。 + // 行の組み立ては SharedLibraryTable 側(後続担当)が行う + const blobParents = useMemo( + () => + entriesByTab.note.filter((entry) => { + const blobs = (entry.extra as Record | undefined)?.blobs; + return Array.isArray(blobs) && blobs.length > 0; + }), + [entriesByTab], + ); const verifyHash = useCallback( async (entry: SharedEntry) => { @@ -264,6 +329,9 @@ export function SharedLibraryView({ } else { await onForkNote(entry.id); } + } catch { + // 失敗の通知は fork 側(呼び出し元のハンドラ)が出す。ここで投げ直すと + // ボタンの onClick から未処理の rejection になるだけなので握る } finally { setBusyId(null); } @@ -363,22 +431,59 @@ export function SharedLibraryView({
)} - + {activeTab === "labels" ? ( +
+ +
+ ) : activeTab === "process" ? ( +
+ {sharedProcessIndex.processes.length === 0 ? ( + // 本文をまだ読めていない間もここに来る(読めた分から増えていく)ので、 + // 個人側の process.empty ではなく共有側の言い方にする +
+ {uiT("library.empty.process")} +
+ ) : ( + + )} +
+ ) : ( + + )}
{/* 詳細パネル(一覧と並置。fixed オーバーレイにしない)。 diff --git a/src/features/sharing/index.ts b/src/features/sharing/index.ts index f4b66aed7..19e65395e 100644 --- a/src/features/sharing/index.ts +++ b/src/features/sharing/index.ts @@ -65,3 +65,21 @@ export { SHARED_INDEXABLE_TYPES, } from "./shared-entry-source"; export { useSharedLibrarySync, type SharedLibrarySyncParams } from "./shared-library-sync"; +export { + SHARED_PROJECTION_VERSION, + projectSharedNote, + parseStoredProjection, + createEmptySharedProjection, + loadSharedProjection, + getSharedProjection, + subscribeSharedProjection, + useSharedProjection, + recordSharedProjectionFromBody, + pruneSharedProjection, + buildSharedPseudoIndex, + buildSharedProcessIndex, + countProjectedLabelNotes, + countProjectedProcessNotes, + type SharedProjection, + type SharedProjectionEntry, +} from "./shared-projection"; diff --git a/src/features/sharing/share-media.test.ts b/src/features/sharing/share-media.test.ts index 1d34cb019..8f18ff176 100644 --- a/src/features/sharing/share-media.test.ts +++ b/src/features/sharing/share-media.test.ts @@ -117,6 +117,30 @@ describe("shareMedia — first share", () => { expect(stored.entry.extra.blobs[0].hash).toMatch(/^sha256:/); }); + it("共有時のフォルダ(noteContexts)が extra に入る", async () => { + // 一覧は本文を読まずに描くので、フォルダはメタデータ側にも要る(鏡の原則) + fs.mediaFiles.set("media-1", btoa("data")); + await shareMedia(makeEntry(), { + sharedRoot: "/tmp/shared", + blobRoot: "/tmp/blobs", + author, + noteContexts: ["材料X", "測定/XRD"], + }); + const stored = JSON.parse([...fs.entries.values()][0]); + expect(stored.entry.extra.noteContexts).toEqual(["材料X", "測定/XRD"]); + }); + + it("noteContexts 未指定なら空配列(未分類)になる", async () => { + fs.mediaFiles.set("media-1", btoa("data")); + await shareMedia(makeEntry(), { + sharedRoot: "/tmp/shared", + blobRoot: "/tmp/blobs", + author, + }); + const stored = JSON.parse([...fs.entries.values()][0]); + expect(stored.entry.extra.noteContexts).toEqual([]); + }); + it("title 未指定なら entry.name がデフォルトで使われる", async () => { fs.mediaFiles.set("media-1", btoa("d")); const result = await shareMedia(makeEntry({ name: "default.jpg" }), { diff --git a/src/features/sharing/share-media.ts b/src/features/sharing/share-media.ts index c0c50978e..4e8a2904f 100644 --- a/src/features/sharing/share-media.ts +++ b/src/features/sharing/share-media.ts @@ -35,6 +35,13 @@ export type ShareMediaOptions = { title?: string; /** ユーザーが入力した説明(任意) */ description?: string; + /** + * 共有した時点の実効フォルダ(素材ギャラリーが表示しているもの = + * 自分で付けた分 ∪ 貼ったノート由来)。ノート共有の extra.noteContexts と同型で、 + * 共有ライブラリの表でも同じ「フォルダ」列で絞り込めるようにする(鏡の原則)。 + * 一覧は本文を読まずに描くので、メタデータ側に持たせる必要がある。 + */ + noteContexts?: string[]; }; export type ShareMediaResult = @@ -102,6 +109,8 @@ export async function shareMedia( // 「複数 BlobRef の集合」になりうる(CSV + 画像セット等)が、 // Phase 2b-media では単一 BlobRef のシンプル形に絞る。 blobs: [blobRef], + // 再共有も同じ経路を通るので、共有側のフォルダは常に上書きされる + noteContexts: options.noteContexts ?? [], }, }; diff --git a/src/features/sharing/share-reference.test.ts b/src/features/sharing/share-reference.test.ts index 6600cf761..f5fcb1a1b 100644 --- a/src/features/sharing/share-reference.test.ts +++ b/src/features/sharing/share-reference.test.ts @@ -94,6 +94,23 @@ describe("shareReference — first share", () => { expect(body.description).toBe("User description"); }); + it("共有時のフォルダ(noteContexts)が extra に入る", async () => { + // 一覧は本文を読まずに描くので、フォルダはメタデータ側にも要る(鏡の原則) + await shareReference(makeUrlEntry(), { + sharedRoot: "/tmp/shared", + author, + noteContexts: ["調査/先行研究"], + }); + const stored = JSON.parse([...fs.entries.values()][0]); + expect(stored.entry.extra.noteContexts).toEqual(["調査/先行研究"]); + }); + + it("noteContexts 未指定なら空配列(未分類)になる", async () => { + await shareReference(makeUrlEntry(), { sharedRoot: "/tmp/shared", author }); + const stored = JSON.parse([...fs.entries.values()][0]); + expect(stored.entry.extra.noteContexts).toEqual([]); + }); + it("og:image の remote URL は共有 body に載せない", async () => { // 載せると、受け取った側がカードを描くたびに publisher(多くは CDN・計測 // ドメイン)へ GET が飛ぶ ——「チームの誰がいつ見たか」を配信元に配る経路になる diff --git a/src/features/sharing/share-reference.ts b/src/features/sharing/share-reference.ts index e5f515c72..e56f259bf 100644 --- a/src/features/sharing/share-reference.ts +++ b/src/features/sharing/share-reference.ts @@ -29,6 +29,12 @@ export type ShareReferenceOptions = { title?: string; /** ユーザーが入力した説明(任意。urlMeta.description より優先) */ description?: string; + /** + * 共有した時点の実効フォルダ(素材ギャラリーが表示しているもの = + * 自分で付けた分 ∪ 貼ったノート由来)。ノート共有の extra.noteContexts と同型で、 + * 共有ライブラリの表でも同じ「フォルダ」列で絞り込めるようにする(鏡の原則)。 + */ + noteContexts?: string[]; }; export type ShareReferenceResult = @@ -90,6 +96,8 @@ export async function shareReference( url: entry.url, domain: entry.urlMeta?.domain ?? null, description: description ?? null, + // 再共有も同じ経路を通るので、共有側のフォルダは常に上書きされる + noteContexts: options.noteContexts ?? [], }, }; diff --git a/src/features/sharing/shared-blob-rows.test.ts b/src/features/sharing/shared-blob-rows.test.ts new file mode 100644 index 000000000..56d392fec --- /dev/null +++ b/src/features/sharing/shared-blob-rows.test.ts @@ -0,0 +1,107 @@ +// 共有ノート内の画像・ファイル(extra.blobs)を素材タブの行に組み立てる純関数のテスト。 +// +// 守りたい不変条件: +// - content-addressed(同じ hash = 同じ素材)なので、複数ノートに貼られていても 1 行 +// - 題名が無い blob でも行が識別できる(hash 先頭 12 桁) +// - 種別は拡張子から推定し、分からなければ "other"(素材一覧から消えない) + +import { describe, it, expect } from "vitest"; +import type { BlobRef, SharedEntry } from "../../lib/storage/shared"; +import { + blobMediaType, + blobRowTitle, + buildSharedBlobRows, + readEntryBlobs, + shortBlobHash, +} from "./shared-blob-rows"; + +const blob = (hash: string, filename?: string): BlobRef => ({ + provider: "local-folder", + uri: `file:///blobs/${hash}`, + hash, + size: 100, + ...(filename ? { filename } : {}), +}); + +const note = (id: string, blobs: unknown[]): SharedEntry => ({ + id, + type: "note", + author: { name: "Ada", email: "ada@example.com" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + hash: `sha256:${id}`, + prov: { derived_from: [] }, + version: 1, + extra: { title: id, blobs }, +}); + +describe("readEntryBlobs", () => { + it("extra.blobs が無い / 配列でない / hash を持たない要素は落とす", () => { + expect(readEntryBlobs({ ...note("n", []), extra: { title: "n" } })).toEqual([]); + expect(readEntryBlobs({ ...note("n", []), extra: { blobs: "x" } })).toEqual([]); + expect(readEntryBlobs(note("n", [null, { uri: "file:///x" }, blob("sha256:a")]))).toHaveLength(1); + }); +}); + +describe("buildSharedBlobRows", () => { + it("同じ hash の blob は 1 行に集約し、親ノートを全部持つ", () => { + const rows = buildSharedBlobRows([ + note("n1", [blob("sha256:aaa", "spectrum.png")]), + note("n2", [blob("sha256:aaa", "spectrum.png"), blob("sha256:bbb", "data.csv")]), + ]); + expect(rows).toHaveLength(2); + const shared = rows.find((r) => r.blob.hash === "sha256:aaa")!; + expect(shared.parents.map((p) => p.id)).toEqual(["n1", "n2"]); + // 代表の親は最初に見つかったノート(呼び出し側で updated_at 降順に並べてある) + expect(shared.parent.id).toBe("n1"); + expect(rows.find((r) => r.blob.hash === "sha256:bbb")!.parents.map((p) => p.id)).toEqual(["n2"]); + }); + + it("同じノートが同じ hash を 2 回持っていても親を二重に数えない", () => { + const rows = buildSharedBlobRows([ + note("n1", [blob("sha256:aaa", "a.png"), blob("sha256:aaa", "a.png")]), + ]); + expect(rows).toHaveLength(1); + expect(rows[0].parents).toHaveLength(1); + }); + + it("代表の BlobRef は題名を持つ方を選ぶ", () => { + const rows = buildSharedBlobRows([ + note("n1", [blob("sha256:aaa")]), + note("n2", [blob("sha256:aaa", "named.png")]), + ]); + expect(rows[0].blob.filename).toBe("named.png"); + }); + + it("行のキーは hash 由来(同じ素材は同じキー)", () => { + const rows = buildSharedBlobRows([note("n1", [blob("sha256:aaa", "a.png")])]); + expect(rows[0].key).toBe("blob:sha256:aaa"); + }); +}); + +describe("blobRowTitle / shortBlobHash", () => { + it("filename があればそれを題名にする", () => { + expect(blobRowTitle({ blob: blob("sha256:aaa", "spectrum.png") })).toBe("spectrum.png"); + }); + + it("filename が無ければ hash の先頭 12 桁(アルゴリズム接頭辞は落とす)", () => { + expect(blobRowTitle({ blob: blob("sha256:0123456789abcdef") })).toBe("0123456789ab"); + expect(shortBlobHash("0123456789abcdef")).toBe("0123456789ab"); + }); +}); + +describe("blobMediaType", () => { + it("拡張子から種別を推定する", () => { + expect(blobMediaType(blob("sha256:a", "x.png"))).toBe("image"); + expect(blobMediaType(blob("sha256:a", "x.MP4"))).toBe("video"); + expect(blobMediaType(blob("sha256:a", "x.m4a"))).toBe("audio"); + expect(blobMediaType(blob("sha256:a", "paper.pdf"))).toBe("pdf"); + expect(blobMediaType(blob("sha256:a", "measure.csv"))).toBe("data"); + expect(blobMediaType(blob("sha256:a", "report.docx"))).toBe("document"); + }); + + it("題名が無い / 未知の拡張子は other に落とす", () => { + expect(blobMediaType(blob("sha256:a"))).toBe("other"); + expect(blobMediaType(blob("sha256:a", "archive.xyz"))).toBe("other"); + }); +}); diff --git a/src/features/sharing/shared-blob-rows.ts b/src/features/sharing/shared-blob-rows.ts new file mode 100644 index 000000000..4668bc69c --- /dev/null +++ b/src/features/sharing/shared-blob-rows.ts @@ -0,0 +1,137 @@ +// 共有ノートの中にある画像・ファイル(SharedEntry.extra.blobs)を、 +// 素材タブの「行」に組み立てる純関数群(PR 2b / spec §19 B)。 +// +// なぜ別モジュールにするか: +// - blob は SharedEntry ではない(引用リンク・fork・検証を持たない仮想行)ので、 +// 表の描画から切り離して「行の作り方」だけを単体テストできるようにする +// - 同じ画像が複数ノートに貼られていても、利用者から見れば素材は 1 つ。 +// hash(content-addressed)で 1 行に畳む判断をここに閉じ込める +// +// 触らないもの: 共有フォーマット(BlobRef の構造)は読むだけ。新しい読み取りも足さない +// (extra.blobs は共有ストアのスナップショットに既に載っている)。 + +import type { BlobRef, SharedEntry } from "../../lib/storage/shared"; +import { mimeToMediaType, type MediaType } from "../asset-browser/media-index"; +import { mimeFromExtension } from "../mobile-capture/inbox/mime"; + +/** 素材タブの 1 行にまとめた blob(同じ hash のものは 1 つに畳んである)。 */ +export type SharedBlobRow = { + /** 行の識別子(表の key / 取り込み中の判定に使う) */ + key: string; + /** 代表の BlobRef(bytes 取得・題名の元) */ + blob: BlobRef; + /** 代表の親ノート。作者・共有日・フォルダ・操作(開く / 取り込む)の起点 */ + parent: SharedEntry; + /** 同じ hash を持つ共有ノート全部(出どころ列の「N 件のノート」) */ + parents: SharedEntry[]; +}; + +/** + * 素材タブに並ぶ行。既存タブ(ノート / ナレッジ / 素材の共有エントリ)は + * すべて kind: "entry"、共有ノート内の画像・ファイルだけが kind: "blob"。 + */ +export type SharedAssetItem = + | { kind: "entry"; entry: SharedEntry } + | ({ kind: "blob" } & SharedBlobRow); + +/** + * mimeFromExtension(モバイル捕獲の表)はカメラ・ボイスメモ由来の拡張子しか持たない。 + * 共有ノートには論文や実験の Office 文書も貼られるので、ここだけ拡張子→MIME を補う。 + * MIME → 種別の判定そのものは mimeToMediaType に委ねる(分類の真実は 1 つに保つ)。 + */ +const OFFICE_EXT_TO_MIME: Record = { + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", +}; + +function officeMimeFromExtension(filename: string): string | null { + const dot = filename.lastIndexOf("."); + if (dot < 0 || dot === filename.length - 1) return null; + return OFFICE_EXT_TO_MIME[filename.slice(dot + 1).toLowerCase()] ?? null; +} + +/** SharedEntry.extra.blobs を型安全に読む(壊れた extra でも落ちないようにする)。 */ +export function readEntryBlobs(entry: SharedEntry): BlobRef[] { + const blobs = (entry.extra as Record | undefined)?.blobs; + if (!Array.isArray(blobs)) return []; + return blobs.filter( + (b): b is BlobRef => + !!b && typeof b === "object" && typeof (b as BlobRef).hash === "string" && !!(b as BlobRef).hash, + ); +} + +/** + * hash の表示用の短縮形。`sha256:...` のようなアルゴリズム接頭辞は情報量が無いので落とす。 + * 題名が無い blob(filename を持たない古い共有)の代わりに出す。 + */ +export function shortBlobHash(hash: string): string { + const colon = hash.indexOf(":"); + const body = colon >= 0 ? hash.slice(colon + 1) : hash; + return body.slice(0, 12); +} + +/** 行の題名。filename があればそれ、無ければ hash の先頭 12 桁。 */ +export function blobRowTitle(row: { blob: BlobRef }): string { + const filename = typeof row.blob.filename === "string" ? row.blob.filename.trim() : ""; + return filename || shortBlobHash(row.blob.hash); +} + +/** + * blob の種別(asset.type.* のキーになる)。 + * BlobRef は mime を持たないので拡張子から推定し、分からなければ "other"。 + */ +export function blobMediaType(blob: BlobRef): MediaType { + const filename = typeof blob.filename === "string" ? blob.filename : ""; + // BlobRef 型に mime は無いが、将来 provider が付けてきた場合は宣言値を優先する + // (共有フォーマットは変えないので、あくまで「あれば読む」に留める) + const declared = (blob as { mime?: unknown }).mime; + const mime = + (typeof declared === "string" && declared ? declared : null) ?? + mimeFromExtension(filename) ?? + officeMimeFromExtension(filename) ?? + ""; + return mimeToMediaType(mime, filename || undefined); +} + +/** 種別列に出す i18n キー。 */ +export function blobKindLabelKey(blob: BlobRef): string { + return `asset.type.${blobMediaType(blob)}`; +} + +/** + * 共有ノート(extra.blobs を持つもの)から素材タブの blob 行を組み立てる。 + * + * - 同じ hash は 1 行に畳む(content-addressed = 中身が同じなら同じ素材) + * - 代表の BlobRef は「題名を持つ最初のもの」を選ぶ(片方だけ filename を + * 持つ共有でも題名が出るようにする) + * - 親ノートの順は渡された順(呼び出し側で updated_at 降順に並べてある) + */ +export function buildSharedBlobRows(parents: SharedEntry[]): SharedBlobRow[] { + const byHash = new Map(); + for (const parent of parents) { + // 同じノートが同じ hash を 2 回持っていても親を二重に数えない + const seenInParent = new Set(); + for (const blob of readEntryBlobs(parent)) { + if (seenInParent.has(blob.hash)) continue; + seenInParent.add(blob.hash); + const existing = byHash.get(blob.hash); + if (!existing) { + byHash.set(blob.hash, { + key: `blob:${blob.hash}`, + blob, + parent, + parents: [parent], + }); + continue; + } + existing.parents.push(parent); + // 代表がまだ題名を持っていなければ、題名を持つ方に差し替える + if (!existing.blob.filename && blob.filename) existing.blob = blob; + } + } + return [...byHash.values()]; +} diff --git a/src/features/sharing/shared-entry-source.test.ts b/src/features/sharing/shared-entry-source.test.ts index 93f160895..de05253a5 100644 --- a/src/features/sharing/shared-entry-source.test.ts +++ b/src/features/sharing/shared-entry-source.test.ts @@ -4,7 +4,7 @@ // - hash 不一致(verified=false)は chunks 空 // - 対象外の type は null -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SharedEntry } from "../../lib/storage/shared"; import type { GraphiumDocument } from "../../lib/document-types"; import { @@ -161,3 +161,27 @@ describe("extractSharedDerivedMeta", () => { expect(extractSharedDerivedMeta(entry({}), new TextEncoder().encode("{"), true)).toBeNull(); }); }); + +describe("パース済み本文の受け取り", () => { + // 語彙索引レーン(shared-library-sync)は同じ body を投影にも渡す。両方が + // 別々に JSON.parse すると本文の大きいノートで丸ごと 2 回走るので、 + // 呼び出し側が 1 回だけパースして配れるようにしてある + it("パース済みの doc を渡されたら body を読み直さない", () => { + const parseSpy = vi.spyOn(JSON, "parse"); + // body はわざと壊す。パースし直していれば chunks が空になる + const input = sharedEntryToSourceInput( + entry({}), + new TextEncoder().encode("{ not json"), + true, + noteDoc, + ); + expect(parseSpy).not.toHaveBeenCalled(); + expect(input?.chunks.length).toBeGreaterThan(0); + parseSpy.mockRestore(); + }); + + it("パース済みが null(呼び出し側で壊れていた)なら chunks は空", () => { + const input = sharedEntryToSourceInput(entry({}), encode(noteDoc), true, null); + expect(input?.chunks).toEqual([]); + }); +}); diff --git a/src/features/sharing/shared-entry-source.ts b/src/features/sharing/shared-entry-source.ts index 1c0538907..4f35ab162 100644 --- a/src/features/sharing/shared-entry-source.ts +++ b/src/features/sharing/shared-entry-source.ts @@ -33,8 +33,13 @@ function extraString(entry: SharedEntry, key: string): string { return typeof v === "string" ? v.trim() : ""; } -/** 本文(JSON テキスト)を GraphiumDocument として読む。壊れていれば null */ -function parseDocument(body: Uint8Array): GraphiumDocument | null { +/** + * 本文(JSON テキスト)を GraphiumDocument として読む。壊れていれば null。 + * + * export しているのは、同じ body を索引と投影の両方に渡す呼び出し側 + * (shared-library-sync の loader)が 1 回だけパースして配れるようにするため。 + */ +export function parseSharedBody(body: Uint8Array): GraphiumDocument | null { try { const doc = JSON.parse(new TextDecoder().decode(body)) as GraphiumDocument; return doc && typeof doc === "object" && Array.isArray(doc.pages) ? doc : null; @@ -51,6 +56,12 @@ export function sharedEntryToSourceInput( entry: SharedEntry, body: Uint8Array, verified: boolean, + /** + * 既にパース済みの本文。投影(shared-projection)と同じ body を使うので、 + * 呼び出し側が 1 回だけパースして両方に配れるようにする。 + * undefined = 未パース(ここで読む)/null = パースしたが壊れていた。 + */ + parsed?: GraphiumDocument | null, ): LexicalSourceInput | null { if (!(SHARED_INDEXABLE_TYPES as readonly string[]).includes(entry.type)) return null; @@ -65,7 +76,7 @@ export function sharedEntryToSourceInput( if (!verified) return { ...base, title: metaTitle, chunks: [] }; if (entry.type === "note" || entry.type === "knowledge") { - const doc = parseDocument(body); + const doc = parsed === undefined ? parseSharedBody(body) : parsed; if (!doc) return { ...base, title: metaTitle, chunks: [] }; const title = metaTitle || doc.title || ""; const chunks = @@ -115,7 +126,7 @@ export function extractSharedDerivedMeta( verified: boolean, ): SharedDerivedMeta | null { if (entry.type !== "note" || !verified) return null; - const doc = parseDocument(body); + const doc = parseSharedBody(body); if (!doc) return null; return { noteContexts: normalizeNoteContexts(doc.noteContexts) ?? [] }; } diff --git a/src/features/sharing/shared-library-sync.test.tsx b/src/features/sharing/shared-library-sync.test.tsx index d6cf4e03c..51772514f 100644 --- a/src/features/sharing/shared-library-sync.test.tsx +++ b/src/features/sharing/shared-library-sync.test.tsx @@ -21,6 +21,7 @@ const h = vi.hoisted(() => ({ ensureLoaded: vi.fn(async () => {}), reconcile: vi.fn(async (..._args: unknown[]) => {}), syncEmbeddings: vi.fn(async () => {}), + readBody: vi.fn(async (..._args: unknown[]) => ({ body: new Uint8Array(), verified: true })), })); vi.mock("../lexical-search", () => ({ @@ -31,6 +32,11 @@ vi.mock("../lexical-search", () => ({ lexicalSearch: { ensureLoaded: h.ensureLoaded, reconcile: h.reconcile }, })); vi.mock("./shared-embeddings", () => ({ syncSharedKnowledgeEmbeddings: h.syncEmbeddings })); +// 本文の読み出しだけ差し替える(ストアの状態と DI ローダーは本物のまま使う) +vi.mock("./shared-library-store", async (importOriginal) => ({ + ...(await importOriginal()), + readSharedEntryBody: h.readBody, +})); vi.mock("../../lib/storage/shared", async (importOriginal) => ({ ...(await importOriginal()), getSharedAiEnabled: () => h.aiEnabled, @@ -38,6 +44,11 @@ vi.mock("../../lib/storage/shared", async (importOriginal) => ({ import { useSharedLibrarySync } from "./shared-library-sync"; import { __setSharedLibraryLoaderForTest, groupSharedEntriesByType } from "./shared-library-store"; +import { + __resetSharedProjectionForTest, + getSharedProjection, +} from "./shared-projection"; +import type { GraphiumDocument } from "../../lib/document-types"; const ROOT = "/tmp/shared-root"; @@ -72,6 +83,8 @@ beforeEach(() => { h.ensureLoaded.mockClear(); h.reconcile.mockClear(); h.syncEmbeddings.mockClear(); + h.readBody.mockClear(); + __resetSharedProjectionForTest(); vi.useFakeTimers(); }); @@ -122,6 +135,58 @@ describe("useSharedLibrarySync", () => { expect(h.reconcile.mock.calls[0][0]).toEqual([]); }); + it("読んだ本文は 1 回だけパースして索引と投影の両方に配る", async () => { + const noteDoc: GraphiumDocument = { + version: 6, + title: "焼成の記録", + pages: [ + { + id: "p1", + title: "焼成の記録", + blocks: [ + { + id: "s1", + type: "step", + content: [{ type: "text", text: "焼成", styles: {} }], + children: [], + }, + ], + labels: {}, + provLinks: [], + knowledgeLinks: [], + }, + ], + } as any; + h.readBody.mockResolvedValue({ + body: new TextEncoder().encode(JSON.stringify(noteDoc)), + verified: true, + }); + // reconcile は「stale なソースを loader で読む」ところだけ本物と同じに振る舞わせる + h.reconcile.mockImplementation(async (...args: unknown[]) => { + const [desired, loader] = args as [ + { sourceId: string }[], + (d: { sourceId: string }) => Promise, + ]; + for (const d of desired) await loader(d); + }); + __setSharedLibraryLoaderForTest(async () => result([entry("a")]), { root: ROOT }); + + const parseSpy = vi.spyOn(JSON, "parse"); + renderHook(() => useSharedLibrarySync({ authenticated: true })); + // 1 回目: 共有ルートの読み込みが片付く(ここで初めて reconcile が仕掛かる) + await advance(2000); + await advance(2000); + + // 索引(sharedEntryToSourceInput)と投影(recordSharedProjectionFromBody)が + // 別々にパースすると 2 回になる + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(h.readBody).toHaveBeenCalledTimes(1); + expect(Object.keys(getSharedProjection().entries)).toEqual(["a"]); + parseSpy.mockRestore(); + h.reconcile.mockReset(); + h.reconcile.mockImplementation(async () => {}); + }); + it("共有ルート未設定なら空一覧で reconcile する(旧ルートの残留を消す)", async () => { __setSharedLibraryLoaderForTest(null, { root: null }); renderHook(() => useSharedLibrarySync({ authenticated: true })); diff --git a/src/features/sharing/shared-library-sync.ts b/src/features/sharing/shared-library-sync.ts index e65a81b83..0a3444dce 100644 --- a/src/features/sharing/shared-library-sync.ts +++ b/src/features/sharing/shared-library-sync.ts @@ -29,7 +29,12 @@ import { refreshSharedLibrary, useSharedLibrary, } from "./shared-library-store"; -import { sharedEntryToSourceInput } from "./shared-entry-source"; +import { parseSharedBody, sharedEntryToSourceInput } from "./shared-entry-source"; +import { + loadSharedProjection, + pruneSharedProjection, + recordSharedProjectionFromBody, +} from "./shared-projection"; import { syncSharedKnowledgeEmbeddings } from "./shared-embeddings"; /** 共有ストアが動いてから reconcile を走らせるまでの待ち(連続した共有操作を 1 回にまとめる) */ @@ -55,6 +60,9 @@ export function useSharedLibrarySync(params: SharedLibrarySyncParams): void { useEffect(() => { if (disabled || !authenticated) return; void refreshSharedLibrary(); + // ラベル・プロセスの投影も手元の控えから先に戻す。版が合わなければ捨てられ、 + // 本文を読み直したときに投影し直される(再構築可能なキャッシュ) + void loadSharedProjection(); }, [disabled, authenticated]); // 2. スナップショット / スイッチの変化に追従して索引を合わせる。 @@ -87,7 +95,16 @@ export function useSharedLibrarySync(params: SharedLibrarySyncParams): void { if (!entry) return null; try { const { body, verified } = await readSharedEntryBody(entry); - return sharedEntryToSourceInput(entry, body, verified); + // 本文の JSON.parse は本文が大きいほど効く。投影と語彙索引がそれぞれ + // 同じ body をパースしないよう、ここで 1 回だけ読んで両方に配る。 + // 本文の中身を見ない経路(reference / data-manifest、hash 不一致)は + // どちらも doc を使わないのでパース自体を省く。 + const needsDoc = verified && (entry.type === "note" || entry.type === "knowledge"); + const parsed = needsDoc ? parseSharedBody(body) : null; + // 本文を読んだこの場でラベル・プロセスも投影する。新しい読み取りは足さない + //(hash が同じなら投影側でスキップされる) + recordSharedProjectionFromBody(entry, body, verified, parsed); + return sharedEntryToSourceInput(entry, body, verified, parsed); } catch { // 読めなかった(消された・権限なし)→ 索引から外す return null; @@ -99,6 +116,10 @@ export function useSharedLibrarySync(params: SharedLibrarySyncParams): void { // LRU キャッシュから読めるので二度読みにならない)。OFF なら entries が // 空なので、消えた分の掃除だけが走る await syncSharedKnowledgeEmbeddings(entries, readSharedEntryBody); + // 共有から消えた id の投影を落とす。掃除の基準は「いま共有フォルダにあるもの」 + // なので、AI スイッチ OFF で reconcile 対象が空になっただけのときに + // 投影まで消さないよう、スナップショット側の一覧を使う + pruneSharedProjection(entriesRef.current.map((e) => e.id)); }, SHARED_RECONCILE_DEBOUNCE_MS); return () => { cancelled = true; diff --git a/src/features/sharing/shared-projection-save.test.ts b/src/features/sharing/shared-projection-save.test.ts new file mode 100644 index 000000000..11d0a696b --- /dev/null +++ b/src/features/sharing/shared-projection-save.test.ts @@ -0,0 +1,123 @@ +// 投影キャッシュの書き込み(appdata への persist)のテスト。 +// +// 対象の不変条件: +// - 同じファイルへの書き込みを直列化する。デバウンスのタイマーだけでは、 +// 書き込みがデバウンス間隔より長引いたときに 2 つの persist が並走し、 +// 解決順が入れ替わると古い投影が新しい投影を上書きしうる +// (process-index の processIndexSaveChain と同じ作法に揃える) +// - 実際に書くのは「そのとき最新の投影」(キューに積んだ時点のコピーではない) +// +// isTauri / app-data-file はモック。ディスクにも Tauri にも触れない。 + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const h = vi.hoisted(() => ({ + write: vi.fn<(key: string, name: string, data: unknown) => Promise>(), +})); + +vi.mock("../../lib/platform", async (importOriginal) => ({ + ...(await importOriginal()), + // 共有はデスクトップのみ。書き込み経路を通すために true 固定にする + isTauri: () => true, +})); +vi.mock("../../lib/storage/app-data-file", () => ({ + readAppDataFile: async () => null, + writeAppDataFile: h.write, +})); + +import { + __flushSharedProjectionSaveForTest, + __resetSharedProjectionForTest, + recordSharedProjectionFromBody, + type SharedProjection, +} from "./shared-projection"; +import type { GraphiumDocument } from "../../lib/document-types"; +import type { SharedEntry } from "../../lib/storage/shared"; + +const doc = (title: string): GraphiumDocument => + ({ + version: 6, + title, + pages: [ + { + id: "p1", + title, + blocks: [{ id: "s1", type: "step", content: [{ type: "text", text: "焼成", styles: {} }], children: [] }], + labels: {}, + provLinks: [], + knowledgeLinks: [], + }, + ], + }) as any; + +function sharedEntry(id: string, hash: string): SharedEntry { + return { + id, + type: "note", + author: { name: "Ada", email: "a@b.co" }, + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-20T00:00:00.000Z", + hash, + prov: { derived_from: [] }, + version: 1, + extra: { title: id }, + } as SharedEntry; +} + +const encode = (d: GraphiumDocument) => new TextEncoder().encode(JSON.stringify(d)); + +beforeEach(() => { + h.write.mockReset(); + __resetSharedProjectionForTest(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("投影の書き込み", () => { + it("前の書き込みが終わるまで次の書き込みを始めない(並走で古い内容に戻らない)", async () => { + let releaseFirst: (() => void) | null = null; + h.write.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => resolve(); + }), + ); + h.write.mockImplementationOnce(async () => {}); + + recordSharedProjectionFromBody(sharedEntry("s-a", "sha256:a"), encode(doc("A")), true); + await vi.advanceTimersByTimeAsync(2100); + expect(h.write).toHaveBeenCalledTimes(1); + + // 1 本目が終わらないうちに次の投影 → デバウンスが明けても書き込みは始まらない + recordSharedProjectionFromBody(sharedEntry("s-b", "sha256:b"), encode(doc("B")), true); + await vi.advanceTimersByTimeAsync(2100); + expect(h.write).toHaveBeenCalledTimes(1); + + releaseFirst!(); + await vi.advanceTimersByTimeAsync(0); + await __flushSharedProjectionSaveForTest(); + expect(h.write).toHaveBeenCalledTimes(2); + + // 2 本目が書くのは「そのとき最新の投影」= 2 件とも入っている + const written = h.write.mock.calls[1][2] as SharedProjection; + expect(Object.keys(written.entries).sort()).toEqual(["s-a", "s-b"]); + }); + + it("書き込みが失敗してもキューは止まらない(次の投影が書ける)", async () => { + h.write.mockRejectedValueOnce(new Error("disk full")); + h.write.mockResolvedValueOnce(undefined); + + recordSharedProjectionFromBody(sharedEntry("s-a", "sha256:a"), encode(doc("A")), true); + await vi.advanceTimersByTimeAsync(2100); + await __flushSharedProjectionSaveForTest(); + + recordSharedProjectionFromBody(sharedEntry("s-b", "sha256:b"), encode(doc("B")), true); + await vi.advanceTimersByTimeAsync(2100); + await __flushSharedProjectionSaveForTest(); + + expect(h.write).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/sharing/shared-projection.test.ts b/src/features/sharing/shared-projection.test.ts new file mode 100644 index 000000000..48c7723e1 --- /dev/null +++ b/src/features/sharing/shared-projection.test.ts @@ -0,0 +1,344 @@ +// 共有ノートの投影キャッシュのテスト。 +// +// 検証の軸: +// - 抽出が個人側と同じ関数を通っていること(ラベルは buildIndexEntry、 +// プロセスは buildProcessEntry の戻り値そのまま = P-1) +// - 受け取った側で解決できない情報を持ち帰らないこと(crossNoteLinks / outgoingLinks) +// - 差分投影(同じ hash はスキップ)と、消えた id の掃除 +// - 版が合わない控えは捨てること(再構築可能なキャッシュ) + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + SHARED_PROJECTION_VERSION, + __resetSharedProjectionForTest, + buildSharedProcessIndex, + buildSharedPseudoIndex, + countProjectedLabelNotes, + countProjectedProcessNotes, + createEmptySharedProjection, + getSharedProjection, + parseStoredProjection, + projectSharedNote, + pruneSharedProjection, + recordSharedProjectionFromBody, + subscribeSharedProjection, + type SharedProjection, +} from "./shared-projection"; +import { INDEX_SCHEMA_VERSION } from "../navigation/index-file"; +import { PROCESS_INDEX_VERSION } from "../network-graph/process-index"; +import type { GraphiumDocument } from "../../lib/document-types"; +import type { SharedEntry } from "../../lib/storage/shared"; + +const styled = (text: string, styles: Record = {}) => ({ + type: "text", + text, + styles, +}); + +const para = (id: string, content: any[]) => ({ id, type: "paragraph", content, children: [] }); + +const step = (id: string, title: string, children: any[] = []) => ({ + id, + type: "step", + content: [styled(title)], + children, +}); + +const doc = (blocks: any[], title = "共有ノート"): GraphiumDocument => + ({ + version: 6, + title, + pages: [{ id: "p1", title, blocks, labels: {}, provLinks: [], knowledgeLinks: [] }], + }) as any; + +function sharedEntry(overrides: Partial = {}): SharedEntry { + return { + id: "shared-1", + type: "note", + author: { name: "Ada", email: "a@b.co" }, + created_at: "2026-08-01T00:00:00.000Z", + updated_at: "2026-08-20T00:00:00.000Z", + hash: "sha256:aaa", + prov: { derived_from: [] }, + extra: { title: "共有された手順" }, + ...overrides, + }; +} + +/** 手順とラベルを持つノート */ +function procedureDoc(): GraphiumDocument { + const d = doc([ + step("s1", "焼成", [ + para("b1", [styled("前駆体粉末", { inlineMaterial: "material_m1" })]), + para("b2", [styled("電気炉", { inlineTool: "tool_t1" })]), + para("b3", [styled("焼成体", { inlineOutput: "output_o1" })]), + ]), + ]); + d.pages[0].labels = { b1: "material" }; + return d; +} + +const encode = (d: GraphiumDocument) => new TextEncoder().encode(JSON.stringify(d)); + +beforeEach(() => { + __resetSharedProjectionForTest(); +}); + +describe("projectSharedNote", () => { + it("ラベル・インラインラベル・手順を取り出し、プロセスを投影する", () => { + const projected = projectSharedNote(sharedEntry(), procedureDoc()); + + expect(projected.hash).toBe("sha256:aaa"); + // extra.title を優先する(一覧の題名と揃える) + expect(projected.title).toBe("共有された手順"); + expect(projected.author).toBe("Ada"); + expect(projected.labels.map((l) => l.label)).toContain("material"); + expect(projected.inlineLabels?.map((l) => l.text)).toEqual( + expect.arrayContaining(["前駆体粉末", "電気炉", "焼成体"]), + ); + expect(projected.steps?.map((s) => s.text)).toEqual(["焼成"]); + expect(projected.process?.noteId).toBe("shared-1"); + // 鮮度の基準は共有エントリの更新時刻 + expect(projected.process?.sourceModifiedAt).toBe("2026-08-20T00:00:00.000Z"); + expect(projected.process?.summary.stepCount).toBe(1); + }); + + it("crossNoteLinks は持ち帰らない(参照先が共有元のローカルノート id のため)", () => { + const d = doc([step("s1", "観察")]); + d.pages[0].provLinks = [ + { + id: "link-1", + sourceBlockId: "s1", + targetBlockId: "source-step", + type: "informed_by", + layer: "prov", + createdBy: "human", + targetNoteId: "local-note-999", + targetEntityId: "source-output", + sourceEntityId: "current-input", + }, + ] as any; + + const projected = projectSharedNote(sharedEntry(), d); + expect(projected.process?.crossNoteLinks).toEqual([]); + }); + + it("手順を持たないノートは process が null", () => { + const projected = projectSharedNote(sharedEntry(), doc([para("b1", [styled("ただのメモ")])])); + expect(projected.process).toBeNull(); + }); + + it("extra.title が無ければ本文のタイトルを使う", () => { + const projected = projectSharedNote(sharedEntry({ extra: {} }), doc([], "本文タイトル")); + expect(projected.title).toBe("本文タイトル"); + }); +}); + +describe("recordSharedProjectionFromBody", () => { + it("note 本文を読んだときに投影が載る", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + expect(Object.keys(getSharedProjection().entries)).toEqual(["shared-1"]); + }); + + it("同じ hash なら投影し直さない(差分投影)", () => { + const entry = sharedEntry(); + recordSharedProjectionFromBody(entry, encode(procedureDoc()), true); + const first = getSharedProjection().entries["shared-1"]; + + // 本文だけ差し替えても hash が同じなら読み直さない + recordSharedProjectionFromBody(entry, encode(doc([step("s9", "別の手順")])), true); + expect(getSharedProjection().entries["shared-1"]).toBe(first); + + // hash が変われば投影し直す + recordSharedProjectionFromBody( + sharedEntry({ hash: "sha256:bbb" }), + encode(doc([step("s9", "別の手順")])), + true, + ); + expect(getSharedProjection().entries["shared-1"].steps?.[0].text).toBe("別の手順"); + }); + + it("hash が合わない本文(verified=false)と note 以外は載せない", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), false); + recordSharedProjectionFromBody( + sharedEntry({ id: "shared-2", type: "knowledge" }), + encode(procedureDoc()), + true, + ); + expect(getSharedProjection().entries).toEqual({}); + }); + + it("壊れた本文は投影しない", () => { + recordSharedProjectionFromBody(sharedEntry(), new TextEncoder().encode("{ not json"), true); + expect(getSharedProjection().entries).toEqual({}); + }); + + // 語彙索引レーンは同じ body を索引にも渡す。両方が別々にパースすると + // 本文の大きいノートで JSON.parse が丸ごと 2 回走るので、渡された doc を使う + it("パース済みの本文を渡されたら body を読み直さない", () => { + const parseSpy = vi.spyOn(JSON, "parse"); + // body はわざと壊す。パースし直していればここで投影が落ちる + recordSharedProjectionFromBody( + sharedEntry(), + new TextEncoder().encode("{ not json"), + true, + procedureDoc(), + ); + expect(parseSpy).not.toHaveBeenCalled(); + expect(getSharedProjection().entries["shared-1"].steps?.length).toBeGreaterThan(0); + parseSpy.mockRestore(); + }); + + it("パース済みが null(呼び出し側で壊れていた)なら投影しない", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true, null); + expect(getSharedProjection().entries).toEqual({}); + }); +}); + +describe("購読者への通知", () => { + // 初回バックフィルでは共有ノートが 1 件ずつ投影される。1 件ごとに通知すると + // Library のラベル / プロセスタブが逐次作り直されてちらつくので、通知は束ねる + it("連続した投影をまとめて 1 回だけ通知する(中身は即座に最新)", () => { + vi.useFakeTimers(); + try { + const notified = vi.fn(); + const unsubscribe = subscribeSharedProjection(notified); + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + recordSharedProjectionFromBody( + sharedEntry({ id: "shared-2", hash: "sha256:ccc" }), + encode(procedureDoc()), + true, + ); + + // 通知はまだ出ていない。それでも読む側は最新のスナップショットを取れる + expect(notified).not.toHaveBeenCalled(); + expect(Object.keys(getSharedProjection().entries)).toEqual(["shared-1", "shared-2"]); + + vi.advanceTimersByTime(500); + expect(notified).toHaveBeenCalledTimes(1); + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("pruneSharedProjection", () => { + it("共有から消えた id の投影を落とす", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + recordSharedProjectionFromBody( + sharedEntry({ id: "shared-2", hash: "sha256:ccc" }), + encode(procedureDoc()), + true, + ); + + pruneSharedProjection(["shared-2"]); + expect(Object.keys(getSharedProjection().entries)).toEqual(["shared-2"]); + }); + + it("消える id が無ければスナップショットを差し替えない", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + const before = getSharedProjection(); + pruneSharedProjection(["shared-1"]); + expect(getSharedProjection()).toBe(before); + }); +}); + +describe("buildSharedPseudoIndex", () => { + it("noteId = sharedId、outgoingLinks は空、source は human", () => { + const entry = sharedEntry(); + recordSharedProjectionFromBody(entry, encode(procedureDoc()), true); + + const index = buildSharedPseudoIndex(getSharedProjection(), [entry]); + expect(index.version).toBe(INDEX_SCHEMA_VERSION); + expect(index.notes).toHaveLength(1); + expect(index.notes[0].noteId).toBe("shared-1"); + expect(index.notes[0].outgoingLinks).toEqual([]); + expect(index.notes[0].source).toBe("human"); + expect(index.notes[0].author).toBe("Ada"); + expect(index.notes[0].modifiedAt).toBe("2026-08-20T00:00:00.000Z"); + }); + + it("投影がまだ無いエントリと note 以外は並べない", () => { + const known = sharedEntry(); + recordSharedProjectionFromBody(known, encode(procedureDoc()), true); + const index = buildSharedPseudoIndex(getSharedProjection(), [ + known, + sharedEntry({ id: "unread" }), + sharedEntry({ id: "shared-k", type: "knowledge" }), + ]); + expect(index.notes.map((n) => n.noteId)).toEqual(["shared-1"]); + }); +}); + +describe("buildSharedProcessIndex", () => { + it("手順を持つ投影だけを並べる", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + recordSharedProjectionFromBody( + sharedEntry({ id: "memo", hash: "sha256:ddd" }), + encode(doc([para("b1", [styled("ただのメモ")])])), + true, + ); + + const index = buildSharedProcessIndex(getSharedProjection()); + expect(index.version).toBe(PROCESS_INDEX_VERSION); + expect(index.processes.map((p) => p.noteId)).toEqual(["shared-1"]); + }); +}); + +describe("件数バッジ", () => { + it("ラベルを持つ数・手順を持つ数を数える", () => { + recordSharedProjectionFromBody(sharedEntry(), encode(procedureDoc()), true); + recordSharedProjectionFromBody( + sharedEntry({ id: "memo", hash: "sha256:ddd" }), + encode(doc([para("b1", [styled("ただのメモ")])])), + true, + ); + + const projection = getSharedProjection(); + const ids = ["shared-1", "memo"]; + expect(countProjectedLabelNotes(projection, ids)).toBe(1); + expect(countProjectedProcessNotes(projection, ids)).toBe(1); + }); +}); + +describe("parseStoredProjection", () => { + const stored = (over: Partial = {}): unknown => ({ + ...createEmptySharedProjection(), + entries: { "shared-1": { hash: "sha256:aaa", title: "t", updatedAt: "", createdAt: "", author: "", headings: [], labels: [], process: null } }, + ...over, + }); + + it("版が揃っていれば受け入れる", () => { + const parsed = parseStoredProjection(stored()); + expect(parsed?.version).toBe(SHARED_PROJECTION_VERSION); + expect(Object.keys(parsed!.entries)).toEqual(["shared-1"]); + }); + + it("ファイルの版が違えば捨てる", () => { + expect(parseStoredProjection(stored({ version: SHARED_PROJECTION_VERSION + 1 }))).toBeNull(); + }); + + it("抽出ロジックの版が違えば捨てる(全再投影)", () => { + expect( + parseStoredProjection( + stored({ logic: { index: INDEX_SCHEMA_VERSION - 1, process: PROCESS_INDEX_VERSION } }), + ), + ).toBeNull(); + expect( + parseStoredProjection( + stored({ logic: { index: INDEX_SCHEMA_VERSION, process: PROCESS_INDEX_VERSION + 1 } }), + ), + ).toBeNull(); + }); + + it("hash を持たないエントリは採らない(差分投影の判定に使えない)", () => { + const parsed = parseStoredProjection(stored({ entries: { bad: {} as any } })); + expect(parsed?.entries).toEqual({}); + }); + + it("null / 非オブジェクトは捨てる", () => { + expect(parseStoredProjection(null)).toBeNull(); + expect(parseStoredProjection("x")).toBeNull(); + }); +}); diff --git a/src/features/sharing/shared-projection.ts b/src/features/sharing/shared-projection.ts new file mode 100644 index 000000000..f0859057b --- /dev/null +++ b/src/features/sharing/shared-projection.ts @@ -0,0 +1,402 @@ +// 共有ノートの投影キャッシュ(.graphium-shared-projection.json) +// +// 何のためにあるか: +// Library に「ラベル」「プロセス」のタブを出すには、共有ノートの本文から +// ラベル・手順を取り出した結果が要る。一覧は本文を読まずに描くので、 +// 読めたときに拾った結果をここへ控える(個人側の note-index / process-index と同じ考え方)。 +// +// 守っていること: +// - 手元だけ。共有フォルダには一切書かない(appdata / デスクトップのみ) +// - 新しい読み取りを足さない。語彙索引レーン(shared-library-sync)が本文を読む +// ついでに投影する。hash が一致していればスキップするので、同じ版は 1 回しか投影しない +// - P-1: プロセスは buildProcessEntry の戻り値をそのまま持つ。一覧のために +// 別経路で構造を組み立てない(二つの真実を作らない) +// - ラベルは buildIndexEntry の結果から取り出す。ノート一覧の索引と同じ抽出にする +// - 再構築可能なキャッシュなので、版が合わなければ黙って捨てる(壊れても実害が無い) +// +// 設計詳細: docs/internal/team-shared-storage-design.md §19 + +import { useSyncExternalStore } from "react"; +import type { GraphiumDocument } from "../../lib/document-types"; +import type { SharedEntry } from "../../lib/storage/shared"; +import { + buildIndexEntry, + INDEX_SCHEMA_VERSION, + type GraphiumIndex, + type NoteIndexEntry, +} from "../navigation/index-file"; +import { + buildProcessEntry, + PROCESS_INDEX_VERSION, + type ProcessIndex, + type ProcessIndexEntry, +} from "../network-graph/process-index"; +import { readAppDataFile, writeAppDataFile } from "../../lib/storage/app-data-file"; +import { isTauri } from "../../lib/platform"; + +/** 投影ファイルの形の版。形を変えたら上げる → 読み込み時に捨てて作り直す */ +export const SHARED_PROJECTION_VERSION = 1; + +const APP_DATA_KEY = "shared-projection"; +const DRIVE_FILE_NAME = ".graphium-shared-projection.json"; + +/** 書き込みのデバウンス。共有の読み込みは連続して起きるので 1 回にまとめる */ +const SAVE_DEBOUNCE_MS = 2000; + +/** + * 購読者への通知をまとめる間隔。 + * + * 投影は共有ノート 1 件ずつ進む(reconcile が stale なソースを順に読む)ので、 + * そのたびに通知すると Library のラベル / プロセスタブが 1 件ごとに作り直される。 + * 数百件の初回バックフィルでは、見えている一覧・グラフがちらつき続ける。 + * 中身(getSharedProjection)は即座に最新なので、通知だけ束ねて再レンダーを減らす。 + */ +const NOTIFY_COALESCE_MS = 200; + +export type SharedProjectionEntry = { + /** 投影したときの entry.hash。一致すれば投影し直さない */ + hash: string; + title: string; + /** entry.updated_at */ + updatedAt: string; + /** entry.created_at */ + createdAt: string; + /** entry.author.name */ + author: string; + headings: NoteIndexEntry["headings"]; + steps?: NoteIndexEntry["steps"]; + labels: NoteIndexEntry["labels"]; + inlineLabels?: NoteIndexEntry["inlineLabels"]; + /** 手順を持たないノートは null(プロセス一覧に出さない) */ + process: ProcessIndexEntry | null; +}; + +export type SharedProjection = { + version: number; + /** + * 抽出ロジックの版。note-index / process-index のどちらかが変われば + * 抽出結果の形が変わるので、全部捨てて投影し直す。 + */ + logic: { index: number; process: number }; + updatedAt: string; + /** sharedId → 投影 */ + entries: Record; +}; + +export function createEmptySharedProjection(): SharedProjection { + return { + version: SHARED_PROJECTION_VERSION, + logic: { index: INDEX_SCHEMA_VERSION, process: PROCESS_INDEX_VERSION }, + updatedAt: new Date().toISOString(), + entries: {}, + }; +} + +// ── 投影(純関数) ── + +function extraTitle(entry: SharedEntry): string { + const title = (entry.extra as Record | undefined)?.title; + return typeof title === "string" ? title.trim() : ""; +} + +/** + * 共有ノート 1 件を投影する。type === "note" のエントリにだけ使う。 + * + * プロセスは buildProcessEntry の戻り値をそのまま持つ(P-1)。ただし + * crossNoteLinks だけは落とす —— 参照先は共有元の**ローカルノート id** なので、 + * 受け取った側で解決できず、解決できないまま持つと嘘の系譜になる。 + */ +export function projectSharedNote( + entry: SharedEntry, + doc: GraphiumDocument, +): SharedProjectionEntry { + const indexEntry = buildIndexEntry(entry.id, doc); + // 鮮度の基準はノートの modifiedTime に相当するもの = 共有エントリの更新時刻 + const process = buildProcessEntry(entry.id, doc, { modifiedTime: entry.updated_at }); + return { + hash: entry.hash, + title: extraTitle(entry) || doc.title || "", + updatedAt: entry.updated_at, + createdAt: entry.created_at, + author: entry.author?.name ?? "", + headings: indexEntry.headings, + ...(indexEntry.steps && indexEntry.steps.length > 0 ? { steps: indexEntry.steps } : {}), + labels: indexEntry.labels, + ...(indexEntry.inlineLabels && indexEntry.inlineLabels.length > 0 + ? { inlineLabels: indexEntry.inlineLabels } + : {}), + process: process ? { ...process, crossNoteLinks: [] } : null, + }; +} + +/** 本文(JSON テキスト)を GraphiumDocument として読む。壊れていれば null */ +function parseDocument(body: Uint8Array): GraphiumDocument | null { + try { + const doc = JSON.parse(new TextDecoder().decode(body)) as GraphiumDocument; + return doc && typeof doc === "object" && Array.isArray(doc.pages) ? doc : null; + } catch { + return null; + } +} + +/** + * 保存されていた投影を受け入れられるか判定する。 + * 版が合わなければ null(=捨てて作り直す。再構築可能なキャッシュなので実害は無い)。 + */ +export function parseStoredProjection(raw: unknown): SharedProjection | null { + if (!raw || typeof raw !== "object") return null; + const candidate = raw as Partial; + if (candidate.version !== SHARED_PROJECTION_VERSION) return null; + const logic = candidate.logic; + if (!logic || typeof logic !== "object") return null; + if (logic.index !== INDEX_SCHEMA_VERSION || logic.process !== PROCESS_INDEX_VERSION) return null; + if (!candidate.entries || typeof candidate.entries !== "object") return null; + const entries: Record = {}; + for (const [id, value] of Object.entries(candidate.entries)) { + // hash が無いものは差分投影の判定に使えないので採らない + if (value && typeof value === "object" && typeof (value as SharedProjectionEntry).hash === "string") { + entries[id] = value as SharedProjectionEntry; + } + } + return { + version: SHARED_PROJECTION_VERSION, + logic: { index: INDEX_SCHEMA_VERSION, process: PROCESS_INDEX_VERSION }, + updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date().toISOString(), + entries, + }; +} + +// ── ストア(React から購読する) ── + +let current: SharedProjection = createEmptySharedProjection(); +const listeners = new Set<() => void>(); +let saveTimer: ReturnType | null = null; +let notifyTimer: ReturnType | null = null; +/** 書き込みの直列化キュー(process-index の processIndexSaveChain と同じ作法) */ +let saveChain: Promise = Promise.resolve(); +let loadPromise: Promise | null = null; + +function notifyAll(): void { + for (const listener of listeners) listener(); +} + +/** すぐ通知する。予約済みの束ねがあれば畳んでから出す(二重通知を避ける) */ +function emit(): void { + if (notifyTimer) { + clearTimeout(notifyTimer); + notifyTimer = null; + } + notifyAll(); +} + +/** 連続した投影を 1 回の通知にまとめる。予約済みなら何もしない(間隔を延ばさない) */ +function scheduleEmit(): void { + if (notifyTimer) return; + notifyTimer = setTimeout(() => { + notifyTimer = null; + notifyAll(); + }, NOTIFY_COALESCE_MS); +} + +function commit(next: SharedProjection): void { + current = next; + scheduleEmit(); + scheduleSave(); +} + +async function persist(): Promise { + try { + await writeAppDataFile(APP_DATA_KEY, DRIVE_FILE_NAME, current); + } catch { + // 書けなくても投影は次回作り直せる。起動を止めない + } +} + +function scheduleSave(): void { + // 共有はデスクトップのみ。ブラウザで空ファイルを作らない + if (!isTauri()) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + // 前の書き込みが終わってから次を書く。デバウンスのタイマーだけでは、 + // 書き込みがデバウンス間隔より長引いたときに 2 つの persist が並走し、 + // 解決順が入れ替わると古い投影が新しい投影を上書きしうる + // (process-index の processIndexSaveChain と同じ理由)。 + saveChain = saveChain.then(persist, persist); + }, SAVE_DEBOUNCE_MS); +} + +/** テスト用。予約済みの書き込みが片付くまで待つ */ +export function __flushSharedProjectionSaveForTest(): Promise { + return saveChain; +} + +/** 起動時に 1 回だけ読む。版が合わなければ捨てて空から始める */ +export function loadSharedProjection(): Promise { + if (loadPromise) return loadPromise; + loadPromise = (async () => { + if (!isTauri()) return; + let stored: SharedProjection | null = null; + try { + stored = parseStoredProjection(await readAppDataFile(APP_DATA_KEY, DRIVE_FILE_NAME)); + } catch { + stored = null; + } + if (!stored) return; + // 読んでいる間に投影された分(新しく読めた本文)を古い控えで上書きしない + current = { + ...stored, + entries: { ...stored.entries, ...current.entries }, + }; + emit(); + })(); + return loadPromise; +} + +export function getSharedProjection(): SharedProjection { + return current; +} + +export function subscribeSharedProjection(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** React 用。スナップショットは変化時だけ差し替わるので参照比較で足りる */ +export function useSharedProjection(): SharedProjection { + return useSyncExternalStore(subscribeSharedProjection, getSharedProjection, getSharedProjection); +} + +/** + * 本文を読んだついでに投影する。共有エントリを読む経路(語彙索引レーン)から呼ぶ。 + * + * - type !== "note" は対象外(ラベル・プロセスは人が書いたノートのもの) + * - hash が合わなかった本文(verified === false)は中身を信用しない + * - 同じ hash が既にあれば何もしない = 差分投影 + */ +export function recordSharedProjectionFromBody( + entry: SharedEntry, + body: Uint8Array, + verified: boolean, + /** + * 既にパース済みの本文。同じ body は語彙索引にも渡るので、呼び出し側が + * 1 回だけパースして両方に配れるようにする(大きい本文の二重パースを避ける)。 + * undefined = 未パース(ここで読む)/null = パースしたが壊れていた。 + */ + parsed?: GraphiumDocument | null, +): void { + if (entry.type !== "note" || !verified) return; + if (current.entries[entry.id]?.hash === entry.hash) return; + const doc = parsed === undefined ? parseDocument(body) : parsed; + if (!doc) return; + commit({ + ...current, + updatedAt: new Date().toISOString(), + entries: { ...current.entries, [entry.id]: projectSharedNote(entry, doc) }, + }); +} + +/** + * 共有から消えた投影を落とす。共有ストアの一覧(=いま共有フォルダにある id)を渡す。 + * 索引の removeMissing と同じ役割で、消えたノートがタブに残り続けるのを防ぐ。 + */ +export function pruneSharedProjection(liveIds: Iterable): void { + const keep = new Set(liveIds); + const ids = Object.keys(current.entries); + const removed = ids.filter((id) => !keep.has(id)); + if (removed.length === 0) return; + const entries: Record = {}; + for (const id of ids) if (keep.has(id)) entries[id] = current.entries[id]; + commit({ ...current, updatedAt: new Date().toISOString(), entries }); +} + +// ── 個人側のビューへ渡す形に組み替える ── + +/** + * LabelGalleryView 用の擬似 GraphiumIndex。 + * + * noteId は sharedId。共有ノートのリンク先は共有元のローカルノート id なので + * outgoingLinks は空にする(解決できないリンクを持たせない)。 + * source は "human" 固定 —— 共有ノート(type === "note")は人が書いたもの。 + */ +export function buildSharedPseudoIndex( + projection: SharedProjection, + entries: SharedEntry[], +): GraphiumIndex { + const notes: NoteIndexEntry[] = []; + for (const entry of entries) { + if (entry.type !== "note") continue; + const projected = projection.entries[entry.id]; + if (!projected) continue; + notes.push({ + noteId: entry.id, + title: projected.title, + modifiedAt: projected.updatedAt, + createdAt: projected.createdAt, + headings: projected.headings, + ...(projected.steps ? { steps: projected.steps } : {}), + labels: projected.labels, + outgoingLinks: [], + source: "human", + author: projected.author || entry.author?.name || undefined, + ...(projected.inlineLabels ? { inlineLabels: projected.inlineLabels } : {}), + }); + } + return { + version: INDEX_SCHEMA_VERSION, + updatedAt: projection.updatedAt, + notes, + }; +} + +/** ProcessGalleryView 用の ProcessIndex。手順を持つ投影だけを並べる */ +export function buildSharedProcessIndex(projection: SharedProjection): ProcessIndex { + const processes: ProcessIndexEntry[] = []; + for (const projected of Object.values(projection.entries)) { + if (projected.process) processes.push(projected.process); + } + return { + version: PROCESS_INDEX_VERSION, + updatedAt: projection.updatedAt, + processes, + }; +} + +/** ラベル(インライン含む)を 1 つ以上持つ投影の数。タブの件数バッジ用 */ +export function countProjectedLabelNotes( + projection: SharedProjection, + ids: Iterable, +): number { + let count = 0; + for (const id of ids) { + const projected = projection.entries[id]; + if (!projected) continue; + if (projected.labels.length > 0 || (projected.inlineLabels?.length ?? 0) > 0) count++; + } + return count; +} + +/** 手順を持つ投影の数。タブの件数バッジ用 */ +export function countProjectedProcessNotes( + projection: SharedProjection, + ids: Iterable, +): number { + let count = 0; + for (const id of ids) { + if (projection.entries[id]?.process) count++; + } + return count; +} + +/** テスト用。モジュールスコープのストアを初期状態に戻す */ +export function __resetSharedProjectionForTest(): void { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = null; + if (notifyTimer) clearTimeout(notifyTimer); + notifyTimer = null; + loadPromise = null; + current = createEmptySharedProjection(); + emit(); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7f6217f37..418a89e5b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -644,7 +644,7 @@ export const en: Record = { "settings.shared.desktopOnly": "Shared storage is currently desktop-only. The browser version cannot read or write to local folders.", "settings.shared.identityRequired": "Register your name and email above (Your identity) before testing the connection.", "settings.shared.aiEnabled.title": "Include the shared library in ⌘K and AI chat", - "settings.shared.aiEnabled.help": "Indexes notes and knowledge pages from the shared folder, plus asset titles and descriptions, in your local search index, and makes them available to AI chat. The index is built on this device only — nothing is written to the shared folder. Anyone who can read the shared folder can do the same (there is no in-app permission control).", + "settings.shared.aiEnabled.help": "Indexes notes and knowledge pages from the shared folder, plus asset titles and descriptions, in your local search index, and makes them available to AI chat. The index is built on this device only — nothing is written to the shared folder. Anyone who can read the shared folder can do the same (there is no in-app permission control). Turning this off also leaves the Labels and Processes tabs of the shared library empty, because they are built from the same reading.", // ── Mobile upload (desktop = receiving side: sync folder + QR to open on the phone) ── "settings.mobilePush.title": "Mobile upload", "settings.mobilePush.help": "Your phone uploads captures to cloud storage; this desktop imports them from the synced folder.", @@ -2368,6 +2368,8 @@ export const en: Record = { "library.tab.note": "Notes", "library.tab.knowledge": "Knowledge", "library.tab.asset": "Assets", + "library.tab.labels": "Labels", + "library.tab.process": "Processes", "library.loadFailed": "Failed to load: {error}", "library.untitled": "(untitled)", "library.unshareConfirm": "Unshare \"{title}\"?", @@ -2380,11 +2382,19 @@ export const en: Record = { "library.empty.note": "No shared notes yet.", "library.empty.knowledge": "No shared knowledge yet.", "library.empty.asset": "No shared assets yet.", + "library.empty.labels": "No labels from shared notes yet. They appear as note contents are read.", + "library.empty.process": "No processes from shared notes yet. They appear as note contents are read.", "library.col.title": "Title", "library.col.kind": "Kind", "library.col.sharedAt": "Shared", "library.col.version": "Version", "library.col.verified": "Verified", + "library.col.origins": "Source notes", + "library.blobOrigins": "{count} notes", + "library.openParentNote": "Open note", + "library.importBlob": "Add to my materials", + "library.importBlobDone": "Added \"{name}\" to your materials. You can find it in the Materials gallery.", + "library.importBlobFailed": "Import failed: {error}", "library.sort.sharedAt": "Shared date", "library.sort.title": "Title", "library.sort.author": "Author", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 842526788..59c05c93a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -644,7 +644,7 @@ export const ja: Record = { "settings.shared.desktopOnly": "共有ストレージは現在デスクトップ版のみ対応です。ブラウザ版ではローカルフォルダの読み書きができません。", "settings.shared.identityRequired": "接続テストの前に、上の「あなたの identity」で名前とメールを登録してください。", "settings.shared.aiEnabled.title": "共有ライブラリを ⌘K と AI チャットの対象に含める", - "settings.shared.aiEnabled.help": "共有フォルダのノート・ナレッジと、素材の題名・説明を手元の検索索引に入れ、AI チャットの参照候補にも使います。索引は手元だけに作られ、共有フォルダには何も書きません。共有フォルダを読める人は誰でも同じことができます(アプリ内の権限制御はありません)。", + "settings.shared.aiEnabled.help": "共有フォルダのノート・ナレッジと、素材の題名・説明を手元の検索索引に入れ、AI チャットの参照候補にも使います。索引は手元だけに作られ、共有フォルダには何も書きません。共有フォルダを読める人は誰でも同じことができます(アプリ内の権限制御はありません)。OFF にすると、同じ読み取りから作っている Library の「ラベル」「プロセス」タブも空のままになります。", // ── モバイル送信(デスクトップ = 受け取り側: 受信フォルダ + スマホで開く QR) ── "settings.mobilePush.title": "モバイル送信", "settings.mobilePush.help": "スマホで撮ったものをクラウドストレージ経由でデスクトップに送ります。この端末は同期フォルダから取り込む側です。", @@ -2365,6 +2365,8 @@ export const ja: Record = { "library.tab.note": "ノート", "library.tab.knowledge": "ナレッジ", "library.tab.asset": "素材", + "library.tab.labels": "ラベル", + "library.tab.process": "プロセス", "library.loadFailed": "読み込みに失敗しました: {error}", "library.untitled": "(無題)", "library.unshareConfirm": "「{title}」の共有を解除しますか?", @@ -2377,11 +2379,19 @@ export const ja: Record = { "library.empty.note": "共有されたノートはまだありません。", "library.empty.knowledge": "共有されたナレッジはまだありません。", "library.empty.asset": "共有された素材はまだありません。", + "library.empty.labels": "共有ノートのラベルはまだありません。本文を読み込むと増えていきます。", + "library.empty.process": "共有ノートの手順はまだありません。本文を読み込むと増えていきます。", "library.col.title": "タイトル", "library.col.kind": "種別", "library.col.sharedAt": "共有日", "library.col.version": "版", "library.col.verified": "検証", + "library.col.origins": "出どころ", + "library.blobOrigins": "{count} 件のノート", + "library.openParentNote": "ノートを開く", + "library.importBlob": "自分の素材に取り込む", + "library.importBlobDone": "「{name}」を素材に取り込みました。素材ギャラリーで確認できます。", + "library.importBlobFailed": "取り込みに失敗しました: {error}", "library.sort.sharedAt": "共有日", "library.sort.title": "タイトル", "library.sort.author": "作者", diff --git a/src/note-app.tsx b/src/note-app.tsx index 607605242..f99c7aefb 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -195,6 +195,8 @@ import { type BulkShareTarget, } from "./features/sharing"; import { LocalFolderBlobProvider, type BlobRef } from "./lib/storage/shared"; +// 共有ノート内の画像・ファイルを自分の素材に取り込むときの mime 判定(fork の materialize と同じ経路) +import { sniffMimeType, extensionForMime } from "./features/sharing/materialize-blobs"; import { DocumentProvenancePanel } from "./features/document-provenance"; import { cn } from "./lib/utils"; import { NoteListView, TrashView, buildKnowledgeMap, findIncomingReferences, readIndexFile, type GraphiumIndex, type NoteIndexEntry } from "./features/navigation"; @@ -9475,12 +9477,15 @@ export function NoteApp() { focusEntryId={sharedLibraryFocusId} onFocusConsumed={() => setSharedLibraryFocusId(null)} onForkNote={async (sharedId) => { + // 失敗は throw で呼び出し側に伝える。黙って return すると、 + // プロセスタブの派生ボタン(onForkProcess)が成否を判定できず + // 「何も起きていないのに成功したように見える」状態になる const root = getSharedRoot(); - if (!root) return; + if (!root) throw new Error("Shared root is not configured."); const result = await forkSharedNote(sharedId, { root }); if (!result.ok) { alert(`Fork failed: ${result.error}`); - return; + throw new Error(result.error); } // Phase 2c-2: shared-blob: 参照を自分のローカルメディアに materialize let docToSave = result.doc; @@ -9553,6 +9558,29 @@ export function NoteApp() { notifySharedLibraryChanged(); }} onBack={() => { setShowSharedLibrary(false); setShowGlobalGraph(false); router.navigate({ view: "home" }); }} + // 共有ノート内の画像・ファイル(extra.blobs)を自分の素材として取り込む。 + // fork の materialize と同じ経路(blob root から bytes → mime sniff → 自分の MediaProvider)。 + // blob root 未設定なら undefined を渡し、表側で操作を無効化させる + onImportBlob={ + getBlobRoot() + ? async (_parent, blob) => { + const blobRoot = getBlobRoot(); + if (!blobRoot) return; + try { + const bytes = await new LocalFolderBlobProvider(blobRoot).get(blob); + const mime = sniffMimeType(bytes); + const filename = + blob.filename || + `shared-${blob.hash.replace(/[^a-z0-9]/gi, "").slice(0, 12)}.${extensionForMime(mime)}`; + const file = new File([bytes as BlobPart], filename, { type: mime }); + await fm.handleUploadMedia(file); + alert(tStatic("library.importBlobDone", { name: filename })); + } catch (e) { + alert(tStatic("library.importBlobFailed", { error: String(e) })); + } + } + : undefined + } /> ) : showTrash ? (