v1.5: Integrated optional category feature, i18n stabilization, and documentation update

This commit is contained in:
leeyj
2026-04-16 15:42:02 +09:00
parent df8ae62b0e
commit aef0179c56
47 changed files with 1699 additions and 544 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app():
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_SECURE=False, # Set to True in production with HTTPS
SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE', 'False').lower() == 'true',
PERMANENT_SESSION_LIFETIME=3600 # 60 minutes (1 hour) session
)
+2 -1
View File
@@ -5,8 +5,9 @@ from flask import session, redirect, url_for, request, current_app # type: ignor
def check_auth(username, password):
"""
환경 변수에 설정된 관리자 계정 정보와 일치하는지 확인합니다.
ADMIN_USERNAME 또는 ADMIN_USER 중 하나를 사용합니다.
"""
admin_user = os.getenv('ADMIN_USERNAME', 'admin')
admin_user = os.getenv('ADMIN_USERNAME') or os.getenv('ADMIN_USER') or 'admin'
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
return username == admin_user and password == admin_password
+5
View File
@@ -37,6 +37,11 @@ def init_db():
except sqlite3.OperationalError:
pass
try:
c.execute("ALTER TABLE memos ADD COLUMN category TEXT")
except sqlite3.OperationalError:
pass
# 2. Separate Tags Table (Normalized)
c.execute('''
+4 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, request, jsonify, session, redirect, url_for # type: ignore
from flask import Blueprint, request, jsonify, session, redirect, url_for, current_app # type: ignore
from ..auth import check_auth
from ..utils.i18n import _t
@@ -13,7 +13,10 @@ def login():
if check_auth(username, password):
session.permanent = True # Enable permanent session to use LIFETIME config
session['logged_in'] = True
current_app.logger.info(f"AUTH: Success login for user '{username}' from {request.remote_addr}")
return jsonify({'message': 'Logged in successfully'})
current_app.logger.warning(f"AUTH: Failed login attempt for user '{username}' from {request.remote_addr}")
return jsonify({'error': _t('msg_auth_failed')}), 401
@auth_bp.route('/logout')
+3 -2
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, session, current_app # type: ignore
from flask import Blueprint, render_template, redirect, url_for, session # type: ignore
from ..auth import login_required
import os
import json
@@ -22,6 +22,7 @@ def login_page():
try:
with open(config_path, 'r', encoding='utf-8') as f:
lang = json.load(f).get('lang', 'ko')
except: pass
except Exception:
pass
return render_template('login.html', lang=lang)
+44 -10
View File
@@ -4,7 +4,7 @@ from ..database import get_db
from ..auth import login_required
from ..constants import GROUP_DONE, GROUP_DEFAULT
from ..utils.i18n import _t
from ..utils import extract_links
from ..utils import extract_links, parse_metadata, parse_and_clean_metadata, generate_auto_title
from ..security import encrypt_content, decrypt_content
memo_bp = Blueprint('memo', __name__)
@@ -17,8 +17,11 @@ def get_memos():
group = request.args.get('group', 'all')
query = request.args.get('query', '')
date = request.args.get('date', '')
category = request.args.get('category')
if date in ('null', 'undefined'):
date = ''
if category in ('null', 'undefined'):
category = ''
conn = get_db()
c = conn.cursor()
@@ -52,8 +55,13 @@ def get_memos():
where_clauses.append("created_at LIKE ?")
params.append(f"{date}%")
# 4. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
if offset == 0 and group == 'all' and not query and not date:
# 4. 카테고리 필터링
if category:
where_clauses.append("category = ?")
params.append(category)
# 5. 초기 로드 시 최근 5일 강조 (필터가 없는 경우에만 적용)
if offset == 0 and group == 'all' and not query and not date and not category:
start_date = (datetime.datetime.now() - datetime.timedelta(days=5)).isoformat()
where_clauses.append("(updated_at >= ? OR is_pinned = 1)")
params.append(start_date)
@@ -155,7 +163,18 @@ def create_memo():
user_tags = data.get('tags', [])
is_encrypted = 1 if data.get('is_encrypted') else 0
password = data.get('password', '').strip()
category = data.get('category')
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
new_content, final_group, final_tags = parse_and_clean_metadata(content, ui_group=group_name, ui_tags=user_tags)
content = new_content
group_name = final_group
user_tags = final_tags
# 제목 자동 생성 (비어있을 경우)
if not title:
title = generate_auto_title(content)
if is_encrypted and password:
content = encrypt_content(content, password)
elif is_encrypted and not password:
@@ -169,9 +188,9 @@ def create_memo():
c = conn.cursor()
try:
c.execute('''
INSERT INTO memos (title, content, color, is_pinned, status, group_name, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, is_encrypted, now, now))
INSERT INTO memos (title, content, color, is_pinned, status, group_name, category, is_encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (title, content, color, is_pinned, status, group_name, category, is_encrypted, now, now))
memo_id = c.lastrowid
for tag in user_tags:
@@ -208,26 +227,39 @@ def update_memo(memo_id):
user_tags = data.get('tags')
is_encrypted = data.get('is_encrypted')
password = data.get('password', '').strip()
category = data.get('category')
now = datetime.datetime.now().isoformat()
conn = get_db()
c = conn.cursor()
# 보안: 암호화된 메모 수정 시 비밀번호 검증
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
c.execute('SELECT content, is_encrypted, group_name FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if memo and memo['is_encrypted']:
# 암호화된 메모지만 '암호화 해제(is_encrypted=0)' 요청이 온 경우는
# 비밀번호 없이도 수정을 시도할 수 있어야 할까? (아니오, 인증이 필요함)
if not password:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 비밀번호가 맞는지 검증 (복호화 시도)
if decrypt_content(memo['content'], password) is None:
conn.close()
return jsonify({'error': _t('msg_auth_failed')}), 403
# 본문 기반 메타데이터 통합 및 정리 ($그룹, #태그 하단 이동)
if content is not None:
new_content, final_group, final_tags = parse_and_clean_metadata(
content,
ui_group=(group_name or memo['group_name']),
ui_tags=(user_tags if user_tags is not None else [])
)
content = new_content
group_name = final_group
user_tags = final_tags
# 제목 자동 생성 (비어있을 경우)
if title == "":
title = generate_auto_title(content or "")
try:
updates = ['updated_at = ?']
params = [now]
@@ -252,6 +284,8 @@ def update_memo(memo_id):
updates.append('group_name = ?'); params.append(group_name.strip() or GROUP_DEFAULT)
if is_encrypted is not None:
updates.append('is_encrypted = ?'); params.append(1 if is_encrypted else 0)
if category is not None:
updates.append('category = ?'); params.append(category)
params.append(memo_id)
c.execute(f"UPDATE memos SET {', '.join(updates)} WHERE id = ?", params)
+4 -1
View File
@@ -15,7 +15,10 @@ DEFAULT_SETTINGS = {
"encrypted_border": "#00f3ff",
"ai_accent": "#8b5cf6",
"enable_ai": True,
"lang": "ko"
"lang": "ko",
"enable_categories": False, # 카테고리 기능 활성화 여부 (고급 옵션)
"categories": [], # 무제한 전체 목록
"pinned_categories": [] # 최대 3개 (Alt+2~4 할당용)
}
@settings_bp.route('/api/settings', methods=['GET'])
+70 -5
View File
@@ -1,26 +1,91 @@
import re
from ..constants import GROUP_DEFAULT
def parse_metadata(text):
def parse_metadata(text, default_group=GROUP_DEFAULT):
"""
텍스트에서 ##그룹명 과 #태그 추출 유틸리티.
텍스트에서 $그룹명 과 #태그 추출 유틸리티.
그룹은 첫 번째 매칭된 것만 반환합니다.
"""
group_name = GROUP_DEFAULT
group_name = default_group
tags = []
if not text:
return group_name, tags
group_match = re.search(r'##(\S+)', text)
# $그룹명 추출 (단어 경계 고려, 첫 번째 매칭만)
group_match = re.search(r'\$(\w+)', text)
if group_match:
group_name = group_match.group(1)
tag_matches = re.finditer(r'(?<!#)#(\S+)', text)
# #태그 추출 (마크다운 헤더 방지: 최소 한 개의 공백이나 시작 지점 뒤에 오는 #)
tag_matches = re.finditer(r'(?<!#)#(\w+)', text)
for match in tag_matches:
tags.append(match.group(1))
return group_name, list(set(tags))
def parse_and_clean_metadata(content, ui_group=GROUP_DEFAULT, ui_tags=None):
"""
본문에서 메타데이터($ , #)를 추출하고 삭제한 뒤, UI 입력값과 합쳐 최하단에 재배치합니다.
"""
if ui_tags is None: ui_tags = []
if not content:
return content, ui_group, ui_tags
# 1. 기존에 생성된 푸터 블록(수평선 + 메타데이터)을 모두 제거
# 전후 공백을 제거한 후, 하단의 수평선(---, ***, ___)과 메타데이터 블록을 반복적으로 탐색하여 제거합니다.
content = content.strip()
# 패턴: (공백+수평선+공백 + (메타데이터 또는 공백))이 문자열 끝에 1회 이상 반복
content = re.sub(r'(?:\s*[\*\-\_]{3,}\s*(?:[\$\#][\s\S]*?)?\s*)+$', '', content).strip()
# 2. 본문에서 기호 정보 추출
content_group, content_tags = parse_metadata(content)
# 3. 본문에서 기호 패턴 삭제
# $그룹 삭제
content = re.sub(r'\$\w+', '', content)
# #태그 삭제 (헤더 제외)
content = re.sub(r'(?<!#)#\w+', '', content)
content = content.strip()
# 4. 데이터 통합
# 본문에 적힌 그룹이 있다면 UI 선택값보다 우선함
final_group = content_group if content_group != GROUP_DEFAULT else ui_group
# 태그는 모두 합침
final_tags = list(set(ui_tags + content_tags))
# 5. 푸터 재생성
footer_parts = []
if final_group and final_group != GROUP_DEFAULT:
footer_parts.append(f"${final_group}")
if final_tags:
footer_tags = " ".join([f"#{t}" for t in sorted(final_tags)])
footer_parts.append(footer_tags)
final_content = content
if footer_parts:
final_content += "\n\n---\n" + "\n".join(footer_parts)
return final_content, final_group, final_tags
def generate_auto_title(content):
"""
본문에서 제목을 추출합니다. (첫 줄 기준, 영문 20자/한글 10자 내외)
"""
if not content:
return ""
# 푸터 제외하고 순수 본문만 추출하여 제목 생성
main_content = re.split(r'\n+---\n', content)[0].strip()
if not main_content: return ""
lines = main_content.split('\n')
first_line = lines[0].strip()
# 마크다운 헤더 기호(#) 제거
first_line = re.sub(r'^#+\s+', '', first_line).strip()
return first_line[:20]
def extract_links(text):
"""
텍스트에서 [[#ID]] 형태의 내부 링크를 찾아 ID 목록(정수)을 반환합니다.