(initialTab);
@@ -226,7 +248,7 @@ export function SharedLibraryView({
const hit = list.find((e) => e.id === focusEntryId);
if (hit) {
const tab = typeToTab(type as SharedEntryType);
- // タブを持たない type(template / report)は一覧から辿れないので、選択せず consume だけする
+ // タブを持たない type(report)は一覧から辿れないので、選択せず consume だけする
if (tab) {
setActiveTab(tab);
setSelected(hit);
@@ -339,6 +361,23 @@ export function SharedLibraryView({
[onForkNote, onForkKnowledge],
);
+ // テンプレートから新規ノート。失敗の通知は呼び出し元のハンドラが出すので、
+ // ここでは握って busy 表示だけ戻す(fork と同じ作法)
+ const handleCreateFromTemplate = useCallback(
+ async (entry: SharedEntry) => {
+ if (!onCreateNoteFromTemplate) return;
+ setBusyId(entry.id);
+ try {
+ await onCreateNoteFromTemplate(entry.id);
+ } catch {
+ // 失敗表示は呼び出し元
+ } finally {
+ setBusyId(null);
+ }
+ },
+ [onCreateNoteFromTemplate],
+ );
+
const handleUnshare = useCallback(
async (entry: SharedEntry) => {
const confirmed = window.confirm(
@@ -440,6 +479,7 @@ export function SharedLibraryView({
projection={projection}
entries={entriesByTab.note}
onNavigateNote={openSharedNote}
+ onOpenNoteList={onOpenNoteList}
/>
) : activeTab === "process" ? (
@@ -447,6 +487,11 @@ export function SharedLibraryView({
className="flex-1 min-h-0 flex flex-col overflow-hidden"
data-testid="shared-library-tab-process"
>
+ {/* ラベルタブと同じ説明バー(共有操作が無いことの説明はここでも同じ) */}
+
{sharedProcessIndex.processes.length === 0 ? (
// 本文をまだ読めていない間もここに来る(読めた分から増えていく)ので、
// 個人側の process.empty ではなく共有側の言い方にする
@@ -505,6 +550,11 @@ export function SharedLibraryView({
? () => handleFork(selected)
: undefined
}
+ onCreateFromTemplate={
+ selected.type === "template" && onCreateNoteFromTemplate
+ ? () => handleCreateFromTemplate(selected)
+ : undefined
+ }
onUnshare={() => handleUnshare(selected)}
onClose={() => setSelected(null)}
/>
@@ -523,6 +573,8 @@ type DetailProps = {
sharedRoot: string;
onVerifyHash: () => void;
onFork?: () => void;
+ /** テンプレートのときだけ渡る(自分作・他人作を問わず出す) */
+ onCreateFromTemplate?: () => void;
onUnshare: () => void;
onClose: () => void;
};
@@ -534,6 +586,7 @@ function SharedEntryDetail({
sharedRoot,
onVerifyHash,
onFork,
+ onCreateFromTemplate,
onUnshare,
onClose,
}: DetailProps) {
@@ -682,6 +735,15 @@ function SharedEntryDetail({
)}
{citationCopied ? uiT("share.copied") : uiT("share.copyCitation")}
+ {onCreateFromTemplate && (
+
+
+ {uiT("library.createFromTemplate")}
+
+ )}
{onFork && !isMine && (
;
}
+ if (entry.type === "template") {
+ const description =
+ typeof extra.description === "string" ? extra.description : null;
+ return (
+
+ {description && (
+
{description}
+ )}
+
+
+ );
+ }
+
if (entry.type === "note" || entry.type === "knowledge") {
// body は GraphiumDocument JSON。読み取り専用エディタでフル内容を表示する
return ;
}
- // template / report はテキスト系として中身をそのまま表示
+ // report はテキスト系として中身をそのまま表示
return (
{body.slice(0, 8000)}
@@ -946,6 +1021,56 @@ export function SharedNotePreview({ body }: { body: string }) {
);
}
+// ── template の read-only preview ──
+//
+// 本文は PageTemplate JSON(GraphiumDocument ではない)。ノートと同じ読み取り専用
+// ビューアで見せるため、擬似 GraphiumDocument に包んでから SharedNotePreview に渡す。
+// なぜ包むだけで足りるか: プレビューが読むのは pages[].blocks だけで、
+// shared-blob: の解決も doc の走査で行われるため。
+function SharedTemplatePreview({ body }: { body: string }) {
+ const pseudoBody = useMemo(() => {
+ try {
+ const template = deserializeTemplate(body);
+ if (!Array.isArray(template?.blocks)) return null;
+ const doc: GraphiumDocument = {
+ version: LATEST_DOCUMENT_VERSION,
+ title: template.name,
+ // 表示専用の擬似ドキュメント。日時はテンプレートの保存時刻で埋める
+ // (プレビューは読まないが GraphiumDocument の必須フィールド)
+ createdAt: template.savedAt,
+ modifiedAt: template.savedAt,
+ pages: [
+ {
+ id: "main",
+ title: template.pageTitle || template.name,
+ blocks: template.blocks,
+ labels: Object.fromEntries(template.labels ?? []),
+ provLinks: [],
+ knowledgeLinks: [],
+ ...(template.tableMeta ? { tableMeta: template.tableMeta } : {}),
+ ...(template.mediaInlineLabels
+ ? { mediaInlineLabels: template.mediaInlineLabels }
+ : {}),
+ },
+ ],
+ };
+ return JSON.stringify(doc);
+ } catch {
+ return null;
+ }
+ }, [body]);
+
+ // PageTemplate として読めない body は raw 表示にフォールバック(ノートと同じ扱い)
+ if (!pseudoBody) {
+ return (
+
+ {body.slice(0, 4000)}
+
+ );
+ }
+ return ;
+}
+
// ── data-manifest の inline preview ──
function DataManifestPreview({ entry }: { entry: SharedEntry }) {
diff --git a/src/features/sharing/bulk-share.test.ts b/src/features/sharing/bulk-share.test.ts
index f43f9f67f..fecba8051 100644
--- a/src/features/sharing/bulk-share.test.ts
+++ b/src/features/sharing/bulk-share.test.ts
@@ -12,11 +12,19 @@ vi.mock("@tauri-apps/api/core", () => ({
import { bulkShare, type BulkShareDeps } from "./bulk-share";
import type { GraphiumDocument } from "../../lib/document-types";
import type { AuthorIdentity } from "../document-provenance/types";
+import type {
+ MediaIndexEntry,
+ MediaSharedRef,
+} from "../asset-browser/media-index";
const author: AuthorIdentity = { name: "Ada", email: "a@b.co" };
class FakeFs {
entries = new Map();
+ /** key = hash → base64(素材の blob) */
+ blobs = new Map();
+ /** key = fileId → base64(read_media_file の戻り値) */
+ mediaFiles = new Map();
install() {
invokeMock.mockReset();
invokeMock.mockImplementation(async (cmd: string, args: any) => {
@@ -29,6 +37,16 @@ class FakeFs {
if (!v) throw new Error("not found");
return v;
}
+ case "read_media_file": {
+ const v = this.mediaFiles.get(args.fileId);
+ if (!v) throw new Error(`media not found: ${args.fileId}`);
+ return v;
+ }
+ case "shared_blob_write":
+ this.blobs.set(args.hash, args.contentBase64);
+ return null;
+ case "shared_blob_exists":
+ return this.blobs.has(args.hash);
default:
throw new Error(`unmocked: ${cmd}`);
}
@@ -175,3 +193,123 @@ describe("bulkShare", () => {
expect(summary.results).toHaveLength(1);
});
});
+
+// --- 素材(media)の一括共有 ---
+// 単体経路(MaterialActionsMenu)と同じ振り分け(URL は reference / それ以外は
+// blob 付き data-manifest)と、同じ実効フォルダが載ることを押さえる。
+
+function makeMediaEntry(overrides: Partial = {}): MediaIndexEntry {
+ return {
+ fileId: "media-1",
+ name: "photo.jpg",
+ type: "image",
+ mimeType: "image/jpeg",
+ url: "file-media://media-1",
+ thumbnailUrl: "",
+ uploadedAt: "2026-05-04T00:00:00Z",
+ usedIn: [],
+ ...overrides,
+ };
+}
+
+function makeMediaDeps(
+ entries: MediaIndexEntry[],
+ overrides: Partial = {},
+): { deps: BulkShareDeps; savedRefs: Map } {
+ const savedRefs = new Map();
+ const base = makeDeps().deps;
+ const deps: BulkShareDeps = {
+ ...base,
+ blobRoot: "/blobs",
+ loadMedia: (fileId) => entries.find((e) => e.fileId === fileId) ?? null,
+ saveMediaSharedRef: (entry, sharedRef) => {
+ savedRefs.set(entry.fileId, sharedRef);
+ },
+ ...overrides,
+ };
+ return { deps, savedRefs };
+}
+
+describe("bulkShare — media", () => {
+ it("shares a media file and writes the sharedRef back", async () => {
+ fs.mediaFiles.set("media-1", btoa("image bytes"));
+ const { deps, savedRefs } = makeMediaDeps([makeMediaEntry()]);
+ const summary = await bulkShare([{ id: "media-1", kind: "media" }], deps);
+
+ expect(summary.shared).toBe(1);
+ expect(summary.failed).toBe(0);
+ expect(summary.results[0].title).toBe("photo.jpg");
+ expect(savedRefs.get("media-1")?.type).toBe("data-manifest");
+ expect(savedRefs.get("media-1")?.blobHash).toMatch(/^sha256:/);
+ });
+
+ it("puts the effective folders (own ∪ derived from notes) on the shared entry", async () => {
+ fs.mediaFiles.set("media-1", btoa("image bytes"));
+ const entry = makeMediaEntry({
+ noteContexts: ["材料X"],
+ usedIn: [{ noteId: "n1", noteTitle: "Note one", blockId: "b1" }],
+ });
+ const { deps } = makeMediaDeps([entry], {
+ noteFolderLookup: new Map([["n1", ["測定/XRD"]]]),
+ });
+ await bulkShare([{ id: "media-1", kind: "media" }], deps);
+
+ const stored = JSON.parse([...fs.entries.values()][0]);
+ expect(stored.entry.extra.noteContexts).toEqual(["材料X", "測定/XRD"]);
+ });
+
+ it("shares a URL bookmark as a reference (no blob root needed)", async () => {
+ const entry = makeMediaEntry({
+ fileId: "url-1",
+ type: "url",
+ name: "Some article",
+ url: "https://example.com/a",
+ });
+ const { deps, savedRefs } = makeMediaDeps([entry], { blobRoot: undefined });
+ const summary = await bulkShare([{ id: "url-1", kind: "media" }], deps);
+
+ expect(summary.shared).toBe(1);
+ expect(savedRefs.get("url-1")?.type).toBe("reference");
+ });
+
+ it("fails a non-URL asset when no blob root is configured", async () => {
+ fs.mediaFiles.set("media-1", btoa("image bytes"));
+ const { deps, savedRefs } = makeMediaDeps([makeMediaEntry()], { blobRoot: undefined });
+ const summary = await bulkShare([{ id: "media-1", kind: "media" }], deps);
+
+ expect(summary.failed).toBe(1);
+ expect(summary.results[0].error).toMatch(/blob/i);
+ // 共有もしていない(shared 側に書かれていない)
+ expect(fs.entries.size).toBe(0);
+ expect(savedRefs.size).toBe(0);
+ });
+
+ it("reports a failed sharedRef write-back as a failure", async () => {
+ fs.mediaFiles.set("media-1", btoa("image bytes"));
+ const { deps } = makeMediaDeps([makeMediaEntry()], {
+ saveMediaSharedRef: () => {
+ throw new Error("index write failed");
+ },
+ });
+ const summary = await bulkShare([{ id: "media-1", kind: "media" }], deps);
+
+ expect(summary.failed).toBe(1);
+ expect(summary.results[0].error).toMatch(/index write failed/);
+ });
+
+ it("keeps going when an asset is missing from the index", async () => {
+ fs.mediaFiles.set("media-1", btoa("image bytes"));
+ const { deps, savedRefs } = makeMediaDeps([makeMediaEntry()]);
+ const summary = await bulkShare(
+ [
+ { id: "gone", kind: "media" },
+ { id: "media-1", kind: "media" },
+ ],
+ deps,
+ );
+
+ expect(summary.failed).toBe(1);
+ expect(summary.shared).toBe(1);
+ expect(savedRefs.has("media-1")).toBe(true);
+ });
+});
diff --git a/src/features/sharing/bulk-share.ts b/src/features/sharing/bulk-share.ts
index f72080b7d..f1a7bd479 100644
--- a/src/features/sharing/bulk-share.ts
+++ b/src/features/sharing/bulk-share.ts
@@ -1,4 +1,4 @@
-// 複数選択したノート / Knowledge を一括で team-shared storage に共有する。
+// 複数選択したノート / Knowledge / 素材を一括で team-shared storage に共有する。
//
// 設計判断:
// - 逐次処理(並列にしない)。Share は blob 化を伴い 1 件が重く、
@@ -12,12 +12,27 @@
import type { GraphiumDocument } from "../../lib/document-types";
import type { AuthorIdentity } from "../document-provenance/types";
+import type {
+ MediaIndexEntry,
+ MediaSharedRef,
+} from "../asset-browser/media-index";
+import {
+ assetFolderValues,
+ type NoteFolderLookup,
+} from "../asset-browser/asset-folders";
+import { t } from "../../i18n";
import { shareNote } from "./share-note";
import { shareKnowledge } from "./share-knowledge";
+import { shareMedia } from "./share-media";
+import { shareReference } from "./share-reference";
+
+/** lookup 未指定時の空表。毎回新しい Map を作らないよう共有する */
+const EMPTY_NOTE_FOLDER_LOOKUP: NoteFolderLookup = new Map();
export type BulkShareTarget = {
id: string;
- kind: "note" | "knowledge";
+ /** media の id は MediaIndexEntry.fileId */
+ kind: "note" | "knowledge" | "media";
};
export type BulkShareItemResult = {
@@ -50,10 +65,106 @@ export type BulkShareDeps = {
loadKnowledge: (id: string) => Promise;
/** false が返ったら保存失敗として扱う(handleSaveWikiFile の契約) */
saveKnowledge: (id: string, doc: GraphiumDocument) => Promise;
+ /**
+ * 素材インデックスから entry を引く(見つからなければ null)。
+ * ノート / Knowledge のように storage から読み直さないのは、素材の共有に
+ * 必要な情報(fileId / type / usedIn / sharedRef)が全てインデックス側にあるため。
+ * kind: "media" を渡すなら必須。
+ */
+ loadMedia?: (fileId: string) => Promise | MediaIndexEntry | null;
+ /**
+ * 素材の sharedRef 書き戻し。単体経路(MaterialActionsMenu の onSharedRefUpdated)
+ * と同じ関数を渡すこと — media index への保存作法を 1 箇所に保つため。
+ * kind: "media" を渡すなら必須。
+ */
+ saveMediaSharedRef?: (
+ entry: MediaIndexEntry,
+ sharedRef: MediaSharedRef,
+ ) => Promise | void;
+ /**
+ * 素材の実効フォルダを導くための参照表。共有側に載せるフォルダを
+ * 素材ギャラリー(と単体共有)が表示している値と一致させるために使う。
+ * 未指定ならフォルダ無しで共有する(共有ライブラリの表で空欄になるだけ)。
+ */
+ noteFolderLookup?: NoteFolderLookup;
onProgress?: (done: number, total: number, currentTitle: string) => void;
isCancelled?: () => boolean;
};
+/**
+ * 素材 1 件を共有する。URL ブックマークはバイト実体を持たないので reference、
+ * それ以外は blob 付き data-manifest(単体経路 material-actions-menu と同じ振り分け)。
+ */
+async function shareOneMedia(
+ target: BulkShareTarget,
+ deps: BulkShareDeps,
+ index: number,
+ total: number,
+): Promise {
+ let title = "";
+ try {
+ const entry = deps.loadMedia ? await deps.loadMedia(target.id) : null;
+ if (!entry) {
+ return { ...target, title: target.id, ok: false, error: "Asset not found" };
+ }
+ title = entry.name || target.id;
+ deps.onProgress?.(index, total, title);
+
+ const isUrlEntry = entry.type === "url";
+ if (!isUrlEntry && !deps.blobRoot) {
+ // blob root が無いと実体バイト列の置き場が無い。ここで止めないと
+ // shareMedia が内部エラーで落ちるだけで、原因が UI に出ない
+ return { ...target, title, ok: false, error: t("share.media.disabled.noBlobRoot") };
+ }
+
+ // 素材ギャラリーの「フォルダ」と同じ値を共有側にも載せる(単体経路と同じ引き方)
+ const noteContexts = assetFolderValues(
+ entry,
+ deps.noteFolderLookup ?? EMPTY_NOTE_FOLDER_LOOKUP,
+ );
+ const result = isUrlEntry
+ ? await shareReference(entry, {
+ sharedRoot: deps.root,
+ author: deps.author,
+ title: entry.name,
+ description: "",
+ noteContexts,
+ })
+ : await shareMedia(entry, {
+ sharedRoot: deps.root,
+ blobRoot: deps.blobRoot!,
+ author: deps.author,
+ title: entry.name,
+ description: "",
+ noteContexts,
+ });
+ if (!result.ok) {
+ return { ...target, title, ok: false, error: result.error };
+ }
+
+ if (!deps.saveMediaSharedRef) {
+ // 共有はできたが sharedRef を残す手段が無い=次回 Share が同 id に繋がらない。
+ // ノート経路の書き戻し失敗と同じ扱いで失敗として報告する(配線漏れの検出も兼ねる)
+ return {
+ ...target,
+ title,
+ ok: false,
+ error: "Shared, but failed to record sharedRef locally",
+ };
+ }
+ await deps.saveMediaSharedRef(entry, result.sharedRef);
+
+ return { ...target, title, ok: true, isUpdate: result.isUpdate };
+ } catch (e) {
+ return {
+ ...target,
+ title: title || target.id,
+ ok: false,
+ error: e instanceof Error ? e.message : String(e),
+ };
+ }
+}
+
export async function bulkShare(
targets: BulkShareTarget[],
deps: BulkShareDeps,
@@ -67,6 +178,11 @@ export async function bulkShare(
break;
}
const target = targets[i];
+ if (target.kind === "media") {
+ // 素材はドキュメントを読まない別経路。ノート / Knowledge の流れは触らない
+ results.push(await shareOneMedia(target, deps, i, targets.length));
+ continue;
+ }
const isKnowledge = target.kind === "knowledge";
let title = "";
try {
diff --git a/src/features/sharing/index.ts b/src/features/sharing/index.ts
index 19e65395e..5ed15df36 100644
--- a/src/features/sharing/index.ts
+++ b/src/features/sharing/index.ts
@@ -14,6 +14,12 @@ export {
type ShareKnowledgeResult,
type ShareKnowledgeOptions,
} from "./share-knowledge";
+export {
+ shareTemplate,
+ type ShareTemplateResult,
+ type ShareTemplateOptions,
+} from "./share-template";
+export { ShareTemplateDialog, type ShareTemplateDialogProps } from "./ShareTemplateDialog";
export {
forkSharedNote,
type ForkSharedNoteResult,
diff --git a/src/features/sharing/share-template.test.ts b/src/features/sharing/share-template.test.ts
new file mode 100644
index 000000000..28e7bc143
--- /dev/null
+++ b/src/features/sharing/share-template.test.ts
@@ -0,0 +1,328 @@
+// shareTemplate のテスト。share-note.test.ts と同じく Tauri invoke をモックする。
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+const invokeMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: invokeMock,
+}));
+
+import { shareTemplate } from "./share-template";
+import type { GraphiumDocument, GraphiumPage } from "../../lib/document-types";
+import type { AuthorIdentity } from "../document-provenance/types";
+import type { PageTemplate } from "../template/types";
+
+const author: AuthorIdentity = { name: "Ada", email: "a@b.co" };
+
+class FakeFs {
+ entries = new Map();
+ blobs = new Map(); // hash → base64
+ install() {
+ invokeMock.mockReset();
+ invokeMock.mockImplementation(async (cmd: string, args: any) => {
+ switch (cmd) {
+ case "shared_write":
+ this.entries.set(`${args.entryType}/${args.id}`, args.content);
+ return null;
+ case "shared_read": {
+ const v = this.entries.get(`${args.entryType}/${args.id}`);
+ if (!v) throw new Error("not found");
+ return v;
+ }
+ case "shared_blob_write":
+ this.blobs.set(args.hash, args.contentBase64);
+ return null;
+ case "shared_blob_exists":
+ return this.blobs.has(args.hash);
+ default:
+ throw new Error(`unmocked: ${cmd}`);
+ }
+ });
+ }
+}
+
+let fs: FakeFs;
+beforeEach(() => {
+ fs = new FakeFs();
+ fs.install();
+});
+
+function makePage(overrides: Partial = {}): GraphiumPage {
+ return {
+ id: "main",
+ title: "焼結の手順",
+ blocks: [
+ {
+ id: "s1",
+ type: "step",
+ content: [{ type: "text", text: "秤量" }],
+ children: [{ id: "p1", type: "paragraph", content: [] }],
+ },
+ { id: "s2", type: "step", content: [{ type: "text", text: "焼成" }], children: [] },
+ ],
+ labels: { s1: "procedure" },
+ provLinks: [],
+ knowledgeLinks: [],
+ ...overrides,
+ };
+}
+
+function makeDoc(page: GraphiumPage): GraphiumDocument {
+ return {
+ version: 6,
+ title: "焼結ノート",
+ pages: [page],
+ createdAt: "2026-09-01T00:00:00Z",
+ modifiedAt: "2026-09-01T00:00:00Z",
+ };
+}
+
+/** 書き込まれた 1 件目の StoredEntry を読む */
+function readStored() {
+ const stored = JSON.parse([...fs.entries.values()][0]);
+ // body は UTF-8 バイト列を base64 にしたもの。atob の結果をそのまま文字列扱いすると
+ // 日本語が壊れるので、バイト列に戻してから TextDecoder で読む
+ const bytes = Uint8Array.from(atob(stored.body_base64), (c) => c.charCodeAt(0));
+ const body = JSON.parse(new TextDecoder().decode(bytes)) as PageTemplate;
+ return { entry: stored.entry, body };
+}
+
+describe("shareTemplate — body / extra", () => {
+ it("type=template で書き込まれ、body は PageTemplate の JSON", async () => {
+ const page = makePage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "焼結テンプレ",
+ description: "毎回これで始める",
+ });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+
+ const { entry, body } = readStored();
+ expect(entry.type).toBe("template");
+ expect(entry.author).toEqual(author);
+ expect(body.name).toBe("焼結テンプレ");
+ expect(body.pageTitle).toBe("焼結の手順");
+ expect(body.blocks).toHaveLength(2);
+ expect(body.labels).toEqual([["s1", "procedure"]]);
+ // 呼び出し側が属性を渡さなければ空(page からは復元できない)
+ expect(body.attributes).toEqual([]);
+ });
+
+ it("options.attributes(ラベルストアのスナップショット)が body に載る", async () => {
+ const page = makePage({ labels: { s1: "procedure", s2: "procedure" } });
+ await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ attributes: [
+ ["s1", { checked: true, executor: "ai", status: "done" }],
+ ["s2", { checked: false, executor: "machine", status: "in-progress" }],
+ ],
+ });
+ const { body } = readStored();
+ expect(body.attributes).toEqual([
+ ["s1", { checked: true, executor: "ai", status: "done" }],
+ ["s2", { checked: false, executor: "machine", status: "in-progress" }],
+ ]);
+ });
+
+ it("ラベルが付いていないブロックの属性は落とす(復元側で適用できないため)", async () => {
+ const page = makePage(); // labels は s1 だけ
+ await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ attributes: [
+ ["s1", { checked: true, executor: "human", status: "done" }],
+ // 別ページ / 削除済みブロックの残骸
+ ["zzz", { checked: true, executor: "ai", status: "done" }],
+ ],
+ });
+ const { body } = readStored();
+ expect(body.attributes).toEqual([
+ ["s1", { checked: true, executor: "human", status: "done" }],
+ ]);
+ });
+
+ it("extra に title / description / stepCount / labelCount / pageTitle が載る", async () => {
+ const page = makePage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "焼結テンプレ",
+ description: "説明",
+ });
+ expect(r.ok).toBe(true);
+ const { entry } = readStored();
+ expect(entry.extra.title).toBe("焼結テンプレ");
+ expect(entry.extra.description).toBe("説明");
+ expect(entry.extra.stepCount).toBe(2);
+ expect(entry.extra.labelCount).toBe(1);
+ expect(entry.extra.pageTitle).toBe("焼結の手順");
+ });
+
+ it("説明が空なら description は null", async () => {
+ const page = makePage();
+ await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ description: " ",
+ });
+ expect(readStored().entry.extra.description).toBeNull();
+ });
+
+ it("tableMeta / mediaInlineLabels が body に残る", async () => {
+ const page = makePage({
+ blocks: [
+ {
+ id: "t1",
+ type: "table",
+ content: { rows: [{ cells: [[{ type: "text", text: "試料" }]] }] },
+ },
+ ],
+ tableMeta: { t1: { caption: "試料表", columns: { 試料: ["note-link"] } } },
+ mediaInlineLabels: { m1: { label: "material", entityId: "e1" } },
+ });
+ await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ });
+ const { body } = readStored();
+ expect(body.tableMeta).toEqual({
+ t1: { caption: "試料表", columns: { 試料: ["note-link"] } },
+ });
+ expect(body.mediaInlineLabels).toEqual({ m1: { label: "material", entityId: "e1" } });
+ });
+
+ it("hash は entry に入り、共有のたびに新しい id になる(再共有の対応付けは持たない)", async () => {
+ const page = makePage();
+ const doc = makeDoc(page);
+ const a = await shareTemplate(doc, page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ });
+ const b = await shareTemplate(doc, page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ });
+ expect(a.ok && b.ok).toBe(true);
+ if (!a.ok || !b.ok) return;
+ expect(a.entry.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
+ expect(a.entry.id).not.toBe(b.entry.id);
+ expect(fs.entries.size).toBe(2);
+ // ノート側の共有状態は触らない
+ expect(doc.sharedRef).toBeUndefined();
+ });
+
+ it("ページ以外(他ページ・チャット・来歴)は body に入らない", async () => {
+ const page = makePage();
+ const doc: GraphiumDocument = {
+ ...makeDoc(page),
+ pages: [page, { ...makePage(), id: "other", blocks: [{ id: "x", type: "paragraph" }] }],
+ chats: [{ id: "c1", scope: "note", messages: [], createdAt: "", updatedAt: "" } as any],
+ };
+ await shareTemplate(doc, page, { sharedRoot: "/tmp/shared", author, title: "T" });
+ const { body } = readStored();
+ expect(JSON.stringify(body)).not.toContain("\"x\"");
+ expect(JSON.stringify(body)).not.toContain("c1");
+ });
+});
+
+describe("shareTemplate — auto blob", () => {
+ const extractFileId = (url: string): string | null => {
+ const m = url.match(/^file-media:\/\/(.+)$/);
+ return m ? m[1] : null;
+ };
+ const fetchBytes = async (id: string): Promise => {
+ if (id === "A") return new Uint8Array([1, 2, 3]);
+ if (id === "B") return new Uint8Array([4, 5, 6]);
+ throw new Error(`unknown ${id}`);
+ };
+ const mediaPage = () =>
+ makePage({
+ blocks: [
+ { id: "b1", type: "image", props: { url: "file-media://A" } },
+ { id: "b2", type: "image", props: { url: "file-media://A" } },
+ { id: "b3", type: "video", props: { url: "file-media://B" } },
+ ],
+ labels: {},
+ });
+
+ it("メディアは shared-blob: に置換され、extra.blobs に dedup 済 BlobRef が載る", async () => {
+ const page = mediaPage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ blobRoot: "/tmp/blob",
+ author,
+ title: "T",
+ __test: { extractFileId, fetchBytes },
+ });
+ expect(r.ok).toBe(true);
+ const { entry, body } = readStored();
+ expect(body.blocks[0].props.url).toMatch(/^shared-blob:sha256:[0-9a-f]{64}$/);
+ expect(body.blocks[0].props.url).toBe(body.blocks[1].props.url);
+ expect(body.blocks[2].props.url).not.toBe(body.blocks[0].props.url);
+ expect(entry.extra.blobs).toHaveLength(2);
+ expect(fs.blobs.size).toBe(2);
+ });
+
+ it("元のページは変更されない(immutable)", async () => {
+ const page = mediaPage();
+ await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ blobRoot: "/tmp/blob",
+ author,
+ title: "T",
+ __test: { extractFileId, fetchBytes },
+ });
+ expect(page.blocks[0].props.url).toBe("file-media://A");
+ });
+
+ it("blobRoot 未設定でメディアがあると ok=false", async () => {
+ const page = mediaPage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ __test: { extractFileId, fetchBytes },
+ });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain("Blob root");
+ });
+
+ it("メディアが無ければ blobRoot 未設定でも共有できる(extra.blobs は付かない)", async () => {
+ const page = makePage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ __test: { extractFileId, fetchBytes },
+ });
+ expect(r.ok).toBe(true);
+ expect(readStored().entry.extra.blobs).toBeUndefined();
+ });
+});
+
+describe("shareTemplate — failure paths", () => {
+ it("invoke が失敗すれば ok=false", async () => {
+ invokeMock.mockReset();
+ invokeMock.mockImplementation(async () => {
+ throw new Error("disk full");
+ });
+ const page = makePage();
+ const r = await shareTemplate(makeDoc(page), page, {
+ sharedRoot: "/tmp/shared",
+ author,
+ title: "T",
+ });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain("disk full");
+ });
+});
diff --git a/src/features/sharing/share-template.ts b/src/features/sharing/share-template.ts
new file mode 100644
index 000000000..2e34cf978
--- /dev/null
+++ b/src/features/sharing/share-template.ts
@@ -0,0 +1,194 @@
+// ページを「テンプレート」として team-shared storage に書き出す(PR 3)。
+//
+// 設計判断(docs/internal/team-shared-storage-design.md §20):
+// - body は GraphiumDocument ではなく PageTemplate の JSON(休眠していた型を本文に使う)。
+// なぜ: テンプレートは記録ではなく雛形で、来歴・チャット・共有参照を引き継がせたくない。
+// ページのブロックとラベル・表のふるまいだけを持たせる。
+// - 本文は「ページをそのまま」。結果や数値を自動で消さない(消してから共有すればよい)。
+// - **再共有の対応付けは持たない**。テンプレートは共有するたびに新しい id になり、
+// 元ノートの sharedRef も触らない。なぜ: 雛形は「あの日のノートの写し」ではなく
+// 独立した配布物で、ノート側の共有状態(記録のコピー)と一対一に紐づけると
+// どちらを更新したのか分からなくなる。
+// - メディアはノート共有と同じ auto-blob(`shared-blob:` 置換 + extra.blobs)。
+// - 連動属性(StepAttributes)は page ではなくラベルストアにしか無いので、
+// 呼び出し側(共有ダイアログ)がスナップショットを options.attributes で渡す。
+// これを渡さないと `/template` 挿入経路で手順のチェック・実行者・状態が復元されない。
+
+import type { GraphiumDocument, GraphiumPage } from "../../lib/document-types";
+import type { AuthorIdentity } from "../document-provenance/types";
+import {
+ LocalFolderSharedProvider,
+ LocalFolderBlobProvider,
+ newSharedId,
+ computeSharedEntryHash,
+ type SharedEntry,
+ type BlobRef,
+} from "../../lib/storage/shared";
+import { createTemplate, serializeTemplate } from "../template/save";
+import type { PageTemplate } from "../template/types";
+import type { StepAttributes } from "../context-label/label-attributes";
+import {
+ autoUploadMediaBlobs,
+ collectMediaRefs,
+ type FetchMediaBytes,
+} from "./auto-blob";
+import { getActiveProvider } from "../../lib/storage/registry";
+import { invoke } from "@tauri-apps/api/core";
+
+export type ShareTemplateOptions = {
+ /** Settings の shared root(テンプレートを置く先) */
+ sharedRoot: string;
+ /**
+ * Settings の blob root。ページに埋め込まれたメディアを共有するために使う。
+ * メディアを含むページでは必須(未設定ならエラーを返す)。
+ */
+ blobRoot?: string | null;
+ /** Settings 登録済みの AuthorIdentity(必須) */
+ author: AuthorIdentity;
+ /** ユーザーが入力したテンプレート名(一覧の表示名) */
+ title: string;
+ /** ユーザーが入力した説明(任意) */
+ description?: string;
+ /**
+ * 連動属性(blockId → StepAttributes)。呼び出し側がラベルストアの
+ * スナップショットから渡す。
+ * なぜ options 経由か: StepAttributes は GraphiumPage に保存されず
+ * ラベルストアの実行時状態にしかないため、doc / page からは復元できない。
+ * 未指定なら空のまま共有する(テストや page 由来の呼び出しでも壊れない)。
+ */
+ attributes?: [string, StepAttributes][];
+ /**
+ * テスト用フック(本番では未指定)。share-note.ts と同じ差し込み口。
+ */
+ __test?: {
+ extractFileId?: (url: string) => string | null;
+ fetchBytes?: FetchMediaBytes;
+ };
+};
+
+export type ShareTemplateResult =
+ | {
+ ok: true;
+ /** 書き込んだ SharedEntry(hash 計算済み) */
+ entry: SharedEntry;
+ /** body に書いた PageTemplate(呼び出し側のプレビュー用) */
+ template: PageTemplate;
+ }
+ | { ok: false; error: string };
+
+const defaultFetchBytes: FetchMediaBytes = async (fileId: string) => {
+ const b64 = await invoke("read_media_file", { fileId });
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+};
+
+/** step ブロックの数を再帰的に数える(一覧の「手順数」表示用) */
+function countSteps(blocks: any[] | undefined): number {
+ let n = 0;
+ for (const b of blocks ?? []) {
+ if (b?.type === "step") n++;
+ if (Array.isArray(b?.children)) n += countSteps(b.children);
+ }
+ return n;
+}
+
+/**
+ * ページをテンプレートとして共有する。
+ *
+ * doc は auto-blob の入口(collectMediaRefs / autoUploadMediaBlobs)が
+ * GraphiumDocument を受け取るために渡す。書き出すのは page 1 枚分だけで、
+ * doc の他のページ・チャット・来歴は body に入らない。
+ */
+export async function shareTemplate(
+ doc: GraphiumDocument,
+ page: GraphiumPage,
+ options: ShareTemplateOptions,
+): Promise {
+ try {
+ // ── メディアの自動 blob 化(ノート共有と同じ経路)──
+ // 対象ページだけを持つ擬似 doc を作って auto-blob に通す。
+ // なぜ: auto-blob は doc 単位の API なので、ページ単位の共有では
+ // 「共有するページだけ」を包んで渡すのが最小の合わせ方。
+ const pseudoDoc: GraphiumDocument = { ...doc, pages: [page] };
+ const extractFileId =
+ options.__test?.extractFileId ??
+ ((url: string) => getActiveProvider().extractFileId(url));
+ const refs = collectMediaRefs(pseudoDoc, extractFileId);
+ if (refs.length > 0 && !options.blobRoot) {
+ return {
+ ok: false,
+ error:
+ "Blob root is not configured. Set it in Settings → Shared storage to share pages that contain media.",
+ };
+ }
+
+ let blocks = page.blocks;
+ let blobs: BlobRef[] = [];
+ if (refs.length > 0 && options.blobRoot) {
+ const blobProvider = new LocalFolderBlobProvider(options.blobRoot);
+ const fetchBytes = options.__test?.fetchBytes ?? defaultFetchBytes;
+ const result = await autoUploadMediaBlobs(pseudoDoc, {
+ extractFileId,
+ fetchBytes,
+ blobProvider,
+ });
+ blocks = result.doc.pages[0].blocks;
+ blobs = result.blobs;
+ }
+
+ const title = options.title.trim() || doc.title || "Untitled";
+ const labels = Object.entries(page.labels ?? {});
+ // 連動属性はラベルが付いたブロックにしか意味がない(復元側の setAttributes は
+ // ラベル未設定のブロックでは何もしない)。他ページや削除済みブロックの属性が
+ // ラベルストアに残っていても本文には混ぜない
+ const labeledIds = new Set(labels.map(([blockId]) => blockId));
+ const attributes = (options.attributes ?? []).filter(([blockId]) =>
+ labeledIds.has(blockId),
+ );
+ const template = createTemplate({
+ name: title,
+ pageTitle: page.title || doc.title,
+ blocks,
+ labels,
+ attributes,
+ tableMeta: page.tableMeta,
+ mediaInlineLabels: page.mediaInlineLabels,
+ });
+
+ const body = new TextEncoder().encode(serializeTemplate(template));
+
+ // テンプレートは毎回新しい id(再共有の対応付けを持たない)
+ const id = newSharedId();
+ const now = new Date().toISOString();
+ const baseEntry: SharedEntry = {
+ id,
+ type: "template",
+ author: options.author,
+ created_at: now,
+ updated_at: now,
+ hash: "", // provider.write が再計算する
+ prov: { derived_from: [] },
+ extra: {
+ title,
+ description: options.description?.trim() || null,
+ // 一覧は本文を読まずに描くので、規模が分かる数だけメタデータ側に置く
+ stepCount: countSteps(blocks),
+ labelCount: template.labels.length,
+ pageTitle: template.pageTitle,
+ ...(blobs.length > 0 ? { blobs } : {}),
+ },
+ };
+
+ const hash = await computeSharedEntryHash(baseEntry, body);
+ const provider = new LocalFolderSharedProvider(options.sharedRoot, {
+ email: options.author.email,
+ });
+ await provider.write(baseEntry, body);
+
+ return { ok: true, entry: { ...baseEntry, hash }, template };
+ } catch (e) {
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
+ }
+}
diff --git a/src/features/sharing/shared-library-sync.test.tsx b/src/features/sharing/shared-library-sync.test.tsx
index 51772514f..437193ebc 100644
--- a/src/features/sharing/shared-library-sync.test.tsx
+++ b/src/features/sharing/shared-library-sync.test.tsx
@@ -6,6 +6,7 @@
// (空一覧で走ると前回セッションの shared 索引が消え、直後に全件読み直しになる)
// - 読み終わったら、その一覧で 1 回だけ reconcile する
// - スイッチ OFF のときは読み込みを待たずに空一覧で reconcile する(掃除が目的)
+// - ラベル・プロセスの投影はスイッチに依らず更新される(索引には入れない・差分だけ読む)
//
// lexicalSearch と埋め込みはモック(IndexedDB には触れない)。
@@ -43,7 +44,11 @@ vi.mock("../../lib/storage/shared", async (importOriginal) => ({
}));
import { useSharedLibrarySync } from "./shared-library-sync";
-import { __setSharedLibraryLoaderForTest, groupSharedEntriesByType } from "./shared-library-store";
+import {
+ __setSharedLibraryLoaderForTest,
+ groupSharedEntriesByType,
+ refreshSharedLibrary,
+} from "./shared-library-store";
import {
__resetSharedProjectionForTest,
getSharedProjection,
@@ -68,6 +73,25 @@ const result = (entries: SharedEntry[]): SharedLibraryLoadResult => ({
errors: {},
});
+/** step ブロックを 1 つ持つ最小のノート(投影の対象になる形) */
+const noteDocFor = (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;
+
/** タイマーを進めてから、その中で走る非同期処理(await 連鎖)を流し切る */
async function advance(ms: number): Promise {
await act(async () => {
@@ -187,6 +211,34 @@ describe("useSharedLibrarySync", () => {
h.reconcile.mockImplementation(async () => {});
});
+ it("スイッチ OFF でも note の投影は更新される(hash が同じものは読まない)", async () => {
+ h.aiEnabled = false;
+ h.readBody.mockResolvedValue({
+ body: new TextEncoder().encode(JSON.stringify(noteDocFor("焼成の記録"))),
+ verified: true,
+ });
+ __setSharedLibraryLoaderForTest(async () => result([entry("a"), entry("b")]), { root: ROOT });
+
+ renderHook(() => useSharedLibrarySync({ authenticated: true }));
+ // 1 回目: ルート読み込み前(空一覧で reconcile)/2 回目: 一覧が届いてから投影が走る
+ await advance(2000);
+ await advance(2000);
+
+ expect(Object.keys(getSharedProjection().entries).sort()).toEqual(["a", "b"]);
+ expect(h.readBody).toHaveBeenCalledTimes(2);
+ // 語彙索引には入れない(reconcile はどの回も desired 空)
+ for (const call of h.reconcile.mock.calls) expect(call[0]).toEqual([]);
+
+
+ // 投影済み(hash 一致)のエントリは、次の一覧更新で読み直さない = 差分だけ読む
+ h.readBody.mockClear();
+ await act(async () => {
+ await refreshSharedLibrary();
+ });
+ await advance(2000);
+ expect(h.readBody).not.toHaveBeenCalled();
+ });
+
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 0a3444dce..4b47c3826 100644
--- a/src/features/sharing/shared-library-sync.ts
+++ b/src/features/sharing/shared-library-sync.ts
@@ -9,6 +9,9 @@
// - スイッチ OFF・共有ルート未設定なら desired を空にして reconcile する。
// 索引から共有分だけが消える(旧ルートの残留もこれで掃除される)
//
+// ラベル・プロセスの投影(shared-projection)は上のスイッチとは独立に更新する。
+// ON なら loader が本文を読んだついでに、OFF なら reconcile のあとに差分だけ読んで投影する。
+//
// reconcile のあとに共有ナレッジの埋め込み(shared-embeddings)も同じ入口から揃える。
// 索引も埋め込みも手元(IndexedDB)にしか作らない。共有フォルダには一切書かない。
@@ -31,6 +34,7 @@ import {
} from "./shared-library-store";
import { parseSharedBody, sharedEntryToSourceInput } from "./shared-entry-source";
import {
+ getSharedProjection,
loadSharedProjection,
pruneSharedProjection,
recordSharedProjectionFromBody,
@@ -47,6 +51,34 @@ export type SharedLibrarySyncParams = {
disabled?: boolean;
};
+/**
+ * ラベル・プロセスの投影だけを更新する(語彙索引には入れない)。
+ *
+ * 検索/AI のスイッチが OFF でも Library のタブが埋まるようにするための経路。
+ * - 対象は type === "note" だけ(投影の対象がノートのみ)
+ * - 投影済みの hash と一致するものは読まない = 差分だけ本文を取りに行く
+ * - readSharedEntryBody は LRU つきなので 1 件ずつ順に読む(共有フォルダへの
+ * 同時アクセスを増やさない)。中断されたら次の本文を取りに行かない
+ */
+async function projectSharedNotes(
+ entries: SharedEntry[],
+ isCancelled: () => boolean,
+): Promise {
+ for (const entry of entries) {
+ if (isCancelled()) return;
+ if (entry.type !== "note") continue;
+ if (getSharedProjection().entries[entry.id]?.hash === entry.hash) continue;
+ try {
+ const { body, verified } = await readSharedEntryBody(entry);
+ if (isCancelled()) return;
+ if (!verified) continue;
+ recordSharedProjectionFromBody(entry, body, verified, parseSharedBody(body));
+ } catch {
+ // 読めなかった(消された・権限なし)→ 投影は前回のまま。掃除は prune に任せる
+ }
+ }
+}
+
export function useSharedLibrarySync(params: SharedLibrarySyncParams): void {
const { authenticated, disabled } = params;
const snapshot = useSharedLibrary();
@@ -116,6 +148,15 @@ export function useSharedLibrarySync(params: SharedLibrarySyncParams): void {
// LRU キャッシュから読めるので二度読みにならない)。OFF なら entries が
// 空なので、消えた分の掃除だけが走る
await syncSharedKnowledgeEmbeddings(entries, readSharedEntryBody);
+ if (cancelled) return;
+ // スイッチ OFF のときは上の reconcile が空一覧なので本文が一切読まれない。
+ // ラベル・プロセスのタブは「共有すれば自動で集まる」と案内している以上、
+ // 検索/AI のスイッチとは切り離して投影だけは更新する。
+ // ON のときは loader 側で投影済みなので、ここは通らない(本文の二度読みを避ける)
+ if (!aiEnabled) {
+ await projectSharedNotes(entriesRef.current, () => cancelled);
+ if (cancelled) return;
+ }
// 共有から消えた id の投影を落とす。掃除の基準は「いま共有フォルダにあるもの」
// なので、AI スイッチ OFF で reconcile 対象が空になっただけのときに
// 投影まで消さないよう、スナップショット側の一覧を使う
diff --git a/src/features/sharing/unshare-entry.test.ts b/src/features/sharing/unshare-entry.test.ts
new file mode 100644
index 000000000..0823090e3
--- /dev/null
+++ b/src/features/sharing/unshare-entry.test.ts
@@ -0,0 +1,155 @@
+// unshareEntry の blob GC のテスト。share-note.test.ts と同じく Tauri invoke をモックする。
+//
+// ここで守りたいのは 2 つ:
+// 1. blob を持つ entry は type を問わず GC される(テンプレートの blob が孤立しない)
+// 2. 他の entry がまだ参照している hash は消さない(content-addressed なので
+// ノートとテンプレートで同じ画像の hash が一致しうる)
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+const invokeMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: invokeMock,
+}));
+
+import { unshareEntry } from "./unshare-entry";
+import { newSharedId, type SharedEntry, type BlobRef } from "../../lib/storage/shared";
+import type { AuthorIdentity } from "../document-provenance/types";
+
+const author: AuthorIdentity = { name: "Ada", email: "a@b.co" };
+
+const TYPE_TO_FOLDER: Record = {
+ note: "notes",
+ reference: "references",
+ "data-manifest": "data-manifests",
+ template: "templates",
+ knowledge: "knowledge",
+ report: "reports",
+};
+
+function blobRef(hash: string): BlobRef {
+ return { provider: "local-folder", uri: `local-folder://${hash}`, hash, size: 3 };
+}
+
+/** 共有ルート(entry JSON)と blob 置き場をメモリ上に持つ偽ファイルシステム */
+class FakeFs {
+ entries = new Map(); // "folder/id" → StoredEntry JSON
+ blobs = new Set();
+ /** list を失敗させたい folder(読み出し不能の再現用) */
+ failListFolders = new Set();
+
+ add(type: string, blobHashes: string[]): SharedEntry {
+ const id = newSharedId();
+ const entry: SharedEntry = {
+ id,
+ type: type as SharedEntry["type"],
+ author,
+ created_at: "2026-09-01T00:00:00Z",
+ updated_at: "2026-09-01T00:00:00Z",
+ hash: "sha256:" + "0".repeat(64),
+ prov: { derived_from: [] },
+ extra: blobHashes.length > 0 ? { blobs: blobHashes.map(blobRef) } : {},
+ };
+ this.entries.set(`${TYPE_TO_FOLDER[type]}/${id}`, JSON.stringify({ entry, body_base64: "" }));
+ for (const h of blobHashes) this.blobs.add(h);
+ return entry;
+ }
+
+ install() {
+ invokeMock.mockReset();
+ invokeMock.mockImplementation(async (cmd: string, args: any) => {
+ switch (cmd) {
+ case "shared_read": {
+ const v = this.entries.get(`${args.entryType}/${args.id}`);
+ if (!v) throw new Error("not found");
+ return v;
+ }
+ case "shared_list": {
+ if (this.failListFolders.has(args.entryType)) throw new Error("list failed");
+ const out: string[] = [];
+ for (const [key, value] of this.entries) {
+ if (key.startsWith(`${args.entryType}/`)) out.push(value);
+ }
+ return out;
+ }
+ case "shared_delete": {
+ this.entries.set(`${args.entryType}/${args.id}`, args.tombstoneContent);
+ return null;
+ }
+ case "shared_blob_delete":
+ this.blobs.delete(args.hash);
+ return null;
+ default:
+ throw new Error(`unmocked: ${cmd}`);
+ }
+ });
+ }
+}
+
+let fs: FakeFs;
+beforeEach(() => {
+ fs = new FakeFs();
+ fs.install();
+});
+
+const opts = { root: "/tmp/shared", author, blobRoot: "/tmp/blobs" };
+
+describe("unshareEntry — blob GC", () => {
+ it("テンプレートの blob も GC される(data-manifest 限定にしない)", async () => {
+ const template = fs.add("template", ["sha256:aaa"]);
+ const r = await unshareEntry(template.id, opts);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.deletedBlobs).toEqual(["sha256:aaa"]);
+ expect(fs.blobs.has("sha256:aaa")).toBe(false);
+ });
+
+ it("ノートの blob も GC される", async () => {
+ const note = fs.add("note", ["sha256:bbb"]);
+ const r = await unshareEntry(note.id, opts);
+ expect(r.ok && r.deletedBlobs).toEqual(["sha256:bbb"]);
+ expect(fs.blobs.has("sha256:bbb")).toBe(false);
+ });
+
+ it("同じ hash を別 type(ノート)が参照していれば消さない", async () => {
+ fs.add("note", ["sha256:shared"]);
+ const template = fs.add("template", ["sha256:shared", "sha256:only-template"]);
+ const r = await unshareEntry(template.id, opts);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.deletedBlobs).toEqual(["sha256:only-template"]);
+ expect(r.retainedBlobs).toEqual(["sha256:shared"]);
+ expect(fs.blobs.has("sha256:shared")).toBe(true);
+ });
+
+ it("tombstone(status=unshared)は参照数に数えない", async () => {
+ const older = fs.add("template", ["sha256:ccc"]);
+ await unshareEntry(older.id, opts); // 1 件目を先に共有解除 → tombstone 化
+ fs.blobs.add("sha256:ccc");
+ const newer = fs.add("template", ["sha256:ccc"]);
+ const r = await unshareEntry(newer.id, opts);
+ expect(r.ok && r.deletedBlobs).toEqual(["sha256:ccc"]);
+ });
+
+ it("参照の数え上げに失敗したら 1 件も消さない(消すより残す)", async () => {
+ const template = fs.add("template", ["sha256:ddd"]);
+ fs.failListFolders.add("notes");
+ const r = await unshareEntry(template.id, opts);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.deletedBlobs).toEqual([]);
+ expect(r.retainedBlobs).toEqual(["sha256:ddd"]);
+ expect(fs.blobs.has("sha256:ddd")).toBe(true);
+ });
+
+ it("blobRoot 未設定なら GC しない(tombstone 化だけ行う)", async () => {
+ const template = fs.add("template", ["sha256:eee"]);
+ const r = await unshareEntry(template.id, { root: "/tmp/shared", author });
+ expect(r.ok && r.deletedBlobs).toEqual([]);
+ expect(fs.blobs.has("sha256:eee")).toBe(true);
+ // tombstone は立っている
+ const stored = JSON.parse(fs.entries.get(`templates/${template.id}`)!);
+ expect(stored.entry.status).toBe("unshared");
+ });
+});
diff --git a/src/features/sharing/unshare-entry.ts b/src/features/sharing/unshare-entry.ts
index b8aad9039..a68cdedd8 100644
--- a/src/features/sharing/unshare-entry.ts
+++ b/src/features/sharing/unshare-entry.ts
@@ -3,8 +3,13 @@
// 設計:
// - author 本人にしか実行できない(provider 側で email 一致チェック)
// - tombstone 後も body は残らないが、`status="unshared"` として _meta/tombstones に保管
-// - data-manifest の場合、参照していた blob は **reference-counted GC** で削除する。
-// 他の active な data-manifest が同じ hash を参照していなければ blob 本体も消す
+// - `extra.blobs` を持つ entry(素材 manifest だけでなく、auto-blob でメディアを
+// 持ち出したノート・テンプレートも該当)は、参照していた blob を
+// **reference-counted GC** で削除する。他の active な entry が同じ hash を
+// 参照していなければ blob 本体も消す。
+// なぜ type で絞らないか: blob は content-addressed なので、同じ画像を
+// ノートとテンプレートで共有すると hash が一致する。片方の type だけ数えると
+// 「まだ使われている blob を消す」か「永久に残す」かのどちらかになる。
// - ローカル側の sharedRef 削除は呼び出し側で行う(ノート編集状態を直接触らないため)
//
// 設計詳細: docs/internal/team-shared-storage-design.md §3 Unshare
@@ -14,24 +19,35 @@ import {
LocalFolderSharedProvider,
LocalFolderBlobProvider,
type SharedEntry,
+ type SharedEntryType,
type BlobRef,
} from "../../lib/storage/shared";
+/**
+ * blob を参照しうる entry type。GC の参照数え上げはこの範囲を全部見る。
+ * - data-manifest: 素材共有(share-media)
+ * - note: ノート共有の auto-blob(share-note)
+ * - template: テンプレート共有の auto-blob(share-template)
+ * 他の type(reference / knowledge / report)は現状 extra.blobs を書かない。
+ * 書くようになったらここに足すこと(漏れると参照中の blob を消す事故になる)。
+ */
+const BLOB_REFERENCING_TYPES: SharedEntryType[] = ["data-manifest", "note", "template"];
+
export type UnshareEntryOptions = {
/** Settings の shared root */
root: string;
/** Settings 登録済みの AuthorIdentity(必須) */
author: AuthorIdentity;
- /** data-manifest の blob GC を行うための blob root(任意) */
+ /** blob GC を行うための blob root(任意。未設定なら GC しない) */
blobRoot?: string;
};
export type UnshareEntryResult =
| {
ok: true;
- /** GC で削除した blob hash 群(data-manifest の場合のみ非空) */
+ /** GC で削除した blob hash 群(blob を持たない entry では空) */
deletedBlobs: string[];
- /** 他 manifest からまだ参照されているため残した blob hash 群 */
+ /** 他 entry からまだ参照されているため残した blob hash 群 */
retainedBlobs: string[];
}
| { ok: false; error: string };
@@ -50,7 +66,7 @@ function extractBlobHashes(entry: SharedEntry): string[] {
}
/**
- * 指定 entry を tombstone 化し、data-manifest なら参照されなくなった blob も GC する。
+ * 指定 entry を tombstone 化し、参照されなくなった blob も GC する。
*/
export async function unshareEntry(
sharedId: string,
@@ -61,15 +77,12 @@ export async function unshareEntry(
email: options.author.email,
});
- // 削除前にエントリを読み、データ manifest なら blob hash を控えておく
+ // 削除前にエントリを読み、blob を参照していれば hash を控えておく。
+ // type では絞らない(ノート・テンプレートも auto-blob で extra.blobs を持つ)
let blobHashesToCheck: string[] = [];
- let isDataManifest = false;
try {
const { entry } = await provider.read(sharedId);
- isDataManifest = entry.type === "data-manifest";
- if (isDataManifest) {
- blobHashesToCheck = extractBlobHashes(entry);
- }
+ blobHashesToCheck = extractBlobHashes(entry);
} catch {
// 読み出せない(既に消えている等)場合は GC せず削除のみ試行
}
@@ -79,15 +92,24 @@ export async function unshareEntry(
const deletedBlobs: string[] = [];
const retainedBlobs: string[] = [];
- if (isDataManifest && blobHashesToCheck.length > 0 && options.blobRoot) {
- // 残存している data-manifest を全件読んで参照中の hash を集める
- const remaining = await provider.list("data-manifest");
+ if (blobHashesToCheck.length > 0 && options.blobRoot) {
+ // 残存している entry を全件読んで参照中の hash を集める。
+ // 1 type でも読めなかったら GC そのものを諦める(数え漏れたまま消すと、
+ // まだ使われている blob を落として他人の共有を壊す。残す方が安全)
const stillReferenced = new Set();
- for (const e of remaining) {
- for (const h of extractBlobHashes(e)) {
- stillReferenced.add(h);
+ let listComplete = true;
+ for (const type of BLOB_REFERENCING_TYPES) {
+ try {
+ for (const e of await provider.list(type)) {
+ for (const h of extractBlobHashes(e)) stillReferenced.add(h);
+ }
+ } catch {
+ listComplete = false;
}
}
+ if (!listComplete) {
+ return { ok: true, deletedBlobs: [], retainedBlobs: blobHashesToCheck };
+ }
const blobProvider = new LocalFolderBlobProvider(options.blobRoot);
for (const hash of blobHashesToCheck) {
diff --git a/src/features/template/TemplatePickerModal.test.tsx b/src/features/template/TemplatePickerModal.test.tsx
new file mode 100644
index 000000000..892f1edd3
--- /dev/null
+++ b/src/features/template/TemplatePickerModal.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+// /template ピッカーのテスト。公式とチームを 1 つの表にまとめた構造を守る。
+//
+// 対象の不変条件:
+// - 表は 1 つだけ。チームのテンプレートは公式の後ろに行として続く
+// (別セクションに分けると同じ土俵で比べられなくなる)
+// - 共有ルートが無い(未設定 / 非デスクトップ)ときはチーム行も空案内も出さない
+// — 共有を使っていない人に「チーム」という概念を見せない
+// - 共有ルートがあれば、まだ 1 件も共有されていなくても空案内の 1 行は出す
+// - 行に出すのは type=template だけ。題名・説明・「チーム」バッジ・作者を見せ、
+// 選ぶと SharedEntry がそのまま呼び出し側に渡る(本文の読み出しは note-app の責務)
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, fireEvent, cleanup, act } from "@testing-library/react";
+import { TemplatePickerModal } from "./TemplatePickerModal";
+import { LocaleProvider, t } from "../../i18n";
+import type { SharedEntry, SharedEntryType } from "../../lib/storage/shared";
+import {
+ __setSharedLibraryLoaderForTest,
+ groupSharedEntriesByType,
+} from "../sharing/shared-library-store";
+import type { SharedLibraryLoadResult } from "../sharing/shared-library-loader";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const ROOT = "/tmp/shared-root";
+const AUTHOR = { name: "Ada", email: "ada@example.com" };
+
+const entry = (
+ id: string,
+ type: SharedEntryType,
+ extra: Record,
+): SharedEntry =>
+ ({
+ id,
+ type,
+ author: AUTHOR,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-02T00:00:00Z",
+ hash: `sha256:${id}`,
+ prov: { derived_from: [] },
+ version: 1,
+ extra,
+ }) as SharedEntry;
+
+const TEMPLATES = [
+ entry("t1", "template", { title: "焼結の実験手順", description: "電気炉での焼結", stepCount: 3 }),
+ entry("t2", "template", { title: "説明なしテンプレ" }),
+];
+const NOTE = entry("n1", "note", { title: "ただのノート" });
+
+const result = (entries: SharedEntry[]): SharedLibraryLoadResult => ({
+ entries: groupSharedEntriesByType(entries),
+ errors: {},
+});
+
+/** ローダーを設定してピッカーを描き、開いたときの読み直しを待つ */
+async function renderPicker(
+ options: { root: string | null; entries?: SharedEntry[] },
+ onSelectShared: (e: SharedEntry) => void = () => {},
+) {
+ const load = vi.fn(async () => result(options.entries ?? []));
+ __setSharedLibraryLoaderForTest(load, { root: options.root });
+ const view = render(
+
+ {}}
+ onSelectShared={onSelectShared}
+ onClose={() => {}}
+ />
+ ,
+ );
+ // 開いた時点の refreshSharedLibrary(非同期)を流し切る
+ await act(async () => {
+ await Promise.resolve();
+ });
+ return { ...view, load };
+}
+
+const teamRows = (container: HTMLElement) =>
+ Array.from(container.querySelectorAll('[data-testid="team-template-row"]'));
+const officialRows = (container: HTMLElement) =>
+ Array.from(container.querySelectorAll('[data-testid="official-template-row"]'));
+
+beforeEach(() => {
+ __setSharedLibraryLoaderForTest(null, { root: null });
+});
+
+afterEach(() => {
+ cleanup();
+ __setSharedLibraryLoaderForTest(null, { root: null });
+});
+
+describe("TemplatePickerModal のチームのテンプレート", () => {
+ it("共有ルートが無いときはチーム行も空案内も出さず、共有ライブラリも読まない", async () => {
+ const { container, load } = await renderPicker({ root: null });
+ expect(teamRows(container).length).toBe(0);
+ expect(container.querySelector('[data-testid="team-template-placeholder"]')).toBeNull();
+ expect(load).not.toHaveBeenCalled();
+ // 公式テンプレートの表は従来どおり 1 つだけ
+ expect(container.querySelectorAll("table").length).toBe(1);
+ expect(officialRows(container).length).toBeGreaterThan(0);
+ });
+
+ it("共有ルートがあり 1 件も無ければ、表の末尾に空案内の 1 行を出す", async () => {
+ const { container } = await renderPicker({ root: ROOT, entries: [NOTE] });
+ const placeholder = container.querySelector('[data-testid="team-template-placeholder"]');
+ expect(placeholder).not.toBeNull();
+ expect(placeholder?.textContent).toContain(t("template.picker.teamEmpty"));
+ // 表は 1 つのまま。note エントリはテンプレートではないので行にならない
+ expect(container.querySelectorAll("table").length).toBe(1);
+ expect(teamRows(container).length).toBe(0);
+ expect(container.textContent).not.toContain("ただのノート");
+ });
+
+ it("type=template を公式と同じ表の後ろに並べ、題名・説明・チームバッジ・作者を見せる", async () => {
+ const { container } = await renderPicker({ root: ROOT, entries: [...TEMPLATES, NOTE] });
+ expect(container.querySelectorAll("table").length).toBe(1);
+ const rows = teamRows(container);
+ expect(rows.length).toBe(2);
+ expect(rows[0].textContent).toContain("焼結の実験手順");
+ expect(rows[0].textContent).toContain("電気炉での焼結");
+ expect(rows[0].textContent).toContain(t("template.modal.sourceTeam"));
+ expect(rows[0].textContent).toContain(AUTHOR.name);
+ // 説明が無いエントリは説明行そのものを出さない
+ expect(rows[1].textContent).toContain("説明なしテンプレ");
+ // 空案内は 1 件でもあれば出さない
+ expect(container.querySelector('[data-testid="team-template-placeholder"]')).toBeNull();
+
+ // 列数は公式と揃える(タグは共有側に無いので空セル)
+ expect(rows[0].querySelectorAll("td").length).toBe(3);
+ // 公式行のあとにチーム行が来る
+ const all = Array.from(container.querySelectorAll("tbody tr"));
+ const official = officialRows(container);
+ expect(all.indexOf(rows[0])).toBeGreaterThan(all.indexOf(official[official.length - 1]));
+ });
+
+ it("行を選ぶと SharedEntry がそのまま渡る", async () => {
+ const onSelectShared = vi.fn();
+ const { container } = await renderPicker({ root: ROOT, entries: TEMPLATES }, onSelectShared);
+ fireEvent.click(teamRows(container)[0]);
+ expect(onSelectShared).toHaveBeenCalledTimes(1);
+ expect(onSelectShared.mock.calls[0][0].id).toBe("t1");
+ });
+
+ it("検索は 1 本の入力で公式とチームの両方に効く", async () => {
+ const { container } = await renderPicker({ root: ROOT, entries: TEMPLATES });
+ const input = container.querySelector("input")!;
+ fireEvent.change(input, { target: { value: "電気炉" } });
+ const rows = teamRows(container);
+ expect(rows.length).toBe(1);
+ expect(rows[0].textContent).toContain("焼結の実験手順");
+ // 公式テンプレートは一致しないので行が消える(表は 1 つのまま)
+ expect(officialRows(container).length).toBe(0);
+ expect(container.querySelectorAll("table").length).toBe(1);
+ });
+});
diff --git a/src/features/template/TemplatePickerModal.tsx b/src/features/template/TemplatePickerModal.tsx
index 5dc1fbca1..debd1915f 100644
--- a/src/features/template/TemplatePickerModal.tsx
+++ b/src/features/template/TemplatePickerModal.tsx
@@ -1,24 +1,74 @@
// テンプレートピッカーモーダル
// /template スラッシュコマンドから呼び出し、テンプレートをテーブル表示で選択する
+//
+// 表は 1 つ。公式テンプレート(getAllTemplates() の TemplateDef)の後ろに、
+// チームのテンプレート(共有ライブラリの type=template = SharedEntry)を行として並べる。
+// 別セクションに分けないのは、選ぶ人にとってはどちらも「使えるテンプレート」で、
+// 枠組みが違うと同じ土俵で比べられなくなるため(見え方は「提供元」列で区別する)。
+// 一方、選んだあとの経路は違う: 公式は id をその場で組み立て、チームは共有ルートから
+// 本文を読み出す。本文の読み出し・hash 照合・shared-blob: の解決は呼び出し側
+// (note-app)が担うので、コールバックを onSelect / onSelectShared に分けてある。
import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { getAllTemplates, type TemplateDef } from "./templates";
+import type { SharedEntry } from "../../lib/storage/shared";
+// 共有ライブラリの読み出しは単一入口のストアから。features/sharing のバレルを経由すると
+// ShareTemplateDialog → share-template → features/template で循環参照になるので、
+// ストアのモジュールを直接指す。
+import {
+ getSharedLibraryRoot,
+ refreshSharedLibrary,
+ useSharedLibrary,
+} from "../sharing/shared-library-store";
type Props = {
onSelect: (templateId: string) => void;
+ /**
+ * チームのテンプレートを選んだとき。本文の読み出しと挿入は呼び出し側の責務。
+ * 未指定でも行は出す(共有ルートがあるのに消えると「無い」と誤解されるため)。
+ */
+ onSelectShared?: (entry: SharedEntry) => void;
onClose: () => void;
};
-export function TemplatePickerModal({ onSelect, onClose }: Props) {
+/** 表の列数。チーム行の空状態を colspan で 1 行に潰すのに使う */
+const COLUMN_COUNT = 3;
+
+/** 共有エントリの題名(共有時に extra.title へ書かれる。無ければ無題) */
+function sharedTitle(entry: SharedEntry, t: (key: string) => string): string {
+ const title = (entry.extra as Record | undefined)?.title;
+ if (typeof title === "string" && title.trim()) return title;
+ return t("library.untitled");
+}
+
+/** 共有エントリの説明(テンプレート共有ダイアログで入力されたもの) */
+function sharedDescription(entry: SharedEntry): string {
+ const description = (entry.extra as Record | undefined)?.description;
+ return typeof description === "string" ? description : "";
+}
+
+export function TemplatePickerModal({ onSelect, onSelectShared, onClose }: Props) {
const t = useT();
const [searchQuery, setSearchQuery] = useState("");
const inputRef = useRef(null);
+ const sharedLibrary = useSharedLibrary();
+
+ // 共有ルート(デスクトップ + 設定済みのときだけ非 null)。
+ // このモーダルは開くたびにマウントされるので、マウント時に固定して構わない。
+ const sharedRoot = useMemo(() => getSharedLibraryRoot(), []);
useEffect(() => {
inputRef.current?.focus();
}, []);
+ // 開いた時点で共有ルートを 1 回読み直す。Library タブを一度も開いていなくても
+ // チーム行が空にならないようにするため(ストア側で進行中の読みは共有される)。
+ useEffect(() => {
+ if (!sharedRoot) return;
+ void refreshSharedLibrary();
+ }, [sharedRoot]);
+
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -42,10 +92,44 @@ export function TemplatePickerModal({ onSelect, onClose }: Props) {
});
}, [allTemplates, searchQuery, t]);
+ const sharedTemplates = useMemo(
+ () => sharedLibrary.entries.filter((e) => e.type === "template"),
+ [sharedLibrary.entries],
+ );
+
+ // 検索は公式と同じ 1 本の入力で両方に効かせる(題名・説明・作者)
+ const filteredShared = useMemo(() => {
+ const q = searchQuery.trim().toLowerCase();
+ if (!q) return sharedTemplates;
+ return sharedTemplates.filter((entry) => {
+ const fields = [
+ sharedTitle(entry, t),
+ sharedDescription(entry),
+ entry.author?.name ?? "",
+ ].join(" ").toLowerCase();
+ return fields.includes(q);
+ });
+ }, [sharedTemplates, searchQuery, t]);
+
const handleSelect = (tmpl: TemplateDef) => {
onSelect(tmpl.id);
};
+ // 件数は画面に出ている行数と合わせる(公式だけ数えるとチーム行の分だけ嘘になる)
+ const visibleCount = filtered.length + filteredShared.length;
+
+ // 共有ルートが無ければチームの存在自体を見せない。あるなら 0 件でも
+ // 「まだ無い」と分かる 1 行を出す(読み込み中は読み込み中と言う)。
+ const teamPlaceholder =
+ sharedRoot && filteredShared.length === 0
+ ? sharedLibrary.loading
+ ? t("template.picker.teamLoading")
+ : t("template.picker.teamEmpty")
+ : null;
+
+ // 公式もチームも 1 行も出せないなら、表の骨だけ見せても意味がないので空表示にする
+ const hasAnyRow = filtered.length > 0 || filteredShared.length > 0 || teamPlaceholder !== null;
+
return (
- {t("template.modal.count", { count: String(filtered.length) })}
+ {t("template.modal.count", { count: String(visibleCount) })}
- {/* テーブル */}
+ {/* テーブル(公式とチームを 1 つの表にまとめる) */}
- {filtered.length === 0 ? (
+ {!hasAnyRow ? (
{t("template.modal.empty")}
@@ -100,6 +184,7 @@ export function TemplatePickerModal({ onSelect, onClose }: Props) {
{filtered.map((tmpl) => (
handleSelect(tmpl)}
className="cursor-pointer hover:bg-muted/50 border-b border-border/50 transition-colors"
>
@@ -126,6 +211,57 @@ export function TemplatePickerModal({ onSelect, onClose }: Props) {
))}
+
+ {/* チームのテンプレートは公式の後ろに続ける。列の意味は公式と同じで、
+ タグは共有側に無いので空セルのまま(列をずらさない) */}
+ {filteredShared.map((entry) => {
+ const description = sharedDescription(entry);
+ const authorName = entry.author?.name ?? "";
+ return (
+
onSelectShared?.(entry)}
+ className="cursor-pointer hover:bg-muted/50 border-b border-border/50 transition-colors"
+ >
+
+
+ {sharedTitle(entry, t)}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+ {authorName && (
+
+ {authorName}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ {teamPlaceholder && (
+
+
+ {teamPlaceholder}
+
+
+ )}
)}
@@ -139,20 +275,26 @@ function SourceBadge({
source,
t,
}: {
- source: "official" | "user";
+ source: "official" | "user" | "team";
t: (key: string) => string;
}) {
- const isOfficial = source === "official";
+ // 公式だけ強調色。ユーザー / チームは同じ弱い色にして、公式との差だけを目立たせる
+ const label =
+ source === "official"
+ ? t("template.source.official")
+ : source === "team"
+ ? t("template.modal.sourceTeam")
+ : t("template.source.user");
return (
- {isOfficial ? t("template.source.official") : t("template.source.user")}
+ {label}
);
}
diff --git a/src/features/template/from-page-template.test.ts b/src/features/template/from-page-template.test.ts
new file mode 100644
index 000000000..bc713e6a9
--- /dev/null
+++ b/src/features/template/from-page-template.test.ts
@@ -0,0 +1,182 @@
+// PageTemplate → 挿入用 / 新規ノート用の変換テスト。
+// id の振り直しと blockId→path 変換を外すと、ラベルや表のふるまいが
+// 別のブロックに付く(ユーザーのノートが静かに壊れる)ので単体で押さえる。
+
+import { describe, it, expect } from "vitest";
+import {
+ pageTemplateToBuildResult,
+ buildDocumentFromTemplate,
+ remapTemplateBlocks,
+} from "./from-page-template";
+import type { PageTemplate } from "./types";
+
+function makeTemplate(overrides: Partial
= {}): PageTemplate {
+ return {
+ name: "焼結テンプレ",
+ savedAt: "2026-09-01T00:00:00Z",
+ pageTitle: "焼結の手順",
+ blocks: [
+ { id: "h1", type: "heading", props: { level: 1 }, content: [], children: [] },
+ {
+ id: "s1",
+ type: "step",
+ content: [],
+ children: [
+ { id: "p1", type: "paragraph", content: [], children: [] },
+ {
+ id: "t1",
+ type: "table",
+ content: {
+ rows: [
+ { cells: [[{ type: "text", text: "試料" }], [{ type: "text", text: "温度" }]] },
+ ],
+ },
+ children: [],
+ },
+ ],
+ },
+ ],
+ labels: [["s1", "procedure"]],
+ attributes: [["s1", { checked: false, executor: "human", status: "planned" }]],
+ ...overrides,
+ };
+}
+
+describe("remapTemplateBlocks", () => {
+ it("すべてのブロックに新しい id を振り、元のテンプレートは変更しない", () => {
+ const tpl = makeTemplate();
+ const { blocks, idMap } = remapTemplateBlocks(tpl);
+ expect(tpl.blocks[0].id).toBe("h1"); // 元は無傷
+ expect(blocks[0].id).not.toBe("h1");
+ expect(idMap.get("s1")).toBe(blocks[1].id);
+ expect(idMap.get("p1")).toBe(blocks[1].children[0].id);
+ // 全部ユニーク
+ const ids = [blocks[0].id, blocks[1].id, ...blocks[1].children.map((c: any) => c.id)];
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("blockId → path(ルートからのインデックス配列)を作る", () => {
+ const { pathMap } = remapTemplateBlocks(makeTemplate());
+ expect(pathMap.get("h1")).toEqual([0]);
+ expect(pathMap.get("s1")).toEqual([1]);
+ expect(pathMap.get("p1")).toEqual([1, 0]);
+ expect(pathMap.get("t1")).toEqual([1, 1]);
+ });
+});
+
+describe("pageTemplateToBuildResult", () => {
+ it("labels と attributes が path に変換される", () => {
+ const r = pageTemplateToBuildResult(makeTemplate());
+ expect(r.labels).toEqual([{ path: [1], label: "procedure" }]);
+ expect(r.attributes).toEqual([
+ { path: [1], attributes: { checked: false, executor: "human", status: "planned" } },
+ ]);
+ });
+
+ it("本文に無い blockId のラベル・属性は捨てる", () => {
+ const r = pageTemplateToBuildResult(
+ makeTemplate({
+ labels: [["missing", "procedure"]],
+ attributes: [["missing", { checked: true, executor: "ai", status: "done" }]],
+ }),
+ );
+ expect(r.labels).toEqual([]);
+ expect(r.attributes).toBeUndefined();
+ });
+
+ it("tableMeta の先頭列のふるまいが columnTypes になる", () => {
+ const r = pageTemplateToBuildResult(
+ makeTemplate({
+ tableMeta: { t1: { caption: "試料表", columns: { 試料: ["note-link"] } } },
+ }),
+ );
+ expect(r.columnTypes).toEqual([{ path: [1, 1], type: "note-link" }]);
+ });
+
+ it("先頭列以外に付いたふるまいは復元しない(適用経路が先頭列しか扱えない)", () => {
+ const r = pageTemplateToBuildResult(
+ makeTemplate({ tableMeta: { t1: { columns: { 温度: ["datetime-auto"] } } } }),
+ );
+ expect(r.columnTypes).toBeUndefined();
+ });
+
+ it("provLinks は付けない", () => {
+ expect(pageTemplateToBuildResult(makeTemplate()).provLinks).toBeUndefined();
+ });
+});
+
+describe("buildDocumentFromTemplate", () => {
+ const templateFrom = {
+ sharedId: "sid-1",
+ hash: "sha256:abc",
+ title: "焼結テンプレ",
+ usedAt: "2026-09-04T00:00:00Z",
+ };
+
+ it("1 ページのノートを組み立て、templateFrom を載せる", () => {
+ const doc = buildDocumentFromTemplate(makeTemplate(), {
+ title: "9/4 の焼結",
+ templateFrom,
+ });
+ expect(doc.title).toBe("9/4 の焼結");
+ expect(doc.pages).toHaveLength(1);
+ expect(doc.pages[0].id).toBe("main");
+ expect(doc.pages[0].title).toBe("9/4 の焼結");
+ expect(doc.templateFrom).toEqual(templateFrom);
+ expect(doc.sharedRef).toBeUndefined();
+ expect(doc.forkedFrom).toBeUndefined();
+ expect(doc.documentProvenance).toBeUndefined();
+ });
+
+ it("page.labels は新しい blockId をキーに復元される", () => {
+ const doc = buildDocumentFromTemplate(makeTemplate(), {
+ title: "N",
+ templateFrom,
+ });
+ const stepId = doc.pages[0].blocks[1].id;
+ expect(stepId).not.toBe("s1");
+ expect(doc.pages[0].labels).toEqual({ [stepId]: "procedure" });
+ });
+
+ it("tableMeta / mediaInlineLabels も新しい blockId に貼り替わる", () => {
+ const doc = buildDocumentFromTemplate(
+ makeTemplate({
+ tableMeta: { t1: { caption: "試料表", columns: { 試料: ["note-link"] } } },
+ mediaInlineLabels: { p1: { label: "material", entityId: "e1" } },
+ }),
+ { title: "N", templateFrom },
+ );
+ const tableId = doc.pages[0].blocks[1].children[1].id;
+ const paraId = doc.pages[0].blocks[1].children[0].id;
+ expect(Object.keys(doc.pages[0].tableMeta ?? {})).toEqual([tableId]);
+ expect(doc.pages[0].tableMeta![tableId].caption).toBe("試料表");
+ expect(doc.pages[0].mediaInlineLabels).toEqual({
+ [paraId]: { label: "material", entityId: "e1" },
+ });
+ });
+
+ it("tableMeta.noteLinks(共有元のノート id)は引き継がない", () => {
+ const doc = buildDocumentFromTemplate(
+ makeTemplate({
+ tableMeta: { t1: { caption: "表", noteLinks: { 行1: "note-of-someone-else" } } },
+ }),
+ { title: "N", templateFrom },
+ );
+ const tableId = doc.pages[0].blocks[1].children[1].id;
+ expect(doc.pages[0].tableMeta![tableId].noteLinks).toBeUndefined();
+ expect(doc.pages[0].tableMeta![tableId].caption).toBe("表");
+ });
+
+ it("注釈が無ければ tableMeta / mediaInlineLabels のフィールド自体を付けない", () => {
+ const doc = buildDocumentFromTemplate(makeTemplate(), { title: "N", templateFrom });
+ expect(doc.pages[0].tableMeta).toBeUndefined();
+ expect(doc.pages[0].mediaInlineLabels).toBeUndefined();
+ });
+
+ it("同じテンプレートから 2 回作っても blockId は衝突しない", () => {
+ const tpl = makeTemplate();
+ const a = buildDocumentFromTemplate(tpl, { title: "A", templateFrom });
+ const b = buildDocumentFromTemplate(tpl, { title: "B", templateFrom });
+ expect(a.pages[0].blocks[1].id).not.toBe(b.pages[0].blocks[1].id);
+ });
+});
diff --git a/src/features/template/from-page-template.ts b/src/features/template/from-page-template.ts
new file mode 100644
index 000000000..89bf91351
--- /dev/null
+++ b/src/features/template/from-page-template.ts
@@ -0,0 +1,187 @@
+// PageTemplate(共有テンプレートの本文)を「挿入用」「新規ノート用」に変換する純関数。
+//
+// 共有テンプレートは 2 通りの使われ方をする:
+// 1. /template ピッカーから **現在のページに挿入** → pageTemplateToBuildResult
+// 2. 共有ライブラリから **テンプレートから新規ノート** → buildDocumentFromTemplate
+//
+// どちらもブロック id を振り直す。なぜ: テンプレートの id は共有した人のページの
+// blockId で、そのまま挿すと同じ文書内・別ノート間で id が衝突し、ラベルや表の
+// ふるまい(blockId をキーに持つ注釈層)が他のブロックに混線する。
+//
+// 副作用を持たないので、UI から切り離してテストできる(この経路はデータの取り違えが
+// そのままユーザーのノート破損になるため、ここで単体テストを持つ意味が大きい)。
+
+import type { CoreLabel } from "../context-label/labels";
+import type {
+ GraphiumDocument,
+ MediaInlineLabel,
+ TableMeta,
+} from "../../lib/document-types";
+import { LATEST_DOCUMENT_VERSION } from "../../lib/document-migration";
+import { readFirstColumnName } from "../table-meta/table-cells";
+import type { TemplateBuildResult } from "./templates";
+import type { PageTemplate } from "./types";
+
+/** 新規ノートに付ける起源情報(GraphiumDocument.templateFrom と同型) */
+export type TemplateFromRef = NonNullable;
+
+export type RemappedTemplateBlocks = {
+ /** id を振り直したブロック(元の template.blocks は変更しない) */
+ blocks: any[];
+ /** 元 blockId → 新 blockId */
+ idMap: Map;
+ /** 元 blockId → ルートからのインデックス配列(挿入後に引き当てるため) */
+ pathMap: Map;
+};
+
+/**
+ * テンプレートのブロックを複製し、すべてのブロックへ新しい id を振る。
+ * 同時に「元 id → 新 id」「元 id → path」の対応表を作る。
+ */
+export function remapTemplateBlocks(template: PageTemplate): RemappedTemplateBlocks {
+ const blocks = structuredClone(template.blocks ?? []);
+ const idMap = new Map();
+ const pathMap = new Map();
+
+ const walk = (list: any[], prefix: number[]): void => {
+ list.forEach((b, i) => {
+ if (!b || typeof b !== "object") return;
+ const path = [...prefix, i];
+ const oldId = typeof b.id === "string" ? b.id : null;
+ const newId = crypto.randomUUID();
+ b.id = newId;
+ if (oldId) {
+ idMap.set(oldId, newId);
+ pathMap.set(oldId, path);
+ }
+ if (Array.isArray(b.children)) walk(b.children, path);
+ });
+ };
+ walk(blocks, []);
+
+ return { blocks, idMap, pathMap };
+}
+
+/**
+ * 共有テンプレートを「現在のページへの挿入」に使える形へ変換する。
+ *
+ * - blocks: id を振り直した複製
+ * - labels: blockId → path に変換(挿入後は path でブロックを引き当てる)
+ * - attributes: 同上(ラベル連動属性。ページには保存されないので挿入後に適用する)
+ * - columnTypes: tableMeta の「先頭列に付いたふるまい」だけ復元できる。
+ * なぜ先頭列だけか: 既存の適用経路(note-app の addFirstColumnType)が
+ * 先頭列名をキーに付けるふるまいしか持たないため。2 列目以降は落ちる。
+ * - provLinks: 付けない(テンプレートは手順間リンクを持たない)
+ */
+export function pageTemplateToBuildResult(template: PageTemplate): TemplateBuildResult {
+ const { blocks, pathMap } = remapTemplateBlocks(template);
+
+ const labels: { path: number[]; label: CoreLabel }[] = [];
+ for (const [blockId, label] of template.labels ?? []) {
+ const path = pathMap.get(blockId);
+ // 本文に存在しない blockId のラベルは捨てる(壊れたテンプレートでも落ちない)
+ if (path) labels.push({ path, label: label as CoreLabel });
+ }
+
+ const attributes: { path: number[]; attributes: any }[] = [];
+ for (const [blockId, attrs] of template.attributes ?? []) {
+ const path = pathMap.get(blockId);
+ if (path) attributes.push({ path, attributes: attrs });
+ }
+
+ // tableMeta の列のふるまいを、挿入後に付け直せる形(path + type)へ落とす。
+ // 先頭列名はブロック本体(ヘッダ行の先頭セル)から読む。
+ const columnTypes: TemplateBuildResult["columnTypes"] = [];
+ const blockByPath = (path: number[]): any | null => {
+ let nodes: any[] = blocks;
+ let node: any = null;
+ for (const idx of path) {
+ node = nodes?.[idx];
+ if (!node) return null;
+ nodes = node.children ?? [];
+ }
+ return node;
+ };
+ for (const [blockId, meta] of Object.entries(template.tableMeta ?? {})) {
+ const path = pathMap.get(blockId);
+ if (!path) continue;
+ const block = blockByPath(path);
+ const firstColumn = readFirstColumnName(block);
+ if (!firstColumn) continue;
+ for (const type of meta.columns?.[firstColumn] ?? []) {
+ columnTypes.push({ path, type });
+ }
+ }
+
+ return {
+ blocks,
+ labels,
+ ...(attributes.length > 0 ? { attributes } : {}),
+ ...(columnTypes.length > 0 ? { columnTypes } : {}),
+ };
+}
+
+/**
+ * 共有テンプレートから新規ノート用の GraphiumDocument を組み立てる。
+ *
+ * ラベル・表のふるまい・メディアラベルは、いずれもノートを開いたときに
+ * 各ストアへ復元される「ページ上の注釈層」(page.labels / page.tableMeta /
+ * page.mediaInlineLabels)なので、新 blockId に貼り替えてそこへ書き戻す。
+ *
+ * 連動属性(PageTemplate.attributes)は本文には入っているが、GraphiumPage に
+ * 保存先が無いためこの経路では復元しない(ラベルストアは開いているページの
+ * 実行時状態で、まだ開いていない新規ノートには書き込めない)。挿入経路の
+ * pageTemplateToBuildResult だけが、挿入直後にストアへ適用する形で扱える。
+ *
+ * `shared-blob:` の解決(materializeSharedBlobs)は呼び出し側の責務。
+ * 素材の書き込みは副作用なので、この純関数の外に置く。
+ */
+export function buildDocumentFromTemplate(
+ template: PageTemplate,
+ meta: { title: string; templateFrom: TemplateFromRef },
+): GraphiumDocument {
+ const { blocks, idMap } = remapTemplateBlocks(template);
+
+ const labels: Record = {};
+ for (const [blockId, label] of template.labels ?? []) {
+ const newId = idMap.get(blockId);
+ if (newId) labels[newId] = label;
+ }
+
+ const tableMeta: Record = {};
+ for (const [blockId, entry] of Object.entries(template.tableMeta ?? {})) {
+ const newId = idMap.get(blockId);
+ if (!newId) continue;
+ // noteLinks は共有した人のローカルノート id を指すので引き継がない
+ // (こちらの環境では解決できず、行から他人のノートを開こうとして壊れる)。
+ const { noteLinks: _dropped, ...rest } = entry;
+ tableMeta[newId] = rest;
+ }
+
+ const mediaInlineLabels: Record = {};
+ for (const [blockId, entry] of Object.entries(template.mediaInlineLabels ?? {})) {
+ const newId = idMap.get(blockId);
+ if (newId) mediaInlineLabels[newId] = entry;
+ }
+
+ const now = new Date().toISOString();
+ return {
+ version: LATEST_DOCUMENT_VERSION,
+ title: meta.title,
+ pages: [
+ {
+ id: "main",
+ title: meta.title,
+ blocks,
+ labels,
+ provLinks: [],
+ knowledgeLinks: [],
+ ...(Object.keys(tableMeta).length > 0 ? { tableMeta } : {}),
+ ...(Object.keys(mediaInlineLabels).length > 0 ? { mediaInlineLabels } : {}),
+ },
+ ],
+ templateFrom: meta.templateFrom,
+ createdAt: now,
+ modifiedAt: now,
+ };
+}
diff --git a/src/features/template/index.ts b/src/features/template/index.ts
index f068a29da..7767caf6f 100644
--- a/src/features/template/index.ts
+++ b/src/features/template/index.ts
@@ -4,4 +4,11 @@ export * from "./load";
export { TemplatePickerModal } from "./TemplatePickerModal";
export { getTemplateSlashMenuItem, setTemplatePickerCallback } from "./slash-menu-item";
export { getAllTemplates, registerUserTemplate } from "./templates";
+export {
+ pageTemplateToBuildResult,
+ buildDocumentFromTemplate,
+ remapTemplateBlocks,
+ type TemplateFromRef,
+ type RemappedTemplateBlocks,
+} from "./from-page-template";
export type { TemplateDef, TemplateSource, TemplateBuildResult } from "./templates";
diff --git a/src/features/template/save.ts b/src/features/template/save.ts
index ee318762b..d9d446d21 100644
--- a/src/features/template/save.ts
+++ b/src/features/template/save.ts
@@ -5,6 +5,7 @@
// ──────────────────────────────────────────────
import type { StepAttributes } from "../context-label/label-attributes";
+import type { MediaInlineLabel, TableMeta } from "../../lib/document-types";
import type { PageTemplate } from "./types";
/**
@@ -16,7 +17,16 @@ export function createTemplate(params: {
blocks: any[];
labels: [string, string][];
attributes: [string, StepAttributes][];
+ /** 表のふるまい(省略可)。空なら書き出さない */
+ tableMeta?: Record;
+ /** メディアブロックのラベル(省略可)。空なら書き出さない */
+ mediaInlineLabels?: Record;
}): PageTemplate {
+ // 空オブジェクトはフィールドごと落とす。なぜ: 旧テンプレート JSON と同じ形を保ち、
+ // 「注釈が無い」ことを {} と undefined の 2 通りで表現しないため。
+ const hasTableMeta = !!params.tableMeta && Object.keys(params.tableMeta).length > 0;
+ const hasMediaLabels =
+ !!params.mediaInlineLabels && Object.keys(params.mediaInlineLabels).length > 0;
return {
name: params.name,
savedAt: new Date().toISOString(),
@@ -24,6 +34,10 @@ export function createTemplate(params: {
blocks: structuredClone(params.blocks),
labels: structuredClone(params.labels),
attributes: structuredClone(params.attributes),
+ ...(hasTableMeta ? { tableMeta: structuredClone(params.tableMeta!) } : {}),
+ ...(hasMediaLabels
+ ? { mediaInlineLabels: structuredClone(params.mediaInlineLabels!) }
+ : {}),
};
}
diff --git a/src/features/template/templates.ts b/src/features/template/templates.ts
index b4727e528..a379c6527 100644
--- a/src/features/template/templates.ts
+++ b/src/features/template/templates.ts
@@ -9,6 +9,7 @@
// path: ルートからのインデックス配列(例: [3, 0, 1] = blocks[3].children[0].children[1])
import type { CoreLabel } from "../context-label/labels";
+import type { StepAttributes } from "../context-label/label-attributes";
import type { ColumnType } from "../../lib/document-types";
export type TemplateSource = "official" | "user";
@@ -30,6 +31,13 @@ export type TemplateBuildResult = {
* 「インデックステーブル」挿入と同じく、先頭列の名前をキーに記録される。
*/
columnTypes?: { path: number[]; type: ColumnType }[];
+ /**
+ * 挿入後にラベル連動属性(手順の実行者・状態)を復元するブロック。
+ * 公式テンプレートは使わない。共有テンプレート(PageTemplate 由来)だけが載せる。
+ * なぜ optional で外付けか: 属性は GraphiumPage に保存されずラベルストアの実行時状態
+ * なので、ブロック JSON に混ぜられない。labels と同じく path で後から適用する。
+ */
+ attributes?: { path: number[]; attributes: StepAttributes }[];
};
export type TemplateDef = {
diff --git a/src/features/template/types.ts b/src/features/template/types.ts
index 1ee0dc285..47e05841e 100644
--- a/src/features/template/types.ts
+++ b/src/features/template/types.ts
@@ -3,6 +3,7 @@
// ──────────────────────────────────────────────
import type { StepAttributes } from "../context-label/label-attributes";
+import type { MediaInlineLabel, TableMeta } from "../../lib/document-types";
// テンプレートとして保存されるページのスナップショット
export type PageTemplate = {
@@ -18,6 +19,19 @@ export type PageTemplate = {
labels: [string, string][];
/** blockId → 連動属性 */
attributes: [string, StepAttributes][];
+ /**
+ * blockId → テーブル注釈(表の名前・列のふるまい)。
+ * なぜ optional: ブロック JSON だけでは「この列は日時が自動で入る」「この列から
+ * ノートを作れる」という表のふるまいが落ちる。雛形として使うときに一番効く情報なので
+ * 残す。追加のみなので、このフィールドを持たない旧テンプレート JSON もそのまま読める。
+ */
+ tableMeta?: Record;
+ /**
+ * blockId → メディアブロックのインラインラベル。
+ * 画像・PDF 等は本文にラベルを埋められず別層で持つため、labels と同じ理由でここに残す。
+ * こちらも additive optional。
+ */
+ mediaInlineLabels?: Record;
};
// テンプレートストアに保存される形式
diff --git a/src/hooks/use-file-manager.ts b/src/hooks/use-file-manager.ts
index 7905debf7..a79758e73 100644
--- a/src/hooks/use-file-manager.ts
+++ b/src/hooks/use-file-manager.ts
@@ -2694,8 +2694,16 @@ export function useFileManager(authenticated: boolean) {
// 外部ファイル(Word / 将来 PowerPoint 等)からの取り込みでノートを新規作成する。
// human_derivation として記録 — 元ファイルからの抽出はユーザー由来の派生
const handleCreateNoteFromImport = useCallback(
- async (doc: GraphiumDocument): Promise => {
- doc = await recordRevision(doc, null, "human_derivation");
+ async (doc: GraphiumDocument, options?: { sources?: string[] }): Promise => {
+ // sources: この新規ノートが取り込んだ元(例: 共有テンプレートの `shared:`)。
+ // 初回リビジョンの prov:used に残すため、ここで recordRevision へ渡す
+ // (呼び出し側で先に recordRevision すると、この行がもう 1 本リビジョンを積んで二重になる)
+ doc = await recordRevision(
+ doc,
+ null,
+ "human_derivation",
+ options?.sources?.length ? { sources: options.sources } : undefined,
+ );
doc = normalizeTableRowIdentities(doc);
const newFileId = await createFile(doc.title, doc);
const now = new Date().toISOString();
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 418a89e5b..5a52b29cc 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -460,6 +460,12 @@ export const en: Record = {
"template.modal.colName": "Template",
"template.modal.colSource": "Source",
"template.modal.colTags": "Tags",
+ "template.modal.sourceTeam": "Team",
+ "template.picker.teamEmpty": "No shared templates yet",
+ "template.picker.teamLoading": "Loading...",
+ "template.picker.hashMismatchConfirm": "This template's contents do not match what was recorded when it was shared. Insert it anyway?",
+ "template.picker.loadFailed": "Could not load the template: {error}",
+ "template.picker.mediaMissing": "Inserted the template, but {count} embedded media could not be restored from blob root.",
"template.source.official": "Official",
"template.source.user": "User",
"template.tag.plan": "Plan",
@@ -644,7 +650,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). Turning this off also leaves the Labels and Processes tabs of the shared library empty, because they are built from the same reading.",
+ "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). The Labels and Processes tabs of the shared library are filled in from shared notes regardless of this setting.",
// ── 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.",
@@ -1804,6 +1810,15 @@ export const en: Record = {
"share.media.dialog.descLabel": "Description (optional)",
"share.media.dialog.share": "Share",
"share.media.dialog.update": "Update",
+ // Shared templates (PR 3)
+ "share.template.shareToTeam": "Share as template",
+ "share.template.dialog.title": "Share as template",
+ "share.template.dialog.help": "Shares the current page as-is so your team can start new notes from it. Remove any results or numbers you don't want to share before sharing.",
+ "share.template.dialog.titleLabel": "Template name",
+ "share.template.dialog.descLabel": "Description (optional)",
+ "share.template.dialog.share": "Share",
+ "share.template.noPage": "No page to share was found.",
+ "share.template.success": "Template saved to team storage.",
// Phase 2c-2 (shared:// citation card)
"slash.sharedCitation": "Cite shared entry",
"slash.sharedCitationSub": "Insert a citation card from your team's shared storage",
@@ -2370,22 +2385,33 @@ export const en: Record = {
"library.tab.asset": "Assets",
"library.tab.labels": "Labels",
"library.tab.process": "Processes",
+ "library.tab.template": "Templates",
"library.loadFailed": "Failed to load: {error}",
"library.untitled": "(untitled)",
"library.unshareConfirm": "Unshare \"{title}\"?",
+ "library.templateHashMismatchConfirm": "This template's content no longer matches its recorded fingerprint (hash). It may have changed since it was shared. Create a new note from it anyway?",
"library.you": "you",
"library.unknownAuthor": "(unknown)",
"library.forkToNotes": "Fork to my notes",
"library.forkToKnowledge": "Fork to my knowledge",
+ "library.createFromTemplate": "New note from template",
+ "library.templateNotFound": "This shared template is no longer available. It may have been unshared.",
+ "library.createFromTemplateFailed": "Could not create a note from this template: {error}",
+ "library.createFromTemplateMediaMissing": "Created the note, but {count} embedded media could not be restored from blob root. They appear as broken references in the new note.",
"library.unshare": "Unshare",
"library.noMatch": "No entries match the current filters",
"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.empty.labels": "No labels from shared notes yet. Share a note and the labels inside it will be collected here.",
+ "library.empty.process": "No procedures from shared notes yet. Share a note and the procedures inside it will be collected here.",
+ "library.labelsHint": "This shows labels collected from shared notes. To make labels appear here, share a note.",
+ "library.processHint": "This shows procedures collected from shared notes. To make procedures appear here, share a note.",
+ "library.openNoteList": "Open note list",
+ "library.empty.template": "No shared templates yet.",
"library.col.title": "Title",
"library.col.kind": "Kind",
+ "library.col.description": "Description",
"library.col.sharedAt": "Shared",
"library.col.version": "Version",
"library.col.verified": "Verified",
diff --git a/src/i18n/index.test.ts b/src/i18n/index.test.ts
index cc765990f..5a54d10f9 100644
--- a/src/i18n/index.test.ts
+++ b/src/i18n/index.test.ts
@@ -146,6 +146,36 @@ describe("syncLocale() / getLocale()", () => {
});
});
+// ── 共有テンプレートの失敗文言(en / ja 両方に必要)──
+// 失敗のときだけ出る文言は実機で気づきにくく、片方の辞書に入れ忘れると
+// キーがそのまま画面に出る。両ロケールで訳が引けることをここで担保する。
+
+describe("共有テンプレートから新規ノートを作るときの文言", () => {
+ const keys = [
+ "library.templateNotFound",
+ "library.createFromTemplateFailed",
+ "library.createFromTemplateMediaMissing",
+ ];
+
+ it("en / ja とも訳が引ける(キーがそのまま返らない)", () => {
+ for (const key of keys) {
+ expect(t(key)).not.toBe(key);
+ }
+ syncLocale("ja");
+ for (const key of keys) {
+ expect(t(key)).not.toBe(key);
+ }
+ });
+
+ it("パラメータが置換される", () => {
+ expect(t("library.createFromTemplateFailed", { error: "boom" })).toContain("boom");
+ expect(t("library.createFromTemplateMediaMissing", { count: "2" })).toContain("2");
+ syncLocale("ja");
+ expect(t("library.createFromTemplateFailed", { error: "boom" })).toContain("boom");
+ expect(t("library.createFromTemplateMediaMissing", { count: "2" })).toContain("2");
+ });
+});
+
// ── detectLocale() の間接テスト ──
describe("detectLocale() の振る舞い(getLocale() 経由で確認)", () => {
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 59c05c93a..903a761e9 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -460,6 +460,12 @@ export const ja: Record = {
"template.modal.colName": "テンプレート",
"template.modal.colSource": "提供元",
"template.modal.colTags": "タグ",
+ "template.modal.sourceTeam": "チーム",
+ "template.picker.teamEmpty": "共有されたテンプレートはまだありません",
+ "template.picker.teamLoading": "読み込み中...",
+ "template.picker.hashMismatchConfirm": "このテンプレートの内容が、共有されたときの記録と一致しません。それでも挿入しますか?",
+ "template.picker.loadFailed": "テンプレートを読み込めませんでした: {error}",
+ "template.picker.mediaMissing": "テンプレートを挿入しましたが、{count} 件の画像・ファイルを共有ストレージから復元できませんでした。",
"template.source.official": "公式",
"template.source.user": "ユーザー定義",
"template.tag.plan": "計画",
@@ -644,7 +650,7 @@ export const ja: Record = {
"settings.shared.desktopOnly": "共有ストレージは現在デスクトップ版のみ対応です。ブラウザ版ではローカルフォルダの読み書きができません。",
"settings.shared.identityRequired": "接続テストの前に、上の「あなたの identity」で名前とメールを登録してください。",
"settings.shared.aiEnabled.title": "共有ライブラリを ⌘K と AI チャットの対象に含める",
- "settings.shared.aiEnabled.help": "共有フォルダのノート・ナレッジと、素材の題名・説明を手元の検索索引に入れ、AI チャットの参照候補にも使います。索引は手元だけに作られ、共有フォルダには何も書きません。共有フォルダを読める人は誰でも同じことができます(アプリ内の権限制御はありません)。OFF にすると、同じ読み取りから作っている Library の「ラベル」「プロセス」タブも空のままになります。",
+ "settings.shared.aiEnabled.help": "共有フォルダのノート・ナレッジと、素材の題名・説明を手元の検索索引に入れ、AI チャットの参照候補にも使います。索引は手元だけに作られ、共有フォルダには何も書きません。共有フォルダを読める人は誰でも同じことができます(アプリ内の権限制御はありません)。Library の「ラベル」「プロセス」タブは、この設定に関わらず共有ノートから集まります。",
// ── モバイル送信(デスクトップ = 受け取り側: 受信フォルダ + スマホで開く QR) ──
"settings.mobilePush.title": "モバイル送信",
"settings.mobilePush.help": "スマホで撮ったものをクラウドストレージ経由でデスクトップに送ります。この端末は同期フォルダから取り込む側です。",
@@ -1802,6 +1808,15 @@ export const ja: Record = {
"share.media.dialog.descLabel": "説明(任意)",
"share.media.dialog.share": "共有",
"share.media.dialog.update": "更新",
+ // 共有テンプレート(PR 3)
+ "share.template.shareToTeam": "テンプレートとして共有",
+ "share.template.dialog.title": "テンプレートとして共有",
+ "share.template.dialog.help": "いま開いているページの中身を、そのまま雛形としてチームに共有します。結果や数値を残したくないときは、消してから共有してください。",
+ "share.template.dialog.titleLabel": "テンプレート名",
+ "share.template.dialog.descLabel": "説明(任意)",
+ "share.template.dialog.share": "共有",
+ "share.template.noPage": "共有できるページが見つかりませんでした。",
+ "share.template.success": "テンプレートをチームの共有ストレージに保存しました。",
// Phase 2c-2(shared:// 引用カード)
"slash.sharedCitation": "共有エントリを引用",
"slash.sharedCitationSub": "チームの共有ストレージから引用カードを挿入",
@@ -2367,22 +2382,33 @@ export const ja: Record = {
"library.tab.asset": "素材",
"library.tab.labels": "ラベル",
"library.tab.process": "プロセス",
+ "library.tab.template": "テンプレート",
"library.loadFailed": "読み込みに失敗しました: {error}",
"library.untitled": "(無題)",
"library.unshareConfirm": "「{title}」の共有を解除しますか?",
+ "library.templateHashMismatchConfirm": "このテンプレートの内容が、記録されている指紋(ハッシュ)と一致しません。共有されたあとに変わった可能性があります。このまま新しいノートを作りますか?",
"library.you": "あなた",
"library.unknownAuthor": "(不明)",
"library.forkToNotes": "自分のノートに派生",
"library.forkToKnowledge": "自分のナレッジに派生",
+ "library.createFromTemplate": "テンプレートから新規ノート",
+ "library.templateNotFound": "この共有テンプレートは見つかりませんでした。共有が解除された可能性があります。",
+ "library.createFromTemplateFailed": "テンプレートから新しいノートを作れませんでした: {error}",
+ "library.createFromTemplateMediaMissing": "ノートを作りましたが、{count} 件の画像・ファイルを共有ストレージから復元できませんでした。新しいノートでは参照切れとして表示されます。",
"library.unshare": "共有解除",
"library.noMatch": "条件に一致するエントリがありません",
"library.empty.note": "共有されたノートはまだありません。",
"library.empty.knowledge": "共有されたナレッジはまだありません。",
"library.empty.asset": "共有された素材はまだありません。",
- "library.empty.labels": "共有ノートのラベルはまだありません。本文を読み込むと増えていきます。",
- "library.empty.process": "共有ノートの手順はまだありません。本文を読み込むと増えていきます。",
+ "library.empty.labels": "共有ノートのラベルはまだありません。ノートを共有すると、その中のラベルがここに集まります。",
+ "library.empty.process": "共有ノートの手順はまだありません。ノートを共有すると、その中の手順がここに集まります。",
+ "library.labelsHint": "共有ノートに含まれるラベルを集めて表示しています。ここに出すには、ノートを共有してください。",
+ "library.processHint": "共有ノートに含まれる手順を集めて表示しています。ここに出すには、ノートを共有してください。",
+ "library.openNoteList": "ノート一覧を開く",
+ "library.empty.template": "共有されたテンプレートはまだありません。",
"library.col.title": "タイトル",
"library.col.kind": "種別",
+ "library.col.description": "説明",
"library.col.sharedAt": "共有日",
"library.col.version": "版",
"library.col.verified": "検証",
diff --git a/src/lib/document-types.ts b/src/lib/document-types.ts
index a5c9f7d74..023b13c51 100644
--- a/src/lib/document-types.ts
+++ b/src/lib/document-types.ts
@@ -778,6 +778,22 @@ export type GraphiumDocument = {
/** ISO-8601 fork 実行日時 */
forkedAt: string;
};
+ /**
+ * 共有テンプレートから新規作成されたノートの起源情報。
+ * forkedFrom と同型(sharedId / hash)だが意味が違う: fork は「記録のコピー」で
+ * 派生元の実験記録に紐づくのに対し、templateFrom は「雛形から作った新しい記録」で
+ * 中身の事実は引き継いでいない。PROV 上も別扱いにしたいのでフィールドを分ける。
+ */
+ templateFrom?: {
+ /** 元 SharedEntry.id(type: "template") */
+ sharedId: string;
+ /** 使用時点の SharedEntry.hash */
+ hash: string;
+ /** 元テンプレートの表示名(extra.title) */
+ title: string;
+ /** ISO-8601 テンプレート適用日時 */
+ usedAt: string;
+ };
/** Skill メタデータ(source === "skill" の場合のみ) */
skillMeta?: SkillMeta;
/**
diff --git a/src/note-app.tsx b/src/note-app.tsx
index f99c7aefb..7f5ffe510 100644
--- a/src/note-app.tsx
+++ b/src/note-app.tsx
@@ -2,7 +2,7 @@
// Google Drive と連携してノートの作成・保存・読み込みを行う
import { Component, useCallback, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode } from "react";
-import { Save, FileDown, Share2, MoreHorizontal, Network, GitBranch, Bot, History, FileText, PanelLeftOpen, BookPlus, BookOpen, Trash2, Archive, ArchiveRestore, StickyNote, Link2, Check, Pin, MoveHorizontal } from "lucide-react";
+import { Save, FileDown, Share2, MoreHorizontal, Network, GitBranch, Bot, History, FileText, PanelLeftOpen, BookPlus, BookOpen, Trash2, Archive, ArchiveRestore, StickyNote, Link2, Check, Pin, MoveHorizontal, LayoutTemplate } from "lucide-react";
import { apiBase, isTauri, tauriDetectionDetail } from "./lib/platform";
import { relaunchApp } from "./lib/relaunch";
import { onMenuAction } from "./lib/menu-events";
@@ -191,7 +191,10 @@ import {
materializeSharedBlobs,
BulkShareModal,
notifySharedLibraryChanged,
+ readSharedEntryBody,
+ getSharedLibrarySnapshot,
useSharedLibrarySync,
+ ShareTemplateDialog,
type BulkShareTarget,
} from "./features/sharing";
import { LocalFolderBlobProvider, type BlobRef } from "./lib/storage/shared";
@@ -245,7 +248,7 @@ import { translatePdfToNote, translateUrlToNote, fetchReaderArticle, isSameLangu
import { SkillListView, SkillBanner, SkillDialog, buildSkillDocument, extractSkillPrompt, buildSkillPromptSection, pickActiveSkills } from "./features/skill";
import type { WikiKind } from "./lib/document-types";
import { MobileCaptureView, MemoGalleryView, MemoPickerModal, getMemoSlashMenuItem, setMemoPickerCallback, CaptureDialog, buildMemoInsertBlock, getTrashedCaptures, getArchivedCaptures, resolveMemoBlockLabel } from "./features/mobile-capture";
-import { TemplatePickerModal, getTemplateSlashMenuItem, setTemplatePickerCallback, getAllTemplates } from "./features/template";
+import { TemplatePickerModal, getTemplateSlashMenuItem, setTemplatePickerCallback, getAllTemplates, buildDocumentFromTemplate, pageTemplateToBuildResult, deserializeTemplate, type PageTemplate } from "./features/template";
import {
CitePickerModal,
getCiteSlashMenuItems,
@@ -451,6 +454,7 @@ function NoteHeaderMenu({
isShared,
shareBusy,
shareDisabledReason,
+ onShareTemplate,
onCopyLink,
fullWidth,
onToggleFullWidth,
@@ -499,6 +503,11 @@ function NoteHeaderMenu({
shareBusy?: boolean;
/** Shared が無効な理由(disabled 時のヒント表示用) */
shareDisabledReason?: string;
+ /**
+ * 現在のページをテンプレートとして共有する(PR 3)。未設定時は項目ごと隠す。
+ * 無効理由はノート共有と同じ shareDisabledReason を使う。
+ */
+ onShareTemplate?: () => void;
/** このノートへのリンク(URL)をクリップボードにコピーする。別ノートに貼るとメンション化される。 */
onCopyLink?: () => void;
/** 本文をフル幅表示しているか(Notion の Full width 相当)。ON でチェックを表示 */
@@ -628,6 +637,18 @@ function NoteHeaderMenu({
? t("share.reshareToTeam")
: t("share.shareToTeam")}
+ {/* 記録のコピー(上)と雛形の配布(下)は別物なので、同じ共有の区画に並べる */}
+ {onShareTemplate && (
+ { onShareTemplate(); setOpen(false); }}
+ title={shareDisabled ? shareDisabledReason : undefined}
+ >
+
+ {t("share.template.shareToTeam")}
+
+ )}
>
)}
{onDeriveWholeNote && (
@@ -1606,6 +1627,130 @@ function NoteEditorInner({
// insertBlocks による onChange で自動的に markDirty される
}, [labelStore, linkStore, addFirstColumnType]);
+ // チームのテンプレート(共有ライブラリの type=template)をピッカーから挿入する。
+ // 公式テンプレートとの違いは「本文が共有ルートにある」ことだけなので、
+ // 読み出し → hash 照合 → shared-blob: の解決 まで済ませてから、
+ // 公式と同じ挿入経路(ブロック挿入 → 次フレームでラベル・属性・列のふるまいを適用)に流す。
+ const handleSharedTemplateSelect = useCallback(async (entry: SharedEntry) => {
+ setTemplatePickerOpen(false);
+ const editor = editorRef.current;
+ // 挿入位置は本文の読み出し(非同期)を跨ぐので先に確保する。
+ // ref は次の選択に備えてここで空に戻す
+ const triggerBlock = templateTriggerBlockRef.current ?? editor?.getTextCursorPosition()?.block;
+ templateTriggerBlockRef.current = null;
+ if (!editor || !triggerBlock) return;
+
+ let template: PageTemplate;
+ try {
+ const { body, verified } = await readSharedEntryBody(entry);
+ if (!verified) {
+ // hash 不一致 = 共有元が壊れている / 想定外に書き換わっている。
+ // 本文自体は読めるので、挿すかどうかは利用者に決めさせる
+ if (!window.confirm(tStatic("template.picker.hashMismatchConfirm"))) return;
+ }
+ // バイト列を生文字列として扱うと日本語が壊れる。必ず TextDecoder で読む
+ template = deserializeTemplate(new TextDecoder().decode(body));
+ } catch (e) {
+ alert(tStatic("template.picker.loadFailed", { error: e instanceof Error ? e.message : String(e) }));
+ return;
+ }
+
+ // shared-blob: を自分のローカル素材へ置き換える(fork・テンプレートから新規ノートと
+ // 同じ materializeSharedBlobs)。doc 単位の関数なので 1 ページの擬似 doc に包む。
+ // ここでブロック id は変えない — このあとの pageTemplateToBuildResult が
+ // labels / attributes / tableMeta を「元の blockId」で引くため、
+ // 先に id が変わると注釈がまとめて落ちる
+ const extraBlobs = (entry.extra as { blobs?: BlobRef[] } | undefined)?.blobs;
+ const blobRoot = getBlobRoot();
+ if (Array.isArray(extraBlobs) && extraBlobs.length > 0 && blobRoot && uploadFile) {
+ const blobProvider = new LocalFolderBlobProvider(blobRoot);
+ const now = new Date().toISOString();
+ const pseudoDoc: GraphiumDocument = {
+ version: LATEST_DOCUMENT_VERSION,
+ title: template.name,
+ pages: [
+ {
+ id: "main",
+ title: template.pageTitle,
+ blocks: template.blocks,
+ labels: {},
+ provLinks: [],
+ knowledgeLinks: [],
+ },
+ ],
+ createdAt: now,
+ modifiedAt: now,
+ };
+ const materialized = await materializeSharedBlobs(pseudoDoc, {
+ blobs: extraBlobs,
+ fetchBytes: (ref) => blobProvider.get(ref),
+ uploadMedia: async (file) => ({ url: await uploadFile(file) }),
+ });
+ template = { ...template, blocks: materialized.doc.pages[0]?.blocks ?? template.blocks };
+ if (materialized.missing.length > 0) {
+ alert(tStatic("template.picker.mediaMissing", { count: String(materialized.missing.length) }));
+ }
+ }
+
+ const { blocks, labels, attributes, columnTypes } = pageTemplateToBuildResult(template);
+ if (blocks.length === 0) return;
+
+ const inserted = editor.insertBlocks(blocks, triggerBlock, "after");
+
+ // スラッシュを打ったブロックが空なら削除(公式テンプレートと同じ後始末)
+ const content = (triggerBlock as any).content;
+ if (
+ Array.isArray(content) &&
+ content.length <= 1 &&
+ (!content[0] ||
+ (content[0].type === "text" &&
+ content[0].text.replace("/", "").trim() === ""))
+ ) {
+ editor.removeBlocks([triggerBlock]);
+ }
+
+ // パスから挿入後のブロックを取得(公式テンプレートと同じ引き当て方)
+ const resolveByPath = (path: number[]): any | null => {
+ let nodes: any[] = inserted as any[];
+ let node: any = null;
+ for (const idx of path) {
+ node = nodes?.[idx];
+ if (!node) return null;
+ nodes = node.children ?? [];
+ }
+ return node;
+ };
+
+ // エディタの状態反映後に注釈層を復元する(公式テンプレートと同じく次フレーム)
+ setTimeout(() => {
+ for (const { path, label } of labels) {
+ const block = resolveByPath(path);
+ if (block?.id) labelStore.setLabel(block.id, label);
+ }
+ // 連動属性はラベルを付けた直後にだけ入る(setAttributes は既定値が無いブロックでは
+ // 何もしない)。ラベルが復元できなかったブロックの属性は落ちるが、
+ // 属性だけ復活しても意味が無いのでそれで正しい
+ for (const { path, attributes: attrs } of attributes ?? []) {
+ const block = resolveByPath(path);
+ if (block?.id) labelStore.setAttributes(block.id, attrs);
+ }
+ for (const { path, type } of columnTypes ?? []) {
+ const block = resolveByPath(path);
+ if (block?.id) addFirstColumnType(block.id, type);
+ }
+ }, 0);
+
+ // 共有テンプレートは focusPath を持たないので、挿入した先頭ブロックにカーソルを置く
+ const firstId = (inserted as any[])[0]?.id;
+ if (firstId) {
+ try {
+ editor.setTextCursorPosition(firstId, "end");
+ } catch {
+ /* no-op */
+ }
+ }
+ }, [labelStore, addFirstColumnType, uploadFile]);
+
// スラッシュだけの空ブロックかどうか("/" もしくは空)。
const isSlashOnlyBlock = useCallback((block: any) => {
const content = block?.content;
@@ -2651,6 +2796,20 @@ function NoteEditorInner({
}
}, [sharedRoot, sharedAuthor, buildDocument, onSave, t, sharedRefState, isWikiDoc]);
+ // ── テンプレートとして共有(PR 3)──
+ // ノート共有(記録のコピー)とは別に、いま開いているページを雛形として配る。
+ // 本文は「共有」を押した時点で組み立てる(ダイアログを開いたまま編集しても最新が出る)。
+ const [shareTemplateOpen, setShareTemplateOpen] = useState(false);
+ const resolveTemplateSource = useCallback(async () => {
+ const doc = await buildDocument();
+ const page = doc.pages[0];
+ if (!page) return null;
+ // 手順の連動属性(チェック・実行者・状態)はページに保存されずラベルストアにしか
+ // 無いので、ここで一緒に渡す。渡さないと共有テンプレートを挿したときに
+ // ラベルだけ戻って属性が既定値に落ちる
+ return { doc, page, attributes: labelStore.getSnapshot().attributes };
+ }, [buildDocument, labelStore]);
+
// ── メモ挿入(メモギャラリーから) ──
useEffect(() => {
if (!pendingMemoInsert || !editorRef.current) return;
@@ -4740,10 +4899,19 @@ function NoteEditorInner({
allowDisplayMode
/>
)}
+ {/* テンプレートとして共有ダイアログ(⋯ メニューから) */}
+ setShareTemplateOpen(false)}
+ onShared={() => window.alert(t("share.template.success"))}
+ />
{/* テンプレートピッカーモーダル(スラッシュメニュー /template から) */}
{templatePickerOpen && (
setTemplatePickerOpen(false)}
/>
)}
@@ -4827,6 +4995,10 @@ function NoteEditorInner({
onDelete={onDeleteNote}
deleteDisabled={!fileId || saving}
onShare={!isSkillDoc ? handleShare : undefined}
+ onShareTemplate={
+ // 雛形として配るのはノートだけ(Wiki / Skill は本文の性格が違う)
+ !isSkillDoc && !isWikiDoc ? () => setShareTemplateOpen(true) : undefined
+ }
shareDisabled={!!shareDisabledReason || saving}
shareDisabledReason={shareDisabledReason}
isShared={isShared}
@@ -8693,6 +8865,13 @@ export function NoteApp() {
noteFolders={noteFolderNames}
noteFolderLookup={noteFolderLookup}
onSharedRefUpdated={fm.handleUpdateMediaSharedRef}
+ onBulkShare={
+ // ノート一覧の一括共有と同じ条件(デスクトップ + 共有ルート + 名前)
+ isTauri() && getSharedRoot() && loadAuthorIdentity()
+ ? (fileIds) =>
+ setBulkShareTargets(fileIds.map((id) => ({ id, kind: "media" as const })))
+ : undefined
+ }
onAddUrlBookmark={fm.handleAddUrlBookmark}
onUploadMedia={fm.handleUploadMedia}
onExtractDocxImages={handleExtractDocxImages}
@@ -9539,6 +9718,74 @@ export function NoteApp() {
setShowGlobalGraph(false);
navigateToNote(`wiki:${newWikiId}`);
}}
+ // テンプレートから新規ノート。fork(記録のコピー)とは別物で、
+ // 雛形として本文・ラベル・表のふるまいだけを引き継ぐ。
+ // 由来は doc.templateFrom と初回リビジョンの prov:used(shared:)に残す
+ onCreateNoteFromTemplate={async (sharedId) => {
+ // 失敗はすべてここで通知してから投げ直す。
+ // なぜ try で全体を包むか: 本文の読み出し(共有ルート未設定・I/O)や
+ // JSON の破損は例外で来るため、囲まないと呼び出し側の catch が
+ // busy 表示を戻すだけになり、ユーザーには「押しても何も起きない」
+ // としか見えない(挿入経路・fork と同じく必ずメッセージを出す)
+ try {
+ const entry = getSharedLibrarySnapshot().entries.find((e) => e.id === sharedId);
+ if (!entry) throw new Error(tStatic("library.templateNotFound"));
+ const { body, verified } = await readSharedEntryBody(entry);
+ if (!verified) {
+ // hash 不一致 = 共有元が壊れている / 想定外に書き換わっている。
+ // 本文自体は読めるので、作るかどうかは利用者に決めさせる
+ if (!window.confirm(tStatic("library.templateHashMismatchConfirm"))) return;
+ }
+ const template = deserializeTemplate(new TextDecoder().decode(body));
+ const extraTitle = (entry.extra as { title?: unknown } | undefined)?.title;
+ const title =
+ typeof extraTitle === "string" && extraTitle.trim()
+ ? extraTitle
+ : template.name || tStatic("library.untitled");
+ let doc = buildDocumentFromTemplate(template, {
+ title,
+ templateFrom: {
+ sharedId: entry.id,
+ hash: entry.hash,
+ title,
+ usedAt: new Date().toISOString(),
+ },
+ });
+ // shared-blob: 参照を自分のローカルメディアへ(fork と同じ経路)
+ const extraBlobs = (entry.extra as { blobs?: BlobRef[] } | undefined)?.blobs;
+ const blobRoot = getBlobRoot();
+ if (Array.isArray(extraBlobs) && extraBlobs.length > 0 && blobRoot) {
+ const blobProvider = new LocalFolderBlobProvider(blobRoot);
+ const materialized = await materializeSharedBlobs(doc, {
+ blobs: extraBlobs,
+ fetchBytes: (ref) => blobProvider.get(ref),
+ uploadMedia: async (file) => ({ url: await fm.handleUploadMedia(file) }),
+ });
+ doc = materialized.doc;
+ if (materialized.missing.length > 0) {
+ alert(
+ tStatic("library.createFromTemplateMediaMissing", {
+ count: String(materialized.missing.length),
+ }),
+ );
+ }
+ }
+ const newFileId = await fm.handleCreateNoteFromImport(doc, {
+ sources: [`shared:${sharedId}`],
+ });
+ setShowGlobalGraph(false);
+ navigateToNote(newFileId);
+ } catch (e) {
+ alert(
+ tStatic("library.createFromTemplateFailed", {
+ error: e instanceof Error ? e.message : String(e),
+ }),
+ );
+ // 投げ直して呼び出し側(表の行)にも失敗を伝える。握ると
+ // 成否で分岐したい将来の呼び出し元が誤判定する
+ throw e;
+ }
+ }}
onUnshare={async (entry) => {
const author = loadAuthorIdentity();
const root = getSharedRoot();
@@ -9581,6 +9828,16 @@ export function NoteApp() {
}
: undefined
}
+ // ラベル/プロセスタブの説明バーから個人のノート一覧へ戻る導線。
+ // サイドバー「すべてのノート」(onShowNoteList)と同一の遷移にする
+ onOpenNoteList={() => {
+ closeAllViews();
+ fm.setShowNoteList(true);
+ setSidebarOpen(false);
+ router.navigate({ view: "notes" });
+ setSelectedFolder(null);
+ setFolderContextFilter([]);
+ }}
/>
) : showTrash ? (
fm.handleSaveWikiFile(id, doc),
+ // 素材はインデックスが最新の実体(storage から読み直さない)
+ loadMedia: (fileId) =>
+ fm.mediaIndex?.media.find((m) => m.fileId === fileId) ?? null,
+ // 単体共有(MaterialActionsMenu)と同じ書き戻し関数・同じフォルダ導出表を使う
+ saveMediaSharedRef: fm.handleUpdateMediaSharedRef,
+ noteFolderLookup,
}}
onClose={(didShareAny) => {
if (didShareAny) notifySharedLibraryChanged();