From 3965a058a7c3a6cd57e8c912de2ae4357299e63d Mon Sep 17 00:00:00 2001 From: kumagallium Date: Fri, 4 Sep 2026 16:28:28 +0900 Subject: [PATCH 1/2] [feat] Share a page as a team template and reuse it from the library and /template A lab could copy a shared note (fork) or a shared procedure, but there was no way to say "this is a blueprint" and hand it around as one. The "template" entry type existed only as a reserved folder. - Add "Share as template" to the note menu: the current page (blocks, labels, step attributes, table behaviors, embedded media via auto-blob) is written as a PageTemplate body under templates/ with a fresh id each time; the note's own sharedRef is untouched - Add a Templates tab to the shared library with a description column, the read-only preview, and "New note from template", which builds a note from the template, restores the media from the blob root, and records templateFrom plus a shared: usage in provenance. Templates never offer fork: they are blueprints, not records - Add a "Team templates" section to the /template picker that inserts a shared template into the current page through the same path as the official templates, restoring labels, step attributes, and first-column behaviors - Count blob references across notes, templates, and data-manifests when unsharing, so a template's media is collected only when nothing else still points at it Co-Authored-By: Claude Fable 5.1 --- docs/ARCHITECTURE.md | 16 + docs/DATA_MODEL.md | 43 +++ manual/ja/notes-and-editor.md | 4 +- manual/ja/storage-and-sync.md | 8 + manual/notes-and-editor.md | 2 + manual/storage-and-sync.md | 8 + src/features/sharing/ShareTemplateDialog.tsx | 167 +++++++++ .../sharing/SharedLibraryTable.test.tsx | 105 ++++++ src/features/sharing/SharedLibraryTable.tsx | 48 ++- .../sharing/SharedLibraryView.stories.tsx | 62 +++- src/features/sharing/SharedLibraryView.tsx | 117 ++++++- src/features/sharing/index.ts | 6 + src/features/sharing/share-template.test.ts | 328 ++++++++++++++++++ src/features/sharing/share-template.ts | 194 +++++++++++ src/features/sharing/unshare-entry.test.ts | 155 +++++++++ src/features/sharing/unshare-entry.ts | 58 +++- .../template/TemplatePickerModal.test.tsx | 140 ++++++++ src/features/template/TemplatePickerModal.tsx | 206 ++++++++--- .../template/from-page-template.test.ts | 182 ++++++++++ src/features/template/from-page-template.ts | 187 ++++++++++ src/features/template/index.ts | 7 + src/features/template/save.ts | 14 + src/features/template/templates.ts | 8 + src/features/template/types.ts | 14 + src/hooks/use-file-manager.ts | 12 +- src/i18n/en.ts | 23 ++ src/i18n/index.test.ts | 30 ++ src/i18n/ja.ts | 23 ++ src/lib/document-types.ts | 16 + src/note-app.tsx | 244 ++++++++++++- 30 files changed, 2353 insertions(+), 74 deletions(-) create mode 100644 src/features/sharing/ShareTemplateDialog.tsx create mode 100644 src/features/sharing/share-template.test.ts create mode 100644 src/features/sharing/share-template.ts create mode 100644 src/features/sharing/unshare-entry.test.ts create mode 100644 src/features/template/TemplatePickerModal.test.tsx create mode 100644 src/features/template/from-page-template.test.ts create mode 100644 src/features/template/from-page-template.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21e9fe88d..b5157c5df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1328,6 +1328,22 @@ images and files embedded in shared notes (`SharedEntry.extra.blobs`) as read-only rows — open the parent note, or copy the file into your own materials. +**Templates** (`SharedEntry.type === "template"`) are a separate share +target from notes: a page is written out as a `PageTemplate` (not a +`GraphiumDocument`), so a template carries only blocks, labels, and +table behavior — no lineage, chats, or shared refs +(`src/features/sharing/share-template.ts`, DATA_MODEL.md §7.1/§7.4). The +Library's **Templates** tab lists them read-only (title / description / +author, no fork action — a template is a blank starting point, not a +record to copy) and offers "New note from template", which reads the +body, converts it back into blocks +(`src/features/template/from-page-template.ts`), re-materializes any +`shared-blob:` media the same way Fork does, and opens the result with +a `templateFrom` origin field instead of `forkedFrom`. The same +conversion also powers a "Team templates" section inside the `/template` +slash-command picker (`TemplatePickerModal`), which inserts the chosen +template at the cursor instead of opening a new note. + Today the shared backend is a local folder. Other backends (cloud buckets, S3, IPFS-style) can be added by implementing the same blob interface. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 1176c8b80..64b3021ad 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -105,6 +105,13 @@ type GraphiumDocument = { // ── shared storage refs (Phase 2) ─────────────────── sharedRef?: { id; type: "note" | "knowledge"; sharedAt; hash }; forkedFrom?: { sharedId; hash; authorName; authorEmail; forkedAt }; + // Origin info for a note created from a shared template (§7.1, type + // "template"). Same shape idea as `forkedFrom` (sharedId/hash) but a + // different meaning: a fork is a copy of a record, while a note created + // from a template does not inherit any facts from the template — it just + // started from its blocks/labels. Kept as a separate field so PROV can + // treat the two differently. + templateFrom?: { sharedId; hash; title; usedAt }; // ── skill metadata (only when source === "skill") ─── skillMeta?: SkillMeta; @@ -1820,6 +1827,24 @@ Key model choices: evolving vocabulary, so it is kept out of the shared format's folder structure — older builds can still list, preview, and fork an entry whose `wikiKind` they do not know. +- **`"template"` body is a `PageTemplate`, not a `GraphiumDocument`** — + sharing a page as a template writes the (previously dormant) + `PageTemplate` JSON (`src/features/template/types.ts`: `name` / + `pageTitle` / `blocks` / `labels` / `attributes` / `tableMeta` / + `mediaInlineLabels`) instead of a note. A template does not carry + lineage, chats, or shared refs — only the page's blocks and label/table + behavior. `attributes` (step checked / executor / status) lives only in + the runtime `LabelStore`, never in `GraphiumPage`, so the share dialog + passes a snapshot of it explicitly; entries whose block carries no label + are dropped. Those attributes are restored when a shared template is + **inserted** via `/template`; the "new note from template" path cannot + restore them, because `GraphiumPage` has nowhere to store them. + `extra` narrows to `{ title, description, stepCount, labelCount, + pageTitle, blobs? }`. Embedded media follow the same auto-blob path as + note sharing. Re-sharing does **not** track a prior version — every + share of a page mints a brand-new `id` (`sharedRef` on the source note + is left untouched, since a template is an independent handout, not a + copy-of-record). ### 7.2 `BlobRef` @@ -1878,6 +1903,24 @@ forkedFrom?: { The fork is treated as a separate identity from the original; PROV records the lineage between them. +A note created from a shared **template** carries `templateFrom` +instead: + +```ts +templateFrom?: { + sharedId: string; // template's SharedEntry.id + hash: string; // SharedEntry.hash at the time the template was used + title: string; // template's extra.title + usedAt: string; // ISO 8601 +}; +``` + +Same shape idea as `forkedFrom` (`sharedId` / `hash`) but a different +meaning: a fork is a copy of a record and inherits its facts, while a +note created from a template starts fresh — it only reuses the +template's blocks/labels, not any content or claims. Kept as a separate +field so PROV can distinguish the two relationships. + ### 7.5 Citation block (`sharedCitation`) A note can cite a shared entry inline through the `sharedCitation` diff --git a/manual/ja/notes-and-editor.md b/manual/ja/notes-and-editor.md index 12729084a..e4fd72e04 100644 --- a/manual/ja/notes-and-editor.md +++ b/manual/ja/notes-and-editor.md @@ -206,7 +206,7 @@ Graphium は `⌘⇧N`(Windows/Linux は `Ctrl+Shift+N`)を「新規ノー **画像**・**動画**・**音声**・**ドキュメント** は、どれも同じピッカーを開きます。新しいものなら **ファイルからアップロード**、すでに取り込んだ素材ならそこから選びます。結果は **挿入方法** の切り替えで決まります — **埋め込み**(「中身をノート内に展開して表示」)か **リンク**(「@リンクとして挿入(中身は展開しない)」)。アップロードしたものは素材ライブラリにも入ります — [素材と引用](/ja/materials-and-citations)を参照してください。 -## テンプレート +## テンプレート {#templates} **テンプレート** を選ぶと **テンプレートを挿入** モーダルが開き、組み込みのレイアウトが 2 つあります。 @@ -219,6 +219,8 @@ Graphium は `⌘⇧N`(Windows/Linux は `Ctrl+Shift+N`)を「新規ノー ![計画テンプレートと実施テンプレートが並んだテンプレート挿入モーダル](/screenshots/template-picker.png) +チームの共有フォルダを設定していれば、組み込みテンプレートの下に **チームのテンプレート** 欄が表示され、チームメンバーがテンプレートとして共有したページが並びます — [ストレージと同期](/ja/storage-and-sync#sharing-notes-with-your-team)を参照してください。 + ## 表を扱う {#tables} ここから先は、どの入口から作った表にも共通する話です。 diff --git a/manual/ja/storage-and-sync.md b/manual/ja/storage-and-sync.md index 392db8564..7057681ff 100644 --- a/manual/ja/storage-and-sync.md +++ b/manual/ja/storage-and-sync.md @@ -95,6 +95,12 @@ Fork したページが入るのは、ノートではなく自分のナレッジ このボタンが出るのは、デスクトップ版で共有フォルダと identity を設定したあとです。共有されるのは保存済みの内容なので、編集中のノートは先に保存してください。 +### ページをテンプレートとして共有する {#share-a-page-as-a-template} + +ノートの `⋯` メニューには **テンプレートとして共有** も並びます(**チームと共有** の隣)。通常の共有と違い、ページを記録としてではなく「使い回せる雛形」として渡します。名前(既定はノートの題名)と、任意で説明を入力します。ページはそのままの状態で共有されます。結果や入力済みの値を自動で消すことはしないので、渡したくないものは共有する前に自分で消してください。 + +同じページをもう一度テンプレートとして共有すると、以前のものを更新するのではなく、新しいエントリが作られます。テンプレートは元のノートに紐づくスナップショットではなく、独立した配布物だからです。 + ### 見る・fork する {#browse-and-fork} 共有フォルダを設定すると、サイドバーに **ライブラリ** セクションと **共有** の項目が現れます。チーム全員が共有したもの(ノート・ナレッジページ・文献・データファイル)が、種類ごとのタブに並びます。他人のエントリは読み取り専用です。**Fork** を押すと、自分のストレージにコピーして自由に編集できます。持っているメディアは自動でローカルのライブラリに実体化され、fork にはその出どころが記録されます。 @@ -109,6 +115,8 @@ Fork したページが入るのは、ノートではなく自分のナレッジ 自分の左ナビゲーションと同じ構成をもう二つ、**ラベル** と **プロセス** のタブが鏡になっています。共有ノートから見つかったラベルと PROV-DM の手順を、自分のノート一覧・プロセス一覧と同じやり方で抽出して表示します。これは後述する共有検索・AI チャットを支えているバックグラウンドの読み込みに相乗りしているため、Graphium がまだ本文を読んでいない共有ノートは、これらのタブにはまだ反映されません。プロセスタブから自分のノートへ fork する操作は、自分の手順を fork するときと同じです。 +**テンプレート** タブには、テンプレートとして共有されたページ(前述)が並びます。**ノート** タブのフォルダ列の代わりに **説明** 列があります。ここに **Fork** はありません — テンプレートはコピーする記録ではなく、まっさらな出発点だからです。代わりに詳細パネルに **テンプレートから新規ノート** があり、自分が共有したものも含めてどのエントリでも押せます。テンプレートを読み込み、そのブロックとラベルから新しいノートを組み立て、埋め込まれたメディアを自分のライブラリに実体化して開きます。新しいノートには、どのテンプレートから作られたかが記録されますが、fork の「派生元」とは別扱いです。テンプレートは事実を何も引き継がず、形だけを渡すものだからです。同じテンプレート一覧はエディタの `/template` ピッカーの中にも現れるので、新しいノートを作らずに、開いているノートへ挿入することもできます — [ノートとエディタ](/ja/notes-and-editor#templates)を参照してください。 + ### 検索と AI チャットでの共有エントリ {#shared-entries-in-search-and-ai-chat} 共有されたノート・ナレッジページ・文献・データファイルは、fork しなくても現れます。`⌘K` 検索パレットに **共有** のセクションが増え、AI チャットも自分のノートと同じように共有エントリを横断検索の対象にします(Internal のグラウンディングスコープ)。索引(と、共有ナレッジページについては埋め込み)は自分の端末上だけに作られます。共有フォルダには何も書き戻さず、AI が共有エントリを根拠にしても、あなた自身が引用カードを挿入しない限り PROV には記録されません。 diff --git a/manual/notes-and-editor.md b/manual/notes-and-editor.md index 88b73db73..cad8f35d5 100644 --- a/manual/notes-and-editor.md +++ b/manual/notes-and-editor.md @@ -220,6 +220,8 @@ The modal is searchable and lists each template's **Source** (**Official** or ** ![The Insert Template modal with the Plan and Run templates](/screenshots/template-picker.png) +If your team has a shared folder set up, a **Team templates** section below the built-in ones lists pages your teammates have shared as templates — see [Storage & sync](/storage-and-sync#sharing-notes-with-your-team). + ## Working with tables {#tables} Everything below applies to any table in a note, whatever it started as. diff --git a/manual/storage-and-sync.md b/manual/storage-and-sync.md index 7be14451f..22cc043cc 100644 --- a/manual/storage-and-sync.md +++ b/manual/storage-and-sync.md @@ -95,6 +95,12 @@ Select notes with the checkboxes in the notes list, or knowledge pages in a Know The button appears in the desktop app once a shared folder and an identity are set. What travels is the saved version of each item, so save the note you are editing before you share it. +### Share a page as a template + +A note's `⋯` menu also has **Share as template**, next to **Share with team**. Unlike a regular share, this hands out the page as a reusable starting point instead of a record: give it a name (defaults to the note's title) and, optionally, a description. It shares the page exactly as it stands — Graphium does not strip results or filled-in values for you, so clear anything you do not want to hand out before sharing. + +Each share of a page as a template creates a brand-new entry rather than updating a previous one, since a template is an independent handout, not a snapshot tied back to the note it came from. + ### Browse and fork Once a shared folder is configured, a **Library** section with a **Shared** entry appears in the sidebar. It lists what everyone on the team has shared — notes, knowledge pages, references, and data files — on a tab each. Entries from others are read-only — press **Fork** to copy one into your own storage, where you can edit it freely. Any media it carries is materialized into your local library automatically, and the fork records where it came from. @@ -109,6 +115,8 @@ The **Assets** tab also has a Folder column, and lists every image or file embed Two more tabs mirror your own left-hand navigation: **Labels** and **Processes**. They show labels and PROV-DM procedures found in shared notes, extracted the same way your own note list and process list are. This piggybacks on the background read that also powers shared search and AI chat (see below), so a shared note whose content Graphium has not read yet will not contribute to these tabs until it does. Forking a process into your own notes works the same way it does for your own procedures. +The **Templates** tab lists pages shared as templates (see above), with a **Description** column in place of the folder column you see on the Notes tab. There is no **Fork** here — a template is a blank starting point, not a record to copy — but its detail panel has **New note from template**, available on any entry including your own. It reads the template, rebuilds its blocks and labels into a new note, materializes any embedded media into your own library, and opens the result. The new note records which template it came from, separately from a fork's "derived from" — a template does not hand you any facts, only a shape to start from. The same template list also appears inside the editor's `/template` picker, so you can insert a shared template into a note you already have open instead of starting a new one — see [Notes & the editor](/notes-and-editor#templates). + ### Shared entries in search and AI chat {#shared-entries-in-search-and-ai-chat} Shared notes, knowledge pages, references, and data files also show up without forking them: the `⌘K` search palette gets a **Shared** section, and AI chat can cross-search them the same way it cross-searches your own notes (Internal grounding scope). Graphium builds a search index and, for shared knowledge pages, an embedding — both on your own device only. Nothing is written back to the shared folder, and the AI never records a shared entry as a source in provenance unless you insert a citation card yourself. diff --git a/src/features/sharing/ShareTemplateDialog.tsx b/src/features/sharing/ShareTemplateDialog.tsx new file mode 100644 index 000000000..62b235ca7 --- /dev/null +++ b/src/features/sharing/ShareTemplateDialog.tsx @@ -0,0 +1,167 @@ +// 「テンプレートとして共有」ダイアログ(PR 3)。 +// 作法は share-media-dialog.tsx に合わせる(同じ枠・同じ入力・同じボタン配置)。 +// +// トリガーは持たない(ノートの ⋯ メニュー側が開閉を持つ)。なぜ: 素材共有と違って +// 入口がメニュー項目なので、ボタンを内蔵すると二重に見える。 +// +// 共有する本文は「開いた時点」ではなく「共有を押した時点」に組み立てる(resolveSource)。 +// ダイアログを開いたまま編集を続けても、共有されるのは最新の本文になる。 + +import { useCallback, useEffect, useState } from "react"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { useT } from "../../i18n"; +import type { GraphiumDocument, GraphiumPage } from "../../lib/document-types"; +import type { StepAttributes } from "../context-label/label-attributes"; +import { loadAuthorIdentity } from "../identity"; +import { getSharedRoot, getBlobRoot, type SharedEntry } from "../../lib/storage/shared"; +import { shareTemplate } from "./share-template"; +import { notifySharedLibraryChanged } from "./shared-library-store"; + +export type ShareTemplateDialogProps = { + open: boolean; + /** タイトル入力の初期値(既定はノート題名) */ + defaultTitle: string; + /** + * 共有対象の本文を組み立てる。null を返した場合は共有しない。 + * 呼び出し側が最新の doc とページ(複数ページなら開いているページ)を返す。 + * + * attributes は手順の連動属性(blockId → StepAttributes)。ページに保存されない + * 実行時の状態なので、ラベルストアを持つ呼び出し側から受け取るしかない。 + */ + resolveSource: () => Promise<{ + doc: GraphiumDocument; + page: GraphiumPage; + attributes?: [string, StepAttributes][]; + } | null>; + onClose: () => void; + /** 共有成功後(共有ライブラリへの通知はこのコンポーネントが済ませてある) */ + onShared?: (entry: SharedEntry) => void; +}; + +export function ShareTemplateDialog({ + open, + defaultTitle, + resolveSource, + onClose, + onShared, +}: ShareTemplateDialogProps) { + const t = useT(); + const [title, setTitle] = useState(defaultTitle); + const [description, setDescription] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // 開くたびに入力を初期化する(前回の説明が残っていると別テンプレートに紛れ込む) + useEffect(() => { + if (!open) return; + setTitle(defaultTitle); + setDescription(""); + setError(null); + }, [open, defaultTitle]); + + const handleShare = useCallback(async () => { + const sharedRoot = getSharedRoot(); + const author = loadAuthorIdentity(); + if (!sharedRoot || !author) return; + setBusy(true); + setError(null); + try { + const source = await resolveSource(); + if (!source) { + setError(t("share.template.noPage")); + return; + } + const result = await shareTemplate(source.doc, source.page, { + sharedRoot, + blobRoot: getBlobRoot() ?? undefined, + author, + title, + description, + attributes: source.attributes, + }); + if (!result.ok) { + setError(result.error); + return; + } + // 共有ライブラリが変わった(Library / 引用ピッカー / 語彙索引はこの通知で追従) + notifySharedLibraryChanged(); + onShared?.(result.entry); + onClose(); + } finally { + setBusy(false); + } + }, [resolveSource, title, description, onShared, onClose, t]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget && !busy) onClose(); + }} + > +
+
+

+ {t("share.template.dialog.title")} +

+

{t("share.template.dialog.help")}

+
+
+ + setTitle(e.target.value)} + disabled={busy} + className="w-full px-3 py-2 text-sm rounded-md border border-border bg-background text-foreground focus:border-primary focus:outline-none" + /> +
+
+ +