Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion manual/ja/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/ に残す**(受信フォルダの設定、または設定 → ストレージ)をオンにしてください。

Expand Down
2 changes: 1 addition & 1 deletion manual/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
122 changes: 121 additions & 1 deletion src/features/mobile-capture/MobileCaptureView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -215,9 +229,13 @@ function MobileMediaPreviewModal({
);
}

/** select の「新しいフォルダ…」を表す番兵。フォルダ名には現れない形 */
const NEW_FOLDER_OPTION = "\u0000new";

export function MobileCaptureView({
captureIndex,
mediaIndex,
vaultFolders,
loading,
onCreateCapture,
onDeleteCapture,
Expand All @@ -229,6 +247,11 @@ export function MobileCaptureView({
}: {
captureIndex: CaptureIndex | null;
mediaIndex?: MediaIndex | null;
/**
* この端末の vault が知っているフォルダ。送り先の候補に混ぜる。
* 受信箱だけ使う構成では空になるので、候補はこの端末の履歴と合わせて作る。
*/
vaultFolders?: readonly string[];
loading: boolean;
onCreateCapture: (text: string) => Promise<void>;
onDeleteCapture?: (captureId: string) => Promise<void>;
Expand All @@ -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<string[]>([]);
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<HTMLDivElement>(null);
const touchStartY = useRef(0);
const pulling = useRef(false);
Expand Down Expand Up @@ -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<string>();
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)。
Expand Down Expand Up @@ -508,6 +565,67 @@ export function MobileCaptureView({
// ファイル選択にフォールバックする。判定は端末固定なので 1 回でよい。
const audioRecordingSupported = useMemo(() => isAudioRecordingSupported(), []);

// 送り先のフォルダ。設定の奥ではなく送る場所のすぐ上に出す — これから送るものが
// どこへ届くのかは、送る前に見えていないと意味がない。履歴が空でも出したいので
// 検索欄(履歴があるときだけ出る)とは別のブロックにしている
const folderPicker = push.configured ? (
<div className="mb-3 flex items-center gap-2">
<FolderIcon size={14} className="shrink-0 text-muted-foreground" />
<span className="shrink-0 text-xs text-muted-foreground">{t("mobile.sendFolder.label")}</span>
{addingFolder ? (
<input
autoFocus
type="text"
value={newFolder}
onChange={(e) => 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"
/>
) : (
<select
value={sendFolder}
onChange={(e) => {
if (e.target.value === NEW_FOLDER_OPTION) {
setNewFolder("");
setAddingFolder(true);
return;
}
applyFolder(e.target.value);
}}
aria-label={t("mobile.sendFolder.label")}
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none focus:border-primary"
>
<option value="">{t("mobile.sendFolder.none")}</option>
{/* 設定シートで打たれた等、候補に無い値が入っていても選択状態を保てるように */}
{sendFolder && !folderOptions.includes(sendFolder) && (
<option value={sendFolder}>{sendFolder}</option>
)}
{folderOptions.map((folder) => (
<option key={folder} value={folder}>
{folder}
</option>
))}
<option value={NEW_FOLDER_OPTION}>{t("mobile.sendFolder.new")}</option>
</select>
)}
</div>
) : null;

// 検索入力(ヘッダー固定ではなくスクロール内 = 統合リストの直上に置く)
const searchInput = (
<div className="relative">
Expand Down Expand Up @@ -611,6 +729,8 @@ export function MobileCaptureView({
未送信ありのときだけセクション内の接続ボタンが主アクションになる。
捕獲の入口は画面下固定の捕獲バー(MobileCaptureBar)。履歴は IndexedDB
永続なので画面を離れても、送信し終えても消えない */}
{folderPicker}

{(timeline.length > 0 || push.items.length > 0) && (
<div className="mb-3">
{searchInput}
Expand Down
8 changes: 7 additions & 1 deletion src/features/mobile-capture/inbox/capture-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
41 changes: 41 additions & 0 deletions src/features/mobile-capture/inbox/push/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]);
});
});
59 changes: 59 additions & 0 deletions src/features/mobile-capture/inbox/push/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading