From bdf9d0bae7dda79e7566be772393f78cac09a5d7 Mon Sep 17 00:00:00 2001 From: shinalok Date: Wed, 5 Aug 2026 12:24:18 +0900 Subject: [PATCH] journal: real markdown editor (EasyMDE) with live preview toggle, fix default template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/markdown_utils.py | 23 ++++ app/routers/journal_pages.py | 26 ++++ app/schemas/journal.py | 9 +- app/services/journal_service.py | 42 +++--- app/static/css/style.css | 123 +++++++++++++++++- app/static/css/vendor/easymde.min.css | 7 + app/static/js/journal-editor.js | 45 +++++++ app/static/js/vendor/easymde.min.js | 7 + app/static/service-worker.js | 5 +- app/templates/base.html | 3 + app/templates/journal.html | 29 ++++- .../partials/journal_day_detail.html | 24 +++- .../partials/journal_editor_toolbar.html | 20 +++ .../versions/0014_fix_template_bullets.py | 81 ++++++++++++ pyproject.toml | 2 + tests/test_journal_service.py | 19 ++- tests/test_markdown_utils.py | 46 +++++++ tests/test_pages_journal.py | 83 ++++++++++++ 18 files changed, 564 insertions(+), 30 deletions(-) create mode 100644 app/markdown_utils.py create mode 100644 app/static/css/vendor/easymde.min.css create mode 100644 app/static/js/journal-editor.js create mode 100644 app/static/js/vendor/easymde.min.js create mode 100644 app/templates/partials/journal_editor_toolbar.html create mode 100644 migrations/versions/0014_fix_template_bullets.py create mode 100644 tests/test_markdown_utils.py 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 %}
- + {% 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이 목록으로 안 잡고 그냥 "

    -

    "로 렌더링해버리는 + # 회귀가 있었다 — 다섯 항목 전부 실제
    • 목록이어야 한다. + assert html.count("
        ") == 5 + assert html.count("
      • ") == 5 + # 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서 + # 굵은 글씨가 아니라

        /

        제목으로 바뀌어버리는 회귀도 있었다. + assert "") == 6 + + def test_category_content_template_persists_and_updates(db_session, test_user): category = journal_service.create_category( db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2") diff --git a/tests/test_markdown_utils.py b/tests/test_markdown_utils.py new file mode 100644 index 0000000..3866e37 --- /dev/null +++ b/tests/test_markdown_utils.py @@ -0,0 +1,46 @@ +from app.markdown_utils import render_markdown + + +def test_bold_and_heading_render_as_html(): + html = render_markdown("# 제목\n\n**굵게** 쓴 문장입니다") + assert "

        제목

        " in html + assert "굵게" in html + + +def test_single_newline_becomes_line_break(): + html = render_markdown("첫째 줄\n둘째 줄") + assert "" in html + assert "
      • 하나
      • " in html + + +def test_dash_and_star_bullets_render_identically(): + # 마크다운 글머리 기호는 -/*/+ 전부 동일하게 처리돼야 한다 — 에디터 쪽 기본 기호(unorderedListStyle)만 + # "-"로 바뀌었을 뿐, 렌더링 결과는 어떤 기호를 써도 같아야 한다. + assert render_markdown("- 하나\n- 둘") == render_markdown("* 하나\n* 둘") == render_markdown("+ 하나\n+ 둘") + + +def test_script_tag_is_stripped_not_executed(): + html = render_markdown('본문') + assert "본문') + assert "onerror" not in html + assert "굵은 글씨" in response.text + + +def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user): + category = _make_category(db_session, test_user.id) + today = date.today() + _make_entry( + db_session, test_user.id, category.id, entry_date=today, content='본문' + ) + + response = auth_client.get(f"/journal/day/{today.isoformat()}") + assert response.status_code == 200 + assert "굵게" in response.text + + +def test_preview_strips_script_tags(auth_client): + response = auth_client.post("/journal/preview", data={"content": '본문'}) + assert response.status_code == 200 + assert "