Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,18 @@ Key pieces:
media is uploaded as `shared-blob:` references; on Fork, those blobs are
re-materialized into the personal copy

The Library mirrors the personal side's left navigation: alongside
Notes / Knowledge / Assets it has **Labels** and **Processes** tabs
that reuse the personal `LabelGalleryView` / `ProcessGalleryView`
components as-is. Since a shared entry is a manifest, not a body,
these tabs are backed by a small local-only cache
(`src/features/sharing/shared-projection.ts`, DATA_MODEL.md §7.6) built
by riding the existing body-read path (the lexical sync lane) instead
of fetching notes just to list them. The Assets tab similarly surfaces
images and files embedded in shared notes (`SharedEntry.extra.blobs`)
as read-only rows — open the parent note, or copy the file into your
own materials.

Today the shared backend is a local folder. Other backends (cloud
buckets, S3, IPFS-style) can be added by implementing the same blob
interface.
Expand Down
51 changes: 50 additions & 1 deletion docs/DATA_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1700,7 +1700,8 @@ Graphium/
└── appdata/
├── note-index.json # the GraphiumIndex
├── graph-layouts.json # saved manual graph arrangements (§5.4)
└── asset-chats:<fileId>.json # AI chats started from a material (§2.5)
├── asset-chats:<fileId>.json # AI chats started from a material (§2.5)
└── shared-projection.json # labels/process extracted from shared notes (§7.6)
```

Media binaries keep their original file extension (derived from the
Expand Down Expand Up @@ -1923,6 +1924,54 @@ PROV-JSON-LD export resolves to a typed external entity. SidePeek saves
bypass revision recording by design, so citations inserted there are
not attributed to an edit activity.

### 7.6 Shared projection cache (`SharedProjection`)

The Library's **Labels** and **Processes** tabs mirror the personal
side's navigation, but a shared entry only carries a manifest — the
body is fetched on demand and kept in an LRU (§7.3). To list labels and
processes without opening every shared note, `src/features/sharing/shared-projection.ts`
keeps a small side cache, written only to the local appdata channel
(`shared-projection.json`, desktop only — never to the shared root):

```ts
const SHARED_PROJECTION_VERSION = 1;

type SharedProjectionEntry = {
hash: string; // SharedEntry.hash when projected; skips re-projection if unchanged
title: string;
updatedAt: string;
createdAt: string;
author: string;
headings: NoteIndexEntry["headings"];
steps?: NoteIndexEntry["steps"];
labels: NoteIndexEntry["labels"];
inlineLabels?: NoteIndexEntry["inlineLabels"];
process: ProcessIndexEntry | null; // null for notes without steps
};

