diff --git a/manual/ja/materials-and-citations.md b/manual/ja/materials-and-citations.md index 8f23ee8bb..4293c4dc7 100644 --- a/manual/ja/materials-and-citations.md +++ b/manual/ja/materials-and-citations.md @@ -6,6 +6,8 @@ サイドバーには **素材** セクションがあり、種類ごとに項目が並びます。**メモ**、**画像**、**ドキュメント**(PDF と Word ファイル)、**データ**(装置が吐く `.csv` / `.txt` / `.dat`。[表として取り込めます](/ja/notes-and-editor#importing-measurement-data))、**動画**、**音声**、**URL**。どれかをクリックするとギャラリーが開きます。 +素材もフォルダに入れられます。ノートと同じフォルダです。リスト表示で選んで **フォルダに入れる** を押し、一覧の上の **フォルダ** ボタンで絞り込みます。どのノートにも貼っていない素材でもフォルダに入れられるので、取り込んだその日に片付けられます。あわせて、その素材を使っているノートのフォルダにも入って見えます。**材料X** のノートに写真を貼れば、その写真も **材料X** で見つかります(薄いチップで表示されます)。これはノートに追随するので、ノートをそのフォルダから出せば写真も出ますが、自分で付けたフォルダは常に残ります。 + ギャラリーの中では次のことができます。 | 操作 | 何ができるか | diff --git a/manual/materials-and-citations.md b/manual/materials-and-citations.md index 6738dbd44..3091bb197 100644 --- a/manual/materials-and-citations.md +++ b/manual/materials-and-citations.md @@ -6,6 +6,8 @@ Research rarely starts from a blank page — it starts from a paper, a web page, The sidebar has a **Materials** section with one entry per type: **Memos**, **Images**, **Documents** (PDFs and Word files), **Data** (the `.csv` / `.txt` / `.dat` files instruments write, [imported as tables](/notes-and-editor#importing-measurement-data)), **Videos**, **Audio**, and **URLs**. Click any of them to open the gallery. +Materials can go in folders too — the same folders your notes use. Select some in the list view and choose **Add to a folder**, then use the **Folders** button above the list to show only what is in one. A material keeps its folder whether or not any note uses it yet, so you can file a PDF the day you download it. It also picks up the folders of the notes it is used in — put a photo in a note that lives in **Material X** and the photo shows up under **Material X** as well, shown in a lighter chip. Those follow the note: move the note out and the photo stops appearing there, while folders you set by hand always stay. + Inside the gallery you can: | Control | What it does | diff --git a/src/features/asset-browser/AssetGalleryView.tsx b/src/features/asset-browser/AssetGalleryView.tsx index fce3e0b47..3cd3b7d61 100644 --- a/src/features/asset-browser/AssetGalleryView.tsx +++ b/src/features/asset-browser/AssetGalleryView.tsx @@ -2,7 +2,17 @@ // メディアタイプ別にサムネイル一覧を表示、ノート紐付き・削除に対応 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { Image, Video, Volume2, FileText, Table, Paperclip, Play, Link, ExternalLink, Plus, LayoutGrid, List as ListIcon, Bot, MoreHorizontal, Download, Images, Loader2, ScanText } from "lucide-react"; +import { Image, Video, Volume2, FileText, Table, Paperclip, Play, Link, ExternalLink, Plus, LayoutGrid, List as ListIcon, Bot, MoreHorizontal, Download, Images, Loader2, ScanText, Folder } from "lucide-react"; +import { UNFILED_PATH } from "../note-context/folder-tree-model"; +import { aggregateNoteContexts, noteContextHue, addNoteContext, removeNoteContext } from "../note-context/context-tags"; +import { ContextTagPicker } from "../note-context/ContextTagPicker"; +import { ContextBadge } from "../note-context/ContextBadge"; +import { + assetFolderValues, + resolveAssetFolders, + type NoteFolderLookup, +} from "./asset-folders"; +import { FilterPopup, type FilterOption } from "@/ui/filter-popup"; import { useT } from "../../i18n"; import { getActiveProvider } from "../../lib/storage/registry"; import { useRangeSelect } from "../../hooks/use-range-select"; @@ -429,6 +439,18 @@ export type AssetGalleryViewProps = { /** 素材を参照している版スナップショット数のオンデマンド集計(削除ダイアログ用) */ countSnapshotRefs?: (entry: MediaIndexEntry) => Promise; onRenameMedia: (entry: MediaIndexEntry, newName: string) => Promise; + /** + * 素材のフォルダ(noteContexts)を保存する。渡されたときだけ付与 UI を出す。 + * ノートと同じフォルダ体系を共有する。 + */ + onSetMediaContexts?: (fileId: string, contexts: string[]) => Promise | void; + /** ノート側で使われているフォルダ名(付与ピッカーの候補に混ぜる。体系を共有するため) */ + noteFolders?: readonly string[]; + /** + * ノート id → そのノートのフォルダ。素材が使われているノートのフォルダを + * 「その素材のフォルダ」として導出するために使う(保存はしない)。 + */ + noteFolderLookup?: NoteFolderLookup; /** URL ブックマーク登録コールバック(type === "url" のときのみ使用) */ onAddUrlBookmark?: (entry: MediaIndexEntry) => void; /** ファイル直接アップロード(image/video/audio/pdf/document、ノート非経由) */ @@ -561,6 +583,9 @@ export function AssetGalleryView({ onArchiveMedia, countSnapshotRefs, onRenameMedia, + onSetMediaContexts, + noteFolders, + noteFolderLookup, onAddUrlBookmark, onUploadMedia, onIngestMedia, @@ -590,6 +615,23 @@ export function AssetGalleryView({ const [sortAsc, setSortAsc] = useState(false); // Documents タブのサブフィルタ(PDF / Word / All) const [docFilter, setDocFilter] = useState<"all" | "pdf" | "word">("all"); + // フォルダでの絞り込み(ノートと同じ体系。UNFILED_PATH は「フォルダに入っていない素材」) + const [folderFilter, setFolderFilter] = useState([]); + // 素材が属するフォルダ(自分で付けたもの + 使われているノートのフォルダ)を求める。 + // 参照表が渡らない文脈(Storybook など)では自分で付けた分だけになる。 + const emptyLookup = useMemo(() => new Map(), []); + const foldersOf = useCallback( + (entry: MediaIndexEntry) => resolveAssetFolders(entry, noteFolderLookup ?? emptyLookup), + [noteFolderLookup, emptyLookup], + ); + const [folderFilterOpen, setFolderFilterOpen] = useState(false); + // 選択した素材へのフォルダ付与(ノート一覧の一括付与と同じ ContextTagPicker) + const [assignOpen, setAssignOpen] = useState(false); + const [assignPos, setAssignPos] = useState({ top: 0, left: 0 }); + const [assignApplied, setAssignApplied] = useState([]); + const assignBtnRef = useRef(null); + const [folderFilterPos, setFolderFilterPos] = useState({ top: 0, left: 0 }); + const folderFilterBtnRef = useRef(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [detailEntry, setDetailEntry] = useState(null); @@ -802,6 +844,19 @@ export function AssetGalleryView({ if (docFilter === "word") return m.type === "document"; return m.type === "document" || m.type === "pdf"; }); + // フォルダ絞り込み(OR・小文字比較)。ノート一覧の文脈フィルタと同じ規則にそろえる + if (folderFilter.length > 0) { + const wantsUnfiled = folderFilter.includes(UNFILED_PATH); + const set = new Set( + folderFilter.filter((c) => c !== UNFILED_PATH).map((c) => c.toLowerCase()), + ); + result = result.filter((m) => { + // 導出込み。ノートに貼っただけの素材も、そのノートのフォルダで拾える + const all = assetFolderValues(m, noteFolderLookup ?? emptyLookup); + if (wantsUnfiled && all.length === 0) return true; + return all.some((c) => set.has(c.trim().toLowerCase())); + }); + } if (searchQuery.trim()) { const q = searchQuery.trim().toLowerCase(); result = result.filter( @@ -824,7 +879,52 @@ export function AssetGalleryView({ } return sortAsc ? cmp : -cmp; }); - }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter]); + }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter, folderFilter, noteFolderLookup, emptyLookup]); + + // フォルダ絞り込みの選択肢。いま見ている種類の素材に実際に付いているものだけ出す + // (空振りする候補を並べない)。未分類は件数が 1 件以上あるときだけ足す。 + const folderFilterOptions = useMemo(() => { + if (!mediaIndex) return []; + const inScope = mediaIndex.media.filter((m) => { + if (m.archivedAt) return false; + if (mediaType !== "document") return m.type === mediaType; + return m.type === "document" || m.type === "pdf"; + }); + const counts = aggregateNoteContexts( + inScope.map((m) => ({ noteContexts: assetFolderValues(m, noteFolderLookup ?? emptyLookup) })), + ); + const options: FilterOption[] = counts.map(({ value, count }) => ({ + value, + label: value, + count, + icon: ( + + ), + })); + const unfiled = inScope.filter( + (m) => assetFolderValues(m, noteFolderLookup ?? emptyLookup).length === 0, + ).length; + if (unfiled > 0) { + options.push({ value: UNFILED_PATH, label: t("nav.unfiled"), count: unfiled }); + } + return options; + }, [mediaIndex, mediaType, t, noteFolderLookup, emptyLookup]); + + // その素材が属するフォルダ(自分で付けたもの + 使われているノートのフォルダ)。 + // 参照表が渡らない文脈(Storybook など)では自分で付けた分だけになる。 + // 付与ピッカーの候補。素材に付いているものと、ノート側のフォルダの両方から集める + // (体系を共有しているので、ノートで使っているフォルダに素材も入れられるべき)。 + const assignSuggestions = useMemo( + () => + aggregateNoteContexts([ + ...(mediaIndex?.media ?? []), + ...(noteFolders ?? []).map((value) => ({ noteContexts: [value] })), + ]), + [mediaIndex, noteFolders], + ); // Documents タブのサブフィルタ用件数 const docCounts = useMemo(() => { @@ -1066,6 +1166,7 @@ export function AssetGalleryView({ return ( { setDetailEntry(null); @@ -1263,6 +1364,29 @@ export function AssetGalleryView({ ))} )} + {/* フォルダ絞り込み。ノートと同じ体系なので、一覧の「フォルダ」列と同じ見え方にする */} + {folderFilterOptions.length > 0 && ( + + )}
{/* ソートボタンは gallery モード専用(list モードは列ヘッダのクリックで揃える) */} {viewMode === "gallery" && ( @@ -1332,6 +1456,22 @@ export function AssetGalleryView({ {t("asset.deselectAll")}
+ {onSetMediaContexts && ( + + )} {bulkActionable && onIngestMedia && (
+ {/* この素材が入っているフォルダ。ノートと同じ体系なので、 + ノート一覧のフォルダ列と同じ ContextBadge で見せる */} + {foldersOf(entry).length > 0 && ( +
+ {foldersOf(entry).map((f) => ( + + ))} +
+ )} {entry.type === "url" && entry.urlMeta?.domain && (

{entry.urlMeta.domain} @@ -1592,6 +1746,7 @@ export function AssetGalleryView({ Full mode は早期 return で別ルートに渡す */} {detailEntry && ( { @@ -1626,6 +1781,51 @@ export function AssetGalleryView({ onSaveImageAsAsset={onSaveImageAsAsset} /> )} + {assignOpen && onSetMediaContexts && ( + setAssignOpen(false)} + suggestions={assignSuggestions} + selected={assignApplied} + onAdd={(value) => { + // 選択中の素材すべてに足す(既に入っているものはそのまま) + void (async () => { + for (const fileId of selectedIds) { + const entry = mediaIndex?.media.find((m) => m.fileId === fileId); + if (!entry) continue; + const next = addNoteContext(entry.noteContexts, value); + await onSetMediaContexts(fileId, next ?? []); + } + setAssignApplied((prev) => (prev.includes(value) ? prev : [...prev, value])); + })(); + }} + onRemove={(value) => { + void (async () => { + for (const fileId of selectedIds) { + const entry = mediaIndex?.media.find((m) => m.fileId === fileId); + if (!entry) continue; + const next = removeNoteContext(entry.noteContexts, value); + await onSetMediaContexts(fileId, next ?? []); + } + setAssignApplied((prev) => prev.filter((v) => v !== value)); + })(); + }} + /> + )} + {folderFilterOpen && ( + setFolderFilterOpen(false)} + title={t("nav.folders")} + options={folderFilterOptions} + selected={folderFilter} + onChange={setFolderFilter} + searchPlaceholder={t("common.search")} + clearLabel={t("nav.clearFilter")} + noMatchText={t("nav.contextEmpty")} + minWidth={220} + /> + )}

); } diff --git a/src/features/asset-browser/MaterialFullView.tsx b/src/features/asset-browser/MaterialFullView.tsx index e0d9fcbf5..3125dc315 100644 --- a/src/features/asset-browser/MaterialFullView.tsx +++ b/src/features/asset-browser/MaterialFullView.tsx @@ -17,6 +17,7 @@ import { Network, Info, StickyNote, Bot } from "lucide-react"; import { cn } from "../../lib/utils"; import { useT } from "../../i18n"; import type { MediaIndex, MediaIndexEntry, MediaSharedRef } from "./media-index"; +import type { NoteFolderLookup } from "./asset-folders"; import { MediaPreview } from "./media-preview"; import type { CitationSource } from "./SelectionPill"; import { AssetGraphPanel, shouldShowAssetGraph, type KnowledgeKindLookup } from "./asset-graph-panel"; @@ -41,6 +42,8 @@ type RightTab = "graph" | "metadata" | "memos" | "chat" | null; export type MaterialFullViewProps = { entry: MediaIndexEntry; + /** ノート id → フォルダ。素材のフォルダ導出に使う(そのまま詳細ヘッダへ渡す) */ + noteFolderLookup?: NoteFolderLookup; onClose: () => void; onToggleFull?: () => void; onDelete?: (entry: MediaIndexEntry) => void; @@ -84,6 +87,7 @@ export type MaterialFullViewProps = { export function MaterialFullView({ entry, + noteFolderLookup, onClose, onToggleFull, onDelete, @@ -289,6 +293,7 @@ export function MaterialFullView({ > ({ noteId, noteTitle: noteId, blockId: "b" }); + +describe("buildNoteFolderLookup", () => { + it("フォルダを持つノートだけ載せる", () => { + expect(lookup.get("n1")).toEqual(["材料X"]); + expect(lookup.get("n3")).toBeUndefined(); + expect(lookup.get("n4")).toBeUndefined(); + }); +}); + +describe("resolveAssetFolders", () => { + it("使われているノートのフォルダを導出する", () => { + const r = resolveAssetFolders({ usedIn: [usage("n1")] }, lookup); + expect(r).toEqual([{ value: "材料X", derived: true }]); + }); + + it("複数のノートで使われていれば全部集める(重複は畳む)", () => { + const r = resolveAssetFolders({ usedIn: [usage("n1"), usage("n2")] }, lookup); + expect(r.map((f) => f.value)).toEqual(["材料X", "実験A"]); + expect(r.every((f) => f.derived)).toBe(true); + }); + + it("自分で付けたものが先に並び、derived ではない", () => { + const r = resolveAssetFolders({ noteContexts: ["下書き"], usedIn: [usage("n1")] }, lookup); + expect(r).toEqual([ + { value: "下書き", derived: false }, + { value: "材料X", derived: true }, + ]); + }); + + it("同じ名前が両方にあるときは「自分で付けた」を優先する", () => { + const r = resolveAssetFolders({ noteContexts: ["材料x"], usedIn: [usage("n1")] }, lookup); + expect(r).toEqual([{ value: "材料x", derived: false }]); + }); + + it("どのノートにも貼られていない素材は、自分で付けたものだけ", () => { + expect(resolveAssetFolders({ noteContexts: ["下書き"], usedIn: [] }, lookup)).toEqual([ + { value: "下書き", derived: false }, + ]); + }); + + it("どこにも属していなければ空", () => { + expect(resolveAssetFolders({ usedIn: [usage("n3")] }, lookup)).toEqual([]); + expect(resolveAssetFolders({ usedIn: [] }, lookup)).toEqual([]); + }); + + it("消えたノートを指す usedIn は無視する", () => { + expect(resolveAssetFolders({ usedIn: [usage("消えたノート")] }, lookup)).toEqual([]); + }); +}); + +describe("assetFolderValues", () => { + it("出どころを問わず名前だけ返す(絞り込み用)", () => { + expect( + assetFolderValues({ noteContexts: ["下書き"], usedIn: [usage("n2")] }, lookup), + ).toEqual(["下書き", "材料X", "実験A"]); + }); +}); diff --git a/src/features/asset-browser/asset-folders.ts b/src/features/asset-browser/asset-folders.ts new file mode 100644 index 000000000..46df3bede --- /dev/null +++ b/src/features/asset-browser/asset-folders.ts @@ -0,0 +1,69 @@ +// 素材が属するフォルダを求める。 +// +// 素材のフォルダには 2 つの出どころがある: +// - 自分で付けたもの(MediaIndexEntry.noteContexts) +// - 使われているノートのフォルダ(usedIn のノートが入っているフォルダ) +// +// 「材料X のノートに貼った写真は材料X のもの」という関係は、人が付け直さなくても +// 成り立っているはずなので、後者は**保存せず、その場で合成する**(導出)。 +// 書き込んでしまうと、あとでノート側のフォルダを変えても素材に古い値が residue として +// 残り、一度貼っただけの素材に意図しないフォルダが溜まっていく。 +// +// 導出なので、ノートからフォルダが外れれば素材からも自然に消える。自分で付けた分は +// ノートに関係なく常に残る。 + +import type { MediaIndexEntry } from "./media-index"; +import { normalizeNoteContexts } from "../note-context/context-tags"; + +/** ノート id → そのノートのフォルダ。導出の参照表 */ +export type NoteFolderLookup = ReadonlyMap; + +/** ノートインデックスのエントリから参照表を作る */ +export function buildNoteFolderLookup( + notes: readonly { noteId: string; noteContexts?: string[] }[], +): NoteFolderLookup { + const map = new Map(); + for (const n of notes) { + const folders = normalizeNoteContexts(n.noteContexts); + if (folders) map.set(n.noteId, folders); + } + return map; +} + +export type AssetFolder = { + value: string; + /** true = 使われているノートから導いたもの(自分で付けたものではない) */ + derived: boolean; +}; + +/** + * 素材が属するフォルダを、出どころ付きで返す。 + * 自分で付けたものを先に、ノート由来をあとに並べる。両方に同じ名前があれば + * 「自分で付けた」を優先する(外しても勝手に戻る、という誤解を避けるため)。 + */ +export function resolveAssetFolders( + entry: Pick, + lookup: NoteFolderLookup, +): AssetFolder[] { + const own = normalizeNoteContexts(entry.noteContexts) ?? []; + const seen = new Set(own.map((c) => c.trim().toLowerCase())); + const out: AssetFolder[] = own.map((value) => ({ value, derived: false })); + + for (const usage of entry.usedIn ?? []) { + for (const folder of lookup.get(usage.noteId) ?? []) { + const key = folder.trim().toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push({ value: folder, derived: true }); + } + } + return out; +} + +/** 絞り込み用に、出どころを問わずフォルダ名だけ取り出す */ +export function assetFolderValues( + entry: Pick, + lookup: NoteFolderLookup, +): string[] { + return resolveAssetFolders(entry, lookup).map((f) => f.value); +} diff --git a/src/features/asset-browser/index.ts b/src/features/asset-browser/index.ts index fc48b94f3..7561958ed 100644 --- a/src/features/asset-browser/index.ts +++ b/src/features/asset-browser/index.ts @@ -17,6 +17,7 @@ export { deleteMediaFile, renameMediaFile, renameMediaEntry, + setMediaEntryContexts, extractFileIdFromUrl, extractMediaFromBlocks, collectPdfFileIdsFromDoc, diff --git a/src/features/asset-browser/material-detail-header.tsx b/src/features/asset-browser/material-detail-header.tsx index ea5522631..9328105c1 100644 --- a/src/features/asset-browser/material-detail-header.tsx +++ b/src/features/asset-browser/material-detail-header.tsx @@ -34,6 +34,11 @@ import { Table, } from "lucide-react"; import { useT } from "../../i18n"; +import { ContextBadge } from "../note-context/ContextBadge"; +import { resolveAssetFolders, type NoteFolderLookup } from "./asset-folders"; + +/** 参照表が渡らない文脈用(自分で付けたフォルダだけになる) */ +const EMPTY_LOOKUP: NoteFolderLookup = new Map(); import { useImeEnterGuard } from "../../hooks/use-ime-enter-guard"; import type { MediaIndexEntry, MediaSharedRef, MediaType } from "./media-index"; import { SharedBadge } from "./share-media-dialog"; @@ -75,6 +80,8 @@ function TypeIcon({ type, size = 14 }: { type: MediaType; size?: number }) { } export type MaterialDetailHeaderProps = { + /** ノート id → フォルダ。使われているノートのフォルダを導出するために使う */ + noteFolderLookup?: NoteFolderLookup; entry: MediaIndexEntry; onClose: () => void; onRename?: (entry: MediaIndexEntry, newName: string) => Promise; @@ -108,6 +115,7 @@ export type MaterialDetailHeaderProps = { export function MaterialDetailHeader({ entry, + noteFolderLookup, onClose, onRename, onIngest, @@ -168,6 +176,8 @@ export function MaterialDetailHeader({ const isShared = !!entry.sharedRef; const usageNoteCount = new Set(entry.usedIn.map((u) => u.noteId)).size; + // 属するフォルダ(自分で付けたもの + 使われているノートのフォルダ) + const folders = resolveAssetFolders(entry, noteFolderLookup ?? EMPTY_LOOKUP); // 名前 + メタチップを共通レンダリング const renderNameBlock = () => ( @@ -209,6 +219,15 @@ export function MaterialDetailHeader({ {t("asset.usedInCount", { count: String(usageNoteCount) })}
)} + {/* 入っているフォルダ(ノートと同じ体系)。ノート由来は薄く出し、 + 「外すならノート側」という違いを見せる */} + {folders.map((f) => ( + + ))} {isShared && } ); diff --git a/src/features/asset-browser/media-contexts.test.ts b/src/features/asset-browser/media-contexts.test.ts new file mode 100644 index 000000000..db984f6d1 --- /dev/null +++ b/src/features/asset-browser/media-contexts.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { setMediaEntryContexts, type MediaIndex, type MediaIndexEntry } from "./media-index"; + +const entry = (fileId: string, extra: Partial = {}): MediaIndexEntry => ({ + fileId, + name: `${fileId}.png`, + type: "image", + mimeType: "image/png", + url: "", + thumbnailUrl: "", + uploadedAt: "2026-09-03T00:00:00.000Z", + usedIn: [], + ...extra, +}); + +const index = (media: MediaIndexEntry[]): MediaIndex => ({ + version: 7, + updatedAt: "2026-09-03T00:00:00.000Z", + media, +}); + +describe("setMediaEntryContexts", () => { + it("指定した素材だけフォルダが変わる", () => { + const next = setMediaEntryContexts(index([entry("a"), entry("b")]), "a", ["材料X"]); + expect(next.media.find((m) => m.fileId === "a")?.noteContexts).toEqual(["材料X"]); + expect(next.media.find((m) => m.fileId === "b")?.noteContexts).toBeUndefined(); + }); + + it("ノートと同じ規則で正規化する(trim・小文字比較の重複除去・表示は初出の形)", () => { + const next = setMediaEntryContexts(index([entry("a")]), "a", [" 材料X ", "材料x", "実験"]); + expect(next.media[0].noteContexts).toEqual(["材料X", "実験"]); + }); + + it("空にすると欄ごと落ちる", () => { + const before = index([entry("a", { noteContexts: ["材料X"] })]); + expect(setMediaEntryContexts(before, "a", []).media[0].noteContexts).toBeUndefined(); + }); + + it("更新時刻を進める", () => { + const next = setMediaEntryContexts(index([entry("a")]), "a", ["材料X"]); + expect(next.updatedAt).not.toBe("2026-09-03T00:00:00.000Z"); + }); + + it("存在しない fileId なら中身は変わらない", () => { + const before = index([entry("a", { noteContexts: ["材料X"] })]); + const next = setMediaEntryContexts(before, "zzz", ["別"]); + expect(next.media).toEqual(before.media); + }); + + it("他のフィールドは保つ(usedIn や OCR を落とさない)", () => { + const before = index([ + entry("a", { ocrText: "読み取り済み", usedIn: [{ noteId: "n1", noteTitle: "N", blockId: "b" }] }), + ]); + const next = setMediaEntryContexts(before, "a", ["材料X"]); + expect(next.media[0].ocrText).toBe("読み取り済み"); + expect(next.media[0].usedIn).toHaveLength(1); + }); +}); diff --git a/src/features/asset-browser/media-index.ts b/src/features/asset-browser/media-index.ts index c5e6fc358..c24c65263 100644 --- a/src/features/asset-browser/media-index.ts +++ b/src/features/asset-browser/media-index.ts @@ -3,6 +3,7 @@ import { getActiveProvider } from "../../lib/storage/registry"; import { isDelimitedDataFile } from "../data-import/file-kind"; +import { normalizeNoteContexts } from "../note-context/context-tags"; // チャートが直接参照する素材(config.assetSources)を利用ノートに数えるため。 // chart-config は純関数の葉モジュールで、こちらへ戻る import は無い import { collectChartAssetFileIds, parseChartBlockConfig } from "../../blocks/chart/chart-config"; @@ -235,6 +236,19 @@ export type MediaIndexEntry = { * 設計: docs/internal/mobile-capture-transport-design-2026-07.md §7 */ capture?: import("../mobile-capture/inbox/types").CaptureMeta; + /** + * 素材が入っているフォルダ。ノートと同じ noteContexts の体系を共有する + * (「材料X」フォルダにノートも画像も入る)。 + * + * 人が付ける情報で、ノートからは導けない。ensureMediaIndex の再構築は既存エントリを + * 土台に usedIn などを埋め直すだけなので、ここは温存される(#699 の「再構築は自分が + * 知らない情報を上書きしない」性質)。 + * + * 再構築で回収できる類の情報ではないため、スキーマ版は上げない — 上げても得るものが + * 無く、全ユーザーに無駄な全走査を強いるだけになる。古いインデックスはこの欄を + * 持たないだけで、そのまま読める(optional・後方互換)。 + */ + noteContexts?: string[]; }; /** @@ -869,6 +883,22 @@ export function renameMediaEntry( return { ...index, updatedAt: new Date().toISOString(), media }; } +/** + * 素材のフォルダ(noteContexts)を差し替える。ノート側の updateNoteContexts に相当する。 + * 正規化はノートと共通の normalizeNoteContexts に任せ、同じ名寄せ規則(小文字比較・ + * 表示は初出の形)で揃える。空になったら欄ごと落とす。 + */ +export function setMediaEntryContexts( + index: MediaIndex, + fileId: string, + contexts: readonly string[], +): MediaIndex { + const next = normalizeNoteContexts([...contexts]); + const media = index.media.map((m) => + m.fileId === fileId ? { ...m, noteContexts: next } : m, + ); + return { ...index, updatedAt: new Date().toISOString(), media }; +} /** メディアファイルを削除 */ export async function deleteMediaFile(fileId: string): Promise { const provider = getActiveProvider(); diff --git a/src/hooks/use-file-manager.ts b/src/hooks/use-file-manager.ts index 913aacde8..7905debf7 100644 --- a/src/hooks/use-file-manager.ts +++ b/src/hooks/use-file-manager.ts @@ -64,6 +64,7 @@ import { } from "../features/navigation"; import { saveMediaIndex, + setMediaEntryContexts, createEmptyIndex, addMediaEntry, removeMediaEntry, @@ -2217,6 +2218,23 @@ export function useFileManager(authenticated: boolean) { saveMediaIndex(updated).catch((err) => console.warn("メディアインデックス保存失敗:", err)); }, []); + /** + * 素材のフォルダ(noteContexts)を差し替えて永続化する。ノート側の + * updateNoteContexts に相当し、同じフォルダ体系を共有する。 + * mediaIndex は再構築されても既存エントリを土台にするので、ここで書いた値は残る。 + */ + const updateMediaContexts = useCallback(async (fileId: string, contexts: string[]) => { + const current = mediaIndexRef.current; + if (!current) return; + const updated = setMediaEntryContexts(current, fileId, contexts); + mediaIndexRef.current = updated; + setMediaIndex(updated); + try { + await saveMediaIndex(updated); + } catch (err) { + console.warn("素材のフォルダ保存に失敗:", err); + } + }, []); const handleRenameMedia = useCallback(async (entry: MediaIndexEntry, newName: string) => { // URL ブックマークは Drive ファイルがないのでインデックスのみ更新 if (entry.type !== "url") { @@ -3101,6 +3119,7 @@ export function useFileManager(authenticated: boolean) { handleRestoreMedia, countSnapshotRefsForAsset, handleRenameMedia, + updateMediaContexts, handleUpdateMediaSharedRef, handleAddUrlBookmark, handleCreateNoteFromDocument, diff --git a/src/note-app.tsx b/src/note-app.tsx index 7dcf0b8aa..607605242 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -199,6 +199,7 @@ import { DocumentProvenancePanel } from "./features/document-provenance"; import { cn } from "./lib/utils"; import { NoteListView, TrashView, buildKnowledgeMap, findIncomingReferences, readIndexFile, type GraphiumIndex, type NoteIndexEntry } from "./features/navigation"; import { UNFILED_PATH, buildFolderTree, collectFolderSource, expandFolderToContextValues } from "./features/note-context/folder-tree-model"; +import { buildNoteFolderLookup } from "./features/asset-browser/asset-folders"; import { addFolderDefinition, ensureFolderDefinitions, removeFolderDefinition, renameFolderDefinition } from "./features/note-context/folder-store"; import { FolderMenu } from "./features/note-context/FolderMenu"; import { computeFolderDrop } from "./features/note-context/folder-drop"; @@ -5953,6 +5954,7 @@ export function NoteApp() { // 空フォルダ定義(appdata)。タグとして実体化する前のフォルダをツリーに出すために持つ。 // 読み込みは fm(プロバイダ)初期化後の useEffect で行う(fm 宣言の直後にある)。 const [emptyFolders, setEmptyFolders] = useState([]); + // フォルダの右クリックメニュー(名前の変更・削除) const [folderMenu, setFolderMenu] = useState<{ path: string; @@ -6066,6 +6068,24 @@ export function NoteApp() { }, [fm.noteIndex]); // パンくずの親フォルダをクリックしたときに、サイドバーと同じ絞り込み(子を含む)へ + // 素材の付与ピッカーに渡すノート側フォルダ名(体系を共有するので候補を混ぜる) + // ノート由来のフォルダに加えて、空フォルダ(appdata の定義)も混ぜる。 + // 子フォルダはまだノートが入っていないことが多く、ノート側からしか集めないと + // 候補に出ず「素材を入れられない」ように見えてしまう。 + + // ノート id → そのノートのフォルダ。素材が「使われているノートのフォルダ」に + // 属して見えるようにするための参照表(保存はせず、その場で合成する)。 + const noteFolderLookup = useMemo( + () => buildNoteFolderLookup(fm.noteIndex?.notes ?? []), + [fm.noteIndex], + ); + const noteFolderNames = useMemo( + () => [ + ...collectFolderSource(fm.noteIndex?.notes ?? []).folders.map((f) => f.value), + ...emptyFolders, + ], + [fm.noteIndex, emptyFolders], + ); // 展開するためのツリー。集計規則はサイドバーと共通(collectFolderSource) const folderTreeForNav = useMemo( () => buildFolderTree(collectFolderSource(fm.noteIndex?.notes ?? []).folders, emptyFolders), @@ -8667,6 +8687,9 @@ export function NoteApp() { onArchiveMedia={fm.handleArchiveMedia} countSnapshotRefs={fm.countSnapshotRefsForAsset} onRenameMedia={handleRenameMediaWithBlockSync} + onSetMediaContexts={fm.updateMediaContexts} + noteFolders={noteFolderNames} + noteFolderLookup={noteFolderLookup} onSharedRefUpdated={fm.handleUpdateMediaSharedRef} onAddUrlBookmark={fm.handleAddUrlBookmark} onUploadMedia={fm.handleUploadMedia} @@ -10094,6 +10117,7 @@ export function NoteApp() { )} {listMaterialPeekEntry && ( setListMaterialPeekEntry(null)} mediaIndex={fm.mediaIndex ?? null}