From 3c42b7b97a8a8a265e9aa249f0fe10f7004f0c27 Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 30 Jun 2026 06:26:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(book):=20=EA=B3=B5=EB=B6=80=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=EB=B0=B0=EC=84=A0=20=E2=80=94=20=EB=85=B8=ED=8A=B8?= =?UTF-8?q?/=ED=98=95=EA=B4=91=ED=8E=9C/=EC=95=94=EA=B8=B0=EC=B9=B4?= =?UTF-8?q?=EB=93=9C(clause=5Fstudy)=20+=20=EC=B1=85=20=EB=A6=AC=EB=8D=94?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/documents.py | 88 ++++++++++++++++++++++ frontend/src/routes/book/[id]/+page.svelte | 80 +++++++++++++++++++- migrations/380_clause_study.sql | 9 +++ 3 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 migrations/380_clause_study.sql 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 clauseDoc}
- - - + + + + {#if studyItems.length}{studyItems.length}{/if}
{selMeta?.clause_part}
{links?.clause_code ?? selMeta?.clause_code}
@@ -133,6 +162,29 @@ {/if} + {#if studyOpen} +
+
공부 — 노트 · 형광펜 · 암기카드{#if studyItems.length} {studyItems.length}{/if}
+
+ + +
+ {#if studyItems.length} +
    + {#each studyItems as it (it.id)} +
  • + {KLABEL[it.kind] ?? it.kind} + {it.payload?.text ?? it.payload?.cue ?? ''} + +
  • + {/each} +
+ {:else} +

본문을 드래그한 뒤 형광펜(▰)/암기카드(+), 또는 위에 노트를 적으세요.

+ {/if} +
+ {/if} +
@@ -205,5 +257,25 @@ .pg .d { font-size:10.5px; color:#9aa090; } .pg .t { font-size:12.5px; color:var(--text-dim); font-weight:600; margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pg .pno { font-family:ui-monospace,Menlo,monospace; color:var(--accent); } .empty { color:#9aa090; text-align:center; padding:80px 0; } - @media(max-width:820px){ .idx{display:none} .read{padding:24px 18px} .conn{grid-template-columns:1fr} .studybar{display:none} .crumbs{max-width:30%} .search input{width:150px} } + .sbtn.on { background:#ecf0e8; color:var(--accent-hover,#3d7256); border-color:var(--border); } + .scount { font-size:9px; font-weight:700; color:#fff; background:var(--accent,#4f8a6b); border-radius:8px; padding:1px 5px; text-align:center; } + .study { margin-top:24px; padding:14px; border:1px solid var(--border); border-radius:12px; background:var(--surface); } + .slab { font-size:11px; font-weight:700; color:var(--text-dim); letter-spacing:.3px; margin-bottom:9px; } + .slab span { color:var(--accent-hover,#3d7256); } + .noteadd { display:flex; gap:8px; align-items:flex-end; margin-bottom:10px; } + .noteadd textarea { flex:1; resize:vertical; border:1px solid var(--border); border-radius:8px; padding:7px 9px; font-size:12.5px; font-family:inherit; color:var(--text); background:var(--paper,#fbfcf9); outline:none; } + .noteadd textarea:focus { border-color:var(--accent); } + .nbtn { flex-shrink:0; font-size:12px; color:#fff; background:var(--accent,#4f8a6b); border:0; border-radius:8px; padding:8px 12px; cursor:pointer; } + .nbtn:hover { background:var(--accent-hover,#3d7256); } + .slist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:5px; } + .sitem { display:flex; align-items:baseline; gap:8px; padding:6px 8px; border-radius:8px; background:var(--paper,#fbfcf9); border:1px solid var(--border); } + .skind { flex-shrink:0; font-size:9.5px; font-weight:700; border-radius:4px; padding:1px 6px; } + .k-note { color:#3d7256; background:#e3efe2; border:1px solid #cfe3cd; } + .k-highlight { color:#8a6306; background:#faf3e2; border:1px solid #ecdca3; } + .k-card { color:#1d4ed8; background:#eef4fc; border:1px solid #d7e4f7; } + .stext { flex:1; font-size:12px; line-height:1.5; color:var(--text); white-space:pre-wrap; word-break:break-word; } + .sdel { flex-shrink:0; background:none; border:0; color:var(--faint,#9aa090); cursor:pointer; font-size:14px; } + .sdel:hover { color:var(--error,#c0392b); } + .shint { font-size:11.5px; color:var(--faint,#9aa090); margin:0; } + @media(max-width:820px){ .idx{display:none} .read{padding:24px 18px} .conn{grid-template-columns:1fr} .studybar{position:static;flex-direction:row} .crumbs{max-width:30%} .search input{width:150px} } diff --git a/migrations/380_clause_study.sql b/migrations/380_clause_study.sql new file mode 100644 index 0000000..7acff29 --- /dev/null +++ b/migrations/380_clause_study.sql @@ -0,0 +1,9 @@ +-- 380_clause_study.sql — 절-문서 공부도구(노트/형광펜/암기카드) 저장. FK 없음(documents 락 회피). +CREATE TABLE IF NOT EXISTS clause_study ( + id bigserial PRIMARY KEY, + doc_id bigint NOT NULL, + kind text NOT NULL, -- 'note' | 'highlight' | 'card' + payload jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_clause_study_doc ON clause_study(doc_id, kind);