Files
playchat/backend/app/api/notes.py
T
colcaroliandClaude Opus 5 216d38c7bd 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>
2026-08-23 15:33:08 +09:00

177 lines
6.3 KiB
Python

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
from app.core.notes import next_position, reorder, rename_section, set_section
from app.db.database import get_db
from app.db.models import NotePage
router = APIRouter(prefix="/api/notes", tags=["notes"])
class NotePageResponse(BaseModel):
id: int
title: str
content: str
position: int
section: str | None
updated_at: datetime
class Config:
from_attributes = True
class NotePageCreate(BaseModel):
title: str = "新しいページ"
content: str = ""
section: str | None = None
class NotePageUpdate(BaseModel):
title: str | None = None
content: str | None = None
section: str | None = None
class SectionRenameRequest(BaseModel):
old_name: str
new_name: str | None = None
class ReorderRequest(BaseModel):
page_id: int
section: str | None = None
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 "新しいページ"
page = NotePage(title=title, content=req.content, position=next_position(db))
db.add(page)
db.commit()
db.refresh(page)
if req.section is not None and req.section.strip():
page = set_section(db, page.id, req.section)
return page
@router.post("/sections/rename")
def rename_section_endpoint(req: SectionRenameRequest, db: DBSession = Depends(get_db)):
"""old_name セクションに属する全ページを new_name セクションへ一括で付け替える。new_name が空ならグループ解除。"""
count = rename_section(db, req.old_name, req.new_name)
return {"renamed": count}
@router.post("/reorder")
def reorder_pages(req: ReorderRequest, db: DBSession = Depends(get_db)):
"""タブのドラッグ&ドロップによる並べ替え。order は全ページIDを新しい順序で並べたもの。"""
ok = reorder(db, req.page_id, req.section, req.order)
if not ok:
raise HTTPException(status_code=400, detail="order must contain exactly the current page ids")
return {"reordered": len(req.order)}
@router.patch("/{page_id}", response_model=NotePageResponse)
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
if req.title is not None:
title = req.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is empty")
page.title = title
if req.content is not None:
page.content = req.content
db.commit()
db.refresh(page)
# section はページの並び順にも影響するため、変更があれば専用ロジックで反映する
if "section" in req.model_fields_set:
page = set_section(db, page_id, req.section)
return page
@router.delete("/{page_id}", status_code=204)
def delete_page(page_id: int, db: DBSession = Depends(get_db)):
page = db.get(NotePage, page_id)
if not page:
raise HTTPException(status_code=404, detail="Note page not found")
db.delete(page)
db.commit()