import io import zipfile from datetime import datetime, timezone from urllib.parse import quote from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession from app.core.notes import import_files, 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("/import", response_model=list[NotePageResponse], status_code=201) async def import_pages(files: list[UploadFile] = File(...), db: DBSession = Depends(get_db)): """アップロードされたファイルをページとして取り込む。 .md / .txt はそのまま1ページ、.zip は中のテキストファイルを1つずつページにする (ZIP内のフォルダ名がセクションになるので、エクスポートしたZIPをそのまま戻せる)。 対象外の拡張子や大きすぎるファイルは黙って飛ばすので、1つも取り込めなければ 400。 """ payload = [(f.filename or "無題", await f.read()) for f in files] created = import_files(db, payload) if not created: raise HTTPException( status_code=400, detail="取り込めるファイルがありませんでした(.md / .txt / .zip のみ)", ) return created @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()