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
2 changes: 2 additions & 0 deletions manual/ja/storage-and-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ Fork したページが入るのは、ノートではなく自分のナレッジ

共有フォルダを設定すると、サイドバーに **ライブラリ** セクションと **共有** の項目が現れます。チーム全員が共有したもの(ノート・ナレッジページ・文献・データファイル)が、種類ごとのタブに並びます。他人のエントリは読み取り専用です。**Fork** を押すと、自分のストレージにコピーして自由に編集できます。持っているメディアは自動でローカルのライブラリに実体化され、fork にはその出どころが記録されます。

共有されたノートには、共有した時点のフォルダも付いてきます。**ノート** のタブに **フォルダ** 列が並び、そこで絞り込みや検索ができます。Fork ではこのフォルダは引き継ぎません。共有した人の整理であって、こちらの分類ではないからです(fork したノートは未分類から始まります)。

### 検索と AI チャットでの共有エントリ {#shared-entries-in-search-and-ai-chat}

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

Once a shared folder is configured, a **Library** section with a **Shared** entry appears in the sidebar. It lists what everyone on the team has shared — notes, knowledge pages, references, and data files — on a tab each. Entries from others are read-only — press **Fork** to copy one into your own storage, where you can edit it freely. Any media it carries is materialized into your local library automatically, and the fork records where it came from.

A shared note also carries the folder it was in when it was shared, so the **Notes** tab has a **Folder** column you can filter and search on. Forking does not bring that folder along: it is the author's own filing, so your copy starts unfiled.

### Shared entries in search and AI chat {#shared-entries-in-search-and-ai-chat}

Shared notes, knowledge pages, references, and data files also show up without forking them: the `⌘K` search palette gets a **Shared** section, and AI chat can cross-search them the same way it cross-searches your own notes (Internal grounding scope). Graphium builds a search index and, for shared knowledge pages, an embedding — both on your own device only. Nothing is written back to the shared folder, and the AI never records a shared entry as a source in provenance unless you insert a citation card yourself.
Expand Down
30 changes: 28 additions & 2 deletions src/features/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
import { collectLexicalHits } from "./lexical-hits";
import { useLexicalStatus } from "../lexical-search";
// 共有ライブラリは重い SharedLibraryView を巻き込まないよう、バレルではなくストアを直接引く
import { useSharedLibrary } from "../sharing/shared-library-store";
import { getSharedNoteContexts, useSharedLibrary } from "../sharing/shared-library-store";
import { getSharedRoot, getSharedAiEnabled } from "../../lib/storage/shared/config";
import { isTauri } from "../../lib/platform";
import type { SharedEntry } from "../../lib/storage/shared";
Expand Down Expand Up @@ -193,8 +193,10 @@ export function Composer(props: ComposerProps) {
return searchShared(prompt, sharedLibrary.entries, {
limit: MAX_SHARED_RESULTS,
sharedHits: sharedTextHits,
// 共有時点のフォルダ名でも当てる(手元のノートと同じ理由 "folder")
noteContextsOf: (entry) => getSharedNoteContexts(entry, sharedLibrary),
});
}, [prompt, sharedEnabled, sharedLibrary.entries, sharedTextHits]);
}, [prompt, sharedEnabled, sharedLibrary, sharedTextHits]);

