From bb3b6388a6f8b1ec4b3075298c582e578bf2dc83 Mon Sep 17 00:00:00 2001 From: kumagallium Date: Thu, 3 Sep 2026 19:06:24 +0900 Subject: [PATCH 1/3] [feat] Put materials in folders too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materials now carry the same folders as notes, so a PDF or a photo can be filed the day it arrives rather than only once some note uses it. Select materials in the list view and choose Add to a folder; the Folders button above the gallery filters by one, with Unfiled for what has not been filed. The folder vocabulary is shared with notes — the picker offers folders already used by either — so 'Material X' means the same thing whichever side you are on. MediaIndexEntry gains an optional noteContexts and the schema version stays at 7 on purpose: every past bump existed to re-collect usedIn by rescanning notes, and a folder cannot be derived that way. Bumping would buy nothing and cost every user a full rescan. The field survives rebuilds because ensureMediaIndex builds on the existing entries rather than recreating them (#699). Verified through server-fs with the API running: the folder lands in the media index on disk and the filter appears after a reload. The test folder was removed from the real index afterwards. Co-Authored-By: Claude Opus 5 --- manual/ja/materials-and-citations.md | 2 + manual/materials-and-citations.md | 2 + .../asset-browser/AssetGalleryView.tsx | 162 +++++++++++++++++- src/features/asset-browser/index.ts | 1 + .../asset-browser/media-contexts.test.ts | 58 +++++++ src/features/asset-browser/media-index.ts | 30 ++++ src/hooks/use-file-manager.ts | 19 ++ src/note-app.tsx | 7 + 8 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 src/features/asset-browser/media-contexts.test.ts diff --git a/manual/ja/materials-and-citations.md b/manual/ja/materials-and-citations.md index 28c0cd411..64272b48e 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**。どれかをクリックするとギャラリーが開きます。メモは独自のギャラリーを持ち、ノートの隣の **メモ** から開きます。 +素材もフォルダに入れられます。ノートと同じフォルダです。リスト表示で選んで **フォルダに入れる** を押し、一覧の上の **フォルダ** ボタンで絞り込みます。どのノートにも貼っていない素材でもフォルダに入れられるので、取り込んだその日に片付けられます。 + ギャラリーの中では次のことができます。 | 操作 | 何ができるか | diff --git a/manual/materials-and-citations.md b/manual/materials-and-citations.md index b726a07b3..2300c98d2 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: **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. Memos have their own gallery — the **Memos** entry next to Notes. +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. + 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..540331897 100644 --- a/src/features/asset-browser/AssetGalleryView.tsx +++ b/src/features/asset-browser/AssetGalleryView.tsx @@ -2,7 +2,11 @@ // メディアタイプ別にサムネイル一覧を表示、ノート紐付き・削除に対応 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 { 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 +433,13 @@ 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[]; /** URL ブックマーク登録コールバック(type === "url" のときのみ使用) */ onAddUrlBookmark?: (entry: MediaIndexEntry) => void; /** ファイル直接アップロード(image/video/audio/pdf/document、ノート非経由) */ @@ -561,6 +572,8 @@ export function AssetGalleryView({ onArchiveMedia, countSnapshotRefs, onRenameMedia, + onSetMediaContexts, + noteFolders, onAddUrlBookmark, onUploadMedia, onIngestMedia, @@ -590,6 +603,16 @@ 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([]); + 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 +825,18 @@ 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 own = m.noteContexts ?? []; + if (wantsUnfiled && own.length === 0) return true; + return own.some((c) => set.has(c.trim().toLowerCase())); + }); + } if (searchQuery.trim()) { const q = searchQuery.trim().toLowerCase(); result = result.filter( @@ -824,7 +859,46 @@ export function AssetGalleryView({ } return sortAsc ? cmp : -cmp; }); - }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter]); + }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter, folderFilter]); + + // フォルダ絞り込みの選択肢。いま見ている種類の素材に実際に付いているものだけ出す + // (空振りする候補を並べない)。未分類は件数が 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); + const options: FilterOption[] = counts.map(({ value, count }) => ({ + value, + label: value, + count, + icon: ( + + ), + })); + const unfiled = inScope.filter((m) => (m.noteContexts ?? []).length === 0).length; + if (unfiled > 0) { + options.push({ value: UNFILED_PATH, label: t("nav.unfiled"), count: unfiled }); + } + return options; + }, [mediaIndex, mediaType, t]); + + // 付与ピッカーの候補。素材に付いているものと、ノート側のフォルダの両方から集める + // (体系を共有しているので、ノートで使っているフォルダに素材も入れられるべき)。 + const assignSuggestions = useMemo( + () => + aggregateNoteContexts([ + ...(mediaIndex?.media ?? []), + ...(noteFolders ?? []).map((value) => ({ noteContexts: [value] })), + ]), + [mediaIndex, noteFolders], + ); // Documents タブのサブフィルタ用件数 const docCounts = useMemo(() => { @@ -1263,6 +1337,29 @@ export function AssetGalleryView({ ))} )} + {/* フォルダ絞り込み。ノートと同じ体系なので、一覧の「フォルダ」列と同じ見え方にする */} + {folderFilterOptions.length > 0 && ( + + )}
{/* ソートボタンは gallery モード専用(list モードは列ヘッダのクリックで揃える) */} {viewMode === "gallery" && ( @@ -1332,6 +1429,22 @@ export function AssetGalleryView({ {t("asset.deselectAll")}
+ {onSetMediaContexts && ( + + )} {bulkActionable && onIngestMedia && (
); } 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/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 bb5222a98..03ef15783 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, @@ -2223,6 +2224,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") { @@ -3107,6 +3125,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 aa3cae1c5..1afa1d849 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -6035,6 +6035,11 @@ export function NoteApp() { }, [fm.noteIndex]); // パンくずの親フォルダをクリックしたときに、サイドバーと同じ絞り込み(子を含む)へ + // 素材の付与ピッカーに渡すノート側フォルダ名(体系を共有するので候補を混ぜる) + const noteFolderNames = useMemo( + () => collectFolderSource(fm.noteIndex?.notes ?? []).folders.map((f) => f.value), + [fm.noteIndex], + ); // 展開するためのツリー。集計規則はサイドバーと共通(collectFolderSource) const folderTreeForNav = useMemo( () => buildFolderTree(collectFolderSource(fm.noteIndex?.notes ?? []).folders, emptyFolders), @@ -8588,6 +8593,8 @@ export function NoteApp() { onArchiveMedia={fm.handleArchiveMedia} countSnapshotRefs={fm.countSnapshotRefsForAsset} onRenameMedia={handleRenameMediaWithBlockSync} + onSetMediaContexts={fm.updateMediaContexts} + noteFolders={noteFolderNames} onSharedRefUpdated={fm.handleUpdateMediaSharedRef} onAddUrlBookmark={fm.handleAddUrlBookmark} onUploadMedia={fm.handleUploadMedia} From 87f4b47d68d6463a3aa489ac09266eed5de41206 Mon Sep 17 00:00:00 2001 From: kumagallium Date: Thu, 3 Sep 2026 21:08:20 +0900 Subject: [PATCH 2/3] [fix] Offer empty folders for materials and show which folder they are in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while trying the feature: Empty folders never appeared in the material picker. Candidates came only from folders already on notes, and a folder with no notes in it yet — which most subfolders are, right after you create them — is not among those. It read as 'materials cannot go in subfolders'. The appdata folder definitions are now offered too. And a material gave no sign of which folder it was in: the feature could assign and filter but never showed the result. Folders now appear as chips under the name in the list and beside the metadata in the detail header, using the same badge as the notes list. Verified in the running app: a subfolder shows up in the picker, assigning it lands the chip on the row. Folders added while testing were cleared from the real index afterwards. Co-Authored-By: Claude Opus 5 --- src/features/asset-browser/AssetGalleryView.tsx | 10 ++++++++++ src/features/asset-browser/material-detail-header.tsx | 5 +++++ src/note-app.tsx | 10 ++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/features/asset-browser/AssetGalleryView.tsx b/src/features/asset-browser/AssetGalleryView.tsx index 540331897..5f7b82497 100644 --- a/src/features/asset-browser/AssetGalleryView.tsx +++ b/src/features/asset-browser/AssetGalleryView.tsx @@ -6,6 +6,7 @@ import { Image, Video, Volume2, FileText, Table, Paperclip, Play, Link, External 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 { FilterPopup, type FilterOption } from "@/ui/filter-popup"; import { useT } from "../../i18n"; import { getActiveProvider } from "../../lib/storage/registry"; @@ -1627,6 +1628,15 @@ export function AssetGalleryView({ )}
+ {/* この素材が入っているフォルダ。ノートと同じ体系なので、 + ノート一覧のフォルダ列と同じ ContextBadge で見せる */} + {(entry.noteContexts ?? []).length > 0 && ( +
+ {(entry.noteContexts ?? []).map((c) => ( + + ))} +
+ )} {entry.type === "url" && entry.urlMeta?.domain && (

{entry.urlMeta.domain} diff --git a/src/features/asset-browser/material-detail-header.tsx b/src/features/asset-browser/material-detail-header.tsx index ea5522631..ca9bb2482 100644 --- a/src/features/asset-browser/material-detail-header.tsx +++ b/src/features/asset-browser/material-detail-header.tsx @@ -34,6 +34,7 @@ import { Table, } from "lucide-react"; import { useT } from "../../i18n"; +import { ContextBadge } from "../note-context/ContextBadge"; import { useImeEnterGuard } from "../../hooks/use-ime-enter-guard"; import type { MediaIndexEntry, MediaSharedRef, MediaType } from "./media-index"; import { SharedBadge } from "./share-media-dialog"; @@ -209,6 +210,10 @@ export function MaterialDetailHeader({ {t("asset.usedInCount", { count: String(usageNoteCount) })} )} + {/* 入っているフォルダ(ノートと同じ体系)。一覧の列と同じ ContextBadge で見せる */} + {(entry.noteContexts ?? []).map((c) => ( + + ))} {isShared && } ); diff --git a/src/note-app.tsx b/src/note-app.tsx index 1afa1d849..80ff50dcd 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -6036,9 +6036,15 @@ export function NoteApp() { // パンくずの親フォルダをクリックしたときに、サイドバーと同じ絞り込み(子を含む)へ // 素材の付与ピッカーに渡すノート側フォルダ名(体系を共有するので候補を混ぜる) + // ノート由来のフォルダに加えて、空フォルダ(appdata の定義)も混ぜる。 + // 子フォルダはまだノートが入っていないことが多く、ノート側からしか集めないと + // 候補に出ず「素材を入れられない」ように見えてしまう。 const noteFolderNames = useMemo( - () => collectFolderSource(fm.noteIndex?.notes ?? []).folders.map((f) => f.value), - [fm.noteIndex], + () => [ + ...collectFolderSource(fm.noteIndex?.notes ?? []).folders.map((f) => f.value), + ...emptyFolders, + ], + [fm.noteIndex, emptyFolders], ); // 展開するためのツリー。集計規則はサイドバーと共通(collectFolderSource) const folderTreeForNav = useMemo( From f389a7341aceb0a1ac4756d82f16ff09706ea091 Mon Sep 17 00:00:00 2001 From: kumagallium Date: Thu, 3 Sep 2026 21:18:59 +0900 Subject: [PATCH 3/3] [feat] A material also belongs to the folders of the notes it is in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put a photo in a note that lives in Material X and the photo now shows up under Material X too, without anyone filing it — the relation is already there in usedIn. This is derived at read time, not written to the index. Writing it would leave residue: change the note's folders later and the material would keep the old ones, and a photo used once would slowly collect folders nobody chose. Deriving means it follows the note — take the note out of the folder and the material stops appearing there — while folders set by hand are never touched and always win on a name clash. Derived folders show in a lighter chip, so it is visible which ones would follow the note rather than being yours to remove. The lookup is not threaded into the material peek that opens from inside the editor, which sits outside this scope; that one still shows hand-set folders only. Verified in the running app: an asset with no folders of its own shows the folder of the note that uses it. Co-Authored-By: Claude Opus 5 --- manual/ja/materials-and-citations.md | 2 +- manual/materials-and-citations.md | 2 +- .../asset-browser/AssetGalleryView.tsx | 52 +++++++++++--- .../asset-browser/MaterialFullView.tsx | 5 ++ .../asset-browser/MaterialSidePeek.tsx | 5 ++ .../asset-browser/asset-folders.test.ts | 68 ++++++++++++++++++ src/features/asset-browser/asset-folders.ts | 69 +++++++++++++++++++ .../asset-browser/material-detail-header.tsx | 20 +++++- src/note-app.tsx | 11 +++ 9 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 src/features/asset-browser/asset-folders.test.ts create mode 100644 src/features/asset-browser/asset-folders.ts diff --git a/manual/ja/materials-and-citations.md b/manual/ja/materials-and-citations.md index 64272b48e..a4d738480 100644 --- a/manual/ja/materials-and-citations.md +++ b/manual/ja/materials-and-citations.md @@ -6,7 +6,7 @@ サイドバーには **素材** セクションがあり、種類ごとに項目が並びます。**画像**、**ドキュメント**(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 2300c98d2..96ccf81a8 100644 --- a/manual/materials-and-citations.md +++ b/manual/materials-and-citations.md @@ -6,7 +6,7 @@ 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: **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. Memos have their own gallery — the **Memos** entry next to Notes. -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. +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: diff --git a/src/features/asset-browser/AssetGalleryView.tsx b/src/features/asset-browser/AssetGalleryView.tsx index 5f7b82497..3cd3b7d61 100644 --- a/src/features/asset-browser/AssetGalleryView.tsx +++ b/src/features/asset-browser/AssetGalleryView.tsx @@ -7,6 +7,11 @@ 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"; @@ -441,6 +446,11 @@ export type AssetGalleryViewProps = { 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、ノート非経由) */ @@ -575,6 +585,7 @@ export function AssetGalleryView({ onRenameMedia, onSetMediaContexts, noteFolders, + noteFolderLookup, onAddUrlBookmark, onUploadMedia, onIngestMedia, @@ -606,6 +617,13 @@ export function AssetGalleryView({ 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); @@ -833,9 +851,10 @@ export function AssetGalleryView({ folderFilter.filter((c) => c !== UNFILED_PATH).map((c) => c.toLowerCase()), ); result = result.filter((m) => { - const own = m.noteContexts ?? []; - if (wantsUnfiled && own.length === 0) return true; - return own.some((c) => set.has(c.trim().toLowerCase())); + // 導出込み。ノートに貼っただけの素材も、そのノートのフォルダで拾える + 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()) { @@ -860,7 +879,7 @@ export function AssetGalleryView({ } return sortAsc ? cmp : -cmp; }); - }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter, folderFilter]); + }, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter, folderFilter, noteFolderLookup, emptyLookup]); // フォルダ絞り込みの選択肢。いま見ている種類の素材に実際に付いているものだけ出す // (空振りする候補を並べない)。未分類は件数が 1 件以上あるときだけ足す。 @@ -871,7 +890,9 @@ export function AssetGalleryView({ if (mediaType !== "document") return m.type === mediaType; return m.type === "document" || m.type === "pdf"; }); - const counts = aggregateNoteContexts(inScope); + const counts = aggregateNoteContexts( + inScope.map((m) => ({ noteContexts: assetFolderValues(m, noteFolderLookup ?? emptyLookup) })), + ); const options: FilterOption[] = counts.map(({ value, count }) => ({ value, label: value, @@ -883,13 +904,17 @@ export function AssetGalleryView({ /> ), })); - const unfiled = inScope.filter((m) => (m.noteContexts ?? []).length === 0).length; + 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]); + }, [mediaIndex, mediaType, t, noteFolderLookup, emptyLookup]); + // その素材が属するフォルダ(自分で付けたもの + 使われているノートのフォルダ)。 + // 参照表が渡らない文脈(Storybook など)では自分で付けた分だけになる。 // 付与ピッカーの候補。素材に付いているものと、ノート側のフォルダの両方から集める // (体系を共有しているので、ノートで使っているフォルダに素材も入れられるべき)。 const assignSuggestions = useMemo( @@ -1141,6 +1166,7 @@ export function AssetGalleryView({ return ( { setDetailEntry(null); @@ -1630,10 +1656,15 @@ export function AssetGalleryView({ {/* この素材が入っているフォルダ。ノートと同じ体系なので、 ノート一覧のフォルダ列と同じ ContextBadge で見せる */} - {(entry.noteContexts ?? []).length > 0 && ( + {foldersOf(entry).length > 0 && (

- {(entry.noteContexts ?? []).map((c) => ( - + {foldersOf(entry).map((f) => ( + ))}
)} @@ -1715,6 +1746,7 @@ export function AssetGalleryView({ Full mode は早期 return で別ルートに渡す */} {detailEntry && ( { 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/material-detail-header.tsx b/src/features/asset-browser/material-detail-header.tsx index ca9bb2482..9328105c1 100644 --- a/src/features/asset-browser/material-detail-header.tsx +++ b/src/features/asset-browser/material-detail-header.tsx @@ -35,6 +35,10 @@ import { } 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"; @@ -76,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; @@ -109,6 +115,7 @@ export type MaterialDetailHeaderProps = { export function MaterialDetailHeader({ entry, + noteFolderLookup, onClose, onRename, onIngest, @@ -169,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 = () => ( @@ -210,9 +219,14 @@ export function MaterialDetailHeader({ {t("asset.usedInCount", { count: String(usageNoteCount) })}
)} - {/* 入っているフォルダ(ノートと同じ体系)。一覧の列と同じ ContextBadge で見せる */} - {(entry.noteContexts ?? []).map((c) => ( - + {/* 入っているフォルダ(ノートと同じ体系)。ノート由来は薄く出し、 + 「外すならノート側」という違いを見せる */} + {folders.map((f) => ( + ))} {isShared && } diff --git a/src/note-app.tsx b/src/note-app.tsx index 80ff50dcd..83b0dc74d 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -197,6 +197,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 { ContextBadge } from "./features/note-context/ContextBadge"; @@ -5922,6 +5923,7 @@ export function NoteApp() { // 空フォルダ定義(appdata)。タグとして実体化する前のフォルダをツリーに出すために持つ。 // 読み込みは fm(プロバイダ)初期化後の useEffect で行う(fm 宣言の直後にある)。 const [emptyFolders, setEmptyFolders] = useState([]); + // フォルダの右クリックメニュー(名前の変更・削除) const [folderMenu, setFolderMenu] = useState<{ path: string; @@ -6039,6 +6041,13 @@ export function NoteApp() { // ノート由来のフォルダに加えて、空フォルダ(appdata の定義)も混ぜる。 // 子フォルダはまだノートが入っていないことが多く、ノート側からしか集めないと // 候補に出ず「素材を入れられない」ように見えてしまう。 + + // ノート id → そのノートのフォルダ。素材が「使われているノートのフォルダ」に + // 属して見えるようにするための参照表(保存はせず、その場で合成する)。 + const noteFolderLookup = useMemo( + () => buildNoteFolderLookup(fm.noteIndex?.notes ?? []), + [fm.noteIndex], + ); const noteFolderNames = useMemo( () => [ ...collectFolderSource(fm.noteIndex?.notes ?? []).folders.map((f) => f.value), @@ -8601,6 +8610,7 @@ export function NoteApp() { onRenameMedia={handleRenameMediaWithBlockSync} onSetMediaContexts={fm.updateMediaContexts} noteFolders={noteFolderNames} + noteFolderLookup={noteFolderLookup} onSharedRefUpdated={fm.handleUpdateMediaSharedRef} onAddUrlBookmark={fm.handleAddUrlBookmark} onUploadMedia={fm.handleUploadMedia} @@ -10024,6 +10034,7 @@ export function NoteApp() { )} {listMaterialPeekEntry && ( setListMaterialPeekEntry(null)} mediaIndex={fm.mediaIndex ?? null}