diff --git a/manual/ja/materials-and-citations.md b/manual/ja/materials-and-citations.md index 1238f21e2..03cdce12b 100644 --- a/manual/ja/materials-and-citations.md +++ b/manual/ja/materials-and-citations.md @@ -35,7 +35,7 @@ | ノートに既にあるリンク | まだ登録されていないリンク(AI チャットの回答がノートに書いたリンクなど)をクリックすると、サイドピークのヘッダーに **素材に登録** ボタンが出る。中身を確かめてから登録すると、その URL は **URL** ギャラリーとグラフに現れる。自動では登録されない | | スラッシュメニューの **ブックマーク** / **PDF** | URL カードや埋め込みの PDF ビューアを、そのままブロックとして挿入する | | ギャラリーのボタン | **アップロード**・**URL を追加**・**PDF を追加**・**Word を追加** — ノートに触らずに素材だけ追加する | -| Markdown の取り込み | ノート一覧の **ファイルを取り込む** ボタンから **Markdown (.md) を取り込む** と **Obsidian Vault フォルダを取り込む**。`[[wikilinks]]` は、同時に取り込んだファイル → 既存のノート・ナレッジのタイトルの順でリンクに解決される。解決できなかったリンクは `[[...]]` のテキストのまま残る。Markdown 書き出しもノートへのリンクを `[[wikilinks]]` として書き出すので、書き出したノートを取り込み直してもリンクは保たれる | +| 手元の資料を入れる | サイドバーの **資料を入れる**(空ノートの先頭のチップ、ノート一覧と素材ギャラリーの空状態、ウィンドウのどこかへのフォルダのドロップも同じ)で投入口が開く。Markdown はノートに、PDF・Word・画像・音声・動画・区切りデータは素材になる。それ以外は「対象外」として件数だけ示し、黙って捨てない。Obsidian の vault はそのままで、`[[wikilinks]]` は同時に入れたファイル → 既存のノート・ナレッジのタイトルの順でリンクに解決され、参照している画像も一緒に入る。解決できなかったリンクは `[[...]]` のテキストのまま残る。終わると入ったものの数と、**ノートを検索する**・**つながりを見る**・**AI に聞く** が並ぶ。Markdown 書き出しはノートへのリンクを `[[wikilinks]]` として書き出すので、書き出したノートを入れ直してもリンクは保たれる | | スマホでの記録 | スマホで撮った写真やメモは受信箱に届く。[スマホでの記録](/ja/mobile)を参照 | Word ファイルには追加の配慮があります。組み込みのプレビューは、ブラウザが表示できない形式(EMF と TIFF。Excel のグラフを貼った文書によくあります)を変換します。また素材メニューの **埋め込み画像を抽出** は、PDF や Word ファイルの中の画像を取り出して、独立した画像素材として登録します。 diff --git a/manual/materials-and-citations.md b/manual/materials-and-citations.md index 58c69e2d5..8ec25a256 100644 --- a/manual/materials-and-citations.md +++ b/manual/materials-and-citations.md @@ -35,7 +35,7 @@ There are several routes, all ending in the same library: | Links already in a note | Clicking a link that is not registered yet — for example one written into the note by an AI chat answer — opens the side peek with an **Add to materials** button in the header. Review the page first, then register it: the URL joins the **URLs** gallery and appears in graphs. Nothing is registered automatically | | Slash menu **Bookmark** / **PDF** | Insert a URL card or an embedded PDF viewer directly as a block | | Gallery buttons | **Upload**, **Add URL**, **Add PDF**, **Add Word** — add materials without touching any note | -| Markdown import | In the note list, the **Import files** button offers **Import Markdown (.md)** and **Import Obsidian Vault folder**. `[[wikilinks]]` are resolved to note links — first against files in the same import, then against your existing notes and knowledge entries by title. Unresolved links stay as plain `[[...]]` text. Markdown export writes note links back as `[[wikilinks]]`, so exported notes re-import with their links intact | +| Bring in what you already have | **Bring in files** in the sidebar (also the first chip on an empty note, the empty note list and materials gallery, or simply dropping a folder anywhere in the window) opens one intake sheet. Markdown becomes notes; PDF, Word, images, audio, video and delimited data become materials; anything else is counted as skipped, never dropped silently. Obsidian vaults work as they are: `[[wikilinks]]` are resolved to note links — first against files in the same batch, then against your existing notes and knowledge entries by title — and referenced images come along. Unresolved links stay as plain `[[...]]` text. When it finishes, a report shows what came in and offers **Search your notes**, **See connections** and **Ask AI**. Markdown export writes note links back as `[[wikilinks]]`, so exported notes re-import with their links intact | | Mobile capture | Photos and memos captured on your phone land in an inbox — see [Mobile](/mobile) | Word files get extra care: the built-in preview converts formats browsers cannot show (EMF and TIFF, common in documents with pasted Excel charts), and the material menu's **Extract embedded images** pulls the embedded images out of a PDF or Word file and registers them as image materials of their own. diff --git a/src/components/FileSidebar.tsx b/src/components/FileSidebar.tsx index 5bfd3037f..d66d1a5b6 100644 --- a/src/components/FileSidebar.tsx +++ b/src/components/FileSidebar.tsx @@ -22,6 +22,8 @@ export type FileSidebarProps = { onNewNote: () => void; /** Quick Memo ダイアログを開く(思いつきを 1 行で書き留める入口) */ onNewMemo?: () => void; + /** 投入口(既存資料の一括持ち込み)を開く。手元の資料をそのまま入れる入口。 */ + onOpenIntake?: () => void; /** ヘッダーの「戻る」操作(履歴を 1 段戻す)。canGoBack が false のときは非表示。 */ onBack?: () => void; /** 戻れる履歴があるか。 */ @@ -185,6 +187,7 @@ export function FileSidebar({ onSelect, onNewNote, onNewMemo, + onOpenIntake, onBack, canGoBack, onRefresh, @@ -363,7 +366,9 @@ export function FileSidebar({ ノートに ⌘⇧N を割り当てないのは、ブラウザの「新規シークレットウィンドウ」と衝突して preventDefault が効かないため。期待を裏切るより、サイドバーボタン経由に一本化する。 「メモ=即速度、ノート=じっくり」の非対称さを UI でも素直に表現する。 - パディングは py-1.5 で抑えて、ロゴが視覚的なトップにくるようにヒエラルキーを保つ。 */} + パディングは py-1.5 で抑えて、ロゴが視覚的なトップにくるようにヒエラルキーを保つ。 + 3 本目(投入口)だけは「作る」ではなく「手元の資料を入れる」動詞。ゼロから書くのでは + なく過去資産の再利用から始めるのが初回の推奨導線なので、常設のボタンとして見せる。 */} {onNewMemo && ( + {onOpenIntake && ( + + )} {/* セクション一覧 — IA 第 2 ラウンド E 案(design.md 2026-09-03)。 diff --git a/src/features/asset-browser/AssetGalleryView.tsx b/src/features/asset-browser/AssetGalleryView.tsx index bceb44827..abd02e697 100644 --- a/src/features/asset-browser/AssetGalleryView.tsx +++ b/src/features/asset-browser/AssetGalleryView.tsx @@ -35,6 +35,7 @@ import type { CitationSource } from "./SelectionPill"; import { UrlBookmarkModal } from "./UrlBookmarkModal"; import { MediaPickerModal } from "./MediaPickerModal"; import { useIsDesktop } from "../../hooks/use-media-query"; +import { IntakeReceptacle, type IntakeFile, type IntakeSource } from "../intake"; type SortKey = "uploadedAt" | "name" | "usedIn"; @@ -455,6 +456,8 @@ export type AssetGalleryViewProps = { onAddUrlBookmark?: (entry: MediaIndexEntry) => void; /** ファイル直接アップロード(image/video/audio/pdf/document、ノート非経由) */ onUploadMedia?: (file: File) => Promise; + /** 投入口の受け皿から直接渡されたファイル群を取り込む(素材が 1 件も無いときの初回受け皿用) */ + onIntakeFiles?: (files: IntakeFile[], source: IntakeSource) => void; /** メディアから Knowledge を生成(URL/PDF 用) */ onIngestMedia?: (entry: MediaIndexEntry) => void; /** URL から PROV ラベル付きノートを生成する(URL エントリー限定) */ @@ -594,6 +597,7 @@ export function AssetGalleryView({ noteFolderLookup, onAddUrlBookmark, onUploadMedia, + onIntakeFiles, onIngestMedia, onCreateProvNote, onTranslatePdf, @@ -1578,9 +1582,15 @@ export function AssetGalleryView({

{t("common.loading")}

) : filtered.length === 0 ? ( -
-

{t("asset.noMedia")}

-
+ onIntakeFiles && mediaIndex && mediaIndex.media.length === 0 ? ( +
+ +
+ ) : ( +
+

{t("asset.noMedia")}

+
+ ) ) : viewMode === "gallery" ? ( // 列数はコンテナ幅に追従(サイドピークが inline で並ぶと自動で減る)。 // モバイル(overlay 表示)はリフロー不要なので従来どおり 2 列固定 diff --git a/src/features/intake/IntakeDropOverlay.stories.tsx b/src/features/intake/IntakeDropOverlay.stories.tsx new file mode 100644 index 000000000..5a24c30bf --- /dev/null +++ b/src/features/intake/IntakeDropOverlay.stories.tsx @@ -0,0 +1,30 @@ +// 全画面ドロップ案内のストーリー +// +// 背景の上に薄い点線の枠と中央カードが重なって見えるかが合否。 + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { IntakeDropOverlay } from "./IntakeDropOverlay"; + +const meta: Meta = { + title: "Features/Intake/IntakeDropOverlay", + component: IntakeDropOverlay, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** 表示中。背景のダミー文章との重なりが分かるようにしている */ +export const Visible: Story = { + args: { visible: true }, + render: (args) => ( +
+

ノート一覧

+

+ ここは背景のダミー文章です。ウィンドウのどこかでファイルをドラッグしている間、 + この上に全画面のドロップ案内が重なって表示されます。 +

+ +
+ ), +}; diff --git a/src/features/intake/IntakeDropOverlay.tsx b/src/features/intake/IntakeDropOverlay.tsx new file mode 100644 index 000000000..fa1dc6968 --- /dev/null +++ b/src/features/intake/IntakeDropOverlay.tsx @@ -0,0 +1,32 @@ +// 全画面ドロップ案内 +// +// ウィンドウのどこかにファイルをドラッグしている間だけ、画面全体に「ここに +// 落とせば入る」ことを知らせる。イベントは一切奪わない(pointer-events: none)。 +// ドロップの実際の受け取りは useGlobalFileDrop 側が window の drop で行う。 + +import { createPortal } from "react-dom"; +import { FolderInput } from "lucide-react"; +import { useT } from "@/i18n"; + +type IntakeDropOverlayProps = { + visible: boolean; +}; + +export function IntakeDropOverlay({ visible }: IntakeDropOverlayProps) { + const t = useT(); + if (!visible) return null; + + return createPortal( +
+
+
+
+ +

{t("intake.dropOverlay")}

+

{t("intake.rule")}

