Let users download shared notes as Markdown

The shared notes had no way out: everything lived in the SQLite row and the
only reader was the panel itself. Add two export endpoints and buttons for
them at the bottom of the note panel.

GET /api/notes/{page_id}/export returns one page as .md, and
GET /api/notes/export returns every page as a zip laid out as
<section>/<NN>_<title>.md. Pages are not concatenated into a single file on
purpose - the headings inside a page would collide with whatever heading
level we used as a page separator, so each page stays a verbatim file and the
numeric prefix carries the tab order instead.

Titles reach the filesystem here, so strip the Windows-forbidden characters
and control characters, cap the length, and send Content-Disposition with an
RFC 5987 filename* so Japanese titles survive. The front end deliberately
omits the anchor's download attribute to let that name win.

notes store: flush() now returns a promise so the download can wait for
pending edits to be saved before asking the server for the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:33:08 +09:00
co-authored by Claude Opus 5
parent f89bc9bc9a
commit 216d38c7bd
8 changed files with 192 additions and 4 deletions
+67 -1
View File
@@ -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 "新しいページ"