type SharedProjection = {
version: number;
logic: { index: number; process: number }; // INDEX_SCHEMA_VERSION / PROCESS_INDEX_VERSION
updatedAt: string;
entries: Record<string, SharedProjectionEntry>; // keyed by SharedEntry.id
};
```

**No new reads.** Projection rides the lexical sync lane (§17 of the
internal shared-storage design) that already fetches shared note bodies
for the vocabulary index; when that lane reads a body it also calls
`projectSharedNote`, which reuses `buildIndexEntry` (labels/headings/
steps — the same extraction the personal note index uses) and
`buildProcessEntry` (the process graph). The projected `process` is the
return value of `buildProcessEntry` unmodified, except `crossNoteLinks`
is always cleared: those links point at the *sharer's* local note ids,
which are meaningless on the receiving side.

**Reconstructible cache.** A `version` or `logic` mismatch discards the
whole file and starts empty; entries missing from a subsequent list
refresh are pruned. Losing this file costs nothing but a few
re-projections next time the affected notes are read.

## 8. Compatibility rules

Hard rules for changing any schema in this document.
Expand Down
4 changes: 4 additions & 0 deletions manual/ja/storage-and-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ Fork したページが入るのは、ノートではなく自分のナレッジ

![共有ライブラリをフォルダで絞り込む](/screenshots/shared-library-folder-filter.png)

**素材** タブにも同じくフォルダ列があり、共有ノートに貼られている画像・ファイルが、素材として直接共有されたものと並んで一覧に出ます。ノートに貼られたものは単独で fork や検証ができるエントリではないため、**ノートを開く** か **自分の素材に取り込む** かのどちらかを行います。同じファイルが複数の共有ノートに貼られている場合は 1 行にまとめられ、何件のノートに含まれるかが件数で示されます。

自分の左ナビゲーションと同じ構成をもう二つ、**ラベル** と **プロセス** のタブが鏡になっています。共有ノートから見つかったラベルと PROV-DM の手順を、自分のノート一覧・プロセス一覧と同じやり方で抽出して表示します。これは後述する共有検索・AI チャットを支えているバックグラウンドの読み込みに相乗りしているため、Graphium がまだ本文を読んでいない共有ノートは、これらのタブにはまだ反映されません。プロセスタブから自分のノートへ fork する操作は、自分の手順を fork するときと同じです。

### 検索と AI チャットでの共有エントリ <Badge type="tip" text="v0.52.0 (2026-09-03) で追加" /> {#shared-entries-in-search-and-ai-chat}

共有されたノート・ナレッジページ・文献・データファイルは、fork しなくても現れます。`⌘K` 検索パレットに **共有** のセクションが増え、AI チャットも自分のノートと同じように共有エントリを横断検索の対象にします(Internal のグラウンディングスコープ)。索引(と、共有ナレッジページについては埋め込み)は自分の端末上だけに作られます。共有フォルダには何も書き戻さず、AI が共有エントリを根拠にしても、あなた自身が引用カードを挿入しない限り PROV には記録されません。
Expand Down
4 changes: 4 additions & 0 deletions manual/storage-and-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ A shared note also carries the folder it was in when it was shared, so the **Not

![Filtering the shared library by folder](/screenshots/shared-library-folder-filter.png)

The **Assets** tab also has a Folder column, and lists every image or file embedded in a shared note alongside items shared directly as a material. A note's images and files are not entries you can fork or verify on their own — you can **open the parent note** or **add the file to your own materials**, and if the same file appears in several shared notes it is shown once with a count of how many notes carry it.

Two more tabs mirror your own left-hand navigation: **Labels** and **Processes**. They show labels and PROV-DM procedures found in shared notes, extracted the same way your own note list and process list are. This piggybacks on the background read that also powers shared search and AI chat (see below), so a shared note whose content Graphium has not read yet will not contribute to these tabs until it does. Forking a process into your own notes works the same way it does for your own procedures.

### Shared entries in search and AI chat <Badge type="tip" text="Added in v0.52.0 (2026-09-03)" /> {#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.
Expand Down
20 changes: 14 additions & 6 deletions src/features/asset-browser/LabelGalleryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export type LabelGalleryViewProps = {
label: string;
onBack: () => void;
onNavigateNote: (noteId: string) => void;
/**
* 戻るボタンを出さない。呼び出し側が種別の切り替え導線を自前で持つとき
* (共有ライブラリのラベルタブ)に使う。既定は従来どおり表示する。
*/
hideBack?: boolean;
};

// ── ネットワークモーダル ──
Expand Down Expand Up @@ -282,6 +287,7 @@ export function LabelGalleryView({
label,
onBack,
onNavigateNote,
hideBack = false,
}: LabelGalleryViewProps) {
const t = useT();
const [searchQuery, setSearchQuery] = useState("");
Expand Down Expand Up @@ -414,12 +420,14 @@ export function LabelGalleryView({
<div className="flex-1 flex flex-col overflow-hidden bg-background">
{/* ヘッダー */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-border">
<button
onClick={onBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t("common.back")}
</button>
{!hideBack && (
<button
onClick={onBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t("common.back")}
</button>
)}
<span
className="inline-block text-sm font-semibold rounded-full px-2.5 py-0.5"
style={{
Expand Down
19 changes: 16 additions & 3 deletions src/features/asset-browser/material-actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,20 @@ import { getSharedRoot, getBlobRoot } from "../../lib/storage/shared";
import { notifySharedLibraryChanged, shareMedia, shareReference } from "../sharing";
import { getActiveProvider } from "../../lib/storage/registry";
import { isWordDocxEntry } from "./media-index";
import { assetFolderValues, type NoteFolderLookup } from "./asset-folders";
import type { MediaIndexEntry, MediaSharedRef } from "./media-index";

/** lookup 未指定時の空表。毎回新しい Map を作らないよう共有する */
const EMPTY_NOTE_FOLDER_LOOKUP: NoteFolderLookup = new Map();

export type MaterialActionsMenuProps = {
entry: MediaIndexEntry;
/**
* ノート id → フォルダ。共有時に「素材ギャラリーが表示している実効フォルダ」を
* extra.noteContexts へ載せるために使う(自分で付けた分 ∪ 貼ったノート由来)。
* 渡されなければフォルダ無しで共有する(表のフォルダ列が空欄になるだけ)。
*/
noteFolderLookup?: NoteFolderLookup;
onIngest?: (entry: MediaIndexEntry) => void;
onCreateProvNote?: (entry: MediaIndexEntry) => void;
/** PDF / URL を原文構成のまま UI 言語へ全文翻訳して 1 ノート化する */
Expand All @@ -50,6 +60,7 @@ export type MaterialActionsMenuProps = {

export function MaterialActionsMenu({
entry,
noteFolderLookup,
onIngest,
onCreateProvNote,
onTranslatePdf,
Expand Down Expand Up @@ -120,9 +131,11 @@ export function MaterialActionsMenu({
setShareBusy(true);
setShareError(null);
try {
// 素材ギャラリーの「フォルダ」と同じ値を共有側にも載せる(AssetGalleryView と同じ引き方)
const noteContexts = assetFolderValues(entry, noteFolderLookup ?? EMPTY_NOTE_FOLDER_LOOKUP);
const result = isUrlEntry
? await shareReference(entry, { sharedRoot, author: sharedAuthor, title: entry.name, description: "" })
: await shareMedia(entry, { sharedRoot, blobRoot: blobRoot!, author: sharedAuthor, title: entry.name, description: "" });
? await shareReference(entry, { sharedRoot, author: sharedAuthor, title: entry.name, description: "", noteContexts })
: await shareMedia(entry, { sharedRoot, blobRoot: blobRoot!, author: sharedAuthor, title: entry.name, description: "", noteContexts });
if (!result.ok) {
setShareError(result.error);
return;
Expand All @@ -133,7 +146,7 @@ export function MaterialActionsMenu({
} finally {
setShareBusy(false);
}
}, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, onSharedRefUpdated]);
}, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, noteFolderLookup, onSharedRefUpdated]);

const itemClass =
"w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-foreground rounded hover:bg-muted transition-colors disabled:text-muted-foreground disabled:cursor-not-allowed";
Expand Down
1 change: 1 addition & 0 deletions src/features/asset-browser/material-detail-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export function MaterialDetailHeader({
const actionsMenu = (
<MaterialActionsMenu
entry={entry}
noteFolderLookup={noteFolderLookup}
onIngest={onIngest}
onCreateProvNote={onCreateProvNote}
onTranslatePdf={onTranslatePdf}
Expand Down
16 changes: 15 additions & 1 deletion src/features/asset-browser/share-media-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ import { loadAuthorIdentity } from "../identity";
import { getSharedRoot, getBlobRoot } from "../../lib/storage/shared";
import { notifySharedLibraryChanged, shareMedia, shareReference } from "../sharing";
import type { MediaIndexEntry, MediaSharedRef } from "./media-index";
import { assetFolderValues, type NoteFolderLookup } from "./asset-folders";

/** lookup 未指定時の空表。毎回新しい Map を作らないよう共有する */
const EMPTY_NOTE_FOLDER_LOOKUP: NoteFolderLookup = new Map();

export type ShareMediaDialogProps = {
entry: MediaIndexEntry;
/**
* ノート id → フォルダ。共有時に「素材ギャラリーが表示している実効フォルダ」を
* extra.noteContexts へ載せるために使う。渡されなければフォルダ無しで共有する。
*/
noteFolderLookup?: NoteFolderLookup;
onSharedRefUpdated?: (entry: MediaIndexEntry, sharedRef: MediaSharedRef) => Promise<void> | void;
/** トリガーボタンの className を上書き(既定はパネルアクション色) */
buttonClassName?: string;
Expand All @@ -24,6 +33,7 @@ export type ShareMediaDialogProps = {
*/
export function ShareMediaDialog({
entry,
noteFolderLookup,
onSharedRefUpdated,
buttonClassName,
}: ShareMediaDialogProps) {
Expand Down Expand Up @@ -71,19 +81,23 @@ export function ShareMediaDialog({
const entryWithRef: MediaIndexEntry = sharedRefState
? { ...entry, sharedRef: sharedRefState }
: entry;
// 素材ギャラリーの「フォルダ」と同じ値を共有側にも載せる(AssetGalleryView と同じ引き方)
const noteContexts = assetFolderValues(entryWithRef, noteFolderLookup ?? EMPTY_NOTE_FOLDER_LOOKUP);
const result = isUrlEntry
? await shareReference(entryWithRef, {
sharedRoot,
author: sharedAuthor,
title,
description,
noteContexts,
})
: await shareMedia(entryWithRef, {
sharedRoot,
blobRoot: blobRoot!,
author: sharedAuthor,
title,
description,
noteContexts,
});
if (!result.ok) {
setError(result.error);
Expand All @@ -99,7 +113,7 @@ export function ShareMediaDialog({
} finally {
setBusy(false);
}
}, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, sharedRefState, title, description, onSharedRefUpdated]);
}, [sharedRoot, blobRoot, sharedAuthor, isUrlEntry, entry, noteFolderLookup, sharedRefState, title, description, onSharedRefUpdated]);

return (
<>
Expand Down
30 changes: 23 additions & 7 deletions src/features/network-graph/ProcessGalleryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export type ProcessGalleryViewProps = {
onBack: () => void;
onNavigateNote: (noteId: string) => void;
onForkProcess: (noteId: string) => Promise<string | null>;
/**
* 戻るボタンを出さない。呼び出し側が別の切り替え導線を持つとき
* (共有ライブラリのプロセスタブ)に使う。既定は従来どおり表示する。
*/
hideBack?: boolean;
/**
* fork ボタンの文言。共有ライブラリでは「自分のノートに派生」と言い方が変わるので
* 差し替えられるようにする。未指定なら従来どおり process.fork。
*/
forkLabel?: string;
};

type SortKey = "stepCount" | "modifiedAt" | "title";
Expand All @@ -46,6 +56,8 @@ export function ProcessGalleryView({
onBack,
onNavigateNote,
onForkProcess,
hideBack = false,
forkLabel,
}: ProcessGalleryViewProps) {
const t = useT();
const [searchQuery, setSearchQuery] = useState("");
Expand Down Expand Up @@ -139,12 +151,14 @@ export function ProcessGalleryView({
{/* 左: 一覧 */}
<div className="flex flex-col overflow-hidden shrink-0" style={{ width: "44%", minWidth: 340 }}>
<div className="flex items-center gap-3 px-6 py-4 border-b border-border">
<button
onClick={onBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t("common.back")}
</button>
{!hideBack && (
<button
onClick={onBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t("common.back")}
</button>
)}
<span className="text-sm font-semibold text-foreground">{t("process.title")}</span>
<span className="text-xs text-muted-foreground">
{t("process.count", { n: String(filtered.length) })}
Expand Down Expand Up @@ -205,7 +219,9 @@ export function ProcessGalleryView({
className="shrink-0 inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded border border-border text-foreground hover:bg-surface-hover disabled:opacity-50 disabled:cursor-wait transition-colors"
>
<GitFork size={11} strokeWidth={2.2} />
{forkingNoteId === selected.noteId ? t("process.forking") : t("process.fork")}
{forkingNoteId === selected.noteId
? t("process.forking")
: (forkLabel ?? t("process.fork"))}
</button>
</div>
{forkError && (
Expand Down
Loading
Loading