Files
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00

382 lines
13 KiB
Python

import calendar
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.markdown_utils import render_markdown
from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
router = APIRouter()
# (enum 값, 이모지, 한글 라벨). 기분 선택 pill(작성/수정 폼)과 표시(day-detail)에서 공유해서 쓴다.
# 여러 개를 동시에 고를 수 있어서(다중 선택) 라벨은 전부 명사형으로 통일한다.
JOURNAL_MOOD_OPTIONS = [
(JournalMood.PAIN, "🤕", "아픔"),
(JournalMood.ACHIEVEMENT, "🏆", "성취"),
(JournalMood.ANGER, "😠", "분노"),
(JournalMood.EXCITED, "🤩", "신남"),
(JournalMood.CALM, "😌", "평온"),
(JournalMood.HAPPY, "😊", "행복"),
(JournalMood.WORRY, "😟", "걱정"),
(JournalMood.TIRED, "😪", "피곤"),
(JournalMood.SAD, "😢", "슬픔"),
]
templates.env.globals["journal_mood_options"] = JOURNAL_MOOD_OPTIONS
templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in JOURNAL_MOOD_OPTIONS}
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
templates.env.filters["markdown"] = render_markdown
def _parse_moods(raw: str) -> list[JournalMood]:
moods = []
for v in raw.split(","):
v = v.strip()
if not v:
continue
try:
moods.append(JournalMood(v))
except ValueError:
continue
return moods
def _parse_tags(raw: str) -> list[str]:
return [t for t in raw.split(",")]
def _month_context(db: Session, user_id: int, year: int, month: int, category_id: int | None) -> dict:
summary_map = journal_service.get_monthly_journal_summary(db, user_id, year, month, category_id)
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month)
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return {
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _render_day_detail(request: Request, db: Session, user_id: int, entry_date: date, **extra):
return templates.TemplateResponse(
request,
"partials/journal_day_detail.html",
{
"entry_date": entry_date,
"items": journal_service.get_day_entries(db, user_id, entry_date),
"categories": journal_service.list_categories(db, user_id),
**extra,
},
)
@router.get("/journal")
def journal_page(
request: Request,
tab: str = "calendar",
year: int | None = None,
month: int | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
journal_service.ensure_default_category(db, current.id)
categories = journal_service.list_categories(db, current.id)
today = date.today()
year = year or today.year
month = month or today.month
tab = "manage" if tab == "manage" else "calendar"
return templates.TemplateResponse(
request,
"journal.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"categories": categories,
"category_id": category_id,
"category_templates": {str(c.id): c.content_template or "" for c in categories},
"entry_counts": journal_service.count_entries_by_category(db, current.id) if tab == "manage" else {},
"on_this_day": journal_service.get_on_this_day(db, current.id, today),
"prompt": journal_service.get_random_prompt(db),
"today_iso": today.isoformat(),
**_month_context(db, current.id, year, month, category_id),
},
)
@router.get("/journal/day/{entry_date}")
def journal_day_detail(request: Request, entry_date: date, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/preview")
def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if not content.strip():
return HTMLResponse('<span class="empty-state">미리보기가 여기에 표시돼요</span>')
return HTMLResponse(render_markdown(content))
@router.post("/journal/new")
def create_entry_page(
request: Request,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if journal_service.get_category(db, category_id, current.id) is None:
return HTMLResponse("카테고리를 찾을 수 없어요")
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
entry = journal_service.create_entry(db, current.id, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError as exc:
return HTMLResponse(str(exc))
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/journal?year={parsed_date.year}&month={parsed_date.month}"
return response
@router.post("/journal/{entry_id}/edit")
def edit_entry_page(
request: Request,
entry_id: int,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
original_date = entry.entry_date
if journal_service.get_category(db, category_id, current.id) is None:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="카테고리를 찾을 수 없어요",
editing_entry_id=entry_id,
)
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return _render_day_detail(
request, db, current.id, original_date, edit_error=message, editing_entry_id=entry_id
)
except ValueError:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="입력값을 확인해주세요",
editing_entry_id=entry_id,
)
journal_service.update_entry(db, entry, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError:
pass # 첨부 실패는 조용히 넘어간다 — 본문 수정은 이미 반영됐다
return _render_day_detail(request, db, current.id, parsed_date)
@router.post("/journal/{entry_id}/delete")
def delete_entry_page(request: Request, entry_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_entry(db, entry)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/{entry_id}/attachments/{attachment_id}/delete")
def delete_attachment_page(
request: Request, entry_id: int, attachment_id: int, db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
attachment = journal_service.get_attachment(db, attachment_id, current.id)
if attachment is None or attachment.entry_id != entry.id:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_attachment(db, attachment)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/categories/new")
def create_category_page(
request: Request,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.create_category(db, current.id, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/edit")
def edit_category_page(
request: Request,
category_id: int,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.update_category(db, category, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/delete")
def delete_category_page(request: Request, category_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
journal_service.delete_category(db, category)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response