diff --git a/manual/ja/mobile.md b/manual/ja/mobile.md index ace538131..4f241165e 100644 --- a/manual/ja/mobile.md +++ b/manual/ja/mobile.md @@ -88,7 +88,7 @@ Graphium はインストール可能な Web アプリ(PWA)です。スマホ 2. **記録して送る。** キューの **送信** をタップすると、自分の Drive の `Graphium/Inbox` フォルダに流れ込みます。転送は TLS を使い、ファイルは*自分が管理する*ストレージに普通のファイルとして置かれます。 3. **フォルダをデスクトップに同期する。** クラウドのクライアント(たとえば Google ドライブ デスクトップ版)を動かし、そのフォルダをデスクトップのディスク上に存在させます。 4. **Graphium にその場所を教える。** デスクトップの[設定 → ストレージ](/ja/settings)の **モバイル送信** セクションに **受信フォルダ** のピッカーがあります。クラウドストレージが同期しているフォルダを選ぶと、Graphium はその中の `Inbox` サブフォルダを読みます。同じセクションの **接続はスマホ側で行います** には、そもそもスマホで Graphium を開くための QR コード(**URL をコピー** 付き)があります。 -5. **取り込む。** ファイルが届くと、デスクトップのサイドバーの **モバイル** に未処理件数が表示されます。開いて 1 件ずつプレビューし、**全部取り込み** か **選択したものを取り込み** を実行します。写真・動画・音声メモは[素材](/ja/materials-and-citations)になり、書いた記録は通常のメモや URL のブックマークになります。重複は自動でスキップされ、トーストが **取り込み・スキップ・失敗** の件数をまとめます。受信箱が空なら **新しいものはありません** と表示されるだけです。 +5. **取り込む。** ファイルが届くと、デスクトップのサイドバーの **モバイル** に未処理件数が表示されます。開いて 1 件ずつプレビューし、**全部取り込み** か **選択したものを取り込み** を実行します。写真・動画・音声メモは[素材](/ja/materials-and-citations)になり、書いた記録は通常のメモや URL のブックマークになります。スマホの画面上部にある **送り先** でフォルダを選んでおくと、届いた素材はそのフォルダに入った状態になります。一覧には、その端末で前に使ったフォルダと、スマホからも同じ保管場所に繋がっている場合はそのフォルダが並びます。**+ 新しいフォルダ…** でその場で作ることもできます。選んだフォルダは切り替えるまで覚えているので、送るたびに選ぶ必要はありません。重複は自動でスキップされ、トーストが **取り込み・スキップ・失敗** の件数をまとめます。受信箱が空なら **新しいものはありません** と表示されるだけです。 既定では、取り込んだファイルは受信箱から削除されます。中身はすでにライブラリに入っているためです。控えを残したい場合は、**処理済みファイルを _imported/ に残す**(受信フォルダの設定、または設定 → ストレージ)をオンにしてください。 diff --git a/manual/mobile.md b/manual/mobile.md index d73121e93..8fc5ea190 100644 --- a/manual/mobile.md +++ b/manual/mobile.md @@ -88,7 +88,7 @@ The pipeline, end to end: 2. **Capture and send.** Tap **Send** on the queue and it drains into the `Graphium/Inbox` folder of your own Drive. Transfers use TLS, and the files sit as ordinary files in storage *you* control. 3. **Sync the folder to your desktop.** Run your cloud client (for example Google Drive for desktop) so that folder exists on the desktop's disk. 4. **Point Graphium at it.** In the desktop's [Settings → Storage](/settings), the **Mobile upload** section has the **Inbox folder** picker — choose the folder your cloud storage syncs to, and Graphium reads the `Inbox` subfolder inside it. The same section shows the QR code under **Connect on your phone** (with **Copy URL**) that opens Graphium on the phone in the first place. -5. **Import.** When files arrive, a **Mobile** entry in the desktop sidebar shows a pending count. Open it to preview each item, then **Import all** or **Import selected**. Photos, videos, and voice notes become [materials](/materials-and-citations); written captures become regular memos and URL bookmarks. Duplicates are skipped automatically, a toast summarizes **Imported · skipped · failed**, and an empty inbox simply says **Nothing new**. +5. **Import.** When files arrive, a **Mobile** entry in the desktop sidebar shows a pending count. Open it to preview each item, then **Import all** or **Import selected**. Photos, videos, and voice notes become [materials](/materials-and-citations); written captures become regular memos and URL bookmarks. Pick a folder under **Send into** at the top of the phone screen and what arrives is already in that folder. The list holds the folders you have used before on that phone, plus the ones in your library if the phone is connected to the same storage; **+ New folder…** creates one on the spot. Your choice is remembered until you change it, so there is nothing to pick each time you send. Duplicates are skipped automatically, a toast summarizes **Imported · skipped · failed**, and an empty inbox simply says **Nothing new**. By default, imported files are deleted from the inbox — their content is already in your library. Check **Keep processed files in _imported/** (in the inbox's folder settings or in Settings → Storage) if you prefer an archive. diff --git a/src/features/mobile-capture/MobileCaptureView.tsx b/src/features/mobile-capture/MobileCaptureView.tsx index 1f050c73a..172a40c00 100644 --- a/src/features/mobile-capture/MobileCaptureView.tsx +++ b/src/features/mobile-capture/MobileCaptureView.tsx @@ -22,7 +22,15 @@ // スマホ単体で 接続/切断・client_id 上書きまで完結する。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { StickyNote, Trash2, Search, X, RefreshCw, Settings as SettingsIcon } from "lucide-react"; +import { + StickyNote, + Trash2, + Search, + X, + RefreshCw, + Settings as SettingsIcon, + Folder as FolderIcon, +} from "lucide-react"; import type { CaptureIndex, CaptureEntry } from "./capture-store"; import type { MediaIndex, MediaIndexEntry } from "../asset-browser/media-index"; import { MediaPreview } from "../asset-browser/media-preview"; @@ -34,6 +42,12 @@ import { MobileCaptureBar } from "./MobileCaptureBar"; import { AudioRecorderSheet } from "./AudioRecorderSheet"; import { isAudioRecordingSupported } from "./audio-recorder"; import { MobileSettingsSheet } from "./MobileSettingsSheet"; +import { + getInboxFolder, + setInboxFolder, + getInboxFolderHistory, + rememberInboxFolder, +} from "./inbox/push/config"; import { StoragePickerSheet } from "./StoragePickerSheet"; import { CaptureHistorySection, type LocalCaptureItem } from "./inbox/CaptureHistorySection"; import { usePushQueue } from "./inbox/use-push-queue"; @@ -215,9 +229,13 @@ function MobileMediaPreviewModal({ ); } +/** select の「新しいフォルダ…」を表す番兵。フォルダ名には現れない形 */ +const NEW_FOLDER_OPTION = "\u0000new"; + export function MobileCaptureView({ captureIndex, mediaIndex, + vaultFolders, loading, onCreateCapture, onDeleteCapture, @@ -229,6 +247,11 @@ export function MobileCaptureView({ }: { captureIndex: CaptureIndex | null; mediaIndex?: MediaIndex | null; + /** + * この端末の vault が知っているフォルダ。送り先の候補に混ぜる。 + * 受信箱だけ使う構成では空になるので、候補はこの端末の履歴と合わせて作る。 + */ + vaultFolders?: readonly string[]; loading: boolean; onCreateCapture: (text: string) => Promise; onDeleteCapture?: (captureId: string) => Promise; @@ -247,6 +270,20 @@ export function MobileCaptureView({ const [refreshing, setRefreshing] = useState(false); const [pullDistance, setPullDistance] = useState(0); const [searchQuery, setSearchQuery] = useState(""); + // 送り先のフォルダ。設定シートと同じ localStorage を見るので、どちらで変えても揃う + const [sendFolder, setSendFolder] = useState(""); + const [folderHistory, setFolderHistory] = useState([]); + const [addingFolder, setAddingFolder] = useState(false); + const [newFolder, setNewFolder] = useState(""); + const applyFolder = useCallback((value: string) => { + const next = value.trim(); + setSendFolder(next); + setInboxFolder(next || null); + if (next) { + rememberInboxFolder(next); + setFolderHistory(getInboxFolderHistory()); + } + }, []); const scrollRef = useRef(null); const touchStartY = useRef(0); const pulling = useRef(false); @@ -473,6 +510,26 @@ export function MobileCaptureView({ // 最小設定シートのストレージ操作(状態・切断・client_id 上書き)の実体。 // ピッカー/シートが開いている間だけ push モジュールを引く。 const pushSettings = usePushSettings(showStoragePicker || showSettingsSheet); + // 初回と、設定シートを閉じた直後に読み直す(シート側で変えられていても揃う) + useEffect(() => { + if (showSettingsSheet) return; + setSendFolder(getInboxFolder() ?? ""); + setFolderHistory(getInboxFolderHistory()); + }, [showSettingsSheet]); + // 候補は vault のフォルダとこの端末の履歴の和集合。小文字で名寄せし、 + // 表示は先に出てきた形を残す(フォルダ名の突き合わせ規則と揃える) + const folderOptions = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const raw of [...(vaultFolders ?? []), ...folderHistory]) { + const value = raw.trim(); + const key = value.toLowerCase(); + if (!value || seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out; + }, [vaultFolders, folderHistory]); // ピッカーの Google 選択。**click から同期的に connect へ到達させる**(GIS 契約)。 // 接続成功 → 残キューを即送信(connectAndDrain)。 @@ -508,6 +565,67 @@ export function MobileCaptureView({ // ファイル選択にフォールバックする。判定は端末固定なので 1 回でよい。 const audioRecordingSupported = useMemo(() => isAudioRecordingSupported(), []); + // 送り先のフォルダ。設定の奥ではなく送る場所のすぐ上に出す — これから送るものが + // どこへ届くのかは、送る前に見えていないと意味がない。履歴が空でも出したいので + // 検索欄(履歴があるときだけ出る)とは別のブロックにしている + const folderPicker = push.configured ? ( +
+ + {t("mobile.sendFolder.label")} + {addingFolder ? ( + setNewFolder(e.target.value)} + onBlur={() => { + if (newFolder.trim()) applyFolder(newFolder); + setAddingFolder(false); + setNewFolder(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + applyFolder(newFolder); + setAddingFolder(false); + setNewFolder(""); + } else if (e.key === "Escape") { + setAddingFolder(false); + setNewFolder(""); + } + }} + placeholder={t("mobile.sendFolder.placeholder")} + className="min-w-0 flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-primary" + /> + ) : ( + + )} +
+ ) : null; + // 検索入力(ヘッダー固定ではなくスクロール内 = 統合リストの直上に置く) const searchInput = (
@@ -611,6 +729,8 @@ export function MobileCaptureView({ 未送信ありのときだけセクション内の接続ボタンが主アクションになる。 捕獲の入口は画面下固定の捕獲バー(MobileCaptureBar)。履歴は IndexedDB 永続なので画面を離れても、送信し終えても消えない */} + {folderPicker} + {(timeline.length > 0 || push.items.length > 0) && (
{searchInput} diff --git a/src/features/mobile-capture/inbox/capture-file.ts b/src/features/mobile-capture/inbox/capture-file.ts index e2b579f03..30add59b0 100644 --- a/src/features/mobile-capture/inbox/capture-file.ts +++ b/src/features/mobile-capture/inbox/capture-file.ts @@ -39,6 +39,11 @@ export type GraphiumUrlCapturePayload = { title?: string; description?: string; ogImage?: string; + /** + * 送信時に指定されたフォルダ。取り込み時に素材の noteContexts へ入る。 + * メディアは名前に埋め込むが(push/naming.ts)、URL は元から JSON なのでここに置く。 + */ + folder?: string; }; export type GraphiumCapturePayload = GraphiumMemoCapturePayload | GraphiumUrlCapturePayload; @@ -83,7 +88,7 @@ export function buildMemoCaptureFile(text: string, now: Date = new Date()): File /** URL 捕獲ファイルを作る(モバイルの [URL] → キュー)。メタはあるものだけ運ぶ。 */ export function buildUrlCaptureFile( - input: { url: string; title?: string; description?: string; ogImage?: string }, + input: { url: string; title?: string; description?: string; ogImage?: string; folder?: string }, now: Date = new Date(), ): File { const title = input.title?.trim(); @@ -96,6 +101,7 @@ export function buildUrlCaptureFile( ...(title ? { title } : {}), ...(description ? { description } : {}), ...(input.ogImage ? { ogImage: input.ogImage } : {}), + ...(input.folder?.trim() ? { folder: input.folder.trim() } : {}), }; return toCaptureFile("url", payload); } diff --git a/src/features/mobile-capture/inbox/push/config.test.ts b/src/features/mobile-capture/inbox/push/config.test.ts index 25be7b545..163c7da61 100644 --- a/src/features/mobile-capture/inbox/push/config.test.ts +++ b/src/features/mobile-capture/inbox/push/config.test.ts @@ -5,10 +5,13 @@ // ある現在のビルドでは、上書き無し = 同梱 ID、上書きの解除 = 同梱 ID に戻る // - フォルダキャッシュは壊れた JSON / 形の違う JSON を無害に null 扱いする // - 選択プロバイダは未保存・不正値を google-drive に倒す(v1 の唯一の実体) +// - 送り先フォルダの履歴は新しい順・重複なし・上限あり。壊れた値は空として扱う import { describe, it, expect, beforeEach } from "vitest"; import { DEFAULT_GOOGLE_PUSH_CLIENT_ID, + getInboxFolderHistory, + rememberInboxFolder, getDriveFolderCache, getGoogleClientId, getGoogleClientIdOverride, @@ -85,3 +88,41 @@ describe("選択プロバイダの永続", () => { expect(getPushProvider()).toBe("google-drive"); }); }); + +describe("送り先フォルダの履歴", () => { + it("使った順に新しいものが先頭へ来る", () => { + rememberInboxFolder("材料X"); + rememberInboxFolder("実験B"); + expect(getInboxFolderHistory()).toEqual(["実験B", "材料X"]); + }); + + it("同じフォルダを使い直しても増えず、先頭へ上がる", () => { + rememberInboxFolder("材料X"); + rememberInboxFolder("実験B"); + rememberInboxFolder("材料X"); + expect(getInboxFolderHistory()).toEqual(["材料X", "実験B"]); + }); + + it("前後の空白は落とす。空文字は覚えない", () => { + rememberInboxFolder(" 材料X "); + rememberInboxFolder(" "); + expect(getInboxFolderHistory()).toEqual(["材料X"]); + }); + + it("上限を超えたら古いものから落ちる", () => { + for (let i = 0; i < 15; i++) rememberInboxFolder(`folder${i}`); + const history = getInboxFolderHistory(); + expect(history).toHaveLength(12); + expect(history[0]).toBe("folder14"); + expect(history).not.toContain("folder0"); + }); + + it("壊れた JSON / 形の違う JSON は空として扱う", () => { + localStorage.setItem("graphium-mobile-inbox-folder-history", "{"); + expect(getInboxFolderHistory()).toEqual([]); + localStorage.setItem("graphium-mobile-inbox-folder-history", JSON.stringify({ a: 1 })); + expect(getInboxFolderHistory()).toEqual([]); + localStorage.setItem("graphium-mobile-inbox-folder-history", JSON.stringify(["ok", 3, null])); + expect(getInboxFolderHistory()).toEqual(["ok"]); + }); +}); diff --git a/src/features/mobile-capture/inbox/push/config.ts b/src/features/mobile-capture/inbox/push/config.ts index f176b1853..16133aabe 100644 --- a/src/features/mobile-capture/inbox/push/config.ts +++ b/src/features/mobile-capture/inbox/push/config.ts @@ -31,6 +31,65 @@ const PROVIDER_KEY = "graphium-push-provider"; export const DEFAULT_GOOGLE_PUSH_CLIENT_ID: string = "743366655410-p5k3us8jof0ni4tintbkliq6dqhan13d.apps.googleusercontent.com"; +const INBOX_FOLDER_KEY = "graphium-mobile-inbox-folder"; + +/** + * 送ったものを入れるフォルダ(未設定なら null)。 + * + * 送信のたびに選ばせず、一度決めたら覚えておく — モバイルは「素早く放り込む」道具で、 + * 毎回の選択はその速さを損なう。切り替えたいときだけ設定から変える。 + */ +export function getInboxFolder(): string | null { + try { + const v = localStorage.getItem(INBOX_FOLDER_KEY); + return v && v.trim() ? v : null; + } catch { + return null; + } +} + +export function setInboxFolder(folder: string | null): void { + try { + if (!folder || !folder.trim()) localStorage.removeItem(INBOX_FOLDER_KEY); + else localStorage.setItem(INBOX_FOLDER_KEY, folder.trim()); + } catch { + // localStorage 不可の環境では黙って無視(他の設定と同じ非致命的挙動) + } +} + +const INBOX_FOLDER_HISTORY_KEY = "graphium-mobile-inbox-folder-history"; +const FOLDER_HISTORY_MAX = 12; + +/** + * この端末で送り先にしたことのあるフォルダ(新しい順)。 + * + * 受信箱だけ使う構成だと、この端末の vault は空でフォルダの候補が手元に無い。 + * PC から一覧を貰う道は Drive のスコープ(drive.file = アプリが作ったものだけ)で + * 塞がっているので、一度打ったものを覚えて次から選べるようにする。 + */ +export function getInboxFolderHistory(): string[] { + try { + const raw = localStorage.getItem(INBOX_FOLDER_HISTORY_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +/** 使ったフォルダを履歴の先頭へ。同じものは畳み、古いものから落とす。 */ +export function rememberInboxFolder(folder: string): void { + const value = folder.trim(); + if (!value) return; + try { + const next = [value, ...getInboxFolderHistory().filter((v) => v !== value)]; + localStorage.setItem(INBOX_FOLDER_HISTORY_KEY, JSON.stringify(next.slice(0, FOLDER_HISTORY_MAX))); + } catch { + // localStorage 不可の環境では黙って無視(他の設定と同じ非致命的挙動) + } +} + /** localStorage に保存された自前 client_id(未設定なら null)。 */ export function getGoogleClientIdOverride(): string | null { try { diff --git a/src/features/mobile-capture/inbox/push/folder-name.test.ts b/src/features/mobile-capture/inbox/push/folder-name.test.ts new file mode 100644 index 000000000..a24c2af21 --- /dev/null +++ b/src/features/mobile-capture/inbox/push/folder-name.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { normalizeCaptureName, parseInboxFolder } from "./naming"; + +const when = new Date("2026-09-03T10:15:30"); + +describe("送信名へのフォルダ埋め込み", () => { + it("フォルダを指定すると名前に載る", () => { + const name = normalizeCaptureName({ + mime: "image/jpeg", + originalName: "IMG_0001.JPG", + when, + seq: 1, + folder: "材料X", + }); + expect(name.startsWith("graphium-20260903-101530-01~~")).toBe(true); + expect(name.endsWith(".jpg")).toBe(true); + }); + + it("指定が無ければ今までどおりの名前", () => { + const name = normalizeCaptureName({ mime: "image/jpeg", originalName: "a.jpg", when, seq: 1 }); + expect(name).toBe("graphium-20260903-101530-01.jpg"); + }); + + it("空白だけの指定は付けない", () => { + const name = normalizeCaptureName({ + mime: "image/jpeg", originalName: "a.jpg", when, seq: 1, folder: " ", + }); + expect(name).toBe("graphium-20260903-101530-01.jpg"); + }); + + it("メモ / URL の捕獲ファイルには付けない(中身の JSON が運ぶ)", () => { + const name = normalizeCaptureName({ + mime: "application/json", + originalName: "graphium-x-url.graphium.json", + when, + seq: 1, + folder: "材料X", + }); + expect(name).toBe("graphium-20260903-101530-01-url.graphium.json"); + }); +}); + +describe("parseInboxFolder", () => { + it("埋め込んだフォルダを取り出し、名前からは外す", () => { + const sent = normalizeCaptureName({ + mime: "image/png", originalName: "a.png", when, seq: 2, folder: "材料X", + }); + expect(parseInboxFolder(sent)).toEqual({ + folder: "材料X", + name: "graphium-20260903-101530-02.png", + }); + }); + + it("サブフォルダ(スラッシュ入り)も往復できる", () => { + const sent = normalizeCaptureName({ + mime: "image/png", originalName: "a.png", when, seq: 1, folder: "プロジェクトA/実験1", + }); + expect(parseInboxFolder(sent).folder).toBe("プロジェクトA/実験1"); + }); + + it("埋め込みが無い名前はそのまま返す", () => { + expect(parseInboxFolder("graphium-20260903-101530-01.jpg")).toEqual({ + name: "graphium-20260903-101530-01.jpg", + }); + }); + + it("人が置いた普通のファイル名も壊さない", () => { + expect(parseInboxFolder("IMG_0001.JPG")).toEqual({ name: "IMG_0001.JPG" }); + }); + + it("壊れたエンコードは「指定なし」として扱い、取り込み自体は通す", () => { + const r = parseInboxFolder("graphium-20260903-101530-01~~%E4%B8.jpg"); + expect(r.folder).toBeUndefined(); + expect(r.name).toBe("graphium-20260903-101530-01.jpg"); + }); + + it("区切りだけで中身が無い場合も名前を戻す", () => { + expect(parseInboxFolder("graphium-20260903-101530-01~~.jpg")).toEqual({ + name: "graphium-20260903-101530-01.jpg", + }); + }); +}); diff --git a/src/features/mobile-capture/inbox/push/naming.ts b/src/features/mobile-capture/inbox/push/naming.ts index d4a7089d6..165fa70ad 100644 --- a/src/features/mobile-capture/inbox/push/naming.ts +++ b/src/features/mobile-capture/inbox/push/naming.ts @@ -15,6 +15,45 @@ import { isGraphiumCaptureName, } from "../capture-file"; +/** + * 送信名にフォルダを埋め込むときの区切り。正規化後の名前は + * `graphium-<日時>-<連番>` なので、この並びには現れない。 + */ +const FOLDER_MARK = "~~"; + +/** + * フォルダ名を送信名に埋め込める形にする。`/`(サブフォルダ)や日本語をそのまま + * ファイル名に置けないので URL エンコードする。 + */ +function encodeFolderSegment(folder: string): string { + return encodeURIComponent(folder.trim()); +} + +/** + * 送信名からフォルダを取り出し、フォルダ抜きの名前と一緒に返す。 + * 埋め込みが無ければ folder は undefined、name はそのまま。 + * + * 受信側はこれで「素材の名前」と「入れるフォルダ」を分ける — 名前にエンコード済みの + * 文字列が残ったままだと、素材一覧に読めない名前が並んでしまう。 + */ +export function parseInboxFolder(name: string): { folder?: string; name: string } { + const dot = name.lastIndexOf("."); + const base = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ""; + const at = base.lastIndexOf(FOLDER_MARK); + if (at < 0) return { name }; + const encoded = base.slice(at + FOLDER_MARK.length); + if (!encoded) return { name: base.slice(0, at) + ext }; + let folder: string | undefined; + try { + folder = decodeURIComponent(encoded).trim() || undefined; + } catch { + // 壊れたエンコードは「フォルダ指定なし」として扱う(取り込み自体は通す) + folder = undefined; + } + return { folder, name: base.slice(0, at) + ext }; +} + // MIME → 正規拡張子。inbox/mime.ts の EXT_TO_MIME と対で保守する //(iPhone カメラ/ボイスメモ由来の heic / mov / m4a / caf を含む)。 const MIME_TO_EXT: Record = { @@ -84,6 +123,11 @@ export function normalizeCaptureName(opts: { originalName: string; when: Date; seq: number; + /** + * 入れるフォルダ(任意)。生のメディアにはメタを運ぶ経路が無いので名前に埋め込む。 + * メモ / URL は JSON の中に持たせるため、ここでは付けない。 + */ + folder?: string; }): string { const stamp = formatCaptureTimestamp(opts.when); const seq = String(opts.seq).padStart(2, "0"); @@ -92,5 +136,7 @@ export function normalizeCaptureName(opts: { return `graphium-${stamp}-${seq}-${kind}${GRAPHIUM_CAPTURE_EXTENSION}`; } const ext = extensionForCapture(opts.mime, opts.originalName); - return `graphium-${stamp}-${seq}.${ext}`; + const folder = opts.folder?.trim(); + const mark = folder ? `${FOLDER_MARK}${encodeFolderSegment(folder)}` : ""; + return `graphium-${stamp}-${seq}${mark}.${ext}`; } diff --git a/src/features/mobile-capture/inbox/push/queue.ts b/src/features/mobile-capture/inbox/push/queue.ts index 8361008fe..3bf703523 100644 --- a/src/features/mobile-capture/inbox/push/queue.ts +++ b/src/features/mobile-capture/inbox/push/queue.ts @@ -32,6 +32,7 @@ import { parseGraphiumCaptureFile, } from "../capture-file"; import { normalizeCaptureName } from "./naming"; +import { getInboxFolder } from "./config"; import { createCaptureThumbnail } from "./thumbnail"; import { PushAuthError, type InboxPusher, type PushProgress } from "./types"; @@ -336,6 +337,9 @@ async function attachThumbnails(records: PushQueueRecord[], files: File[]): Prom export async function enqueuePushFiles(files: File[]): Promise { if (files.length === 0) return []; const when = new Date(); + // 送信のたびに選ばせず、設定した既定のフォルダを使う(モバイルは素早く放り込む道具で、 + // 毎回の選択はその速さを損なう)。メディアは名前に埋め込んで運ぶ。 + const folder = getInboxFolder() ?? undefined; const records: PushQueueRecord[] = await Promise.all( files.map(async (file, index) => { const mime = file.type || "application/octet-stream"; @@ -344,6 +348,7 @@ export async function enqueuePushFiles(files: File[]): Promise { const { bytes, mime } = await this.readBytes(ref); const checksum = await computeBlobHash(bytes); + // 送信時に指定されたフォルダは名前に埋め込まれている(push/naming.ts)。 + // ここで取り出して meta に移す — 名前に残したままだと素材名がエンコード済みの + // 文字列になってしまう。 + const { folder } = parseInboxFolder(ref.name); return { blob: new Blob([bytes as BlobPart], { type: mime }), meta: { @@ -67,6 +72,7 @@ export class FolderInbox implements InboxTransport { mime, bytes: bytes.length, kind: kindFromMime(mime), + ...(folder ? { folder } : {}), }, }; } diff --git a/src/features/mobile-capture/inbox/types.ts b/src/features/mobile-capture/inbox/types.ts index 31a681db0..6f8a3a0ab 100644 --- a/src/features/mobile-capture/inbox/types.ts +++ b/src/features/mobile-capture/inbox/types.ts @@ -44,6 +44,12 @@ export type CaptureMeta = { quickNote?: string; /** 撮影時タグ(Phase 1b)。 */ tags?: string[]; + /** + * 送信時に指定されたフォルダ。取り込み時に素材の noteContexts へ入れる。 + * 生の写真・動画・音声にはメタを運ぶ経路が無いので、送信名に埋め込んだものを + * transport が取り出してここへ載せる(push/naming.ts の parseInboxFolder)。 + */ + folder?: string; }; /** diff --git a/src/hooks/use-file-manager.ts b/src/hooks/use-file-manager.ts index a79758e73..693821a9d 100644 --- a/src/hooks/use-file-manager.ts +++ b/src/hooks/use-file-manager.ts @@ -2168,6 +2168,11 @@ export function useFileManager(authenticated: boolean) { ? { derivedFromAssets: options.derivedFromAssets } : {}), ...(options?.capture ? { capture: options.capture } : {}), + // 送信時に指定されたフォルダをそのまま素材に付ける。モバイルで「いまは材料X」と + // 決めておけば、取り込んだ時点で片付いている状態になる。 + ...(options?.capture?.folder + ? { noteContexts: normalizeNoteContexts([options.capture.folder]) } + : {}), }; const current = mediaIndexRef.current ?? createEmptyIndex(); const updated = addMediaEntry(current, entry); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5a52b29cc..b44a33de0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1024,6 +1024,10 @@ export const en: Record = { "nav.folders": "Folders", "nav.foldersTooltip": "Organize notes into folders. A note can live in more than one folder.", "nav.unfiled": "Unfiled", + "mobile.sendFolder.label": "Send into", + "mobile.sendFolder.placeholder": "e.g. Material X (leave empty for none)", + "mobile.sendFolder.none": "No folder", + "mobile.sendFolder.new": "+ New folder…", "nav.newFolder": "New folder", "nav.newNoteInFolder": "New in folder", "nav.renameFolder": "Rename", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 903a761e9..87375fd6a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1025,6 +1025,10 @@ export const ja: Record = { "nav.folders": "フォルダ", "nav.foldersTooltip": "ノートをフォルダで整理します。1 つのノートを複数のフォルダに入れることもできます", "nav.unfiled": "未分類", + "mobile.sendFolder.label": "送り先", + "mobile.sendFolder.placeholder": "例: 材料X(空ならフォルダなし)", + "mobile.sendFolder.none": "フォルダなし", + "mobile.sendFolder.new": "+ 新しいフォルダ…", "nav.newFolder": "新しいフォルダ", "nav.newNoteInFolder": "このフォルダに新規", "nav.renameFolder": "名前を変更", diff --git a/src/note-app.tsx b/src/note-app.tsx index 7f5ffe510..ffc752690 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -204,7 +204,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 { buildNoteFolderLookup, type NoteFolderLookup } from "./features/asset-browser/asset-folders"; import { addFolderDefinition, ensureFolderDefinitions, removeFolderDefinition, renameFolderDefinition } from "./features/note-context/folder-store"; import { FolderMenu } from "./features/note-context/FolderMenu"; import { computeFolderDrop } from "./features/note-context/folder-drop"; @@ -755,6 +755,12 @@ function NoteHeaderMenu({ type NoteEditorProps = { fileId: string | null; initialDoc: GraphiumDocument | null; + /** + * ノート id → そのノートのフォルダ。エディタ内から開く素材サイドピークで、 + * 素材が「使われているノートのフォルダ」に属して見えるようにするために渡す + * (素材ギャラリー側と同じ導出を使い、見え方を揃える)。 + */ + noteFolderLookup?: NoteFolderLookup; onSave: (doc: GraphiumDocument) => void; onDeriveNote: (title: string, sourceBlockId: string) => void; /** `@` メニューの「新規ノートを作成」用。空ノートを作って ID を返す(ナビゲーションしない) */ @@ -1076,6 +1082,7 @@ async function applyAtomReinforcement(opts: { function NoteEditorInner({ fileId, initialDoc, + noteFolderLookup, onSave, onDeriveNote, onCreateLinkedNote, @@ -5563,6 +5570,7 @@ function NoteEditorInner({ setMaterialSidePeekEntry(null)} mediaIndex={mediaIndex ?? null} onRegisterAsset={materialPeekUrlUnregistered ? handleRegisterUrlFromPeek : undefined} @@ -5585,6 +5593,7 @@ function NoteEditorInner({ {materialSidePeekEntry && !isDesktop && ( setMaterialSidePeekEntry(null)} mediaIndex={mediaIndex ?? null} onRegisterAsset={materialPeekUrlUnregistered ? handleRegisterUrlFromPeek : undefined} @@ -7919,6 +7928,10 @@ export function NoteApp() { ...(payload.ogImage ? { ogImage: payload.ogImage } : {}), }, capture: meta, + // 送信時に指定されたフォルダをそのまま素材に付ける(メディアと同じ扱い) + ...(payload.folder?.trim() + ? { noteContexts: [payload.folder.trim()] } + : {}), }); return { fileId }; }, @@ -9896,6 +9909,7 @@ export function NoteApp() {