journal: multi-select emotions and per-category writing templates

- Mood switches from a single enum column to a many-to-many
  JournalEntryMood table so an entry can carry several feelings at
  once; the vocabulary is trimmed to 9 named emotions (dropped the
  overlapping satisfaction scale) with unified noun-style labels.
- Categories can define a content_template that pre-fills the "new
  entry" textarea when selected (only if the user hasn't started
  typing), seeded with a Story/Feelings/Decisions/Insights/Actions
  reflection template on the default "일상" category.
- Fixes a real attribute-injection bug found while building the
  template feature: Jinja's built-in |tojson filter doesn't escape
  double quotes, which breaks a double-quoted x-data="..." attribute
  when the JSON payload contains one; added |forceescape and a
  regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:12:59 +09:00
co-authored by Claude Sonnet 5
parent 54c971875c
commit c466cc6639
8 changed files with 172 additions and 9 deletions
+3
View File
@@ -43,6 +43,9 @@ class JournalCategory(Base):
name: Mapped[str] = mapped_column(String(50), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워주는 틀(예: Story/Feelings/Decisions/... 회고 양식).
# 사용자가 직접 타이핑을 시작하면 더 이상 덮어쓰지 않는다(프론트에서 처리).
content_template: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
+12 -3
View File
@@ -30,6 +30,9 @@ JOURNAL_MOOD_OPTIONS = [
]
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}
def _parse_moods(raw: str) -> list[JournalMood]:
@@ -111,6 +114,7 @@ def journal_page(
"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),
@@ -274,14 +278,18 @@ def delete_attachment_page(
@router.post("/journal/categories/new")
def create_category_page(
request: Request, name: str = Form(""), color: str | None = Form(None), db: Session = Depends(get_db)
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)
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)
@@ -303,6 +311,7 @@ def edit_category_page(
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)
@@ -314,7 +323,7 @@ def edit_category_page(
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color)
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)
+10
View File
@@ -8,6 +8,7 @@ from app.models.journal import JournalAttachmentType, JournalMood
class JournalCategoryBase(BaseModel):
name: str
color: str | None = None
content_template: str | None = None
@field_validator("name")
@classmethod
@@ -25,6 +26,14 @@ class JournalCategoryBase(BaseModel):
v = v.strip()
return v or None
@field_validator("content_template")
@classmethod
def blank_template_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
class JournalCategoryCreate(JournalCategoryBase):
pass
@@ -36,6 +45,7 @@ class JournalCategoryOut(BaseModel):
id: int
name: str
color: str | None
content_template: str | None
sort_order: int | None
created_at: datetime
+28 -2
View File
@@ -34,6 +34,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상"
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
-
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
-
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
-
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
-
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
- """
# ---- 카테고리 ----
@@ -54,7 +75,9 @@ def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
category = JournalCategory(user_id=user_id, name=data.name, color=data.color)
category = JournalCategory(
user_id=user_id, name=data.name, color=data.color, content_template=data.content_template
)
db.add(category)
db.commit()
db.refresh(category)
@@ -64,6 +87,7 @@ def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> J
def update_category(db: Session, category: JournalCategory, data: JournalCategoryCreate) -> JournalCategory:
category.name = data.name
category.color = data.color
category.content_template = data.content_template
db.commit()
db.refresh(category)
return category
@@ -79,7 +103,9 @@ def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
)
if existing is not None:
return existing
return create_category(db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME))
return create_category(
db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME, content_template=DEFAULT_CATEGORY_TEMPLATE)
)
def reorder_categories(db: Session, user_id: int, ordered_ids: list[int]) -> None:
+21 -2
View File
@@ -59,6 +59,10 @@
<label for="edit-category-color-{{ category.id }}">색상</label>
<input type="color" id="edit-category-color-{{ category.id }}" name="color" value="{{ category.color or '#d97757' }}" />
</div>
<div class="field">
<label for="edit-category-template-{{ category.id }}">기본 작성 틀 (선택)</label>
<textarea id="edit-category-template-{{ category.id }}" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요">{{ category.content_template or '' }}</textarea>
</div>
<div id="journal-category-edit-error-{{ category.id }}" class="form-error-text"></div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
@@ -85,6 +89,10 @@
<label for="new-category-color">색상 (선택)</label>
<input type="color" id="new-category-color" name="color" value="#d97757" />
</div>
<div class="field">
<label for="new-category-template">기본 작성 틀 (선택)</label>
<textarea id="new-category-template" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요"></textarea>
</div>
<div id="journal-category-error" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
@@ -113,6 +121,17 @@
open: false,
moods: [],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
categoryTemplates: {{ category_templates|tojson|forceescape }},
content: '',
lastAutoFilled: '',
onCategoryChange(categoryId) {
const tmpl = this.categoryTemplates[categoryId] || '';
if (this.content === this.lastAutoFilled) {
this.content = tmpl;
this.lastAutoFilled = tmpl;
}
},
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
}"
>
<button type="button" class="btn btn-primary btn-block" @click="open = !open">
@@ -135,7 +154,7 @@
>
<div class="field">
<label for="new-entry-category">카테고리</label>
<select id="new-entry-category" name="category_id">
<select id="new-entry-category" name="category_id" x-ref="categorySelect" @change="onCategoryChange($event.target.value)">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category_id == category.id }}>{{ category.name }}</option>
{% endfor %}
@@ -154,7 +173,7 @@
<div class="field">
<label for="new-entry-content">내용</label>
<textarea id="new-entry-content" name="content" rows="5" required placeholder="오늘 하루는 어땠나요?"></textarea>
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea>
</div>
<div class="field">