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>
This commit is contained in:
2026-08-05 12:24:18 +09:00
co-authored by Claude Sonnet 5
parent c466cc6639
commit bdf9d0bae7
18 changed files with 564 additions and 30 deletions
+23
View File
@@ -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을
그대로 통과시키므로(<script> 등 포함) bleach로 허용 태그만 남기고 나머지는 전부 지운다 —
이 함수가 반환하는 Markup만 템플릿에서 이스케이프 없이(그대로 안전하게) 렌더링해야 한다."""
html = markdown.markdown(text, extensions=_MARKDOWN_EXTENSIONS)
return Markup(bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, strip=True))
+26
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.markdown_utils import render_markdown
from app.models.journal import JournalMood from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
@@ -33,6 +34,7 @@ templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면 # Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다. # 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False} templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
templates.env.filters["markdown"] = render_markdown
def _parse_moods(raw: str) -> list[JournalMood]: def _parse_moods(raw: str) -> list[JournalMood]:
@@ -133,6 +135,17 @@ def journal_day_detail(request: Request, entry_date: date, db: Session = Depends
return _render_day_detail(request, db, current.id, entry_date) 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") @router.post("/journal/new")
def create_entry_page( def create_entry_page(
request: Request, request: Request,
@@ -149,6 +162,9 @@ def create_entry_page(
if isinstance(current, RedirectResponse): if isinstance(current, RedirectResponse):
return current return current
if journal_service.get_category(db, category_id, current.id) is None:
return HTMLResponse("카테고리를 찾을 수 없어요")
try: try:
parsed_date = date.fromisoformat(entry_date) parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate( data = JournalEntryCreate(
@@ -202,6 +218,16 @@ def edit_entry_page(
original_date = entry.entry_date 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: try:
parsed_date = date.fromisoformat(entry_date) parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate( data = JournalEntryUpdate(
+6 -3
View File
@@ -29,10 +29,13 @@ class JournalCategoryBase(BaseModel):
@field_validator("content_template") @field_validator("content_template")
@classmethod @classmethod
def blank_template_to_none(cls, v: str | None) -> str | None: def blank_template_to_none(cls, v: str | None) -> str | None:
if v is None: # color/name과 달리 여기선 .strip()으로 값 자체를 바꾸지 않는다 — 템플릿 맨 끝의
# "- "(대시+공백)처럼 의미 있는 trailing whitespace가 있을 수 있고, 그걸 지우면
# markdown이 그 줄을 목록으로 인식하지 못하게 된다(빈 값인지 판단만 strip으로 하고,
# 실제로 저장하는 값은 원본을 그대로 쓴다).
if v is None or not v.strip():
return None return None
v = v.strip() return v
return v or None
class JournalCategoryCreate(JournalCategoryBase): class JournalCategoryCreate(JournalCategoryBase):
+21 -21
View File
@@ -34,27 +34,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"} ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400 THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상" DEFAULT_CATEGORY_NAME = "일상"
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?** # 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
- # 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
**2. Feelings : 오늘 들었던 생각과 나의 감정은?** _BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
- DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
[
**3. Decisions : 오늘 내가 내린 결정이 있나요?** "**1. Story : 오늘 무슨 일이 있나요?**",
_BULLET,
- "**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?** "**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
- "**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요. "→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
**5. Actions : 내가 다음에 할 행동은 무엇인가요?** _BULLET,
]
- """ )
# ---- 카테고리 ---- # ---- 카테고리 ----
+122 -1
View File
@@ -902,8 +902,129 @@ label {
} }
.journal-entry-content { .journal-entry-content {
white-space: pre-wrap;
margin-top: 4px; margin-top: 4px;
line-height: 1.6;
}
.journal-preview {
margin-top: 6px;
padding: 10px 12px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
min-height: 24px;
font-size: 14px;
}
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
.markdown-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.md-tool-btn {
min-width: 30px;
height: 30px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
cursor: pointer;
}
.md-tool-btn:active {
background: var(--color-bg);
}
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */
.markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 15px;
padding: 6px 8px;
}
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
border-left-color: var(--color-text);
}
.markdown-editor + .EasyMDEContainer .editor-statusbar {
color: var(--color-text-muted);
}
.journal-entry-content > *:first-child {
margin-top: 0;
}
.journal-entry-content > *:last-child {
margin-bottom: 0;
}
.journal-entry-content p,
.journal-entry-content ul,
.journal-entry-content ol,
.journal-entry-content blockquote,
.journal-entry-content pre {
margin: 0 0 8px;
}
.journal-entry-content h1,
.journal-entry-content h2,
.journal-entry-content h3,
.journal-entry-content h4 {
margin: 12px 0 6px;
line-height: 1.3;
}
.journal-entry-content h1 { font-size: 19px; }
.journal-entry-content h2 { font-size: 17px; }
.journal-entry-content h3,
.journal-entry-content h4 { font-size: 15px; }
.journal-entry-content ul,
.journal-entry-content ol {
padding-left: 20px;
}
.journal-entry-content blockquote {
margin-left: 0;
padding-left: 10px;
border-left: 3px solid var(--color-accent);
color: var(--color-text-muted);
}
.journal-entry-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 1px 5px;
}
.journal-entry-content pre {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 10px;
overflow-x: auto;
}
.journal-entry-content pre code {
border: none;
padding: 0;
}
.journal-entry-content a {
color: var(--color-accent);
} }
.journal-entry-tags { .journal-entry-tags {
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
(function () {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
function initEditors(root) {
if (typeof EasyMDE === "undefined") return;
var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
textareas.forEach(function (textarea) {
textarea.setAttribute("data-easymde-initialized", "true");
var easymde = new EasyMDE({
element: textarea,
toolbar: false,
spellChecker: false,
autoDownloadFontAwesome: false,
status: false,
placeholder: textarea.getAttribute("placeholder") || "",
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
unorderedListStyle: "-",
});
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
textarea._easymde = easymde;
});
}
document.addEventListener("DOMContentLoaded", function () {
initEditors(document);
});
document.body.addEventListener("htmx:afterSwap", function (evt) {
initEditors(evt.target);
});
})();
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1,13 +1,16 @@
const CACHE_NAME = "habit-tracker-v5"; const CACHE_NAME = "habit-tracker-v6";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js", "/static/js/app.js",
"/static/js/push-register.js", "/static/js/push-register.js",
"/static/js/habit-reorder.js", "/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js", "/static/js/journal-category-reorder.js",
"/static/js/journal-editor.js",
"/static/js/vendor/htmx.min.js", "/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js", "/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js", "/static/js/vendor/sortable.min.js",
"/static/js/vendor/easymde.min.js",
"/static/icons/icon-192.png", "/static/icons/icon-192.png",
"/static/icons/icon-512.png", "/static/icons/icon-512.png",
"/static/icons/icon-apple-180.png", "/static/icons/icon-apple-180.png",
+3
View File
@@ -16,11 +16,14 @@
<link rel="icon" href="/static/icons/icon-192.png" /> <link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" /> <link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/vendor/easymde.min.css" />
<script src="/static/js/vendor/htmx.min.js" defer></script> <script src="/static/js/vendor/htmx.min.js" defer></script>
<script src="/static/js/push-register.js" defer></script> <script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script> <script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script> <script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/journal-category-reorder.js" defer></script> <script src="/static/js/journal-category-reorder.js" defer></script>
<script src="/static/js/vendor/easymde.min.js" defer></script>
<script src="/static/js/journal-editor.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script> <script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
</head> </head>
+28 -1
View File
@@ -124,11 +124,20 @@
categoryTemplates: {{ category_templates|tojson|forceescape }}, categoryTemplates: {{ category_templates|tojson|forceescape }},
content: '', content: '',
lastAutoFilled: '', lastAutoFilled: '',
previewMode: false,
togglePreview() {
this.previewMode = !this.previewMode;
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
},
onCategoryChange(categoryId) { onCategoryChange(categoryId) {
const tmpl = this.categoryTemplates[categoryId] || ''; const tmpl = this.categoryTemplates[categoryId] || '';
if (this.content === this.lastAutoFilled) { if (this.content === this.lastAutoFilled) {
this.content = tmpl; this.content = tmpl;
this.lastAutoFilled = 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); }, init() { this.onCategoryChange(this.$refs.categorySelect.value); },
@@ -173,7 +182,25 @@
<div class="field"> <div class="field">
<label for="new-entry-content">내용</label> <label for="new-entry-content">내용</label>
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea> {% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
id="new-entry-content"
name="content"
rows="8"
required
placeholder="오늘 하루는 어땠나요?"
class="markdown-editor"
x-model="content"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#new-entry-preview"
hx-swap="innerHTML"
hx-params="content"
></textarea>
</div>
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div> </div>
<div class="field"> <div class="field">
+22 -2
View File
@@ -14,6 +14,11 @@
editing: {{ 'true' if editing_entry_id == item.id else 'false' }}, editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}], 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) }, 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')); }
},
}" }"
> >
<div x-show="!editing"> <div x-show="!editing">
@@ -24,7 +29,7 @@
{% endif %} {% endif %}
</div> </div>
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %} {% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
<div class="journal-entry-content">{{ item.content }}</div> <div class="journal-entry-content">{{ item.content|markdown }}</div>
{% if item.tags %} {% if item.tags %}
<div class="journal-entry-tags"> <div class="journal-entry-tags">
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %} {% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
@@ -94,7 +99,22 @@
</div> </div>
<div class="field"> <div class="field">
<label>내용</label> <label>내용</label>
<textarea name="content" rows="4" required>{{ item.content }}</textarea> {% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
name="content"
rows="6"
required
class="markdown-editor"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#journal-edit-preview-{{ item.id }}"
hx-swap="innerHTML"
hx-params="content"
>{{ item.content }}</textarea>
</div>
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div> </div>
<div class="field"> <div class="field">
<label>기분 (여러 개 선택 가능)</label> <label>기분 (여러 개 선택 가능)</label>
@@ -0,0 +1,20 @@
<div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
<button type="button" class="md-tool-btn" title="굵게" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBold()"><strong>B</strong></button>
<button type="button" class="md-tool-btn" title="기울임" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><em>I</em></button>
<button type="button" class="md-tool-btn" title="취소선" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleStrikethrough()"><s>S</s></button>
<button type="button" class="md-tool-btn" title="제목" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">H</button>
<button type="button" class="md-tool-btn" title="인용" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBlockquote()"></button>
<button type="button" class="md-tool-btn" title="글머리 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()"></button>
<button type="button" class="md-tool-btn" title="번호 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleOrderedList()">1.</button>
<button type="button" class="md-tool-btn" title="코드" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()">&lt;/&gt;</button>
<button type="button" class="md-tool-btn" title="링크" @click="$el.closest('.field').querySelector('textarea')._easymde?.drawLink()">🔗</button>
</span>
<button
type="button"
class="md-tool-btn md-preview-toggle"
style="margin-left:auto;"
@click="togglePreview()"
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
></button>
</div>
@@ -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 렌더러가 목록으로 인식하지 못하고
그냥 문단 텍스트("-")로 렌더링됐다(제목 줄과 "-" 사이의 빈 줄 자체는 맞게 넣어서 제목이 밑줄로
오인되는 문제는 없었음). "- "(대시+공백)로 통일해야 실제 빈 목록 항목(<li></li>)이 된다.
아직 이 기본값을 커스터마이징하지 않은(즉 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)
)
+2
View File
@@ -20,6 +20,8 @@ dependencies = [
"authlib>=1.3", "authlib>=1.3",
"httpx>=0.27", "httpx>=0.27",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임) "pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
"markdown>=3.7", # 저널 기록 내용을 마크다운으로 렌더링
"bleach>=6.0", # 렌더링된 마크다운 HTML을 허용 태그만 남기고 sanitize (XSS 방지)
] ]
[project.optional-dependencies] [project.optional-dependencies]
+18 -1
View File
@@ -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): def test_ensure_default_category_creates_once(db_session, test_user):
first = journal_service.ensure_default_category(db_session, test_user.id) first = journal_service.ensure_default_category(db_session, test_user.id)
assert first.name == journal_service.DEFAULT_CATEGORY_NAME 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) second = journal_service.ensure_default_category(db_session, test_user.id)
assert second.id == first.id assert second.id == first.id
assert len(journal_service.list_categories(db_session, test_user.id)) == 1 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이 목록으로 안 잡고 그냥 "<p>-</p>"로 렌더링해버리는
# 회귀가 있었다 — 다섯 항목 전부 실제 <ul><li> 목록이어야 한다.
assert html.count("<ul>") == 5
assert html.count("<li>") == 5
# 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서
# 굵은 글씨가 아니라 <h1>/<h2> 제목으로 바뀌어버리는 회귀도 있었다.
assert "<h1" not in html
assert "<h2" not in html
assert html.count("<strong>") == 6
def test_category_content_template_persists_and_updates(db_session, test_user): def test_category_content_template_persists_and_updates(db_session, test_user):
category = journal_service.create_category( category = journal_service.create_category(
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2") db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
+46
View File
@@ -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 "<h1>제목</h1>" in html
assert "<strong>굵게</strong>" in html
def test_single_newline_becomes_line_break():
html = render_markdown("첫째 줄\n둘째 줄")
assert "<br" in html
def test_list_renders_as_html_list():
html = render_markdown("- 하나\n- 둘")
assert "<ul>" in html
assert "<li>하나</li>" 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('<script>alert("xss")</script>본문')
assert "<script" not in html
assert "alert" not in html or "&lt;script" not in html # 태그는 지워지고 텍스트만 남아야 함
def test_javascript_href_is_neutralized():
html = render_markdown('[click me](javascript:alert(1))')
assert "javascript:" not in html
def test_onerror_attribute_is_stripped():
html = render_markdown('<img src=x onerror="alert(1)">본문')
assert "onerror" not in html
assert "<img" not in html # img는 허용 태그 목록에 없음
def test_allowed_link_href_is_preserved():
html = render_markdown("[내 블로그](https://example.com)")
assert 'href="https://example.com"' in html
+83
View File
@@ -101,6 +101,22 @@ def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
assert journal_service.list_entries(db_session, test_user.id) == [] assert journal_service.list_entries(db_session, test_user.id) == []
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
"/journal/new",
data={
"category_id": str(others_category.id),
"entry_date": date.today().isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user): def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
today = date.today() today = date.today()
@@ -111,12 +127,58 @@ def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
assert "오늘의 기록" in response.text assert "오늘의 기록" in response.text
def test_journal_day_detail_renders_content_as_markdown(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 "<strong>굵은 글씨</strong>" 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='<script>alert(1)</script>본문'
)
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<script" not in response.text
def test_journal_day_detail_requires_login(client): def test_journal_day_detail_requires_login(client):
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False) response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/login" assert response.headers["location"] == "/login"
def test_preview_renders_markdown(auth_client):
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
assert response.status_code == 200
assert "<strong>굵게</strong>" in response.text
def test_preview_strips_script_tags(auth_client):
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
assert response.status_code == 200
assert "<script" not in response.text
def test_preview_shows_placeholder_for_blank_content(auth_client):
response = auth_client.post("/journal/preview", data={"content": " "})
assert response.status_code == 200
assert "미리보기가 여기에 표시돼요" in response.text
def test_preview_requires_login(client):
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_edit_entry_updates_content(auth_client, db_session, test_user): def test_edit_entry_updates_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용") entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
@@ -163,6 +225,27 @@ def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user)
assert response.status_code == 404 assert response.status_code == 404
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(others_category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
db_session.refresh(entry)
assert entry.category_id == category.id
assert entry.content == "원래 내용"
def test_delete_entry_removes_it(auth_client, db_session, test_user): def test_delete_entry_removes_it(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id) category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록") entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")