// 素材(ファイル名 + OCR で読み取った画像内の文字 + 索引済みテキスト)
const mediaHits = useMemo(() => {
Expand Down Expand Up @@ -691,6 +693,28 @@ type NoteRowProps = {
onClick: () => void;
};

/**
* 「なぜこの行が出たか」の理由バッジ。今はフォルダ名で当たったときだけ出す
* (題名にも本文にも見えない場所で当たっているので、印が無いと結果が不気味になる)。
*/
function FolderReasonBadge() {
const t = useT();
return (
<span
style={{
fontSize: 10,
color: "var(--ink-3)",
flexShrink: 0,
padding: "1px 5px",
borderRadius: "var(--r-1)",
border: "1px solid var(--rule-2)",
}}
>
{t("nav.noteContexts")}
</span>
);
}

function NoteRow({ hit, active, onMouseEnter, onClick }: NoteRowProps) {
const { entry, titleMatches, bodySnippet } = hit;
const isWiki = entry.source === "ai";
Expand Down Expand Up @@ -753,6 +777,7 @@ function NoteRow({ hit, active, onMouseEnter, onClick }: NoteRowProps) {
</span>
)}
</span>
{hit.reasons.includes("folder") && <FolderReasonBadge />}
{entry.author && (
<span
style={{
Expand Down Expand Up @@ -865,6 +890,7 @@ function SharedRow({ hit, active, onMouseEnter, onClick, onInsertCitation }: Sha
</span>
)}
</span>
{hit.reasons.includes("folder") && <FolderReasonBadge />}
{authorName && (
<span
style={{
Expand Down
45 changes: 45 additions & 0 deletions src/features/composer/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function entry(partial: Partial<NoteIndexEntry> & { noteId: string; title: strin
wikiKind: partial.wikiKind,
author: partial.author,
model: partial.model,
noteContexts: partial.noteContexts,
};
}

Expand Down Expand Up @@ -443,3 +444,47 @@ describe("searchShared()", () => {
expect(searchShared("焼結", [])).toEqual([]);
});
});

describe("フォルダ名でのヒット(reason: folder)", () => {
const notes: NoteIndexEntry[] = [
entry({ noteId: "n1", title: "無題のメモ", noteContexts: ["卒論/焼結"] }),
entry({ noteId: "n2", title: "焼結の記録", noteContexts: ["共通/装置"] }),
entry({ noteId: "n3", title: "関係ないノート" }),
];

it("ノート: タイトルに無くてもフォルダ名で当たる", () => {
const hits = searchNotes("卒論", notes);
expect(hits.map((h) => h.entry.noteId)).toEqual(["n1"]);
expect(hits[0].reasons).toContain("folder");
});

it("ノート: 大文字小文字を無視する", () => {
const upper = [entry({ noteId: "n4", title: "x", noteContexts: ["Thesis/Sintering"] })];
expect(searchNotes("thesis", upper).map((h) => h.entry.noteId)).toEqual(["n4"]);
});

it("ノート: タイトル一致にもフォルダ一致にも当たれば両方が理由に残る", () => {
const hits = searchNotes("焼結", notes);
const n1 = hits.find((h) => h.entry.noteId === "n1");
expect(n1?.reasons).toEqual(expect.arrayContaining(["folder"]));
const n2 = hits.find((h) => h.entry.noteId === "n2");
expect(n2?.reasons).toContain("title-prefix");
expect(n2?.reasons).not.toContain("folder");
});

const sharedEntries: SharedEntry[] = [
shared({ id: "s1", title: "無題の共有ノート" }),
shared({ id: "s2", title: "関係ない共有ノート" }),
];
const noteContextsOf = (e: SharedEntry) => (e.id === "s1" ? ["卒論/焼結"] : []);

it("共有: noteContextsOf で渡したフォルダ名で当たる", () => {
const hits = searchShared("卒論", sharedEntries, { noteContextsOf });
expect(hits.map((h) => h.entry.id)).toEqual(["s1"]);
expect(hits[0].reasons).toContain("folder");
});

it("共有: noteContextsOf を渡さなければフォルダでは当たらない", () => {
expect(searchShared("卒論", sharedEntries)).toEqual([]);
});
});
47 changes: 41 additions & 6 deletions src/features/composer/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ export type SearchHit = {
bodySnippet?: TextSnippet;
};

export type SearchReason = "title-prefix" | "title-contains" | "heading" | "label" | "author" | "body";
export type SearchReason =
| "title-prefix"
| "title-contains"
| "heading"
| "label"
| "author"
| "folder"
| "body";

/** 抜粋(複数範囲を強調できる) */
export type TextSnippet = {
Expand All @@ -52,6 +59,8 @@ export type TextHit = {

/** 本文ヒットの加点: 見出し一致(25)より下、ラベル/作者フィルタ(30/20)とは独立 */
const BODY_BASE_SCORE = 12;
/** フォルダ名一致の加点。ラベル一致と同じ重み(どちらも人が付けた分類軸なので揃える) */
const FOLDER_SCORE = 30;
/** 本文ヒットの相対スコアによる上乗せの最大値 */
const BODY_RELATIVE_SCORE_MAX = 10;

Expand Down Expand Up @@ -204,6 +213,8 @@ export function searchNotes(

// フリーテキスト
if (textLower) {
// フォルダ名一致(人が付けた分類軸。一覧の「フォルダ」列と同じ値で当てる)
const folderHit = (entry.noteContexts ?? []).some((c) => c.toLowerCase().includes(textLower));
const occurrences = findAllOccurrences(entry.title, textLower);
if (occurrences.length > 0) {
titleMatches = occurrences;
Expand All @@ -222,11 +233,21 @@ export function searchNotes(
if (headingHit) {
score += 25;
reasons.push("heading");
} else if (!bodyHit && parsed.labelTokens.length === 0 && parsed.authorTokens.length === 0) {
// フィルタもタイトル/見出し/本文も当たっていない → 落とす
} else if (
!bodyHit &&
!folderHit &&
parsed.labelTokens.length === 0 &&
parsed.authorTokens.length === 0
) {
// フィルタもタイトル/見出し/フォルダ/本文も当たっていない → 落とす
continue;
}
}
// フォルダ名は題名・見出しに当たっていても理由として残す(なぜ出たかが分かるように)
if (folderHit) {
score += FOLDER_SCORE;
reasons.push("folder");
}
// 本文(語彙インデックス)ヒット。タイトル・見出しに当たっていても抜粋は添える
if (bodyHit) {
score += BODY_BASE_SCORE + relativeBoost(bodyHit.score, bodyMax, BODY_RELATIVE_SCORE_MAX);
Expand Down Expand Up @@ -412,7 +433,7 @@ export function searchMedia(

// ── 共有ライブラリ検索 ──

export type SharedSearchReason = "title-prefix" | "title-contains" | "author" | "body";
export type SharedSearchReason = "title-prefix" | "title-contains" | "author" | "folder" | "body";

export type SharedHit = {
entry: SharedEntry;
Expand All @@ -429,6 +450,12 @@ export type SharedSearchOptions = {
limit?: number;
/** 共有エントリの本文ヒット。entry.id → ヒット。無ければ題名・作者だけで当てる */
sharedHits?: ReadonlyMap<string, TextHit>;
/**
* 共有エントリのフォルダ(共有時点の noteContexts)を引く関数。
* 値は共有ストアのスナップショット由来なので、ここを純関数に保つために注入で受ける。
* 未指定ならフォルダでは当てない。
*/
noteContextsOf?: (entry: SharedEntry) => string[];
};

/**
Expand Down Expand Up @@ -499,6 +526,10 @@ export function searchShared(
let bodySnippet: TextSnippet | undefined;

if (textLower) {
// 共有時点のフォルダ名一致(手元のノートの folder 理由と揃える)
const folderHit = (options.noteContextsOf?.(entry) ?? []).some((c) =>
c.toLowerCase().includes(textLower),
);
const occurrences = findAllOccurrences(title, textLower);
if (occurrences.length > 0) {
titleMatches = occurrences;
Expand All @@ -509,10 +540,14 @@ export function searchShared(
score += 50;
reasons.push("title-contains");
}
} else if (!bodyHit && parsed.authorTokens.length === 0) {
// 題名にも本文にも当たらず、作者フィルタも無い → 落とす
} else if (!bodyHit && !folderHit && parsed.authorTokens.length === 0) {
// 題名・本文・フォルダのどれにも当たらず、作者フィルタも無い → 落とす
continue;
}
if (folderHit) {
score += FOLDER_SCORE;
reasons.push("folder");
}
// 本文ヒットは題名に当たっていても抜粋として添える(ノート行と同じ扱い)
if (bodyHit) {
score += BODY_BASE_SCORE + relativeBoost(bodyHit.score, bodyMax, BODY_RELATIVE_SCORE_MAX);
Expand Down
117 changes: 117 additions & 0 deletions src/features/sharing/SharedLibraryTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
// 共有ライブラリの表「フォルダ列」が、ノート一覧のフォルダ列と同じ見せ方になっているかのテスト。
//
// 対象の不変条件(鏡の原則 — 同じ意味のものは同じ見た目で出す):
// - 列ヘッダにノート一覧と同じ説明ツールチップ(nav.noteContextsTooltip)が付く
// - フォルダ無しの行のダッシュは、ノート一覧と同じ薄さ(text-muted-foreground/30)
// - 絞り込みポップアップの選択肢に、表のピル(ContextBadge)と同じ色のドットが付く
// (未分類は実在するフォルダではないので色を持たない = 中空のドット)

import { describe, it, expect, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { SharedLibraryTable } from "./SharedLibraryTable";
import { LocaleProvider, t } from "../../i18n";
import { ContextBadge } from "../note-context/ContextBadge";
import type { SharedEntry } from "../../lib/storage/shared";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const AUTHOR = { name: "Ada", email: "ada@example.com" };

const entry = (id: string, title: string, noteContexts?: string[]): SharedEntry => ({
id,
type: "note",
author: AUTHOR,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
hash: `sha256:${id}`,
prov: { derived_from: [] },
version: 1,
extra: noteContexts ? { title, noteContexts } : { title },
});

const ENTRIES = [
entry("n1", "焼結の記録", ["卒論/焼結"]),
entry("n2", "装置メモ", ["共通/装置"]),
entry("n3", "フォルダ無し"),
];

function renderTable() {
return render(
<LocaleProvider>
<SharedLibraryTable
tab="note"
entries={ENTRIES}
currentIdentity={AUTHOR}
hashStatus={{}}
selectedId={null}
busyId={null}
copiedId={null}
onSelect={() => {}}
onVerifyHash={() => {}}
onCopyCitation={() => {}}
onUnshare={() => {}}
/>
</LocaleProvider>,
);
}

/**
* 表のピル(ContextBadge)が使う色。jsdom は inline style の hsl() を rgb() に
* 変換するので、期待値も同じ経路(実際の ContextBadge を描画)から取って比べる。
*/
function badgeColor(value: string): string {
const { container, unmount } = render(
<LocaleProvider>
<ContextBadge value={value} />
</LocaleProvider>,
);
const color = (container.firstElementChild as HTMLElement).style.color;
unmount();
return color;
}

afterEach(() => cleanup());

describe("SharedLibraryTable のフォルダ列", () => {
it("列ヘッダにノート一覧と同じ説明ツールチップが付く", () => {
const { container } = renderTable();
const headers = Array.from(container.querySelectorAll("th"));
const folderTh = headers.find((th) => th.textContent?.includes(t("nav.noteContexts")));
expect(folderTh?.getAttribute("title")).toBe(t("nav.noteContextsTooltip"));
});

it("フォルダ無しの行のダッシュはノート一覧と同じ薄さで出る", () => {
const { container } = renderTable();
const dash = Array.from(container.querySelectorAll("span")).find(
(el) => el.textContent === "—",
);
expect(dash?.className).toContain("text-muted-foreground/30");
});

it("絞り込みの選択肢に表のピルと同じ色のドットが付く(未分類は色無し)", () => {
const { container } = renderTable();
const filterBtn = container.querySelector(
`button[aria-label="${t("library.filterFolder")}"]`,
) as HTMLButtonElement;
fireEvent.click(filterBtn);

// ポップアップは portal 経由で body 直下に出る
const options = Array.from(
document.body.querySelectorAll('button[role="menuitemcheckbox"]'),
) as HTMLElement[];
for (const folder of ["卒論/焼結", "共通/装置"]) {
const opt = options.find((o) => o.textContent?.includes(folder));
const dot = opt?.querySelector("span.rounded-full") as HTMLElement | null;
expect(dot, `${folder} のドット`).toBeTruthy();
expect(dot?.style.backgroundColor).toBe(badgeColor(folder));
}

// 未分類の選択肢は色を持たず、境界線だけのドットになる
const unfiled = options.find((o) => o.textContent?.includes(t("nav.unfiled")));
expect(unfiled, "未分類の選択肢").toBeTruthy();
const unfiledDot = unfiled?.querySelector("span.rounded-full") as HTMLElement | null;
expect(unfiledDot?.style.backgroundColor).toBe("");
expect(unfiledDot?.className).toContain("border-border");
});
});
Loading
Loading