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:
@@ -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 "新しいページ"
|
||||
|
||||
Reference in New Issue
Block a user