diff --git a/app/markdown_utils.py b/app/markdown_utils.py new file mode 100644 index 0000000..27e88d7 --- /dev/null +++ b/app/markdown_utils.py @@ -0,0 +1,23 @@ +import bleach +import markdown +from markupsafe import Markup + +# nl2br: 빈 줄 없이 그냥 엔터만 쳐도 줄바꿈되게 한다 — 지금까지 백엔드가 순수 텍스트를 +# white-space: pre-wrap으로 보여주던 것과 체감이 최대한 비슷하도록. +_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists"] + +_ALLOWED_TAGS = [ + "p", "br", "strong", "em", "del", + "h1", "h2", "h3", "h4", + "ul", "ol", "li", + "blockquote", "code", "pre", "hr", "a", +] +_ALLOWED_ATTRS = {"a": ["href", "title"]} + + +def render_markdown(text: str) -> Markup: + """저널 기록 내용을 마크다운 HTML로 렌더링한다. markdown 라이브러리는 기본적으로 원본 HTML을 + 그대로 통과시키므로( + + diff --git a/app/templates/journal.html b/app/templates/journal.html index 74876c7..02fcf04 100644 --- a/app/templates/journal.html +++ b/app/templates/journal.html @@ -124,11 +124,20 @@ categoryTemplates: {{ category_templates|tojson|forceescape }}, content: '', lastAutoFilled: '', + previewMode: false, + togglePreview() { + this.previewMode = !this.previewMode; + if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); } + }, onCategoryChange(categoryId) { const tmpl = this.categoryTemplates[categoryId] || ''; if (this.content === this.lastAutoFilled) { this.content = tmpl; this.lastAutoFilled = tmpl; + this.$nextTick(() => { + if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); } + this.$refs.contentField.dispatchEvent(new Event('input')); + }); } }, init() { this.onCategoryChange(this.$refs.categorySelect.value); }, @@ -173,7 +182,25 @@
- + {% include "partials/journal_editor_toolbar.html" %} +
+ +
+
diff --git a/app/templates/partials/journal_day_detail.html b/app/templates/partials/journal_day_detail.html index 5eac0b2..ac4e13d 100644 --- a/app/templates/partials/journal_day_detail.html +++ b/app/templates/partials/journal_day_detail.html @@ -14,6 +14,11 @@ editing: {{ 'true' if editing_entry_id == item.id else 'false' }}, moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}], toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) }, + previewMode: false, + togglePreview() { + this.previewMode = !this.previewMode; + if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); } + }, }" >
@@ -24,7 +29,7 @@ {% endif %}
{% if item.title %}
{{ item.title }}
{% endif %} -
{{ item.content }}
+
{{ item.content|markdown }}
{% if item.tags %}
{% for tag in item.tags %}#{{ tag }}{% endfor %} @@ -94,7 +99,22 @@
- + {% include "partials/journal_editor_toolbar.html" %} +
+ +
+
diff --git a/app/templates/partials/journal_editor_toolbar.html b/app/templates/partials/journal_editor_toolbar.html new file mode 100644 index 0000000..9628b5c --- /dev/null +++ b/app/templates/partials/journal_editor_toolbar.html @@ -0,0 +1,20 @@ +
+ + + + + + + + + + + + +
diff --git a/migrations/versions/0014_fix_template_bullets.py b/migrations/versions/0014_fix_template_bullets.py new file mode 100644 index 0000000..601e6cf --- /dev/null +++ b/migrations/versions/0014_fix_template_bullets.py @@ -0,0 +1,81 @@ +"""fix default 일상 template: bare "-" bullets don't render as list items + +Revision ID: 0014_fix_template_bullets +Revises: 0013_journal_category_template +Create Date: 2026-08-05 + +0013에서 넣은 기본 틀의 "-" 줄들이 뒤에 공백이 없어서, markdown 렌더러가 목록으로 인식하지 못하고 +그냥 문단 텍스트("-")로 렌더링됐다(제목 줄과 "-" 사이의 빈 줄 자체는 맞게 넣어서 제목이 밑줄로 +오인되는 문제는 없었음). "- "(대시+공백)로 통일해야 실제 빈 목록 항목(
  • )이 된다. +아직 이 기본값을 커스터마이징하지 않은(즉 0013이 넣어준 원래 문구 그대로인) "일상" 카테고리만 +갱신한다 — 이미 사용자가 직접 수정한 틀은 덮어쓰지 않는다. + +(참고: revision id를 "0014_fix_category_template_bullets"로 처음 만들었다가 alembic_version. +version_num이 VARCHAR(32)라 34자짜리 id가 안 들어가서 DataError가 났다 — 25자로 줄여 다시 만든 +파일이다. 실제 데이터 UPDATE 자체는 그때 이미 반영됐고 버전 기록만 실패한 상태였다.) +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0014_fix_template_bullets" +down_revision: Union[str, None] = "0013_journal_category_template" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_BULLET = "- " + +_OLD_TEMPLATE = "\n\n".join( + [ + "**1. Story : 오늘 무슨 일이 있었나요?**", + "-", + "**2. Feelings : 오늘 들었던 생각과 나의 감정은?**", + "-", + "**3. Decisions : 오늘 내가 내린 결정이 있나요?**", + "-", + "**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**", + "-", + "→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.", + "**5. Actions : 내가 다음에 할 행동은 무엇인가요?**", + _BULLET, + ] +) + +_NEW_TEMPLATE = "\n\n".join( + [ + "**1. Story : 오늘 무슨 일이 있었나요?**", + _BULLET, + "**2. Feelings : 오늘 들었던 생각과 나의 감정은?**", + _BULLET, + "**3. Decisions : 오늘 내가 내린 결정이 있나요?**", + _BULLET, + "**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**", + _BULLET, + "→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.", + "**5. Actions : 내가 다음에 할 행동은 무엇인가요?**", + _BULLET, + ] +) + + +def upgrade() -> None: + journal_category = sa.table( + "journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text) + ) + op.execute( + journal_category.update() + .where(journal_category.c.name == "일상", journal_category.c.content_template == _OLD_TEMPLATE) + .values(content_template=_NEW_TEMPLATE) + ) + + +def downgrade() -> None: + journal_category = sa.table( + "journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text) + ) + op.execute( + journal_category.update() + .where(journal_category.c.name == "일상", journal_category.c.content_template == _NEW_TEMPLATE) + .values(content_template=_OLD_TEMPLATE) + ) diff --git a/pyproject.toml b/pyproject.toml index 2c3f00c..80b8086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "authlib>=1.3", "httpx>=0.27", "pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임) + "markdown>=3.7", # 저널 기록 내용을 마크다운으로 렌더링 + "bleach>=6.0", # 렌더링된 마크다운 HTML을 허용 태그만 남기고 sanitize (XSS 방지) ] [project.optional-dependencies] diff --git a/tests/test_journal_service.py b/tests/test_journal_service.py index cfe64dc..5ed230a 100644 --- a/tests/test_journal_service.py +++ b/tests/test_journal_service.py @@ -41,13 +41,30 @@ def _upload(filename: str, content_type: str, data: bytes) -> UploadFile: def test_ensure_default_category_creates_once(db_session, test_user): first = journal_service.ensure_default_category(db_session, test_user.id) assert first.name == journal_service.DEFAULT_CATEGORY_NAME - assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip() + assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE second = journal_service.ensure_default_category(db_session, test_user.id) assert second.id == first.id assert len(journal_service.list_categories(db_session, test_user.id)) == 1 +def test_default_category_template_renders_as_headers_and_lists_not_bare_dashes(db_session, test_user): + from app.markdown_utils import render_markdown + + category = journal_service.ensure_default_category(db_session, test_user.id) + html = render_markdown(category.content_template) + + # "-"에 뒤 공백이 없으면 markdown이 목록으로 안 잡고 그냥 "

    -

    "로 렌더링해버리는 + # 회귀가 있었다 — 다섯 항목 전부 실제