Two additions to the shared notes, both about getting content in and out of the prompt. Per-chat pinning. Notes normally reach the model as a title list only, and the body arrives just if the model decides to call read_note. A 📌 on the tab now puts a page's full body in the system prompt for the chat that is open, every turn, no tool round trip. The selection is per session (new table session_note_pages, unique on session_id + page_id), so different conversations carry different pages. The title list marks pinned pages so the model does not read them twice, and bodies are capped per page by SESSION_NOTES_CONTENT_LIMIT. A new chat has no session row until its first message, so the front end cannot PUT the selection yet. POST /chat/stream therefore accepts note_page_ids and applies it before building the context, which makes the pins count from the very first turn. File import. Dropping .md/.txt on the editor - or picking them with the new ⬆ button - creates pages, and a .zip is unpacked into one page per text entry with the first path component as the section. That is exactly the shape the export writes, so a downloaded zip round-trips back in. Titles that collide get a " (2)" suffix rather than overwriting, since restoring a backup into a live workspace should not destroy anything. UploadFile needs python-multipart, added to the lockfile. Toasts grew a kind: the import success message was rendering in the red error toast, as was the existing session-import message. Both are green now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
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()
|