diff --git a/app/api/documents.py b/app/api/documents.py index 0443fb5..9369be2 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -2048,3 +2048,91 @@ async def get_related_documents( doc_id=doc_id, related=[RelatedItem(**{k: r[k] for k in ("id", "title", "ai_domain", "material_type", "year")}, sim=float(r["sim"]) if r["sim"] is not None else None) for r in rows], ) + + +# ─── 절 공부도구 (노트/형광펜/암기카드) — clause_study ─── +class StudyItem(BaseModel): + id: int + kind: str + payload: dict = {} + created_at: datetime | None = None + + +class StudyListResponse(BaseModel): + doc_id: int + items: list[StudyItem] + + +class StudyCreate(BaseModel): + kind: str # note | highlight | card + payload: dict = {} + + +def _parse_payload(p): + import json + if isinstance(p, str): + try: + return json.loads(p) + except Exception: + return {} + return p or {} + + +@router.get("/{doc_id}/study", response_model=StudyListResponse) +async def list_study( + doc_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """절-문서의 공부도구 항목(노트/형광펜/암기카드) 목록.""" + from sqlalchemy import text as sql_text + rows = ( + await session.execute( + sql_text("SELECT id, kind, payload, created_at FROM clause_study " + "WHERE doc_id = :id ORDER BY created_at DESC").bindparams(id=doc_id) + ) + ).mappings().all() + return StudyListResponse( + doc_id=doc_id, + items=[StudyItem(id=r["id"], kind=r["kind"], payload=_parse_payload(r["payload"]), + created_at=r["created_at"]) for r in rows], + ) + + +@router.post("/{doc_id}/study", response_model=StudyItem, status_code=201) +async def add_study( + doc_id: int, + body: StudyCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """노트/형광펜/암기카드 1건 추가.""" + import json + from sqlalchemy import text as sql_text + if body.kind not in ("note", "highlight", "card"): + raise HTTPException(status_code=400, detail="kind 는 note/highlight/card") + row = ( + await session.execute( + sql_text("INSERT INTO clause_study(doc_id, kind, payload) " + "VALUES (:d, :k, cast(:p AS jsonb)) RETURNING id, kind, payload, created_at") + .bindparams(d=doc_id, k=body.kind, p=json.dumps(body.payload, ensure_ascii=False)) + ) + ).mappings().first() + await session.commit() + return StudyItem(id=row["id"], kind=row["kind"], payload=_parse_payload(row["payload"]), + created_at=row["created_at"]) + + +@router.delete("/{doc_id}/study/{study_id}", status_code=204) +async def delete_study( + doc_id: int, + study_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + from sqlalchemy import text as sql_text + await session.execute( + sql_text("DELETE FROM clause_study WHERE id = :s AND doc_id = :d") + .bindparams(s=study_id, d=doc_id) + ) + await session.commit() diff --git a/frontend/src/routes/book/[id]/+page.svelte b/frontend/src/routes/book/[id]/+page.svelte index 649edec..a9155d7 100644 --- a/frontend/src/routes/book/[id]/+page.svelte +++ b/frontend/src/routes/book/[id]/+page.svelte @@ -17,6 +17,33 @@ let loading = $state(false); let q = $state(''); + // 공부도구 (노트/형광펜/암기카드) — clause_study + let studyItems = $state([]); + let studyOpen = $state(false); + let noteDraft = $state(''); + const KLABEL = { note: '노트', highlight: '형광펜', card: '암기카드' }; + async function loadStudy(id) { + try { const r = await api(`/documents/${id}/study`); studyItems = r?.items ?? []; } + catch { studyItems = []; } + } + async function addStudy(kind, payload) { + if (!selectedId) return; + try { await api(`/documents/${selectedId}/study`, { method: 'POST', body: JSON.stringify({ kind, payload }) }); await loadStudy(selectedId); } + catch (e) { console.warn(e); } + } + function selText() { return (typeof window !== 'undefined' && window.getSelection ? window.getSelection().toString() : '').trim(); } + function addNote() { const t = noteDraft.trim(); if (!t) return; addStudy('note', { text: t }); noteDraft = ''; } + function addHighlight() { const s = selText(); if (!s) { studyOpen = true; alert('본문에서 형광펜 칠할 부분을 먼저 드래그하세요'); return; } addStudy('highlight', { text: s }); studyOpen = true; } + function addCard() { + const s = selText(); + const code = links?.clause_code ?? selMeta?.clause_code ?? ''; + addStudy('card', { cue: `${code} ${strip(clauseDoc?.title, code)}`.trim(), fact: s || (clauseDoc?.md_content ?? clauseDoc?.extracted_text ?? '').replace(/[#*>]/g, '').slice(0, 280).trim() }); + studyOpen = true; + } + async function delStudy(id) { + try { await api(`/documents/${selectedId}/study/${id}`, { method: 'DELETE' }); await loadStudy(selectedId); } catch {} + } + let parts = $derived.by(() => { const out = [], idx = {}; for (const c of clauses) { @@ -51,6 +78,7 @@ try { const [d, l] = await Promise.all([api(`/documents/${id}`), api(`/documents/${id}/backlinks`)]); clauseDoc = d; links = l; + loadStudy(id); const sel = clauses.find((c) => c.id === id); if (sel) expanded = { ...expanded, [sel.clause_part || '·']: true }; goto(`/book/${parentId}?c=${id}`, { replaceState: true, keepFocus: true, noScroll: true }); @@ -98,9 +126,10 @@
본문을 드래그한 뒤 형광펜(▰)/암기카드(+), 또는 위에 노트를 적으세요.
+ {/if} +