+
+
+
, + document.body, + ); +} diff --git a/src/features/intake/IntakeModal.stories.tsx b/src/features/intake/IntakeModal.stories.tsx new file mode 100644 index 000000000..2b98e721d --- /dev/null +++ b/src/features/intake/IntakeModal.stories.tsx @@ -0,0 +1,137 @@ +// 投入口モーダルのストーリー +// +// idle(受け皿) / running(進行中) / done(復元レポート)の 3 状態を並べる。 +// 各状態の合否はストーリー先頭のコメントを参照。 + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { IntakeModal } from "./IntakeModal"; + +const meta: Meta = { + title: "Features/Intake/IntakeModal", + component: IntakeModal, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +const noop = () => {}; + +/** 受け皿。「規則の 1 文が読める」「ボタン 2 つの主従が分かる」が合否 */ +export const Idle: Story = { + args: { + open: true, + state: { kind: "idle" }, + onClose: noop, + onFilesSelected: noop, + }, +}; + +/** dragActive: true。受け皿が強調される */ +export const IdleDragging: Story = { + args: { + open: true, + state: { kind: "idle" }, + dragActive: true, + onClose: noop, + onFilesSelected: noop, + }, +}; + +/** 進行中。進捗バーとファイル名の表示が既存の importProgress と揃っているかが合否 */ +export const Running: Story = { + args: { + open: true, + state: { + kind: "running", + done: 12, + total: 40, + current: "2023-05-14 焼成テスト.md", + failed: [], + }, + onClose: noop, + onFilesSelected: noop, + }, +}; + +/** 進行中 + 失敗あり。失敗件数の表示位置が合否 */ +export const RunningWithFailures: Story = { + args: { + open: true, + state: { + kind: "running", + done: 30, + total: 40, + current: "2023-05-14 焼成テスト.md", + failed: ["broken.md", "old-notes.md"], + }, + onClose: noop, + onFilesSelected: noop, + }, +}; + +/** 復元レポート。数字の一覧と「次の動作」の並びが合否 */ +export const Done: Story = { + args: { + open: true, + state: { + kind: "done", + notes: 38, + materials: 17, + linksResolved: 120, + linksUnresolved: 0, + failed: [], + skipped: 0, + aiAvailable: true, + }, + onClose: noop, + onFilesSelected: noop, + onSearch: noop, + onShowGraph: noop, + onAskAi: noop, + }, +}; + +/** 復元レポート + 未解決リンク・失敗あり。注意書きが2つとも出るかが合否 */ +export const DoneWithIssues: Story = { + args: { + open: true, + state: { + kind: "done", + notes: 36, + materials: 17, + linksResolved: 117, + linksUnresolved: 3, + failed: ["broken.md", "old-notes.md"], + skipped: 4, + aiAvailable: true, + }, + onClose: noop, + onFilesSelected: noop, + onSearch: noop, + onShowGraph: noop, + onAskAi: noop, + }, +}; + +/** 復元レポート・AI 未設定。「AI に聞く」の代わりに「AI を設定する」が出るかが合否 */ +export const DoneNoAi: Story = { + args: { + open: true, + state: { + kind: "done", + notes: 38, + materials: 17, + linksResolved: 120, + linksUnresolved: 0, + failed: [], + skipped: 0, + aiAvailable: false, + }, + onClose: noop, + onFilesSelected: noop, + onSearch: noop, + onShowGraph: noop, + onSetupAi: noop, + }, +}; diff --git a/src/features/intake/IntakeModal.tsx b/src/features/intake/IntakeModal.tsx new file mode 100644 index 000000000..a3d177813 --- /dev/null +++ b/src/features/intake/IntakeModal.tsx @@ -0,0 +1,195 @@ +// 投入口モーダル — 既存資料を一括で持ち込むための入口 +// +// 「何がノートになり、何が素材になるか」を最初に 1 文で言い切り(idle)、 +// 進行中は Obsidian の vault インポートと同じ見た目の進捗バーを見せ(running)、 +// 終わったら数字と次の動作を並べる復元レポートを出す(done)。 +// +// この部品は状態を持たない。呼び出し側が state を差し替えることで idle → +// running → done を進める。ファイル選択・ドロップの受け取りだけをこの中で +// 完結させ、実際の取り込み処理(Markdown パース・素材登録等)は onFilesSelected +// を受けた呼び出し側の責務にする。 + +import { useT } from "@/i18n"; +import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/ui/modal"; +import { Button } from "@/ui/button"; +import { IntakeReceptacle } from "./IntakeReceptacle"; +import type { IntakeFile, IntakeSource } from "./types"; + +export type IntakeState = + | { kind: "idle" } + | { kind: "running"; done: number; total: number; current?: string; failed: string[] } + | { + kind: "done"; + notes: number; + materials: number; + linksResolved: number; + linksUnresolved: number; + failed: string[]; + /** 対象外で入れなかった件数 */ + skipped: number; + aiAvailable: boolean; + }; + +type IntakeModalProps = { + open: boolean; + onClose: () => void; + state: IntakeState; + /** 呼び出し側が「ウィンドウのどこかでドラッグ中」を知らせたいとき用。受け皿を強調する */ + dragActive?: boolean; + /** 隠し input(webkitdirectory)でフォルダを選んだとき / ファイルを選んだとき / 受け皿に落としたとき。すべて同じ形で渡す */ + onFilesSelected: (files: IntakeFile[], source: IntakeSource) => void; + /** 復元レポートの次の動作 */ + onSearch?: () => void; + onShowGraph?: () => void; + onAskAi?: () => void; + /** AI 未設定のとき「AI を設定する」を押した */ + onSetupAi?: () => void; +}; + +export function IntakeModal({ + open, + onClose, + state, + dragActive = false, + onFilesSelected, + onSearch, + onShowGraph, + onAskAi, + onSetupAi, +}: IntakeModalProps) { + const t = useT(); + + return ( + +
+ + {state.kind === "done" ? t("intake.doneTitle") : t("intake.title")} + + + {state.kind === "idle" && ( + + +

{t("intake.obsidianHint")}

+
+ )} + + {state.kind === "running" && ( + + {/* × は出すが、押しても取り込みループ自体は中断できない + (今の実装は一括処理のため)。閉じると進捗の表示だけが消える */} +
+
+ + {t("intake.running", { done: String(state.done), total: String(state.total) })} + + {state.failed.length > 0 && ( + + {t("noteList.importFailedCount", { count: String(state.failed.length) })} + + )} +
+
+
+
+ {state.current && ( +
+ {t("noteList.importProcessing", { name: state.current })} +
+ )} +
+ + )} + + {state.kind === "done" && ( + <> + +
+ {/* 数字は 1 列に縦に並べる。2 列グリッドだと左列の数字と右列の見出しが + 隣り合って「36Materials」のように読めてしまう */} +
+
+
{t("intake.statNotes")}
+
{state.notes}
+
+
+
{t("intake.statMaterials")}
+
{state.materials}
+
+
+
{t("intake.statLinks")}
+
+ {state.linksResolved} + {state.linksUnresolved > 0 && ( + + {t("intake.statUnresolved", { count: String(state.linksUnresolved) })} + + )} +
+
+
+
{t("intake.statFailed")}
+
0 ? "text-destructive" : "text-foreground" + }`} + > + {state.failed.length} +
+
+
+ + {state.linksUnresolved > 0 && ( +

{t("import.unresolvedLinksNote")}

+ )} + {state.skipped > 0 && ( +

+ {t("intake.skipped", { count: String(state.skipped) })} +

+ )} + {state.failed.length > 0 && ( +

+ {t("noteList.importFailedFiles", { names: state.failed.join(", ") })} +

+ )} + +
+

{t("intake.next")}

+
+ + + {state.aiAvailable ? ( + + ) : ( + + )} +
+
+
+
+ + + + + )} +
+ + ); +} diff --git a/src/features/intake/IntakeReceptacle.stories.tsx b/src/features/intake/IntakeReceptacle.stories.tsx new file mode 100644 index 000000000..dcadc55eb --- /dev/null +++ b/src/features/intake/IntakeReceptacle.stories.tsx @@ -0,0 +1,48 @@ +// 投入口の受け皿単体のストーリー +// +// IntakeModal から切り出した共通部品。「点線の箱・アイコン・2 行・ボタン 2 つ」の +// 見た目が単体でも変わっていないかが合否。 + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { IntakeReceptacle } from "./IntakeReceptacle"; + +const meta: Meta = { + title: "Features/Intake/IntakeReceptacle", + component: IntakeReceptacle, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +const noop = () => {}; + +/** 既定の見た目 */ +export const Default: Story = { + args: { + onFilesSelected: noop, + }, +}; + +/** ウィンドウのどこかでドラッグ中のときの強調表示 */ +export const Emphasized: Story = { + args: { + emphasized: true, + onFilesSelected: noop, + }, +}; + +/** lead を差し替えたケース(例: 素材が空のとき) */ +export const WithLead: Story = { + args: { + lead: "No notes yet. Start with what you already have.", + onFilesSelected: noop, + }, +}; diff --git a/src/features/intake/IntakeReceptacle.tsx b/src/features/intake/IntakeReceptacle.tsx new file mode 100644 index 000000000..7e6bffa7d --- /dev/null +++ b/src/features/intake/IntakeReceptacle.tsx @@ -0,0 +1,96 @@ +// 投入口の受け皿(点線の箱+アイコン+2 行+ボタン 2 つ) +// +// 元は IntakeModal の idle 本文に直書きしていたものを切り出した共通部品。 +// 見た目は変えていない。IntakeModal のほか、フルスクリーンのドロップ案内とは +// 別に「モーダルを開かず直接この受け皿を置きたい」場面(例: ノート一覧の空状態) +// でも同じものを使い回せるようにする。 + +import { useRef, useState, type DragEvent } from "react"; +import { FolderInput } from "lucide-react"; +import { useT } from "@/i18n"; +import { Button } from "@/ui/button"; +import { collectDroppedFiles } from "./collect-dropped-files"; +import { toIntakeFiles, type IntakeFile, type IntakeSource } from "./types"; + +type IntakeReceptacleProps = { + /** 1 行目。未指定は intake.dropHere */ + lead?: string; + /** 外側からの強調(ウィンドウのどこかでドラッグ中) */ + emphasized?: boolean; + onFilesSelected: (files: IntakeFile[], source: IntakeSource) => void; +}; + +export function IntakeReceptacle({ lead, emphasized = false, onFilesSelected }: IntakeReceptacleProps) { + const t = useT(); + const folderInputRef = useRef(null); + const filesInputRef = useRef(null); + // 受け皿の中に入っているかどうか(外側の emphasized とは独立に、 + // 部品内のドラッグ判定でも強調できるようにする) + const [internalOver, setInternalOver] = useState(false); + + const emphasize = emphasized || internalOver; + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + setInternalOver(false); + const files = await collectDroppedFiles(e.dataTransfer); + if (files.length > 0) onFilesSelected(files, "drop"); + }; + + return ( +
{ + e.preventDefault(); + setInternalOver(true); + }} + onDragLeave={() => setInternalOver(false)} + onDrop={handleDrop} + className={`rounded-xl border border-dashed px-6 py-8 flex flex-col items-center text-center gap-3 transition-colors ${ + emphasize ? "border-primary bg-accent" : "border-border bg-muted/30" + }`} + > +
+ +
+

{lead ?? t("intake.dropHere")}

+

{t("intake.rule")}

+
+ + +
+ + {/* フォルダ選択用(Markdown インポートの既存実装と同じ書き方) */} + )} + multiple + className="hidden" + onChange={(e) => { + const files = toIntakeFiles(e.target.files ?? []); + e.target.value = ""; + if (files.length > 0) onFilesSelected(files, "folder"); + }} + /> + {/* ファイル選択用 */} + { + const files = toIntakeFiles(e.target.files ?? []); + e.target.value = ""; + if (files.length > 0) onFilesSelected(files, "files"); + }} + /> +
+ ); +} diff --git a/src/features/intake/classify.test.ts b/src/features/intake/classify.test.ts new file mode 100644 index 000000000..eb3d90ba1 --- /dev/null +++ b/src/features/intake/classify.test.ts @@ -0,0 +1,79 @@ +// classifyIntakeFiles のテスト +// +// notes / materials / skipped への振り分けを、拡張子・隠しパスの各パターンで確認する。 + +import { describe, it, expect } from "vitest"; +import { classifyIntakeFiles } from "./classify"; +import type { IntakeFile } from "./types"; + +function intakeFile(name: string, path: string, type: string): IntakeFile { + return { file: new File(["dummy"], name, { type }), path }; +} + +describe("classifyIntakeFiles", () => { + it("Markdown はノートに分類される", () => { + const f = intakeFile("note.md", "note.md", "text/markdown"); + const result = classifyIntakeFiles([f]); + expect(result.notes).toEqual([f]); + expect(result.materials).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it("PDF は素材に分類される", () => { + const f = intakeFile("paper.pdf", "paper.pdf", "application/pdf"); + const result = classifyIntakeFiles([f]); + expect(result.materials).toEqual([f]); + }); + + it("画像は素材に分類される", () => { + const f = intakeFile("photo.png", "photo.png", "image/png"); + const result = classifyIntakeFiles([f]); + expect(result.materials).toEqual([f]); + }); + + it("CSV は区切りテキストとして素材に分類される", () => { + const f = intakeFile("data.csv", "data.csv", "text/csv"); + const result = classifyIntakeFiles([f]); + expect(result.materials).toEqual([f]); + }); + + it(".obsidian 配下のファイルは対象外", () => { + const f = intakeFile("app.json", ".obsidian/app.json", "application/json"); + const result = classifyIntakeFiles([f]); + expect(result.skipped).toEqual([f]); + }); + + it("パス途中がドット始まりの隠しファイルも対象外(notes/.hidden.md)", () => { + // 拡張子は .md だが、隠しパスの判定を先に見るため notes 扱いにはしない + const f = intakeFile(".hidden.md", "notes/.hidden.md", "text/markdown"); + const result = classifyIntakeFiles([f]); + expect(result.skipped).toEqual([f]); + expect(result.notes).toEqual([]); + }); + + it("readme.txt は isDelimitedDataFile の対象拡張子(.txt)のため素材扱い", () => { + const f = intakeFile("readme.txt", "readme.txt", "text/plain"); + const result = classifyIntakeFiles([f]); + expect(result.materials).toEqual([f]); + expect(result.skipped).toEqual([]); + }); + + it("拡張子不明のファイルは対象外", () => { + const f = intakeFile("mystery.xyz", "mystery.xyz", ""); + const result = classifyIntakeFiles([f]); + expect(result.skipped).toEqual([f]); + }); + + it("type が空の PDF(ドロップ由来)は拡張子から推定して素材に分類される", () => { + const f = intakeFile("paper.pdf", "paper.pdf", ""); + const result = classifyIntakeFiles([f]); + expect(result.materials).toEqual([f]); + expect(result.skipped).toEqual([]); + }); + + it("type が空で拡張子も不明なファイルは対象外", () => { + const f = intakeFile("x.unknownext", "x.unknownext", ""); + const result = classifyIntakeFiles([f]); + expect(result.skipped).toEqual([f]); + }); +}); diff --git a/src/features/intake/classify.ts b/src/features/intake/classify.ts new file mode 100644 index 000000000..b732e6d31 --- /dev/null +++ b/src/features/intake/classify.ts @@ -0,0 +1,75 @@ +// 投入口に集まったファイルの仕分け +// +// 「ノートになるもの(Markdown)」「素材になるもの(PDF/画像/CSV 等)」 +// 「対象外(隠しファイル・未対応形式)」の 3 群に分ける。判定ロジック自体は +// markdown-import / asset-browser の既存関数を再利用し、ここでは並び替えのみ行う。 + +import { isMarkdownFile } from "../markdown-import/import"; +import { mimeToMediaType } from "../asset-browser/media-index"; +import type { IntakeFile } from "./types"; + +export type ClassifiedIntakeFiles = { + notes: IntakeFile[]; + materials: IntakeFile[]; + skipped: IntakeFile[]; +}; + +/** path のいずれかのセグメントがドット始まりか(.obsidian, .git, .DS_Store 等) */ +function isHiddenPath(path: string): boolean { + return path.split("/").some((segment) => segment.startsWith(".")); +} + +// 拡張子 → MIME の最小限の対応表。ドロップ由来の File は環境によって +// file.type が空文字になることがあり、その場合に mimeToMediaType へ渡す +// フォールバックとして使う +const EXTENSION_TO_MIME: Record = { + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + heic: "image/heic", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + mp3: "audio/mpeg", + wav: "audio/wav", + m4a: "audio/mp4", + ogg: "audio/ogg", +}; + +/** ファイル名の拡張子から MIME タイプを推定する。不明なら空文字を返す */ +export function guessMimeType(fileName: string): string { + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex < 0) return ""; + const ext = fileName.slice(dotIndex + 1).toLowerCase(); + return EXTENSION_TO_MIME[ext] ?? ""; +} + +export function classifyIntakeFiles(files: IntakeFile[]): ClassifiedIntakeFiles { + const notes: IntakeFile[] = []; + const materials: IntakeFile[] = []; + const skipped: IntakeFile[] = []; + + for (const f of files) { + if (isHiddenPath(f.path)) { + skipped.push(f); + continue; + } + if (isMarkdownFile(f.file)) { + notes.push(f); + continue; + } + if (mimeToMediaType(f.file.type || guessMimeType(f.file.name), f.file.name) !== "other") { + materials.push(f); + continue; + } + skipped.push(f); + } + + return { notes, materials, skipped }; +} diff --git a/src/features/intake/collect-dropped-files.test.ts b/src/features/intake/collect-dropped-files.test.ts new file mode 100644 index 000000000..4d6950205 --- /dev/null +++ b/src/features/intake/collect-dropped-files.test.ts @@ -0,0 +1,117 @@ +// collectDroppedFiles のテスト +// +// - フォルダの再帰的な収集(readEntries が空になるまで繰り返し呼ばれる) +// - webkitGetAsEntry が使えない環境でのフォールバック(dt.files を素通し) + +import { describe, it, expect, vi } from "vitest"; +import { collectDroppedFiles } from "./collect-dropped-files"; + +type FakeEntry = { + isFile: boolean; + isDirectory: boolean; + fullPath: string; + file?: (success: (f: File) => void) => void; + createReader?: () => { readEntries: (success: (entries: FakeEntry[]) => void) => void }; +}; + +function fakeFileEntry(fullPath: string, file: File): FakeEntry { + return { + isFile: true, + isDirectory: false, + fullPath, + file: (success) => success(file), + }; +} + +function fakeDirEntry(fullPath: string, batches: FakeEntry[][]): FakeEntry { + let call = 0; + return { + isFile: false, + isDirectory: true, + fullPath, + createReader: () => ({ + readEntries: (success) => { + const batch = batches[call] ?? []; + call += 1; + success(batch); + }, + }), + }; +} + +function makeDataTransfer(entries: (FakeEntry | null)[], fallbackFiles: File[] = []): DataTransfer { + const items = entries.map((entry) => ({ + webkitGetAsEntry: () => entry, + })); + return { + items, + files: fallbackFiles, + } as unknown as DataTransfer; +} + +// entry が取れる項目・取れない項目(getAsFile で拾う)が混在するケース用 +function makeMixedDataTransfer( + parts: ({ entry: FakeEntry } | { file: File })[], +): DataTransfer { + const items = parts.map((part) => { + if ("entry" in part) { + return { kind: "file", webkitGetAsEntry: (): FakeEntry | null => part.entry }; + } + return { kind: "file", webkitGetAsEntry: (): FakeEntry | null => null, getAsFile: () => part.file }; + }); + return { items, files: [] } as unknown as DataTransfer; +} + +describe("collectDroppedFiles", () => { + it("フォルダを再帰的に辿り、readEntries が空になるまで繰り返し呼ぶ", async () => { + const a = fakeFileEntry("/vault/a.md", new File(["a"], "a.md")); + const b = fakeFileEntry("/vault/sub/b.md", new File(["b"], "b.md")); + const c = fakeFileEntry("/vault/sub/c.md", new File(["c"], "c.md")); + const readEntriesSpy = vi.fn(); + + const subDir = fakeDirEntry("/vault/sub", [[b], [c], []]); + // 1 回目に一部・2 回目に残り・3 回目に空、を検証するためスパイでラップ + const originalCreateReader = subDir.createReader!; + subDir.createReader = () => { + const reader = originalCreateReader(); + const original = reader.readEntries; + reader.readEntries = (success) => { + readEntriesSpy(); + original(success); + }; + return reader; + }; + + const rootDir = fakeDirEntry("/vault", [[a, subDir], []]); + + const dt = makeDataTransfer([rootDir]); + const files = await collectDroppedFiles(dt); + + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(["vault/a.md", "vault/sub/b.md", "vault/sub/c.md"]); + // sub ディレクトリの readEntries は 3 回呼ばれている(空が返るまで) + expect(readEntriesSpy).toHaveBeenCalledTimes(3); + }); + + it("webkitGetAsEntry が使えない環境では dt.files をそのまま返す", async () => { + const f1 = new File(["x"], "note.md"); + const f2 = new File(["y"], "photo.png"); + const dt = makeDataTransfer([null], [f1, f2]); + + const files = await collectDroppedFiles(dt); + + expect(files.map((f) => f.file)).toEqual([f1, f2]); + expect(files.map((f) => f.path)).toEqual(["note.md", "photo.png"]); + }); + + it("entry あり 1 件 + entry null で getAsFile あり 1 件 → 両方入る(丸ごとフォールバックしない)", async () => { + const a = fakeFileEntry("/vault/a.md", new File(["a"], "a.md")); + const b = new File(["b"], "b.png"); + const dt = makeMixedDataTransfer([{ entry: a }, { file: b }]); + + const files = await collectDroppedFiles(dt); + + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(["b.png", "vault/a.md"]); + }); +}); diff --git a/src/features/intake/collect-dropped-files.ts b/src/features/intake/collect-dropped-files.ts new file mode 100644 index 000000000..1f66c8ec4 --- /dev/null +++ b/src/features/intake/collect-dropped-files.ts @@ -0,0 +1,124 @@ +// ドロップされたファイル群の収集 +// +// DataTransfer.items[i].webkitGetAsEntry() が使えるブラウザでは、フォルダごと +// 落とされた場合に FileSystemDirectoryEntry を再帰的に辿ってフォルダ構造を +// 復元する。使えない環境(entry が全部 null 等)では dt.files をそのまま渡す +// フォールバックにする。 +// +// ドットで始まるファイル・フォルダ(.obsidian, .git, .DS_Store 等)もここでは +// 落とさない。対象外の判定は classify(次段)に任せ、「何件除外したか」を +// 利用者に見せる方針のため、黙って捨てない。 + +import { type IntakeFile, toIntakeFiles } from "./types"; + +// lib.dom.d.ts に無い File System Entries API の最小限の型 +interface FileSystemEntryLike { + isFile: boolean; + isDirectory: boolean; + fullPath: string; +} +interface FileSystemFileEntryLike extends FileSystemEntryLike { + isFile: true; + file(success: (file: File) => void, error?: (err: unknown) => void): void; +} +interface FileSystemDirectoryReaderLike { + readEntries( + success: (entries: FileSystemEntryLike[]) => void, + error?: (err: unknown) => void, + ): void; +} +interface FileSystemDirectoryEntryLike extends FileSystemEntryLike { + isDirectory: true; + createReader(): FileSystemDirectoryReaderLike; +} + +function entryToFile(entry: FileSystemFileEntryLike): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +/** + * ディレクトリの全エントリを読み切る。Chrome 系は readEntries を 1 回呼ぶと + * 最大 100 件しか返さない仕様があるため、空配列が返るまで繰り返し呼ぶ。 + */ +function readAllEntries(reader: FileSystemDirectoryReaderLike): Promise { + return new Promise((resolve, reject) => { + const all: FileSystemEntryLike[] = []; + const readNext = () => { + reader.readEntries((entries) => { + if (entries.length === 0) { + resolve(all); + return; + } + all.push(...entries); + readNext(); + }, reject); + }; + readNext(); + }); +} + +async function walkEntry(entry: FileSystemEntryLike, out: IntakeFile[]): Promise { + if (entry.isFile) { + const file = await entryToFile(entry as FileSystemFileEntryLike); + // fullPath は "/foo/bar.md" 形式。先頭の "/" を落として相対パスにする + const path = entry.fullPath.replace(/^\/+/, ""); + out.push({ file, path }); + return; + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntryLike).createReader(); + const children = await readAllEntries(reader); + for (const child of children) { + await walkEntry(child, out); + } + } +} + +/** + * items を 1 件ずつ見て、entry が取れたものは辿り、取れなくても + * item.getAsFile() が使えればファイルとして拾う(混在ドロップ対応)。 + * それぞれ 1 件も取れなかった要素だけを返し、全滅していれば呼び出し元で + * dt.files へフォールバックする + */ +async function collectViaItems(items: DataTransferItem[]): Promise { + const out: IntakeFile[] = []; + let collectedAny = false; + + for (const item of items) { + // webkitGetAsEntry は型定義上 DataTransferItem に存在するが、環境によっては + // 未実装で undefined/null を返す + const getEntry = (item as unknown as { webkitGetAsEntry?: () => FileSystemEntryLike | null }) + .webkitGetAsEntry; + const entry = typeof getEntry === "function" ? getEntry.call(item) : null; + if (entry) { + collectedAny = true; + await walkEntry(entry, out); + continue; + } + // entry が取れなくても、この項目がファイルなら getAsFile で拾う + if (item.kind === "file" && typeof item.getAsFile === "function") { + const file = item.getAsFile(); + if (file) { + collectedAny = true; + out.push({ file, path: file.name }); + } + } + } + + return collectedAny ? out : null; +} + +/** ドロップされた DataTransfer からファイル一覧を(フォルダなら再帰的に)収集する */ +export async function collectDroppedFiles(dt: DataTransfer): Promise { + const items = dt.items ? Array.from(dt.items) : []; + + if (items.length > 0) { + const collected = await collectViaItems(items); + if (collected !== null) return collected; + } + + // entry も getAsFile も 1 件も取れなかったときだけ dt.files にフォールバック + return toIntakeFiles(Array.from(dt.files ?? [])); +} diff --git a/src/features/intake/index.ts b/src/features/intake/index.ts new file mode 100644 index 000000000..cc380564b --- /dev/null +++ b/src/features/intake/index.ts @@ -0,0 +1,16 @@ +export { IntakeModal } from "./IntakeModal"; +export type { IntakeState } from "./IntakeModal"; + +export { IntakeReceptacle } from "./IntakeReceptacle"; +export { IntakeDropOverlay } from "./IntakeDropOverlay"; + +export { useIntake } from "./use-intake"; +export { useGlobalFileDrop } from "./use-global-file-drop"; + +export { runIntake, mergeOutcome } from "./run-intake"; +export type { IntakeDeps, IntakeOutcome, MarkdownImportResult, IntakeProgress } from "./run-intake"; + +export { classifyIntakeFiles } from "./classify"; +export { collectDroppedFiles } from "./collect-dropped-files"; +export { toIntakeFiles } from "./types"; +export type { IntakeFile, IntakeSource } from "./types"; diff --git a/src/features/intake/run-intake.test.ts b/src/features/intake/run-intake.test.ts new file mode 100644 index 000000000..0471062e9 --- /dev/null +++ b/src/features/intake/run-intake.test.ts @@ -0,0 +1,164 @@ +// runIntake のテスト +// +// 偽の deps(importMarkdown / uploadAsset)で、進捗の単調増加・失敗時の継続・ +// skipped の集計を確認する。 + +import { describe, it, expect, vi } from "vitest"; +import { runIntake, mergeOutcome, type IntakeDeps, type IntakeOutcome, type IntakeProgress, type MarkdownImportResult } from "./run-intake"; +import type { IntakeFile } from "./types"; + +function mdFile(name: string): IntakeFile { + return { file: new File(["# " + name], name, { type: "text/markdown" }), path: name }; +} +function pdfFile(name: string): IntakeFile { + return { file: new File(["dummy"], name, { type: "application/pdf" }), path: name }; +} +function otherFile(name: string): IntakeFile { + return { file: new File(["dummy"], name, { type: "" }), path: name }; +} + +function makeDeps(overrides: Partial = {}): IntakeDeps { + const importMarkdown = vi.fn( + async (files: IntakeFile[], onProgress: (p: IntakeProgress) => void): Promise => { + for (let i = 0; i < files.length; i++) { + onProgress({ done: i + 1, total: files.length, current: files[i].file.name, failed: [] }); + } + return { + created: files.length, + linksResolved: 0, + linksUnresolved: 0, + failed: [], + lastNewId: files.length > 0 ? "note-last" : null, + }; + }, + ); + const uploadAsset = vi.fn(async (_file: File) => ({})); + return { importMarkdown, uploadAsset, ...overrides }; +} + +describe("runIntake", () => { + it("md 3 + pdf 2 の通常ケース: 進捗の done が単調増加して最後に total に達する", async () => { + const files = [mdFile("a.md"), mdFile("b.md"), mdFile("c.md"), pdfFile("d.pdf"), pdfFile("e.pdf")]; + const deps = makeDeps(); + const progresses: IntakeProgress[] = []; + + const outcome = await runIntake(files, deps, (p) => progresses.push(p)); + + expect(outcome.notes).toBe(3); + expect(outcome.materials).toBe(2); + expect(outcome.skipped).toBe(0); + expect(outcome.failed).toEqual([]); + + // done は単調増加 + const doneSeq = progresses.map((p) => p.done); + for (let i = 1; i < doneSeq.length; i++) { + expect(doneSeq[i]).toBeGreaterThanOrEqual(doneSeq[i - 1]); + } + expect(doneSeq[doneSeq.length - 1]).toBe(5); + expect(progresses.every((p) => p.total === 5)).toBe(true); + + // importMarkdown には classify 前の全ファイル(notes + materials)が + // ctx.allFiles として渡る(画像参照の解決に使うため) + expect(deps.importMarkdown).toHaveBeenCalledWith(expect.any(Array), expect.any(Function), { allFiles: files }); + }); + + it("uploadAsset が 1 件 throw しても止まらず failed に入る", async () => { + const files = [mdFile("a.md"), pdfFile("ok.pdf"), pdfFile("bad.pdf")]; + const uploadAsset = vi.fn(async (file: File) => { + if (file.name === "bad.pdf") throw new Error("upload failed"); + return {}; + }); + const deps = makeDeps({ uploadAsset }); + + const outcome = await runIntake(files, deps, () => {}); + + expect(outcome.materials).toBe(1); + expect(outcome.failed).toEqual(["bad.pdf"]); + }); + + it("対象外ファイルが skipped に数えられる", async () => { + const files = [mdFile("a.md"), pdfFile("b.pdf"), otherFile("mystery.xyz")]; + const deps = makeDeps(); + + const outcome = await runIntake(files, deps, () => {}); + + expect(outcome.skipped).toBe(1); + expect(outcome.notes).toBe(1); + expect(outcome.materials).toBe(1); + }); +}); + +describe("runIntake の堅牢性", () => { + it("importMarkdown が丸ごと throw しても notes 全件を失敗にして素材登録まで進む", async () => { + const files = [mdFile("a.md"), mdFile("b.md"), pdfFile("c.pdf")]; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const deps = makeDeps({ + importMarkdown: vi.fn(async () => { + throw new Error("storage unavailable"); + }), + }); + + const outcome = await runIntake(files, deps, () => {}); + + expect(outcome.notes).toBe(0); + expect(outcome.failed).toEqual(["a.md", "b.md"]); + expect(outcome.materials).toBe(1); + expect(deps.uploadAsset).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("notes が 0 件なら importMarkdown を呼ばない", async () => { + const deps = makeDeps(); + const outcome = await runIntake([pdfFile("c.pdf")], deps, () => {}); + expect(deps.importMarkdown).not.toHaveBeenCalled(); + expect(outcome.materials).toBe(1); + }); + + it("afterRun が throw しても結果は返る", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const deps = makeDeps({ + afterRun: async () => { + throw new Error("refresh failed"); + }, + }); + const outcome = await runIntake([mdFile("a.md")], deps, () => {}); + expect(outcome.notes).toBe(1); + warn.mockRestore(); + }); +}); + +describe("mergeOutcome", () => { + it("notes/materials/links/skipped を加算し、failed を連結、lastNewId は後勝ち", () => { + const a: IntakeOutcome = { + notes: 2, + materials: 1, + linksResolved: 3, + linksUnresolved: 1, + failed: ["a.md"], + skipped: 1, + lastNewId: "note-a", + }; + const b: IntakeOutcome = { + notes: 1, + materials: 2, + linksResolved: 0, + linksUnresolved: 2, + failed: ["b.pdf"], + skipped: 0, + lastNewId: null, + }; + + const merged = mergeOutcome(a, b); + + expect(merged).toEqual({ + notes: 3, + materials: 3, + linksResolved: 3, + linksUnresolved: 3, + failed: ["a.md", "b.pdf"], + skipped: 1, + // b の lastNewId が null なので a を保つ + lastNewId: "note-a", + }); + }); +}); diff --git a/src/features/intake/run-intake.ts b/src/features/intake/run-intake.ts new file mode 100644 index 000000000..e9eac05ac --- /dev/null +++ b/src/features/intake/run-intake.ts @@ -0,0 +1,141 @@ +// 投入口の実行器(純関数) +// +// classify → Markdown インポート → 素材アップロードの順に処理を進める。 +// UI に依存しない形にして、IntakeModal の running/done 表示や useIntake から +// 同じロジックを呼べるようにする。 + +import { classifyIntakeFiles } from "./classify"; +import type { IntakeFile } from "./types"; + +/** Markdown インポート(importMarkdown 実装)が返す結果 */ +export type MarkdownImportResult = { + created: number; + linksResolved: number; + linksUnresolved: number; + failed: string[]; + lastNewId: string | null; +}; + +/** 投入口全体の進捗(notes + materials を合算した done/total) */ +export type IntakeProgress = { done: number; total: number; current?: string; failed: string[] }; + +export type IntakeDeps = { + /** + * notes を Graphium ノートとして取り込む。onProgress は notes 内の進捗 + * (done/total は notes 件数基準)。ctx.allFiles は classify 前の全ファイル + * (notes + materials + skipped)で、Markdown 内の画像参照(![[img.png]] 等)を + * 同じフォルダの他ファイルから解決するために使う。 + */ + importMarkdown: ( + files: IntakeFile[], + onProgress: (p: IntakeProgress) => void, + ctx: { allFiles: IntakeFile[] }, + ) => Promise; + /** 素材を 1 件アップロードする */ + uploadAsset: (file: File) => Promise; + /** 全件終了後に 1 回だけ呼ぶ(インデックス再構築など) */ + afterRun?: () => Promise | void; +}; + +export type IntakeOutcome = { + notes: number; + materials: number; + linksResolved: number; + linksUnresolved: number; + failed: string[]; + skipped: number; + lastNewId: string | null; +}; + +/** + * 2 回分の IntakeOutcome を 1 つに畳む。実行中に次のバッチが積まれたとき、 + * バッチごとの結果を合算して最終的な done を 1 回だけ出すために使う。 + * notes / materials / linksResolved / linksUnresolved / skipped は加算、 + * failed は連結、lastNewId は後勝ち(新しい方が null なら前を保つ)。 + */ +export function mergeOutcome(a: IntakeOutcome, b: IntakeOutcome): IntakeOutcome { + return { + notes: a.notes + b.notes, + materials: a.materials + b.materials, + linksResolved: a.linksResolved + b.linksResolved, + linksUnresolved: a.linksUnresolved + b.linksUnresolved, + failed: [...a.failed, ...b.failed], + skipped: a.skipped + b.skipped, + lastNewId: b.lastNewId ?? a.lastNewId, + }; +} + +/** + * 投入口の実行本体。classify → notes(importMarkdown)→ materials(uploadAsset) + * の順で処理し、進捗を全体の done/total に写像して onProgress に流す。 + */ +export async function runIntake( + files: IntakeFile[], + deps: IntakeDeps, + onProgress: (p: IntakeProgress) => void, +): Promise { + const { notes, materials, skipped } = classifyIntakeFiles(files); + const total = notes.length + materials.length; + const failed: string[] = []; + + // notes: importMarkdown 側の進捗(0..notes.length)をそのまま全体の done として流す。 + // 実装側が丸ごと throw しても(保存先が開けない等)素材の登録まで止めない。 + // その場合は notes 全件を失敗扱いにして先へ進む + let markdownResult: MarkdownImportResult = { + created: 0, + linksResolved: 0, + linksUnresolved: 0, + failed: [], + lastNewId: null, + }; + if (notes.length > 0) { + try { + markdownResult = await deps.importMarkdown( + notes, + (p) => { + onProgress({ done: p.done, total, current: p.current, failed: [...failed, ...p.failed] }); + }, + { allFiles: files }, + ); + } catch (err) { + console.warn("[intake] Markdown の取り込みが途中で失敗:", err); + markdownResult = { ...markdownResult, failed: notes.map((n) => n.file.name) }; + } + } + failed.push(...markdownResult.failed); + + // materials: 1 件ずつアップロード。失敗しても続行する + let materialsUploaded = 0; + const notesDone = notes.length; + for (let i = 0; i < materials.length; i++) { + const m = materials[i]; + onProgress({ done: notesDone + i, total, current: m.file.name, failed }); + try { + await deps.uploadAsset(m.file); + materialsUploaded += 1; + } catch (err) { + console.warn(`[intake] 素材のアップロードに失敗: ${m.file.name}`, err); + failed.push(m.file.name); + } + } + onProgress({ done: total, total, failed }); + + // 後処理(一覧の再読込など)が失敗しても、入ったものは入っているので結果は返す + if (deps.afterRun) { + try { + await deps.afterRun(); + } catch (err) { + console.warn("[intake] 取り込み後の処理に失敗:", err); + } + } + + return { + notes: markdownResult.created, + materials: materialsUploaded, + linksResolved: markdownResult.linksResolved, + linksUnresolved: markdownResult.linksUnresolved, + failed, + skipped: skipped.length, + lastNewId: markdownResult.lastNewId, + }; +} diff --git a/src/features/intake/types.ts b/src/features/intake/types.ts new file mode 100644 index 000000000..376ac80d1 --- /dev/null +++ b/src/features/intake/types.ts @@ -0,0 +1,25 @@ +// 投入口(既存資料の一括持ち込み)の共通型 +// +// フォルダ選択・複数ファイル選択・ドロップの 3 経路すべてが、この形に揃えてから +// 分類・実行の各処理に渡す。path はフォルダ構造の復元(将来のノートの入れ子等)に +// 使えるよう、フォルダ内の相対パスを "/" 区切り・先頭スラッシュ無しで持つ。 + +/** 投入口に渡す 1 ファイル。path はフォルダ内の相対パス("/" 区切り・先頭スラッシュ無し)。単体選択なら file.name と同じ */ +export type IntakeFile = { file: File; path: string }; + +/** ファイルがどの経路で渡されたか */ +export type IntakeSource = "folder" | "files" | "drop"; + +/** + * `` や `` から来た FileList/File[] を + * IntakeFile[] に変換する。webkitRelativePath があればそれを path にする + * (フォルダ選択時にブラウザが付与する)。無ければ file.name を単体ファイルの + * path として使う。 + */ +export function toIntakeFiles(files: FileList | File[]): IntakeFile[] { + return Array.from(files).map((file) => { + // webkitRelativePath は型定義に存在しないため any 経由で読む + const relativePath = (file as unknown as { webkitRelativePath?: string }).webkitRelativePath; + return { file, path: relativePath && relativePath.length > 0 ? relativePath : file.name }; + }); +} diff --git a/src/features/intake/use-global-file-drop.test.ts b/src/features/intake/use-global-file-drop.test.ts new file mode 100644 index 000000000..519c0c5fb --- /dev/null +++ b/src/features/intake/use-global-file-drop.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +// useGlobalFileDrop のテスト +// +// jsdom には DragEvent が無いため、bubbles:true の Event を作って +// dataTransfer を Object.defineProperty で後付けする。target は +// dispatchEvent を呼ぶ要素(bubble して window まで届く)で決める。 + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useGlobalFileDrop } from "./use-global-file-drop"; +import * as collectModule from "./collect-dropped-files"; +import type { IntakeFile } from "./types"; + +function makeFileDragEvent(type: string): Event { + const e = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(e, "dataTransfer", { + value: { types: ["Files"], files: [], items: [] }, + configurable: true, + }); + return e; +} + +function dispatchFrom(target: EventTarget, type: string): Event { + const e = makeFileDragEvent(type); + target.dispatchEvent(e); + return e; +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +describe("useGlobalFileDrop", () => { + it("enter で dragActive が true になり、leave で false に戻る", () => { + const onFiles = vi.fn(); + const { result } = renderHook(() => useGlobalFileDrop({ enabled: true, onFiles })); + + act(() => { + dispatchFrom(window, "dragenter"); + }); + expect(result.current.dragActive).toBe(true); + + act(() => { + dispatchFrom(window, "dragleave"); + }); + expect(result.current.dragActive).toBe(false); + }); + + it("[data-intake-drop] を持つ要素からの enter はカウントしない", () => { + const onFiles = vi.fn(); + const dropZone = document.createElement("div"); + dropZone.setAttribute("data-intake-drop", ""); + document.body.appendChild(dropZone); + + const { result } = renderHook(() => useGlobalFileDrop({ enabled: true, onFiles })); + + act(() => { + dispatchFrom(dropZone, "dragenter"); + }); + expect(result.current.dragActive).toBe(false); + }); + + it("suspended のときは dragActive にならず onFiles も呼ばれないが、既定動作は止める", async () => { + const onFiles = vi.fn(); + const { result } = renderHook(() => useGlobalFileDrop({ enabled: true, onFiles, suspended: true })); + + let enterEvent: Event; + act(() => { + enterEvent = dispatchFrom(window, "dragenter"); + }); + expect(result.current.dragActive).toBe(false); + expect(enterEvent!.defaultPrevented).toBe(true); + + let dropEvent: Event; + await act(async () => { + dropEvent = dispatchFrom(window, "drop"); + }); + expect(onFiles).not.toHaveBeenCalled(); + expect(dropEvent!.defaultPrevented).toBe(true); + }); + + it("drop で onFiles が呼ばれ、collectDroppedFiles のフォールバック経由で files が渡る", async () => { + const droppedFile = new File(["x"], "note.md"); + const fakeFiles: IntakeFile[] = [{ file: droppedFile, path: "note.md" }]; + vi.spyOn(collectModule, "collectDroppedFiles").mockResolvedValue(fakeFiles); + + const onFiles = vi.fn(); + renderHook(() => useGlobalFileDrop({ enabled: true, onFiles })); + + await act(async () => { + dispatchFrom(window, "drop"); + // collectDroppedFiles は Promise 経由なので 1 tick 待つ + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onFiles).toHaveBeenCalledWith(fakeFiles); + }); +}); diff --git a/src/features/intake/use-global-file-drop.ts b/src/features/intake/use-global-file-drop.ts new file mode 100644 index 000000000..9c53b5917 --- /dev/null +++ b/src/features/intake/use-global-file-drop.ts @@ -0,0 +1,156 @@ +// ウィンドウ全体でのファイルドラッグ&ドロップ監視 +// +// エディタ内・受け皿・モーダル内など「自前でドロップを受ける場所」の上では +// 何もせず、それ以外の場所へのドラッグ・ドロップだけを拾う。 +// +// src/features/media-ocr/drag-idle.ts も window の drop を監視しているが、 +// あちらは capture フェーズで「今どこかでドラッグ中か」を集計するだけの +// 一元管理役で、ここは bubble フェーズで実際のファイル取り込みを担当する。 +// フェーズが違うため干渉しない。 + +import { useEffect, useRef, useState } from "react"; +import { collectDroppedFiles } from "./collect-dropped-files"; +import type { IntakeFile } from "./types"; + +const DEFAULT_IGNORE_SELECTOR = + ".bn-editor, .ProseMirror, [contenteditable='true'], [data-intake-drop], [data-modal-portal]"; + +function defaultShouldIgnore(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return target.closest(DEFAULT_IGNORE_SELECTOR) !== null; +} + +// モーダルの中でも受け皿の外(見出しやフッター)に落とされたファイルは、 +// 誰も受け取らないとブラウザがそのファイルを開いてしまう。取り込みはしないが +// 既定動作だけ止める(受け皿の上は受け皿自身が処理する) +function shouldSwallow(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + // Composer(自前 createPortal, role="dialog")やモバイルの sheet.tsx は + // data-modal-portal を持たないため、role/aria-modal でも同じ扱いにする + const inModal = + target.closest("[data-modal-portal]") !== null || + target.closest("[role='dialog']") !== null || + target.closest("[aria-modal='true']") !== null; + return inModal && target.closest("[data-intake-drop]") === null; +} + +function hasFiles(dt: DataTransfer | null): boolean { + if (!dt) return false; + return Array.from(dt.types).includes("Files"); +} + +type UseGlobalFileDropOptions = { + enabled: boolean; + onFiles: (files: IntakeFile[]) => void; + /** true を返した target ではこの hook は何もしない(既定: エディタ内・受け皿・モーダル内) */ + shouldIgnore?: (target: EventTarget | null) => boolean; + /** + * true の間はどこに落とされても preventDefault するだけで何もしない + * (カウンタも overlay も動かさず onFiles も呼ばない)。Composer など + * data-modal-portal を持たない自前ダイアログが開いている間に使う。 + * enabled=false と違いリスナー自体は付けたままにする + */ + suspended?: boolean; +}; + +export function useGlobalFileDrop({ enabled, onFiles, shouldIgnore, suspended }: UseGlobalFileDropOptions) { + const [dragActive, setDragActive] = useState(false); + // 最新のコールバックを ref で持ち、effect の依存を enabled のみに絞る + const onFilesRef = useRef(onFiles); + onFilesRef.current = onFiles; + const shouldIgnoreRef = useRef(shouldIgnore ?? defaultShouldIgnore); + shouldIgnoreRef.current = shouldIgnore ?? defaultShouldIgnore; + const suspendedRef = useRef(suspended ?? false); + suspendedRef.current = suspended ?? false; + + useEffect(() => { + if (!enabled) return; + + // ignore 対象の上を通っただけでカウンタが狂わないよう、 + // ignore 判定に当たる enter/leave はカウントしない + let depth = 0; + + const onDragEnter = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + // suspended 中はどこに落とされても既定動作だけ止め、カウンタも overlay も動かさない + if (suspendedRef.current) { + e.preventDefault(); + return; + } + if (shouldIgnoreRef.current(e.target)) return; + depth += 1; + if (depth === 1) setDragActive(true); + }; + + const onDragOver = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + if (suspendedRef.current) { + e.preventDefault(); + return; + } + if (shouldSwallow(e.target)) { + e.preventDefault(); + return; + } + if (shouldIgnoreRef.current(e.target)) return; + // ここで preventDefault しないとブラウザがファイルをそのまま開いてしまう + e.preventDefault(); + }; + + const onDragLeave = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + if (suspendedRef.current) { + e.preventDefault(); + return; + } + if (shouldIgnoreRef.current(e.target)) return; + depth = Math.max(0, depth - 1); + if (depth === 0) setDragActive(false); + }; + + const onDrop = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + if (suspendedRef.current) { + e.preventDefault(); + return; + } + if (shouldSwallow(e.target)) { + e.preventDefault(); + depth = 0; + setDragActive(false); + return; + } + if (shouldIgnoreRef.current(e.target)) return; + e.preventDefault(); + depth = 0; + setDragActive(false); + if (!e.dataTransfer) return; + void collectDroppedFiles(e.dataTransfer).then((files) => { + onFilesRef.current(files); + }); + }; + + const onDragEnd = () => { + depth = 0; + setDragActive(false); + }; + + window.addEventListener("dragenter", onDragEnter); + window.addEventListener("dragover", onDragOver); + window.addEventListener("dragleave", onDragLeave); + window.addEventListener("drop", onDrop); + window.addEventListener("dragend", onDragEnd); + + return () => { + window.removeEventListener("dragenter", onDragEnter); + window.removeEventListener("dragover", onDragOver); + window.removeEventListener("dragleave", onDragLeave); + window.removeEventListener("drop", onDrop); + window.removeEventListener("dragend", onDragEnd); + depth = 0; + setDragActive(false); + }; + }, [enabled]); + + return { dragActive }; +} diff --git a/src/features/intake/use-intake.test.ts b/src/features/intake/use-intake.test.ts new file mode 100644 index 000000000..d8743fd84 --- /dev/null +++ b/src/features/intake/use-intake.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +// useIntake のテスト +// +// - running → done の遷移と open の連動 +// - 実行中に closeIntake しても state.kind は running のまま保たれる +// - 実行中に 2 回目の run が来たら待ち行列に積み、完了後に合算した done になる +// - files.length === 0 の run は受け皿を開くだけ + +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useIntake } from "./use-intake"; +import type { IntakeDeps, IntakeProgress, MarkdownImportResult } from "./run-intake"; +import type { IntakeFile } from "./types"; + +function mdFile(name: string): IntakeFile { + return { file: new File(["# " + name], name, { type: "text/markdown" }), path: name }; +} + +// importMarkdown を await new Promise(setTimeout) で待たせ、実行中の再入を作れるようにする +function makeDeps(overrides: Partial = {}): IntakeDeps & { aiAvailable: boolean } { + const importMarkdown = vi.fn( + async (files: IntakeFile[], onProgress: (p: IntakeProgress) => void): Promise => { + await new Promise((r) => setTimeout(r, 20)); + onProgress({ done: files.length, total: files.length, failed: [] }); + return { + created: files.length, + linksResolved: 0, + linksUnresolved: 0, + failed: [], + lastNewId: files.length > 0 ? "note-last" : null, + }; + }, + ); + const uploadAsset = vi.fn(async (_file: File) => ({})); + return { importMarkdown, uploadAsset, aiAvailable: true, ...overrides }; +} + +describe("useIntake", () => { + it("run で running → done に遷移し、open が true になる", async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useIntake(deps)); + + let runPromise: Promise; + act(() => { + runPromise = result.current.run([mdFile("a.md")]); + }); + + expect(result.current.open).toBe(true); + expect(result.current.state.kind).toBe("running"); + + await act(async () => { + await runPromise; + }); + + expect(result.current.state.kind).toBe("done"); + expect(result.current.open).toBe(true); + if (result.current.state.kind === "done") { + expect(result.current.state.notes).toBe(1); + } + }); + + it("実行中に closeIntake しても open は false だが state.kind は running のまま", async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useIntake(deps)); + + let runPromise: Promise; + act(() => { + runPromise = result.current.run([mdFile("a.md")]); + }); + expect(result.current.state.kind).toBe("running"); + + act(() => { + result.current.closeIntake(); + }); + + expect(result.current.open).toBe(false); + expect(result.current.state.kind).toBe("running"); + + await act(async () => { + await runPromise; + }); + // 完了すれば結果を見せるために open は true に戻る + expect(result.current.state.kind).toBe("done"); + expect(result.current.open).toBe(true); + }); + + it("実行中に 2 回目の run → 完了後の done が両バッチを合算した数字になる", async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useIntake(deps)); + + let runPromise: Promise; + act(() => { + runPromise = result.current.run([mdFile("a.md"), mdFile("b.md")]); + }); + expect(result.current.state.kind).toBe("running"); + + act(() => { + // 実行中の再入は待ち行列に積まれるだけで別の Promise は発火しない + void result.current.run([mdFile("c.md")]); + }); + + await act(async () => { + await runPromise; + }); + + expect(result.current.state.kind).toBe("done"); + if (result.current.state.kind === "done") { + expect(result.current.state.notes).toBe(3); + } + expect(deps.importMarkdown).toHaveBeenCalledTimes(2); + }); + + it("files.length === 0 の run は受け皿を開くだけ", async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useIntake(deps)); + + await act(async () => { + await result.current.run([]); + }); + + expect(result.current.open).toBe(true); + expect(result.current.state.kind).toBe("idle"); + expect(deps.importMarkdown).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/intake/use-intake.ts b/src/features/intake/use-intake.ts new file mode 100644 index 000000000..029119310 --- /dev/null +++ b/src/features/intake/use-intake.ts @@ -0,0 +1,92 @@ +// 投入口の薄い hook +// +// IntakeModal に渡す state(idle/running/done)の遷移を runIntake の進捗にひもづける。 +// deps は ref に持ち、呼び出し側で毎レンダー新しい関数を渡しても run の identity が +// 変わらないようにする(useEffect の依存等で意図せず再実行されるのを防ぐ)。 + +import { useCallback, useRef, useState } from "react"; +import type { IntakeState } from "./IntakeModal"; +import { runIntake, mergeOutcome, type IntakeDeps, type IntakeOutcome } from "./run-intake"; +import type { IntakeFile } from "./types"; + +export function useIntake(deps: IntakeDeps & { aiAvailable: boolean }) { + const depsRef = useRef(deps); + depsRef.current = deps; + + const [open, setOpen] = useState(false); + const [state, setState] = useState({ kind: "idle" }); + const [lastOutcome, setLastOutcome] = useState(null); + // 実行中かどうかを state と別に持つ(state だけだと非同期の連打判定が 1 tick 遅れる) + const runningRef = useRef(false); + // 実行中に来た run() の分をここに積み、今のバッチが終わり次第続けて処理する + const pendingRef = useRef([]); + + const openIntake = useCallback(() => { + setOpen(true); + }, []); + + const closeIntake = useCallback(() => { + setOpen(false); + // 実行中に閉じても進捗は保つ(次に開いたときに進行中がそのまま見える)。 + // idle に戻すのは実行中でないときだけ + if (!runningRef.current) { + setState({ kind: "idle" }); + } + }, []); + + const run = useCallback(async (files: IntakeFile[]) => { + if (runningRef.current) { + // 実行中の再入は待ち行列に積み、今のバッチが終わったら続けて処理する + pendingRef.current.push(...files); + setOpen(true); + return; + } + // 空のドロップ(フォルダの中身が読めなかった等)は受け皿を開くだけにする + if (files.length === 0) { + setOpen(true); + return; + } + runningRef.current = true; + setOpen(true); + + let combined: IntakeOutcome | null = null; + let batch = files; + + try { + while (batch.length > 0) { + setState({ kind: "running", done: 0, total: batch.length, failed: combined?.failed ?? [] }); + const outcome = await runIntake(batch, depsRef.current, (p) => { + setState({ kind: "running", done: p.done, total: p.total, current: p.current, failed: p.failed }); + }); + combined = combined ? mergeOutcome(combined, outcome) : outcome; + // このバッチの処理中に積まれた分があれば、続けて次のバッチとして処理する + batch = pendingRef.current.splice(0, pendingRef.current.length); + } + + if (combined) { + setLastOutcome(combined); + setState({ + kind: "done", + notes: combined.notes, + materials: combined.materials, + linksResolved: combined.linksResolved, + linksUnresolved: combined.linksUnresolved, + failed: combined.failed, + skipped: combined.skipped, + aiAvailable: depsRef.current.aiAvailable, + }); + // 進行中に × で閉じられていても、結果(復元レポート)は必ず見せる + setOpen(true); + } + } catch (err) { + // runIntake は内部で失敗を吸収するのでここには来ないはずだが、 + // 来たときに running のまま固まらないよう受け皿に戻す + console.error("[intake] 取り込みが失敗しました:", err); + setState({ kind: "idle" }); + } finally { + runningRef.current = false; + } + }, []); + + return { open, state, openIntake, closeIntake, run, lastOutcome }; +} diff --git a/src/features/navigation/NoteListToolbar.tsx b/src/features/navigation/NoteListToolbar.tsx index 7922f99c8..68c57c08c 100644 --- a/src/features/navigation/NoteListToolbar.tsx +++ b/src/features/navigation/NoteListToolbar.tsx @@ -2,7 +2,7 @@ // 2026-05-22: ラベルフィルタは列ヘッダ filter popup に移行したため、ここからは外した。 // 単一情報源(列ヘッダ)を維持するため、ツールバー側にラベル選択を残さない。 -import { useRef, useState } from "react"; +import { useRef, useState, type Ref } from "react"; import { useT } from "../../i18n"; export type SortKey = @@ -38,6 +38,7 @@ export function NoteListToolbar({ searchQuery, onSearchChange, sortOptions, + searchInputRef, }: { sortKey: K; sortDir: SortDirection; @@ -46,6 +47,8 @@ export function NoteListToolbar({ onSearchChange: (query: string) => void; /** 並び替え候補を差し替える(省略時は既定の SORT_KEYS) */ sortOptions?: { key: K; labelKey: string }[]; + /** 検索欄に外部からフォーカスを当てるための ref(復元レポートの「ノートを検索する」から使う) */ + searchInputRef?: Ref; }) { const t = useT(); const [showSortMenu, setShowSortMenu] = useState(false); @@ -89,6 +92,7 @@ export function NoteListToolbar({ {/* テキスト検索 */}
onSearchChange(e.target.value)} diff --git a/src/features/navigation/NoteListView.stories.tsx b/src/features/navigation/NoteListView.stories.tsx index 67c599cd0..91a7ab858 100644 --- a/src/features/navigation/NoteListView.stories.tsx +++ b/src/features/navigation/NoteListView.stories.tsx @@ -143,11 +143,22 @@ export const Default: Story = { noteIndex: MOCK_INDEX, onOpenNote: (id: string) => console.log("open:", id), onBack: () => console.log("back"), + onOpenIntake: () => console.log("open intake"), }, }; export const Empty: Story = { - name: "ノートなし", + name: "ノートなし(投入口の受け皿)", + args: { + noteIndex: { version: 1, updatedAt: now.toISOString(), notes: [] }, + onOpenNote: () => {}, + onBack: () => console.log("back"), + onIntakeFiles: () => {}, + }, +}; + +export const EmptyWithoutIntake: Story = { + name: "ノートなし(従来の文言のみ)", args: { noteIndex: { version: 1, updatedAt: now.toISOString(), notes: [] }, onOpenNote: () => {}, diff --git a/src/features/navigation/NoteListView.tsx b/src/features/navigation/NoteListView.tsx index f21fd99c1..d200ec8b5 100644 --- a/src/features/navigation/NoteListView.tsx +++ b/src/features/navigation/NoteListView.tsx @@ -2,9 +2,7 @@ // 全ノートをテーブル形式で表示し、ソート・フィルタ・検索・削除に対応 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { BookOpen, Download, Filter, Archive, Image as ImageIcon, FileText, Share2, Plus } from "lucide-react"; -import { Dropdown } from "@/ui/dropdown"; -import { MenuItem } from "@/ui/menu-item"; +import { BookOpen, Filter, Archive, Image as ImageIcon, FileText, Share2, Plus, FolderInput } from "lucide-react"; import { FilterPopup, type FilterOption } from "@/ui/filter-popup"; import { IndexFileNoteListSource, @@ -12,6 +10,7 @@ import { } from "./note-list-source"; import type { GraphiumIndex } from "./index-file"; import { NoteListToolbar, type SortKey, type SortDirection } from "./NoteListToolbar"; +import { IntakeReceptacle, type IntakeFile, type IntakeSource } from "../intake"; import { useT, getDisplayLabelName } from "../../i18n"; import { Breadcrumb } from "../../components/Breadcrumb"; import { bestHitsBySource, useLexicalStatus } from "../lexical-search"; @@ -103,7 +102,6 @@ export function NoteListView({ onDeleteNotes, onArchiveNotes, onOpenWikiPeek, - onImportMarkdown, onIngestNotes, onSetNoteContexts, onDeleteContextEverywhere, @@ -116,6 +114,9 @@ export function NoteListView({ onNewNoteInFolder, onDragNotesToFolder, onDraggingNotesChange, + onOpenIntake, + onIntakeFiles, + focusSearchSignal, }: { noteIndex: GraphiumIndex | null; /** クリック時のコールバック(サイドピーク表示用) */ @@ -128,15 +129,6 @@ export function NoteListView({ onArchiveNotes?: (noteIds: string[]) => Promise; /** Knowledge アイコン押下で対応 wiki エントリをサイドピークで開くコールバック */ onOpenWikiPeek?: (wikiNoteId: string) => void; - /** - * Markdown ファイル / Obsidian Vault フォルダのインポート。 - * webkitdirectory のフォルダ選択時は files に複数の MD と画像が混在し、 - * 単体選択時は 1 つの .md のみが渡る。 - */ - onImportMarkdown?: ( - files: File[], - onProgress: (p: { done: number; total: number; current?: string; failed: string[] }) => void, - ) => Promise; /** 選択中ノートを Knowledge 化(既存トーストキューに登録) */ onIngestNotes?: (noteIds: string[]) => Promise; /** @@ -180,6 +172,15 @@ export function NoteListView({ * デスクトップ + shared root + identity が揃っている場合にのみ渡される。 */ onShareSelected?: (noteIds: string[]) => void; + /** 投入口(既存資料の一括持ち込み)を開く。ヘッダのアイコンボタンは渡されたときだけ表示。 */ + onOpenIntake?: () => void; + /** + * 投入口の受け皿から直接渡されたファイル群を取り込む(一覧が空のときの受け皿用)。 + * 渡されたときだけ、空状態を受け皿に差し替える。 + */ + onIntakeFiles?: (files: IntakeFile[], source: IntakeSource) => void; + /** これが変化するたびに検索欄へフォーカスを当てる(復元レポートの「ノートを検索する」用) */ + focusSearchSignal?: number; }) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -226,20 +227,14 @@ export function NoteListView({ // 削除確認ダイアログ const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); - // インポート(Markdown / Obsidian Vault のみ。Word は素材ライブラリ経由に統一) - const importMdInputRef = useRef(null); - const importMdFolderInputRef = useRef(null); - const importButtonRef = useRef(null); - const [importing, setImporting] = useState(false); - const [importMenuPos, setImportMenuPos] = useState<{ top: number; left: number } | null>(null); - const [importProgress, setImportProgress] = useState<{ - done: number; - total: number; - current?: string; - failed: string[]; - } | null>(null); + // 検索欄への外部フォーカス(復元レポートの「ノートを検索する」から使う) + const searchInputRef = useRef(null); const t = useT(); + useEffect(() => { + if (focusSearchSignal) searchInputRef.current?.focus(); + }, [focusSearchSignal]); + // インデックスからノート一覧を構築 useEffect(() => { if (!noteIndex) { @@ -536,115 +531,19 @@ export function NoteListView({ {t("nav.newNoteInFolder")} )} - {/* インポートボタン(選択中でなければ表示) */} - {!someSelected && onImportMarkdown && ( + {/* 投入口ボタン(選択中でなければ表示) */} + {!someSelected && onOpenIntake && ( )} - {importMenuPos && ( - setImportMenuPos(null)} - minWidth={220} - > -
- {onImportMarkdown && ( - <> - { - setImportMenuPos(null); - importMdInputRef.current?.click(); - }} - > - {t("noteList.importMarkdown")} - - { - setImportMenuPos(null); - importMdFolderInputRef.current?.click(); - }} - > - {t("noteList.importObsidianVault")} - - - )} -
-
- )} - { - const files = Array.from(e.target.files ?? []); - e.target.value = ""; - if (files.length === 0 || !onImportMarkdown) return; - setImporting(true); - setImportProgress({ done: 0, total: files.length, failed: [] }); - try { - await onImportMarkdown(files, (p) => setImportProgress(p)); - } finally { - setImporting(false); - setImportProgress((prev) => { - if (!prev) return null; - if (prev.failed.length === 0) { - setTimeout(() => setImportProgress(null), 2500); - } - return prev; - }); - } - }} - /> - )} - multiple - className="hidden" - onChange={async (e) => { - const files = Array.from(e.target.files ?? []); - e.target.value = ""; - if (files.length === 0 || !onImportMarkdown) return; - setImporting(true); - // 進捗 total は MD ファイル数。画像は副次なので別カウントしない - const mdCount = files.filter((f) => /\.(md|markdown)$/i.test(f.name)).length; - setImportProgress({ done: 0, total: Math.max(mdCount, 1), failed: [] }); - try { - await onImportMarkdown(files, (p) => setImportProgress(p)); - } finally { - setImporting(false); - setImportProgress((prev) => { - if (!prev) return null; - if (prev.failed.length === 0) { - setTimeout(() => setImportProgress(null), 2500); - } - return prev; - }); - } - }} - /> {/* 一括アクション(複数選択時) */} {someSelected && (
@@ -717,46 +616,6 @@ export function NoteListView({ )}
- {/* インポート進捗 */} - {importProgress && ( -
-
- - {t("noteList.importProgress", { done: String(importProgress.done), total: String(importProgress.total) })} - {importProgress.failed.length > 0 && ( - - {t("noteList.importFailedCount", { count: String(importProgress.failed.length) })} - - )} - - {!importing && ( - - )} -
-
-
-
- {importProgress.current && importing && ( -
- {t("noteList.importProcessing", { name: importProgress.current })} -
- )} - {importProgress.failed.length > 0 && !importing && ( -
- {t("noteList.importFailedFiles", { names: importProgress.failed.join(", ") })} -
- )} -
- )} - {/* ツールバー */} {/* テーブル */} @@ -773,11 +633,17 @@ export function NoteListView({

{t("nav.loadingNotes")}

) : filtered.length === 0 ? ( -
-

- {entries.length === 0 ? t("nav.noNotes") : t("nav.noMatchingNotes")} -

-
+ !loading && entries.length === 0 && onIntakeFiles ? ( +
+ +
+ ) : ( +
+

+ {entries.length === 0 ? t("nav.noNotes") : t("nav.noMatchingNotes")} +

+
+ ) ) : ( diff --git a/src/features/onboarding/EmptyNoteGuide.stories.tsx b/src/features/onboarding/EmptyNoteGuide.stories.tsx index d376e3aaf..29b896652 100644 --- a/src/features/onboarding/EmptyNoteGuide.stories.tsx +++ b/src/features/onboarding/EmptyNoteGuide.stories.tsx @@ -48,6 +48,33 @@ export const Visible: Story = { ), }; +export const WithIntake: Story = { + name: "投入口チップあり(onOpenIntake 指定)", + render: () => ( + +
+
+ (上の領域は本来 BlockNote タイトル + 空の本文) +
+
+ console.info("[Story] Open composer")} + onOpenIntake={() => console.info("[Story] Open intake")} + /> +
+
+ ), +}; + export const Hidden: Story = { name: "非表示(編集が始まっている)", render: () => ( diff --git a/src/features/onboarding/EmptyNoteGuide.tsx b/src/features/onboarding/EmptyNoteGuide.tsx index 455e13449..d9cf16d70 100644 --- a/src/features/onboarding/EmptyNoteGuide.tsx +++ b/src/features/onboarding/EmptyNoteGuide.tsx @@ -8,6 +8,8 @@ // Wiki / Skill / AI 派生ドキュメントなど「システム生成で空のはずがない」場合は // 呼び出し側で `visible={false}` を渡す。 +import type { ReactNode } from "react"; +import { FolderInput } from "lucide-react"; import { useT } from "@/i18n"; type EmptyNoteGuideProps = { @@ -16,19 +18,28 @@ type EmptyNoteGuideProps = { onOpenComposer?: () => void; /** AI が使えるか(バックエンド到達 + モデル登録済み)。false なら ⌘K チップを予示しない */ aiEnabled?: boolean; + /** 投入口(既存資料の一括持ち込み)チップを押したときのハンドラ。未指定なら intake チップ自体を出さない */ + onOpenIntake?: () => void; }; type Chip = { key: string; /** 表示キー。Cmd/Ctrl の分岐は呼び出し側で UA 検出しない(後続 PR で対応) */ - display: string; + display: ReactNode; labelI18nKey: string; descI18nKey: string; /** クリック時に action がある場合だけ pointer カーソル + onClick を有効化 */ - action?: "composer"; + action?: "composer" | "intake"; }; const chips: Chip[] = [ + { + key: "intake", + display: , + labelI18nKey: "onboarding.chip.intake.label", + descI18nKey: "onboarding.chip.intake.desc", + action: "intake", + }, { key: "cmdk", display: "⌘K", @@ -52,16 +63,18 @@ const chips: Chip[] = [ }, ]; -export function EmptyNoteGuide({ visible, onOpenComposer, aiEnabled = true }: EmptyNoteGuideProps) { +export function EmptyNoteGuide({ visible, onOpenComposer, aiEnabled = true, onOpenIntake }: EmptyNoteGuideProps) { const t = useT(); if (!visible) return null; // AI が使えない(モデル未登録等)なら ⌘K チップは予示しない(押しても Composer を開けないため)。 - const visibleChips = chips.filter((c) => aiEnabled || c.key !== "cmdk"); + // onOpenIntake が無ければ intake チップも出さない(押せない chip を出さない)。 + const visibleChips = chips.filter((c) => (aiEnabled || c.key !== "cmdk") && (onOpenIntake || c.key !== "intake")); const handleClick = (action?: Chip["action"]) => { if (action === "composer") onOpenComposer?.(); + if (action === "intake") onOpenIntake?.(); }; return ( @@ -98,6 +111,20 @@ export function EmptyNoteGuide({ visible, onOpenComposer, aiEnabled = true }: Em > {visibleChips.map((chip) => { const clickable = chip.action != null; + // display が文字列(⌘K / @ / /)のときだけ 。アイコン等の ReactNode は + // 同じ見た目の で描く( でアイコンを包まない) + const displayStyle = { + fontFamily: "ui-monospace, 'SF Mono', monospace", + fontSize: 11, + fontWeight: 600, + minWidth: 22, + textAlign: "center" as const, + padding: "1px 5px", + borderRadius: "var(--r-1)", + background: "var(--paper-3)", + color: "var(--ink)", + lineHeight: 1.4, + }; return ( ); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b44a33de0..fcaff61b1 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -114,6 +114,8 @@ export const en: Record = { // ── サイドバー ── "sidebar.newNote": "+ Note", "sidebar.newNoteTooltip": "Open a new note", + "sidebar.intake": "Bring in files", + "sidebar.intakeTooltip": "Bring in Markdown, PDF, Word, images and CSV you already have", "sidebar.newMemo": "+ Memo", "sidebar.newMemoTooltip": "Capture a quick memo (⌘+⇧+M)", "sidebar.provTemplate": "+ PROV Template", @@ -1853,6 +1855,8 @@ export const en: Record = { // ── Onboarding / Empty note guide ── "onboarding.guide.aria": "Empty note tips", "onboarding.guide.lead": "Start writing, or try one of these:", + "onboarding.chip.intake.label": "Bring in files", + "onboarding.chip.intake.desc": "Drop a folder of Markdown, PDF or CSV. Markdown becomes notes, the rest become materials", "onboarding.chip.cmdk.label": "Ask AI", "onboarding.chip.cmdk.desc": "Ask AI about your note, or insert its answer", "onboarding.chip.at.label": "Link a note", @@ -1942,12 +1946,8 @@ export const en: Record = { // ハードコード日本語の i18n 移行(fix/i18n-hardcoded-japanese) // ノート一覧: インポート・一括 Knowledge 化 - "noteList.importFiles": "Import files", - "noteList.importMarkdown": "Import Markdown (.md)", - "noteList.importObsidianVault": "Import Obsidian Vault folder", "noteList.ingestTooltip": "Extract summaries and claims from the selected notes and add them to Knowledge", "noteList.ingestSelected": "Add {count} to Knowledge", - "noteList.importProgress": "Importing Markdown: {done} / {total}", "noteList.importFailedCount": "({count} failed)", "noteList.importProcessing": "Processing: {name}", "noteList.importFailedFiles": "Failed files: {names}", @@ -2032,10 +2032,33 @@ export const en: Record = { "ingest.noSources": "No sources available", // Markdown インポート - "import.noMarkdownFiles": "No Markdown files found.", - "import.importedCount": "Imported {count} note(s).", "import.unresolvedLinksNote": "Unresolved [[links]] were kept as plain text.", + // 投入口モーダル(既存資料の一括取り込み) + "intake.title": "Bring in what you already have", + "intake.dropHere": "Drop a folder or files here", + "intake.rule": "Markdown becomes notes. PDF, Word, images and CSV become materials.", + "intake.obsidianHint": "Obsidian vaults work as they are. [[links]] and images come along.", + "intake.chooseFolder": "Choose a folder", + "intake.chooseFiles": "Choose files", + "intake.running": "Bringing in {done} / {total}", + "intake.doneTitle": "Brought in", + "intake.statNotes": "Notes", + "intake.statMaterials": "Materials", + "intake.statLinks": "Links kept", + "intake.statUnresolved": "{count} unresolved", + "intake.statFailed": "Failed", + "intake.next": "Next, try one of these:", + "intake.search": "Search your notes", + "intake.graph": "See connections", + "intake.askAi": "Ask AI", + "intake.setupAi": "Set up AI", + "intake.setupAiHint": "Add a model in Settings to ask questions about what came in.", + "intake.skipped": "{count} skipped (types Graphium doesn't take)", + "intake.dropOverlay": "Drop to bring in", + "intake.emptyNotesLead": "No notes yet. Start with what you already have.", + "intake.emptyMaterialsLead": "No materials yet. Drop in the PDFs, images and data you already have.", + // 素材ビューア・抽出 "asset.docxNoImages": "No image objects could be extracted from this Word file.", "asset.docxUnsupportedImages": "No supported image formats found (EMF/WMF, etc.).", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 87375fd6a..7c5fcb7d3 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -114,6 +114,8 @@ export const ja: Record = { // ── サイドバー ── "sidebar.newNote": "+ ノート", "sidebar.newNoteTooltip": "新規ノートを開く", + "sidebar.intake": "資料を入れる", + "sidebar.intakeTooltip": "手元の Markdown・PDF・Word・画像・CSV をまとめて入れる", "sidebar.newMemo": "+ メモ", "sidebar.newMemoTooltip": "メモをすばやく書き留める(⌘+⇧+M)", "sidebar.provTemplate": "+ PROV テンプレート", @@ -1851,6 +1853,8 @@ export const ja: Record = { // ── オンボーディング / 空ノート予示 ── "onboarding.guide.aria": "空のノートのヒント", "onboarding.guide.lead": "まずは普通に書いてみてください。以下でも始められます:", + "onboarding.chip.intake.label": "手元の資料を入れる", + "onboarding.chip.intake.desc": "Markdown・PDF・CSV のフォルダをまとめて。Markdown はノートに、他は素材になります", "onboarding.chip.cmdk.label": "AI に頼む", "onboarding.chip.cmdk.desc": "AI に質問して、答えをノートに挿入", "onboarding.chip.at.label": "ノートを繋ぐ", @@ -1940,12 +1944,8 @@ export const ja: Record = { // ハードコード日本語の i18n 移行(fix/i18n-hardcoded-japanese) // ノート一覧: インポート・一括ナレッジ化 - "noteList.importFiles": "ファイルを取り込む", - "noteList.importMarkdown": "Markdown (.md) を取り込む", - "noteList.importObsidianVault": "Obsidian Vault フォルダを取り込む", "noteList.ingestTooltip": "選択したノートから要約と知見を抽出してナレッジに追加します", "noteList.ingestSelected": "{count} 件をナレッジ化", - "noteList.importProgress": "Markdown 取り込み: {done} / {total}", "noteList.importFailedCount": "(失敗: {count})", "noteList.importProcessing": "処理中: {name}", "noteList.importFailedFiles": "失敗したファイル: {names}", @@ -2030,10 +2030,33 @@ export const ja: Record = { "ingest.noSources": "ソースが 1 件もありません", // Markdown インポート - "import.noMarkdownFiles": "Markdown ファイルが見つかりませんでした。", - "import.importedCount": "{count} 件のノートを取り込みました。", "import.unresolvedLinksNote": "解決できなかった [[リンク]] はテキストとして残しています。", + // 投入口モーダル(既存資料の一括取り込み) + "intake.title": "手元の資料を入れる", + "intake.dropHere": "フォルダやファイルをここに落とす", + "intake.rule": "Markdown はノートに、PDF・Word・画像・CSV は素材になります", + "intake.obsidianHint": "Obsidian の vault はそのままで大丈夫です。[[リンク]] と画像も一緒に入ります", + "intake.chooseFolder": "フォルダを選ぶ", + "intake.chooseFiles": "ファイルを選ぶ", + "intake.running": "入れています {done} / {total}", + "intake.doneTitle": "入りました", + "intake.statNotes": "ノート", + "intake.statMaterials": "素材", + "intake.statLinks": "引き継いだつながり", + "intake.statUnresolved": "{count} 件は未解決", + "intake.statFailed": "失敗", + "intake.next": "次に、どれか試してみてください", + "intake.search": "ノートを検索する", + "intake.graph": "つながりを見る", + "intake.askAi": "AI に聞く", + "intake.setupAi": "AI を設定する", + "intake.setupAiHint": "設定でモデルを登録すると、入れたものについて聞けます", + "intake.skipped": "{count} 件は対象外の種類なので入れていません", + "intake.dropOverlay": "ここに落として入れる", + "intake.emptyNotesLead": "まだノートがありません。手元の資料から始めませんか", + "intake.emptyMaterialsLead": "まだ素材がありません。手元の PDF・画像・データを入れてみませんか", + // 素材ビューア・抽出 "asset.docxNoImages": "この Word からは画像オブジェクトを取り出せませんでした。", "asset.docxUnsupportedImages": "対応形式の画像が含まれていませんでした(EMF/WMF など)。", diff --git a/src/note-app.tsx b/src/note-app.tsx index ffc752690..a78868b13 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -98,6 +98,8 @@ import { type ChartAssetSourceResult, } from "./blocks/chart"; import { buildSavedPageFields } from "./features/note-save"; +import { IntakeModal, IntakeDropOverlay, useIntake, useGlobalFileDrop } from "./features/intake"; +import type { IntakeFile, IntakeProgress, MarkdownImportResult } from "./features/intake"; import { syncTableRowIdentitiesToEditor } from "./lib/table-row-identity"; import { DocumentSearchBar } from "./features/document-search/DocumentSearchBar"; import { setupLabelAutoAssign } from "./features/context-label/label-auto"; @@ -859,6 +861,8 @@ type NoteEditorProps = { skillPrompts?: string; /** Cmd+K Composer を開くコールバック(空ノート予示の ⌘K チップから呼ばれる) */ onOpenComposer?: () => void; + /** 投入口(既存資料の一括持ち込み)を開くコールバック(空ノート予示のチップから呼ばれる) */ + onOpenIntake?: () => void; /** Composer 送信をノートスコープで受けるための imperative ref。 * NoteApp 側で ref を作り、NoteEditorInner が useEffect でハンドラを登録する。 * ノート未開時は null のままになり、NoteApp はそれを検知して no-op 扱いする。 */ @@ -1127,6 +1131,7 @@ function NoteEditorInner({ agentConfigured = true, skillPrompts, onOpenComposer, + onOpenIntake, composerSubmitRef, archived = false, onRestoreFromArchive, @@ -5462,6 +5467,7 @@ function NoteEditorInner({ visible={showEmptyNoteGuide} onOpenComposer={onOpenComposer} aiEnabled={!!aiAvailable && agentConfigured} + onOpenIntake={onOpenIntake} /> {/* D2 配置: WikiContextDrawer(関連・文脈)を本文の下に展開する。 @@ -6855,6 +6861,135 @@ export function NoteApp() { [closeAllViews, fm, router], ); + // 投入口(既存資料の一括持ち込み)の Markdown インポート実装。 + // pass 1 で各 MD を doc に変換してノート作成、pass 2 で [[リンク]] を解決する。 + // ctx.allFiles は classify 前の全ファイル(notes + materials)で、画像参照 + // (![[img.png]] 等)を同じフォルダの他ファイルから解決するために使う。 + const importMarkdownFiles = useCallback( + async ( + notes: IntakeFile[], + onProgress: (p: IntakeProgress) => void, + ctx: { allFiles: IntakeFile[] }, + ): Promise => { + const { + importMarkdownToGraphiumDoc, + buildWikiLinkResolver, + applyWikiLinkResolution, + } = await import("./features/markdown-import/import"); + + // 画像参照を相対パスで解決するため、フォルダ内の全ファイルからルックアップを作る。 + // path は "/" を含むかどうかで vault モード(フォルダ選択・vault ドロップ)か + // 単体ファイルかを判定する(単体選択時は path = file.name のみ)。 + const isVaultMode = ctx.allFiles.some((f) => f.path.includes("/")); + const allByPath = new Map(); + if (isVaultMode) { + for (const f of ctx.allFiles) { + allByPath.set(f.path.toLowerCase(), f.file); + // 末尾のファイル名のみのキーでも引けるように + const baseName = f.path.split("/").pop()?.toLowerCase(); + if (baseName && !allByPath.has(baseName)) allByPath.set(baseName, f.file); + } + } + + const resolveImage = isVaultMode + ? async (relativePath: string): Promise => { + const lc = relativePath.toLowerCase(); + const direct = allByPath.get(lc); + if (direct) return direct; + const baseName = lc.split("/").pop(); + if (baseName) { + const byName = allByPath.get(baseName); + if (byName) return byName; + } + return null; + } + : undefined; + + // pass 1: 各 MD を doc に変換 → ノート作成 + const baseNameToNoteId = new Map(); + const docsByNoteId = new Map< + string, + { doc: import("./lib/document-types").GraphiumDocument; wikilinks: { target: string; display: string }[] } + >(); + const failed: string[] = []; + let lastNewId: string | null = null; + + for (let i = 0; i < notes.length; i++) { + const file = notes[i]; + onProgress({ done: i, total: notes.length, current: file.file.name, failed: [...failed] }); + try { + const { doc, wikilinks } = await importMarkdownToGraphiumDoc(file.file, { + resolveImage, + uploadImage: fm.handleUploadMedia, + }); + const newId = await fm.handleCreateNoteFromImport(doc); + const baseName = file.file.name.replace(/\.(md|markdown)$/i, ""); + baseNameToNoteId.set(baseName.toLowerCase(), newId); + docsByNoteId.set(newId, { doc, wikilinks }); + lastNewId = newId; + } catch (err) { + console.error("Markdown インポート失敗:", file.file.name, err); + failed.push(file.file.name); + } + onProgress({ done: i + 1, total: notes.length, failed: [...failed] }); + } + + // pass 2: wikilinks を解決して保存。 + // 解決先は「今回のインポートで作成したノート」→「既存ノート(タイトル一致)」。 + // 全件未解決のノートも必ず保存し直す: pass 1 で保存した本文には + // プレースホルダ({{GWLINK_n}})が残っており、[[リンク]] テキストへ + // 復元した姿で上書きする必要がある。 + let unresolvedLinkCount = 0; + let resolvedLinkCount = 0; + if (docsByNoteId.size > 0) { + // 既存ノートの解決先は noteIndex(タイトルを持ち、アーカイブ除外済み。 + // wiki も含むので、エクスポートした wiki への @リンクも復元できる) + const resolver = buildWikiLinkResolver(baseNameToNoteId, fm.noteIndex?.notes ?? []); + const resolution = applyWikiLinkResolution(docsByNoteId, resolver); + unresolvedLinkCount = resolution.unresolvedCount; + resolvedLinkCount = resolution.resolvedCount; + for (const [noteId, updated] of resolution.updates) { + try { + await fm.handleSaveImportedDoc(noteId, updated); + } catch (err) { + console.warn("Markdown リンク解決の保存失敗:", noteId, err); + } + } + console.info(`[markdown-import] リンク解決: ${resolution.resolvedCount} / ${resolution.resolvedCount + resolution.unresolvedCount}`); + } + + const successCount = notes.length - failed.length; + return { + created: successCount, + linksResolved: resolvedLinkCount, + linksUnresolved: unresolvedLinkCount, + failed, + lastNewId, + }; + }, + [fm], + ); + + // 投入口(既存資料の一括持ち込み): サイドバー・空ノートのチップ・一覧と + // 素材の空状態・どこでもドロップの 4 面すべてがこの 1 つの state を開閉する。 + const intake = useIntake({ + importMarkdown: importMarkdownFiles, + uploadAsset: (file) => fm.handleUploadAsset(file), + afterRun: () => fm.refreshFiles(), + aiAvailable: aiUiEnabled, + }); + // ウィンドウのどこにファイルをドロップしても投入口が拾う(エディタ内・ + // モーダル内・受け皿の上は useGlobalFileDrop 側の既定 ignore で除外される) + const { dragActive: intakeDragActive } = useGlobalFileDrop({ + enabled: true, + onFiles: (files) => void intake.run(files), + // Composer は自前 createPortal(role="dialog")で data-modal-portal を持たないため、 + // 開いている間は投入口を止めて背後で走らせない + suspended: composer.open, + }); + // 一覧ビューの検索欄へフォーカスを送る合図(復元レポートの「検索する」から使う) + const [focusSearchSignal, setFocusSearchSignal] = useState(0); + // ─── サイドピークを開く/閉じる唯一の入口 ─── // ビューは変えず URL の peek だけを差し替えて履歴を 1 段積む。これで // 「ピーク① → ピーク②」と辿ってから戻ると ① に帰り、もう一度戻るとピーク前に戻る。 @@ -6887,6 +7022,47 @@ export function NoteApp() { router.replace({ ...current, peek: undefined }); }, [router]); + // ノート一覧を表示する。サイドバーの「ノート」見出しと、投入口の復元レポート + // (取り込んだノートが見える場所へ)の両方から使う共通関数。 + const showNoteList = useCallback(() => { + closeAllViews(); + fm.setShowNoteList(true); + setSidebarOpen(false); + router.navigate({ view: "notes" }); + // 「ノート」見出し = 全ノート一覧なので、フォルダ絞り込みは解除する + setSelectedFolder(null); + setFolderContextFilter([]); + }, [closeAllViews, fm, router]); + // 全体グラフを表示する。サイドバーと、投入口の復元レポート(「グラフを見る」)の + // 両方から使う共通関数。state 変数 showGlobalGraph と名前が衝突するため + // showGlobalGraphView とする。 + const showGlobalGraphView = useCallback(() => { + // 他の排他ビューを全部畳んでから全体グラフを表示する(他の onShow* と同じ作法)。 + closeAllViews(); + setShowGlobalGraph(true); + setListSidePeekNoteId(null); + dropPeekFromUrl(); + setSidebarOpen(false); + }, [closeAllViews, dropPeekFromUrl]); + + // 投入口モーダルを閉じる。取り込みが完了していた(done)場合は、結果に応じて + // 続きの遷移を行う: ノート 1 件だけならそのまま開く(従来の単体インポートの動作を + // 維持)。複数件 or 素材込みなら、まだ一覧が見えていなければ一覧を開いて見える場所へ + // 導く。素材だけ入ったとき(notes === 0)は遷移しない — 素材ギャラリーから開いた + // ときにギャラリーごと畳んでしまわないため。 + const handleIntakeClose = useCallback(() => { + const wasDone = intake.state.kind === "done"; + const outcome = intake.lastOutcome; + intake.closeIntake(); + if (wasDone && outcome) { + if (outcome.notes === 1 && outcome.materials === 0 && outcome.lastNewId) { + navigateToNote(outcome.lastNewId); + } else if (outcome.notes > 0 && !fm.showNoteList) { + showNoteList(); + } + } + }, [intake, navigateToNote, fm.showNoteList, showNoteList]); + // 新規ノートは保存されて初めて fileId が決まる。決まった時点で URL を差し替える。 // これが無いと、書いたノートがリロードで開かず(URL が home のまま)、 // 「戻る」も 1 段ずれる。移動ではないので履歴は積まない(replace)。 @@ -8641,21 +8817,14 @@ export function NoteApp() { // 下の useEffect が URL を差し替える。ここで home を積んでおくことで // 「新規ノートを開いた直後に戻る」が直前のノートへ帰る。 onNewNote: () => { closeAllViews(); fm.handleNewNote(); setSidebarOpen(false); router.navigate({ view: "home" }); }, + onOpenIntake: () => { intake.openIntake(); setSidebarOpen(false); }, onNewMemo: () => { setShowQuickMemoDialog(true); setSidebarOpen(false); }, onRefresh: fm.refreshFiles, onShowReleaseNotes: () => setShowReleaseNotes(true), onShowSettings: () => { setShowSettings(true); setSidebarOpen(false); }, agentConfigured, recentNotes: fm.recentNotes, - onShowNoteList: () => { - closeAllViews(); - fm.setShowNoteList(true); - setSidebarOpen(false); - router.navigate({ view: "notes" }); - // 「ノート」見出し = 全ノート一覧なので、フォルダ絞り込みは解除する - setSelectedFolder(null); - setFolderContextFilter([]); - }, + onShowNoteList: showNoteList, noteListActive: fm.showNoteList, selectedFolder, onSelectFolder: (path: string, contextValues: string[]) => { @@ -8708,14 +8877,7 @@ export function NoteApp() { mediaIndex: fm.mediaIndex, onShowAssetGallery: (type: import("./features/asset-browser").MediaType) => { closeAllViews(); fm.setActiveAssetType(type); setSidebarOpen(false); router.navigate({ view: "assets", mediaType: type }); }, noteIndex: fm.noteIndex, - onShowGlobalGraph: () => { - // 他の排他ビューを全部畳んでから全体グラフを表示する(他の onShow* と同じ作法)。 - closeAllViews(); - setShowGlobalGraph(true); - setListSidePeekNoteId(null); - dropPeekFromUrl(); - setSidebarOpen(false); - }, + onShowGlobalGraph: showGlobalGraphView, globalGraphActive: showGlobalGraph, onShowLabelGallery: (label: string) => { closeAllViews(); fm.setActiveLabel(label); setSidebarOpen(false); router.navigate({ view: "labels", label }); }, activeAssetType: fm.activeAssetType, @@ -8887,6 +9049,7 @@ export function NoteApp() { } onAddUrlBookmark={fm.handleAddUrlBookmark} onUploadMedia={fm.handleUploadMedia} + onIntakeFiles={(files) => void intake.run(files)} onExtractDocxImages={handleExtractDocxImages} resolveKnowledgeWikiId={(entry) => { if (entry.type === "url" && entry.url) { @@ -9427,113 +9590,9 @@ export function NoteApp() { enqueueIngest(id, title, doc); } } : undefined} - onImportMarkdown={async (files, onProgress) => { - const { - importMarkdownToGraphiumDoc, - buildWikiLinkResolver, - applyWikiLinkResolution, - isMarkdownFile, - } = await import("./features/markdown-import/import"); - - const mdFiles = files.filter(isMarkdownFile); - if (mdFiles.length === 0) { - window.alert(tStatic("import.noMarkdownFiles")); - return; - } - - // 画像参照を相対パスで解決するため、フォルダ内の全ファイルからルックアップを作る。 - // webkitdirectory 経由の File は webkitRelativePath を持つ("vault/foo.md" 等)。 - // 単体選択時は path = name のみなので vault モードかどうかで分岐する。 - const isVaultMode = mdFiles.some((f) => (f as any).webkitRelativePath); - const allByPath = new Map(); - if (isVaultMode) { - for (const f of files) { - const rel: string = (f as any).webkitRelativePath || f.name; - allByPath.set(rel.toLowerCase(), f); - // 末尾のファイル名のみのキーでも引けるように - const baseName = rel.split("/").pop()?.toLowerCase(); - if (baseName && !allByPath.has(baseName)) allByPath.set(baseName, f); - } - } - - const resolveImage = isVaultMode - ? async (relativePath: string): Promise => { - const lc = relativePath.toLowerCase(); - const direct = allByPath.get(lc); - if (direct) return direct; - const baseName = lc.split("/").pop(); - if (baseName) { - const byName = allByPath.get(baseName); - if (byName) return byName; - } - return null; - } - : undefined; - - // pass 1: 各 MD を doc に変換 → ノート作成 - const baseNameToNoteId = new Map(); - const docsByNoteId = new Map< - string, - { doc: import("./lib/document-types").GraphiumDocument; wikilinks: { target: string; display: string }[] } - >(); - const failed: string[] = []; - let lastNewId: string | null = null; - - for (let i = 0; i < mdFiles.length; i++) { - const file = mdFiles[i]; - onProgress({ done: i, total: mdFiles.length, current: file.name, failed: [...failed] }); - try { - const { doc, wikilinks } = await importMarkdownToGraphiumDoc(file, { - resolveImage, - uploadImage: fm.handleUploadMedia, - }); - const newId = await fm.handleCreateNoteFromImport(doc); - const baseName = file.name.replace(/\.(md|markdown)$/i, ""); - baseNameToNoteId.set(baseName.toLowerCase(), newId); - docsByNoteId.set(newId, { doc, wikilinks }); - lastNewId = newId; - } catch (err) { - console.error("Markdown インポート失敗:", file.name, err); - failed.push(file.name); - } - onProgress({ done: i + 1, total: mdFiles.length, failed: [...failed] }); - } - - // pass 2: wikilinks を解決して保存。 - // 解決先は「今回のインポートで作成したノート」→「既存ノート(タイトル一致)」。 - // 全件未解決のノートも必ず保存し直す: pass 1 で保存した本文には - // プレースホルダ({{GWLINK_n}})が残っており、[[リンク]] テキストへ - // 復元した姿で上書きする必要がある。 - let unresolvedLinkCount = 0; - if (docsByNoteId.size > 0) { - // 既存ノートの解決先は noteIndex(タイトルを持ち、アーカイブ除外済み。 - // wiki も含むので、エクスポートした wiki への @リンクも復元できる) - const resolver = buildWikiLinkResolver(baseNameToNoteId, fm.noteIndex?.notes ?? []); - const resolution = applyWikiLinkResolution(docsByNoteId, resolver); - unresolvedLinkCount = resolution.unresolvedCount; - for (const [noteId, updated] of resolution.updates) { - try { - await fm.handleSaveImportedDoc(noteId, updated); - } catch (err) { - console.warn("Markdown リンク解決の保存失敗:", noteId, err); - } - } - console.info(`[markdown-import] リンク解決: ${resolution.resolvedCount} / ${resolution.resolvedCount + resolution.unresolvedCount}`); - } - - await fm.refreshFiles(); - - const successCount = mdFiles.length - failed.length; - if (successCount > 0) { - const msg = [tStatic("import.importedCount", { count: String(successCount) })]; - if (unresolvedLinkCount > 0) { - msg.push("", tStatic("import.unresolvedLinksNote")); - } - window.alert(msg.join("\n")); - } - - if (lastNewId && mdFiles.length === 1) navigateToNote(lastNewId); - }} + onOpenIntake={intake.openIntake} + onIntakeFiles={(files) => void intake.run(files)} + focusSearchSignal={focusSearchSignal} /> ) : showMemos ? ( + {/* 投入口: 既存資料の一括持ち込み。4 面(サイドバー・空ノートのチップ・一覧と素材の空状態・どこでもドロップ)がすべてここを開く */} + + void intake.run(files)} + onSearch={() => { intake.closeIntake(); showNoteList(); setFocusSearchSignal((n) => n + 1); }} + onShowGraph={() => { intake.closeIntake(); showGlobalGraphView(); }} + onAskAi={() => { intake.closeIntake(); composer.openComposer(); }} + onSetupAi={() => { + intake.closeIntake(); + window.dispatchEvent(new CustomEvent("graphium-open-settings", { detail: { tab: "ai" } })); + }} + />