- Paste-to-embed: pasting an image into the markdown editor uploads it and
inserts  at the cursor. Unlike gallery attachments these aren't
tied to a journal_entry (the entry may not exist yet while composing), so
they're stored per-user under app/media/journal/{user_id}/pasted/ with no
DB row, served through an ownership-scoped route, and never cleaned up
automatically when an entry is deleted -- an accepted tradeoff at this
app's personal scale.
- The markdown sanitizer was stripping all <img> tags (not on the bleach
allowlist), which would have silently deleted every pasted image on save;
added img/src/alt/title while keeping event-handler attributes blocked.
- Cap embedded image width in both the editor pane and the rendered preview
so a large pasted photo can't overflow its card.
- Fix real data loss risk found while testing this: docker-compose.yml had
no volume for app/media, so every container recreate during a deploy wiped
uploaded photos, and deploy_sftp.py was syncing app/media/ (runtime user
data, not source) into the remote build context. Added the volume mount
and excluded media/ from the sync script. Recovered and relocated the
real attachments that had already landed in the wrong place on the NAS
during earlier deploys this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
434 lines
16 KiB
Python
434 lines
16 KiB
Python
import calendar
|
|
import mimetypes
|
|
import random
|
|
import uuid
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from fastapi import UploadFile
|
|
from PIL import Image
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import settings
|
|
from app.models.journal import (
|
|
JournalAttachment,
|
|
JournalAttachmentType,
|
|
JournalCategory,
|
|
JournalEntry,
|
|
JournalEntryMood,
|
|
JournalPrompt,
|
|
JournalTag,
|
|
)
|
|
from app.schemas.journal import (
|
|
JournalAttachmentOut,
|
|
JournalCalendarDay,
|
|
JournalCategoryCreate,
|
|
JournalDayDetailItem,
|
|
JournalEntryCreate,
|
|
JournalEntryUpdate,
|
|
JournalOnThisDayItem,
|
|
)
|
|
|
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
|
THUMBNAIL_WIDTH = 400
|
|
DEFAULT_CATEGORY_NAME = "일상"
|
|
# 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
|
|
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
|
|
# 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
|
|
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
|
|
_BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
|
|
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
|
|
DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
|
|
[
|
|
"**1. Story : 오늘 무슨 일이 있었나요?**",
|
|
_BULLET,
|
|
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
|
|
_BULLET,
|
|
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
|
|
_BULLET,
|
|
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
|
|
_BULLET,
|
|
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
|
|
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
|
|
_BULLET,
|
|
]
|
|
)
|
|
|
|
|
|
# ---- 카테고리 ----
|
|
|
|
|
|
def list_categories(db: Session, user_id: int) -> list[JournalCategory]:
|
|
stmt = select(JournalCategory).where(JournalCategory.user_id == user_id)
|
|
stmt = stmt.order_by(
|
|
JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at
|
|
)
|
|
return list(db.scalars(stmt))
|
|
|
|
|
|
def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory | None:
|
|
return db.scalar(
|
|
select(JournalCategory).where(JournalCategory.id == category_id, JournalCategory.user_id == user_id)
|
|
)
|
|
|
|
|
|
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
|
|
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)
|
|
return category
|
|
|
|
|
|
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
|
|
|
|
|
|
def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
|
|
"""유저가 카테고리를 하나도 만든 적 없으면 기본 카테고리를 자동 생성한다."""
|
|
existing = db.scalar(
|
|
select(JournalCategory)
|
|
.where(JournalCategory.user_id == user_id)
|
|
.order_by(JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at)
|
|
.limit(1)
|
|
)
|
|
if existing is not None:
|
|
return existing
|
|
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:
|
|
categories = db.scalars(
|
|
select(JournalCategory).where(JournalCategory.id.in_(ordered_ids), JournalCategory.user_id == user_id)
|
|
).all()
|
|
category_map = {c.id: c for c in categories}
|
|
for index, category_id in enumerate(ordered_ids):
|
|
category = category_map.get(category_id)
|
|
if category is not None:
|
|
category.sort_order = index
|
|
db.commit()
|
|
|
|
|
|
def count_entries_by_category(db: Session, user_id: int) -> dict[int, int]:
|
|
rows = db.execute(
|
|
select(JournalEntry.category_id, func.count(JournalEntry.id))
|
|
.where(JournalEntry.user_id == user_id)
|
|
.group_by(JournalEntry.category_id)
|
|
).all()
|
|
return {row[0]: row[1] for row in rows}
|
|
|
|
|
|
def delete_category(db: Session, category: JournalCategory) -> None:
|
|
"""카테고리를 지우면 안의 엔트리도 함께 지워진다 — 첨부파일 디스크 삭제까지 하려면
|
|
ORM cascade에만 맡기지 않고 delete_entry를 하나씩 거쳐야 한다."""
|
|
entries = list(db.scalars(select(JournalEntry).where(JournalEntry.category_id == category.id)))
|
|
for entry in entries:
|
|
delete_entry(db, entry)
|
|
db.delete(category)
|
|
db.commit()
|
|
|
|
|
|
# ---- 태그 ----
|
|
|
|
|
|
def get_or_create_tags(db: Session, user_id: int, names: list[str]) -> list[JournalTag]:
|
|
tags = []
|
|
for name in names:
|
|
tag = db.scalar(select(JournalTag).where(JournalTag.user_id == user_id, JournalTag.name == name))
|
|
if tag is None:
|
|
tag = JournalTag(user_id=user_id, name=name)
|
|
db.add(tag)
|
|
db.flush()
|
|
tags.append(tag)
|
|
return tags
|
|
|
|
|
|
# ---- 엔트리 ----
|
|
|
|
|
|
def list_entries(
|
|
db: Session,
|
|
user_id: int,
|
|
category_id: int | None = None,
|
|
start: date | None = None,
|
|
end: date | None = None,
|
|
) -> list[JournalEntry]:
|
|
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id)
|
|
if category_id is not None:
|
|
stmt = stmt.where(JournalEntry.category_id == category_id)
|
|
if start is not None:
|
|
stmt = stmt.where(JournalEntry.entry_date >= start)
|
|
if end is not None:
|
|
stmt = stmt.where(JournalEntry.entry_date <= end)
|
|
stmt = stmt.order_by(JournalEntry.entry_date.desc(), JournalEntry.created_at.desc())
|
|
return list(db.scalars(stmt))
|
|
|
|
|
|
def get_entry(db: Session, entry_id: int, user_id: int) -> JournalEntry | None:
|
|
return db.scalar(select(JournalEntry).where(JournalEntry.id == entry_id, JournalEntry.user_id == user_id))
|
|
|
|
|
|
def create_entry(db: Session, user_id: int, data: JournalEntryCreate) -> JournalEntry:
|
|
tags = get_or_create_tags(db, user_id, data.tags)
|
|
entry = JournalEntry(
|
|
user_id=user_id,
|
|
category_id=data.category_id,
|
|
entry_date=data.entry_date,
|
|
title=data.title,
|
|
content=data.content,
|
|
tags=tags,
|
|
moods=[JournalEntryMood(mood=m) for m in data.moods],
|
|
)
|
|
db.add(entry)
|
|
db.commit()
|
|
db.refresh(entry)
|
|
return entry
|
|
|
|
|
|
def update_entry(db: Session, entry: JournalEntry, data: JournalEntryUpdate) -> JournalEntry:
|
|
tags = get_or_create_tags(db, entry.user_id, data.tags)
|
|
entry.category_id = data.category_id
|
|
entry.entry_date = data.entry_date
|
|
entry.title = data.title
|
|
entry.content = data.content
|
|
entry.tags = tags
|
|
entry.moods = [JournalEntryMood(mood=m) for m in data.moods]
|
|
db.commit()
|
|
db.refresh(entry)
|
|
return entry
|
|
|
|
|
|
def delete_entry(db: Session, entry: JournalEntry) -> None:
|
|
for attachment in list(entry.attachments):
|
|
_delete_attachment_files(attachment)
|
|
db.delete(entry)
|
|
db.commit()
|
|
|
|
|
|
# ---- 첨부파일 ----
|
|
|
|
|
|
def _media_dir(user_id: int, entry_id: int) -> Path:
|
|
return Path(settings.journal_media_root) / str(user_id) / str(entry_id)
|
|
|
|
|
|
def _delete_attachment_files(attachment: JournalAttachment) -> None:
|
|
for path_str in (attachment.file_path, attachment.thumbnail_path):
|
|
if path_str:
|
|
Path(path_str).unlink(missing_ok=True)
|
|
|
|
|
|
def save_attachment(db: Session, entry: JournalEntry, upload_file: UploadFile) -> JournalAttachment:
|
|
content_type = upload_file.content_type or ""
|
|
if content_type in ALLOWED_IMAGE_TYPES:
|
|
media_type = JournalAttachmentType.IMAGE
|
|
elif content_type in ALLOWED_VIDEO_TYPES:
|
|
media_type = JournalAttachmentType.VIDEO
|
|
else:
|
|
raise ValueError("지원하지 않는 파일 형식이에요 (사진: jpg/png/webp/gif, 영상: mp4/mov)")
|
|
|
|
data = upload_file.file.read()
|
|
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
|
|
if len(data) > max_bytes:
|
|
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
|
|
|
|
target_dir = _media_dir(entry.user_id, entry.id)
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
ext = mimetypes.guess_extension(content_type) or Path(upload_file.filename or "").suffix or ""
|
|
stored_name = f"{uuid.uuid4().hex}{ext}"
|
|
file_path = target_dir / stored_name
|
|
file_path.write_bytes(data)
|
|
|
|
thumbnail_path: Path | None = None
|
|
if media_type == JournalAttachmentType.IMAGE:
|
|
thumbnail_path = target_dir / f"{uuid.uuid4().hex}_thumb.jpg"
|
|
try:
|
|
with Image.open(file_path) as img:
|
|
img = img.convert("RGB")
|
|
w, h = img.size
|
|
if w > THUMBNAIL_WIDTH:
|
|
img = img.resize((THUMBNAIL_WIDTH, round(h * THUMBNAIL_WIDTH / w)))
|
|
img.save(thumbnail_path, "JPEG", quality=85)
|
|
except Exception:
|
|
# 손상되었거나 Pillow가 못 읽는 이미지여도 원본 업로드 자체는 실패시키지 않는다.
|
|
thumbnail_path.unlink(missing_ok=True)
|
|
thumbnail_path = None
|
|
|
|
attachment = JournalAttachment(
|
|
entry_id=entry.id,
|
|
media_type=media_type,
|
|
file_path=str(file_path),
|
|
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
|
|
original_filename=upload_file.filename or stored_name,
|
|
file_size=len(data),
|
|
)
|
|
db.add(attachment)
|
|
db.commit()
|
|
db.refresh(attachment)
|
|
return attachment
|
|
|
|
|
|
def get_attachment(db: Session, attachment_id: int, user_id: int) -> JournalAttachment | None:
|
|
return db.scalar(
|
|
select(JournalAttachment)
|
|
.join(JournalEntry, JournalAttachment.entry_id == JournalEntry.id)
|
|
.where(JournalAttachment.id == attachment_id, JournalEntry.user_id == user_id)
|
|
)
|
|
|
|
|
|
def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
|
|
_delete_attachment_files(attachment)
|
|
db.delete(attachment)
|
|
db.commit()
|
|
|
|
|
|
# ---- 에디터에 붙여넣은 이미지 ----
|
|
# 글을 쓰는 중(아직 엔트리가 저장되기 전)에 클립보드로 붙여넣은 이미지라 JournalAttachment처럼
|
|
# entry_id에 묶을 수가 없다 — DB 행 없이 유저별 폴더에만 저장하고, 마크다운 본문에
|
|
#  형태로 직접 참조한다. 그래서 첨부파일 갤러리(삭제 버튼 등)에는 안 뜨고, 엔트리를
|
|
# 지워도 자동으로 같이 지워지지 않는다(개인 규모 사용량이라 감수할 만한 트레이드오프).
|
|
def pasted_image_dir(user_id: int) -> Path:
|
|
return Path(settings.journal_media_root) / str(user_id) / "pasted"
|
|
|
|
|
|
def save_pasted_image(user_id: int, upload_file: UploadFile) -> str:
|
|
"""붙여넣은 이미지를 저장하고 파일명(서빙 URL에 쓸 값)을 반환한다."""
|
|
content_type = upload_file.content_type or ""
|
|
if content_type not in ALLOWED_IMAGE_TYPES:
|
|
raise ValueError("이미지 파일만 붙여넣을 수 있어요 (jpg/png/webp/gif)")
|
|
|
|
data = upload_file.file.read()
|
|
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
|
|
if len(data) > max_bytes:
|
|
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
|
|
|
|
target_dir = pasted_image_dir(user_id)
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
ext = mimetypes.guess_extension(content_type) or ".png"
|
|
filename = f"{uuid.uuid4().hex}{ext}"
|
|
(target_dir / filename).write_bytes(data)
|
|
return filename
|
|
|
|
|
|
def get_pasted_image_path(user_id: int, filename: str) -> Path | None:
|
|
# Path(...).name이 디렉터리 구분자를 전부 제거해줘서 "../"류 경로 탈출을 막아준다.
|
|
safe_name = Path(filename).name
|
|
path = pasted_image_dir(user_id) / safe_name
|
|
return path if path.is_file() else None
|
|
|
|
|
|
# ---- 캘린더 / day-detail / 회상 ----
|
|
|
|
|
|
def _to_day_detail_item(entry: JournalEntry) -> JournalDayDetailItem:
|
|
return JournalDayDetailItem(
|
|
id=entry.id,
|
|
category_id=entry.category_id,
|
|
category_name=entry.category.name,
|
|
category_color=entry.category.color,
|
|
title=entry.title,
|
|
content=entry.content,
|
|
moods=[m.mood for m in entry.moods],
|
|
tags=[t.name for t in entry.tags],
|
|
attachments=[
|
|
JournalAttachmentOut(
|
|
id=a.id,
|
|
media_type=a.media_type,
|
|
original_filename=a.original_filename,
|
|
has_thumbnail=a.thumbnail_path is not None,
|
|
)
|
|
for a in entry.attachments
|
|
],
|
|
)
|
|
|
|
|
|
def get_monthly_journal_summary(
|
|
db: Session, user_id: int, year: int, month: int, category_id: int | None = None
|
|
) -> dict[date, JournalCalendarDay]:
|
|
"""해당 월의 날짜별 엔트리 개수와, 그날 등장한 카테고리 색상 목록(점 표시용)을 집계한다."""
|
|
days_in_month = calendar.monthrange(year, month)[1]
|
|
first_day = date(year, month, 1)
|
|
last_day = date(year, month, days_in_month)
|
|
|
|
stmt = (
|
|
select(JournalEntry.entry_date, JournalCategory.color)
|
|
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
|
|
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
|
|
)
|
|
if category_id is not None:
|
|
stmt = stmt.where(JournalEntry.category_id == category_id)
|
|
rows = db.execute(stmt).all()
|
|
|
|
counts: dict[date, int] = {}
|
|
colors_by_date: dict[date, list[str]] = {}
|
|
for entry_date, color in rows:
|
|
counts[entry_date] = counts.get(entry_date, 0) + 1
|
|
colors = colors_by_date.setdefault(entry_date, [])
|
|
color = color or "var(--color-accent)"
|
|
if color not in colors:
|
|
colors.append(color)
|
|
|
|
return {
|
|
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d])
|
|
for d in counts
|
|
}
|
|
|
|
|
|
def get_day_entries(
|
|
db: Session, user_id: int, target_date: date, category_id: int | None = None
|
|
) -> list[JournalDayDetailItem]:
|
|
stmt = select(JournalEntry).where(
|
|
JournalEntry.user_id == user_id, JournalEntry.entry_date == target_date
|
|
)
|
|
if category_id is not None:
|
|
stmt = stmt.where(JournalEntry.category_id == category_id)
|
|
stmt = stmt.order_by(JournalEntry.created_at)
|
|
return [_to_day_detail_item(e) for e in db.scalars(stmt)]
|
|
|
|
|
|
def get_on_this_day(db: Session, user_id: int, today: date) -> list[JournalOnThisDayItem]:
|
|
"""오늘과 월/일이 같은 과거 연도의 엔트리를 반환한다("1년 전 오늘" 회상 카드).
|
|
|
|
MONTH()/DAY() 같은 DB 종속 함수 대신 파이썬에서 필터링해 SQLite(테스트)/MariaDB(운영)
|
|
양쪽에서 동일하게 동작하게 한다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다.
|
|
"""
|
|
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id).order_by(JournalEntry.entry_date.desc())
|
|
matches = [
|
|
e
|
|
for e in db.scalars(stmt)
|
|
if e.entry_date.month == today.month
|
|
and e.entry_date.day == today.day
|
|
and e.entry_date.year != today.year
|
|
]
|
|
items = []
|
|
for e in matches:
|
|
base = _to_day_detail_item(e)
|
|
items.append(
|
|
JournalOnThisDayItem(
|
|
**base.model_dump(),
|
|
entry_date=e.entry_date,
|
|
years_ago=today.year - e.entry_date.year,
|
|
)
|
|
)
|
|
return items
|
|
|
|
|
|
# ---- 프롬프트 ----
|
|
|
|
|
|
def get_random_prompt(db: Session) -> JournalPrompt | None:
|
|
prompts = list(db.scalars(select(JournalPrompt)))
|
|
return random.choice(prompts) if prompts else None
|