diff --git a/CLAUDE.md b/CLAUDE.md index ee8e510..78657c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte - **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される - **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる - **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない +- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる - **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`) ### SSE ストリーミング diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 67d4638..24903d2 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -1,6 +1,10 @@ -from datetime import datetime +import io +import zipfile +from datetime import datetime, timezone +from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession @@ -46,11 +50,73 @@ class ReorderRequest(BaseModel): order: list[int] +# ファイル名に使えない文字(Windows の禁止文字。制御文字は _safe_filename 側で弾く) +_UNSAFE_FILENAME_CHARS = '\\/:*?"<>|' + + +def _safe_filename(name: str, fallback: str) -> str: + """タイトルやセクション名をファイル名に使える形へ落とす。空になったら fallback を使う。""" + cleaned = "".join("_" if c in _UNSAFE_FILENAME_CHARS or ord(c) < 32 else c for c in name) + cleaned = cleaned.strip().strip(".") + return cleaned[:80] or fallback + + +def _attachment_headers(filename: str) -> dict[str, str]: + """日本語のファイル名は Content-Disposition にそのまま置けないため RFC 5987 形式を併記する。""" + ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace("?", "_") + return { + "Content-Disposition": ( + f'attachment; filename="{ascii_fallback}"; ' + f"filename*=UTF-8''{quote(filename, safe='')}" + ) + } + + @router.get("", response_model=list[NotePageResponse]) def list_pages(db: DBSession = Depends(get_db)): return db.query(NotePage).order_by(NotePage.position, NotePage.id).all() +@router.get("/export") +def export_all_pages(db: DBSession = Depends(get_db)): + """全ページを 1ページ1ファイルの Markdown にして ZIP でダウンロードさせる。 + + 本文はそのまま書き出す(1つのファイルに連結すると、ページ内の見出しの階層が + ページ間の見出しと混ざってしまうため)。タブの並び順を残すためファイル名には + 通し番号を付け、セクションはフォルダにする。 + """ + pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all() + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for i, page in enumerate(pages, start=1): + entry = f"{i:02d}_{_safe_filename(page.title, f'page_{page.id}')}.md" + if page.section: + entry = f"{_safe_filename(page.section, 'section')}/{entry}" + archive.writestr(entry, page.content) + + filename = f"notes_{datetime.now(timezone.utc).strftime('%Y%m%d')}.zip" + return Response( + content=buffer.getvalue(), + media_type="application/zip", + headers=_attachment_headers(filename), + ) + + +@router.get("/{page_id}/export") +def export_page(page_id: int, db: DBSession = Depends(get_db)): + """1ページを Markdown ファイルとしてダウンロードさせる。本文はそのまま、ファイル名がタイトルになる。""" + page = db.get(NotePage, page_id) + if not page: + raise HTTPException(status_code=404, detail="Note page not found") + filename = f"{_safe_filename(page.title, f'page_{page.id}')}.md" + return Response( + content=page.content, + media_type="text/markdown; charset=utf-8", + headers=_attachment_headers(filename), + ) + + @router.post("", response_model=NotePageResponse, status_code=201) def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)): title = req.title.strip() or "新しいページ" diff --git a/docs/api.md b/docs/api.md index cc3ebf3..d7d8112 100644 --- a/docs/api.md +++ b/docs/api.md @@ -187,6 +187,28 @@ SSE でストリーミング応答を返す。 --- +### GET /api/notes/export +全ページを ZIP でダウンロードさせる(`Content-Disposition: attachment`)。 +1 ページ 1 ファイルの Markdown で、本文はそのまま。エントリ名は `<セクション>/<通し番号>_<タイトル>.md` +(セクション無しのページは ZIP 直下)。通し番号は `position` 順なのでタブの並びが残る。 +ファイル名は `notes_YYYYMMDD.zip`。 + +--- + +### GET /api/notes/{page_id}/export +1 ページを Markdown ファイルとしてダウンロードさせる。本文はそのままで、ファイル名がタイトルになる。 +ページが存在しない場合は `404`。 + +日本語タイトルを扱うため `Content-Disposition` には RFC 5987 形式を併記する: + +``` +attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E3%83%AA%E3%82%B9%E3%83%88.md +``` + +ファイル名に使えない文字(`\ / : * ? " < > |` と制御文字)は `_` に置き換え、80 文字で切る。 + +--- + ## ログ ### GET /logs diff --git a/docs/features.md b/docs/features.md index c0b5e17..d777d22 100644 --- a/docs/features.md +++ b/docs/features.md @@ -109,6 +109,9 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。 AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。 - 自分が編集中(未保存)のページは AI の更新で上書きされない。 +- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを + ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として + 手元に保存できる。未保存の編集は書き出す前に保存される。 ## 8-9. 自動タグ付けとワードプール diff --git a/docs/notes-guide.md b/docs/notes-guide.md index 9ce22e3..d81c728 100644 --- a/docs/notes-guide.md +++ b/docs/notes-guide.md @@ -18,10 +18,28 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。 | タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) | | ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) | | 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 | +| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック | +| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック | 本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。 ページが1つも無い場合は「+ 最初のページを作成」から始められる。 +## 手元にダウンロードする + +パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、 +保存してから書き出すので取りこぼしはない。 + +| ボタン | 出てくるもの | +|---|---| +| **⬇ このページ (.md)** | 開いているページの本文そのままの Markdown ファイル。ファイル名はページのタイトル | +| **⬇ すべて (.zip)** | 全ページをまとめた ZIP。1 ページ 1 Markdown で、セクションはフォルダになる | + +ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。タブの並び順を残すためで、 +展開したときも画面と同じ順番で並ぶ。ファイル名に使えない文字(`/` や `:` など)は `_` に置き換わる。 + +複数ページを 1 つのファイルに連結せず ZIP にしているのは、ページ内の見出し(`#`)が +ページ間の見出しと混ざって階層が壊れるのを避けるため。 + ## セクションでまとめる 複数のページを1つのグループとしてタブ上にまとめられる。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 236fe53..a2641f5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -314,6 +314,29 @@ export async function deleteNotePage(pageId: number): Promise { await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) } +/** + * ダウンロードのためだけにアンカーをクリックする。 + * ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない + * (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。 + */ +function triggerDownload(url: string): void { + const a = document.createElement('a') + a.href = url + document.body.appendChild(a) + a.click() + a.remove() +} + +/** 1ページを Markdown ファイルとしてダウンロードする。 */ +export function exportNotePage(pageId: number): void { + triggerDownload(`${BASE}/api/notes/${pageId}/export`) +} + +/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */ +export function exportAllNotePages(): void { + triggerDownload(`${BASE}/api/notes/export`) +} + /** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */ export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> { const res = await fetch(`${BASE}/api/notes/sections/rename`, { diff --git a/frontend/src/components/NotePanel.vue b/frontend/src/components/NotePanel.vue index 35ff910..a6cd613 100644 --- a/frontend/src/components/NotePanel.vue +++ b/frontend/src/components/NotePanel.vue @@ -212,10 +212,26 @@ onUnmounted(() => { +
+ + +
+
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。 🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。 タブはドラッグで並べ替え・他のセクションへの移動ができます。 + ⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
@@ -355,6 +371,26 @@ onUnmounted(() => { font-size: 12px; cursor: pointer; } +.actions { + flex-shrink: 0; + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 6px 8px; + border-top: 1px solid #2a2a2a; +} +.action-btn { + background: #1f1f1f; + color: #aaa; + border: 1px solid #333; + border-radius: 6px; + padding: 4px 10px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; } +.action-btn:disabled { opacity: 0.4; cursor: not-allowed; } .hint { flex-shrink: 0; padding: 8px 10px; diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 892f8f8..26db0d5 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -5,6 +5,8 @@ import { createNotePage, updateNotePage, deleteNotePage, + exportNotePage, + exportAllNotePages, renameNoteSection, reorderNotePages, type NotePage, @@ -140,13 +142,28 @@ export const useNotesStore = defineStore('notes', () => { } } - /** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */ - function flush(): void { + /** + * 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 + * await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。 + */ + function flush(): Promise { if (saveTimer !== null) { clearTimeout(saveTimer) saveTimer = null } - for (const id of [...dirtyIds.value]) void save(id) + return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined) + } + + /** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */ + async function downloadPage(id: number): Promise { + await flush() + exportNotePage(id) + } + + /** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */ + async function downloadAll(): Promise { + await flush() + exportAllNotePages() } return { @@ -164,5 +181,7 @@ export const useNotesStore = defineStore('notes', () => { renameSection, reorder, flush, + downloadPage, + downloadAll, } })