Refactor: Modularize memo.py to 3-layer architecture, Fix tag extraction regex, and Enhance infinite scroll for large screens

This commit is contained in:
leeyj
2026-04-20 11:04:00 +09:00
parent 6fca65eef1
commit ac58e14c8c
10 changed files with 476 additions and 372 deletions
+210
View File
@@ -0,0 +1,210 @@
import datetime
from ..database import get_db
class MemoRepository:
@staticmethod
def get_all(filters, limit=20, offset=0):
"""필터 조건에 따른 메모 목록 조회 및 연관 데이터(태그, 링크 등) 통합 조회"""
conn = get_db()
c = conn.cursor()
where_clauses = []
params = []
group = filters.get('group', 'all')
query = filters.get('query', '')
date = filters.get('date', '')
category = filters.get('category', '')
if group == 'done':
where_clauses.append("status = 'done'")
elif group.startswith('tag:'):
tag_name = group.split(':')[-1]
where_clauses.append("status != 'done'")
where_clauses.append("id IN (SELECT memo_id FROM tags WHERE name = ?)")
params.append(tag_name)
elif group != 'all':
where_clauses.append("status != 'done'")
where_clauses.append("group_name = ?")
params.append(group)
else:
where_clauses.append("status != 'done'")
if query:
where_clauses.append("(title LIKE ? OR content LIKE ?)")
params.extend([f"%{query}%", f"%{query}%"])
if date:
where_clauses.append("created_at LIKE ?")
params.append(f"{date}%")
if category:
where_clauses.append("category = ?")
params.append(category)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query_sql = f"SELECT * FROM memos WHERE {where_sql} ORDER BY is_pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
c.execute(query_sql, params + [limit, offset])
memo_rows = c.fetchall()
if not memo_rows:
conn.close()
return []
memos = [dict(r) for r in memo_rows]
memo_ids = [m['id'] for m in memos]
placeholders = ','.join(['?'] * len(memo_ids))
# Bulk Fetch Tags
c.execute(f'SELECT memo_id, name, source FROM tags WHERE memo_id IN ({placeholders})', memo_ids)
tags_map = {}
for t in c.fetchall():
tags_map.setdefault(t['memo_id'], []).append(dict(t))
# Bulk Fetch Attachments
c.execute(f'SELECT id, memo_id, filename, original_name, file_type, size FROM attachments WHERE memo_id IN ({placeholders})', memo_ids)
attachments_map = {}
for a in c.fetchall():
attachments_map.setdefault(a['memo_id'], []).append(dict(a))
# Bulk Fetch Backlinks
c.execute(f'''
SELECT ml.target_id, m.id as source_id, m.title
FROM memo_links ml
JOIN memos m ON ml.source_id = m.id
WHERE ml.target_id IN ({placeholders})
''', memo_ids)
backlinks_map = {}
for l in c.fetchall():
backlinks_map.setdefault(l['target_id'], []).append(dict(l))
# Bulk Fetch Forward Links
c.execute(f'''
SELECT ml.source_id, m.id as target_id, m.title
FROM memo_links ml
JOIN memos m ON ml.target_id = m.id
WHERE ml.source_id IN ({placeholders})
''', memo_ids)
links_map = {}
for l in c.fetchall():
links_map.setdefault(l['source_id'], []).append(dict(l))
for m in memos:
m['tags'] = tags_map.get(m['id'], [])
m['attachments'] = attachments_map.get(m['id'], [])
m['backlinks'] = backlinks_map.get(m['id'], [])
m['links'] = links_map.get(m['id'], [])
conn.close()
return memos
@staticmethod
def get_by_id(memo_id):
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = c.fetchone()
if not row:
conn.close()
return None
memo = dict(row)
c.execute('SELECT name, source FROM tags WHERE memo_id = ?', (memo_id,))
memo['tags'] = [dict(r) for r in c.fetchall()]
c.execute('SELECT id, filename, original_name, file_type, size FROM attachments WHERE memo_id = ?', (memo_id,))
memo['attachments'] = [dict(r) for r in c.fetchall()]
conn.close()
return memo
@staticmethod
def create(data, tags=[], links=[], attachment_filenames=[]):
conn = get_db()
c = conn.cursor()
try:
placeholders = ', '.join(['?'] * len(data))
columns = ', '.join(data.keys())
c.execute(f'INSERT INTO memos ({columns}) VALUES ({placeholders})', list(data.values()))
memo_id = c.lastrowid
for tag in tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
conn.commit()
return memo_id
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
@staticmethod
def update(memo_id, updates, tags=None, links=None, attachment_filenames=None):
conn = get_db()
c = conn.cursor()
try:
if updates:
sql = "UPDATE memos SET " + ", ".join([f"{k} = ?" for k in updates.keys()]) + " WHERE id = ?"
c.execute(sql, list(updates.values()) + [memo_id])
if tags is not None:
c.execute("DELETE FROM tags WHERE memo_id = ?", (memo_id,))
for tag in tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
if links is not None:
c.execute("DELETE FROM memo_links WHERE source_id = ?", (memo_id,))
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
if attachment_filenames is not None:
c.execute('UPDATE attachments SET memo_id = NULL WHERE memo_id = ?', (memo_id,))
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
conn.commit()
return True
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
@staticmethod
def delete(memo_id):
conn = get_db()
c = conn.cursor()
try:
# Physical file lookup is done in service layer
c.execute('DELETE FROM attachments WHERE memo_id = ?', (memo_id,))
c.execute('DELETE FROM memos WHERE id = ?', (memo_id,))
conn.commit()
return True
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
@staticmethod
def get_heatmap(days=365):
conn = get_db()
c = conn.cursor()
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
c.execute('''
SELECT strftime('%Y-%m-%d', created_at) as date, COUNT(*) as count
FROM memos
WHERE created_at >= ?
GROUP BY date
''', (start_date,))
stats = [dict(s) for s in c.fetchall()]
conn.close()
return stats
+2
View File
@@ -7,6 +7,7 @@ def register_blueprints(app):
from .file import file_bp from .file import file_bp
from .ai import ai_bp from .ai import ai_bp
from .settings import settings_bp from .settings import settings_bp
from .stats import stats_bp # [Added]
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@@ -14,3 +15,4 @@ def register_blueprints(app):
app.register_blueprint(file_bp) app.register_blueprint(file_bp)
app.register_blueprint(ai_bp) app.register_blueprint(ai_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(stats_bp) # [Added]
+32 -368
View File
@@ -1,241 +1,43 @@
import datetime
from flask import Blueprint, request, jsonify, current_app # type: ignore from flask import Blueprint, request, jsonify, current_app # type: ignore
from ..database import get_db
from ..auth import login_required from ..auth import login_required
from ..constants import GROUP_DONE, GROUP_DEFAULT from ..services.memo_service import MemoService
from ..utils.i18n import _t from ..utils.i18n import _t
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__) memo_bp = Blueprint('memo', __name__)
@memo_bp.route('/api/memos', methods=['GET']) @memo_bp.route('/api/memos', methods=['GET'])
@login_required @login_required
def get_memos(): def get_memos():
filters = {
'group': request.args.get('group', 'all'),
'query': request.args.get('query', ''),
'date': request.args.get('date', '').replace('null', '').replace('undefined', ''),
'category': request.args.get('category', '').replace('null', '').replace('undefined', '')
}
limit = request.args.get('limit', 20, type=int) limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int) offset = request.args.get('offset', 0, type=int)
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() memos = MemoService.get_all_memos(filters, limit, offset)
c = conn.cursor()
where_clauses = []
params = []
# 1. 그룹/태그 필터링
if group == GROUP_DONE:
where_clauses.append("status = 'done'")
elif group.startswith('tag:'):
tag_name = group.split(':')[-1]
where_clauses.append("status != 'done'")
where_clauses.append("id IN (SELECT memo_id FROM tags WHERE name = ?)")
params.append(tag_name)
elif group != 'all':
where_clauses.append("status != 'done'")
where_clauses.append("group_name = ?")
params.append(group)
else:
where_clauses.append("status != 'done'")
# 2. 검색어 필터링
if query:
where_clauses.append("(title LIKE ? OR content LIKE ?)")
params.append(f"%{query}%")
params.append(f"%{query}%")
# 3. 날짜 필터링 (캘린더 선택)
if date:
where_clauses.append("created_at LIKE ?")
params.append(f"{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)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query_sql = f"SELECT * FROM memos WHERE {where_sql} ORDER BY is_pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
c.execute(query_sql, params + [limit, offset])
memo_rows = c.fetchall()
if not memo_rows:
conn.close()
return jsonify([])
memos = [dict(r) for r in memo_rows]
memo_ids = [m['id'] for m in memos]
placeholders = ','.join(['?'] * len(memo_ids))
# --- 🚀 Bulk Fetch: N+1 문제 해결 ---
# 태그 한꺼번에 가져오기
c.execute(f'SELECT memo_id, name, source FROM tags WHERE memo_id IN ({placeholders})', memo_ids)
tags_rows = c.fetchall()
tags_map = {}
for t in tags_rows:
tags_map.setdefault(t['memo_id'], []).append(dict(t))
# 첨부파일 한꺼번에 가져오기
c.execute(f'SELECT id, memo_id, filename, original_name, file_type, size FROM attachments WHERE memo_id IN ({placeholders})', memo_ids)
attachments_rows = c.fetchall()
attachments_map = {}
for a in attachments_rows:
attachments_map.setdefault(a['memo_id'], []).append(dict(a))
# 백링크 한꺼번에 가져오기
c.execute(f'''
SELECT ml.target_id, m.id as source_id, m.title
FROM memo_links ml
JOIN memos m ON ml.source_id = m.id
WHERE ml.target_id IN ({placeholders})
''', memo_ids)
backlinks_rows = c.fetchall()
backlinks_map = {}
for l in backlinks_rows:
backlinks_map.setdefault(l['target_id'], []).append(dict(l))
# 전방 링크(Forward Links) 한꺼번에 가져오기
c.execute(f'''
SELECT ml.source_id, m.id as target_id, m.title
FROM memo_links ml
JOIN memos m ON ml.target_id = m.id
WHERE ml.source_id IN ({placeholders})
''', memo_ids)
links_rows = c.fetchall()
links_map = {}
for l in links_rows:
links_map.setdefault(l['source_id'], []).append(dict(l))
# 데이터 가공 및 병합
for m in memos:
m['tags'] = tags_map.get(m['id'], [])
m['attachments'] = attachments_map.get(m['id'], [])
m['backlinks'] = backlinks_map.get(m['id'], [])
m['links'] = links_map.get(m['id'], [])
conn.close()
return jsonify(memos) return jsonify(memos)
@memo_bp.route('/api/memos/<int:memo_id>', methods=['GET']) @memo_bp.route('/api/memos/<int:memo_id>', methods=['GET'])
@login_required @login_required
def get_memo(memo_id): def get_memo(memo_id):
conn = get_db() memo = MemoService.get_memo_by_id(memo_id)
c = conn.cursor() if not memo:
c.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'Memo not found'}), 404 return jsonify({'error': 'Memo not found'}), 404
memo = dict(row)
# 태그 가져오기
c.execute('SELECT name, source FROM tags WHERE memo_id = ?', (memo_id,))
memo['tags'] = [dict(r) for r in c.fetchall()]
# 첨부파일 가져오기
c.execute('SELECT id, filename, original_name, file_type, size FROM attachments WHERE memo_id = ?', (memo_id,))
memo['attachments'] = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify(memo) return jsonify(memo)
@memo_bp.route('/api/stats/heatmap', methods=['GET'])
@login_required
def get_heatmap_stats():
days = request.args.get('days', 365, type=int)
conn = get_db()
c = conn.cursor()
# 파라미터로 받은 일수만큼 데이터 조회
start_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
c.execute('''
SELECT strftime('%Y-%m-%d', created_at) as date, COUNT(*) as count
FROM memos
WHERE created_at >= ?
GROUP BY date
''', (start_date,))
stats = c.fetchall()
conn.close()
return jsonify([dict(s) for s in stats])
@memo_bp.route('/api/memos', methods=['POST']) @memo_bp.route('/api/memos', methods=['POST'])
@login_required @login_required
def create_memo(): def create_memo():
try: try:
data = request.json data = request.json
title = data.get('title', '').strip() memo_id = MemoService.create_memo(data)
content = data.get('content', '').strip()
color = data.get('color', '#2c3e50')
is_pinned = 1 if data.get('is_pinned') else 0
status = data.get('status', 'active').strip()
group_name = data.get('group_name', GROUP_DEFAULT).strip()
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:
return jsonify({'error': 'Password required for encryption'}), 400
now = datetime.datetime.now().isoformat()
if not title and not content:
return jsonify({'error': 'Title or content required'}), 400
conn = get_db()
c = conn.cursor()
c.execute('''
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:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
attachment_filenames = data.get('attachment_filenames', [])
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
conn.commit()
conn.close()
current_app.logger.info(f"Memo Created: ID {memo_id}, Title: '{title}', Encrypted: {is_encrypted}")
return jsonify({'id': memo_id, 'message': 'Memo created'}), 201 return jsonify({'id': memo_id, 'message': 'Memo created'}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e: except Exception as e:
if 'conn' in locals() and conn:
conn.rollback()
conn.close()
current_app.logger.error(f"CREATE_MEMO FAILED: {str(e)}") current_app.logger.error(f"CREATE_MEMO FAILED: {str(e)}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@@ -244,176 +46,38 @@ def create_memo():
def update_memo(memo_id): def update_memo(memo_id):
try: try:
data = request.json data = request.json
title = data.get('title') success, message = MemoService.update_memo(memo_id, data)
content = data.get('content') if not success:
color = data.get('color') if message in ["msg_encrypted_locked", "msg_auth_failed"]:
is_pinned = data.get('is_pinned') return jsonify({'error': _t(message)}), 403
status = data.get('status') return jsonify({'error': message}), 404
group_name = data.get('group_name')
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, group_name FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if memo and memo['is_encrypted']:
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 "")
updates = ['updated_at = ?']
params = [now]
# 암호화 처리 로직: 암호화가 활성화된 경우(또는 새로 설정하는 경우) 본문 암호화
final_content = content.strip() if content is not None else None
if (is_encrypted or (is_encrypted is None and memo['is_encrypted'])) and password:
if final_content is not None:
final_content = encrypt_content(final_content, password)
if title is not None:
updates.append('title = ?'); params.append(title.strip())
if final_content is not None:
updates.append('content = ?'); params.append(final_content)
if color is not None:
updates.append('color = ?'); params.append(color)
if is_pinned is not None:
updates.append('is_pinned = ?'); params.append(1 if is_pinned else 0)
if status is not None:
updates.append('status = ?'); params.append(status.strip())
if group_name is not None:
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)
if user_tags is not None:
c.execute("DELETE FROM tags WHERE memo_id = ? AND source = 'user'", (memo_id,))
for tag in user_tags:
if tag.strip():
c.execute('INSERT INTO tags (memo_id, name, source) VALUES (?, ?, ?)', (memo_id, tag.strip(), 'user'))
if content is not None:
c.execute("DELETE FROM memo_links WHERE source_id = ?", (memo_id,))
links = extract_links(content)
for target_id in links:
c.execute('INSERT INTO memo_links (source_id, target_id) VALUES (?, ?)', (memo_id, target_id))
# [Bug Fix] 첨부파일 링크는 본문 수정 여부와 상관없이 항상 갱신
attachment_filenames = data.get('attachment_filenames')
if attachment_filenames is not None:
c.execute('UPDATE attachments SET memo_id = NULL WHERE memo_id = ?', (memo_id,))
for fname in set(attachment_filenames):
c.execute('UPDATE attachments SET memo_id = ? WHERE filename = ?', (memo_id, fname))
if is_encrypted is not None:
if is_encrypted and password and content:
enc_content = encrypt_content(content, password)
c.execute("UPDATE memos SET is_encrypted = 1, content = ? WHERE id = ?", (enc_content, memo_id))
elif is_encrypted == 0:
c.execute("UPDATE memos SET is_encrypted = 0 WHERE id = ?", (memo_id,))
conn.commit()
conn.close()
current_app.logger.info(f"Memo Updated: ID {memo_id}, Fields: {list(data.keys())}")
return jsonify({'message': 'Updated'}) return jsonify({'message': 'Updated'})
except Exception as e: except Exception as e:
if 'conn' in locals() and conn:
conn.rollback()
conn.close()
current_app.logger.error(f"UPDATE_MEMO FAILED: {str(e)}") current_app.logger.error(f"UPDATE_MEMO FAILED: {str(e)}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE']) @memo_bp.route('/api/memos/<int:memo_id>', methods=['DELETE'])
@login_required @login_required
def delete_memo(memo_id): def delete_memo(memo_id):
import os success, message = MemoService.delete_memo(memo_id)
from flask import current_app if not success:
if message == "msg_encrypted_locked":
conn = get_db() return jsonify({'error': _t(message)}), 403
c = conn.cursor() return jsonify({'error': message}), 404
return jsonify({'message': 'Deleted memo and all associated files'})
# 1. 암호화 여부 확인 및 파일 목록 가져오기
c.execute('SELECT is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone()
if not memo:
conn.close()
return jsonify({'error': 'Memo not found'}), 404
if memo['is_encrypted']:
conn.close()
return jsonify({'error': _t('msg_encrypted_locked')}), 403
# 2. 물리적 파일 삭제 준비
c.execute('SELECT filename FROM attachments WHERE memo_id = ?', (memo_id,))
files = c.fetchall()
upload_folder = current_app.config['UPLOAD_FOLDER']
try:
# 3. 물리 파일 삭제 루프
for f in files:
filepath = os.path.join(upload_folder, f['filename'])
if os.path.exists(filepath):
os.remove(filepath)
current_app.logger.info(f"Physical file deleted on memo removal: {f['filename']}")
# 4. 메모 삭제 (외래 키 제약 조건에 의해 tags 등은 자동 삭제되거나 처리됨)
# Note: attachments 테이블의 memo_id는 SET NULL 설정이므로 수동으로 레코드도 삭제해줍니다.
c.execute('DELETE FROM attachments WHERE memo_id = ?', (memo_id,))
c.execute('DELETE FROM memos WHERE id = ?', (memo_id,))
conn.commit()
current_app.logger.info(f"Memo and its {len(files)} files deleted: ID {memo_id}")
return jsonify({'message': 'Deleted memo and all associated files'})
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@memo_bp.route('/api/memos/<int:memo_id>/decrypt', methods=['POST']) @memo_bp.route('/api/memos/<int:memo_id>/decrypt', methods=['POST'])
@login_required @login_required
def decrypt_memo_route(memo_id): def decrypt_memo_route(memo_id):
data = request.json data = request.json
password = data.get('password') password = data.get('password')
if not password: return jsonify({'error': 'Password required'}), 400 if not password:
conn = get_db(); c = conn.cursor() return jsonify({'error': 'Password required'}), 400
c.execute('SELECT content, is_encrypted FROM memos WHERE id = ?', (memo_id,))
memo = c.fetchone(); conn.close()
if not memo: return jsonify({'error': 'Memo not found'}), 404
if not memo['is_encrypted']: return jsonify({'content': memo['content']})
decrypted = decrypt_content(memo['content'], password)
if decrypted is None:
current_app.logger.warning(f"Decryption FAILED: ID {memo_id}")
return jsonify({'error': 'Invalid password'}), 403
current_app.logger.info(f"Decryption SUCCESS: ID {memo_id}") content, error_msg = MemoService.decrypt_memo(memo_id, password)
return jsonify({'content': decrypted}) if error_msg:
code = 404 if error_msg == "Memo not found" else 403
return jsonify({'error': error_msg}), code
return jsonify({'content': content})
+12
View File
@@ -0,0 +1,12 @@
from flask import Blueprint, request, jsonify # type: ignore
from ..auth import login_required
from ..services.memo_service import MemoService
stats_bp = Blueprint('stats', __name__)
@stats_bp.route('/api/stats/heatmap', methods=['GET'])
@login_required
def get_heatmap_stats():
days = request.args.get('days', 365, type=int)
stats = MemoService.get_heatmap_stats(days)
return jsonify(stats)
+152
View File
@@ -0,0 +1,152 @@
import os
import datetime
from flask import current_app
from ..models.memo_repo import MemoRepository
from ..utils import extract_links, parse_and_clean_metadata, generate_auto_title
from ..security import encrypt_content, decrypt_content
from ..constants import GROUP_DEFAULT
class MemoService:
@staticmethod
def get_all_memos(filters, limit=20, offset=0):
return MemoRepository.get_all(filters, limit, offset)
@staticmethod
def get_memo_by_id(memo_id):
return MemoRepository.get_by_id(memo_id)
@staticmethod
def create_memo(data):
content = data.get('content', '').strip()
group_name = data.get('group_name', GROUP_DEFAULT).strip()
user_tags = data.get('tags', [])
is_encrypted = 1 if data.get('is_encrypted') else 0
password = data.get('password', '').strip()
# 1. 메타데이터 파싱 및 본문 정리
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
# 2. 제목 자동 생성
title = data.get('title', '').strip()
if not title:
title = generate_auto_title(content)
# 3. 암호화 처리
if is_encrypted and password:
content = encrypt_content(content, password)
elif is_encrypted and not password:
raise ValueError('Password required for encryption')
now = datetime.datetime.now().isoformat()
repo_data = {
'title': title,
'content': content,
'color': data.get('color', '#2c3e50'),
'is_pinned': 1 if data.get('is_pinned') else 0,
'status': data.get('status', 'active').strip(),
'group_name': group_name,
'category': data.get('category'),
'is_encrypted': is_encrypted,
'created_at': now,
'updated_at': now
}
links = extract_links(content)
attachment_filenames = data.get('attachment_filenames', [])
return MemoRepository.create(repo_data, tags=user_tags, links=links, attachment_filenames=attachment_filenames)
@staticmethod
def update_memo(memo_id, data):
password = data.get('password', '').strip()
# 1. 기존 메모 정보 조회 (암호화 검증용)
memo = MemoRepository.get_by_id(memo_id)
if not memo:
return None, "Memo not found"
if memo['is_encrypted']:
if not password:
return None, "msg_encrypted_locked"
if decrypt_content(memo['content'], password) is None:
return None, "msg_auth_failed"
# 2. 데이터 가공
content = data.get('content')
group_name = data.get('group_name')
user_tags = data.get('tags')
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
title = data.get('title')
if title == "": # 빈 문자열일 때만 자동 생성
title = generate_auto_title(content or "")
now = datetime.datetime.now().isoformat()
updates = {'updated_at': now}
# 암호화 처리
is_encrypted = data.get('is_encrypted')
final_content = content.strip() if content is not None else None
# 기존 암호화 상태 유지 혹은 신규 설정 시 암호화 적용
if (is_encrypted or (is_encrypted is None and memo['is_encrypted'])) and password:
if final_content is not None:
final_content = encrypt_content(final_content, password)
if title is not None: updates['title'] = title.strip()
if final_content is not None: updates['content'] = final_content
if data.get('color') is not None: updates['color'] = data.get('color')
if data.get('is_pinned') is not None: updates['is_pinned'] = 1 if data.get('is_pinned') else 0
if data.get('status') is not None: updates['status'] = data.get('status').strip()
if group_name is not None: updates['group_name'] = group_name.strip()
if is_encrypted is not None: updates['is_encrypted'] = 1 if is_encrypted else 0
if data.get('category') is not None: updates['category'] = data.get('category')
links = extract_links(content) if content is not None else None
attachment_filenames = data.get('attachment_filenames')
MemoRepository.update(memo_id, updates, tags=user_tags, links=links, attachment_filenames=attachment_filenames)
return True, "Updated"
@staticmethod
def delete_memo(memo_id):
memo = MemoRepository.get_by_id(memo_id)
if not memo: return False, "Memo not found"
if memo['is_encrypted']: return False, "msg_encrypted_locked"
# 물리 파일 삭제
upload_folder = current_app.config['UPLOAD_FOLDER']
for f in memo['attachments']:
filepath = os.path.join(upload_folder, f['filename'])
if os.path.exists(filepath):
os.remove(filepath)
MemoRepository.delete(memo_id)
return True, "Deleted"
@staticmethod
def decrypt_memo(memo_id, password):
memo = MemoRepository.get_by_id(memo_id)
if not memo: return None, "Memo not found"
if not memo['is_encrypted']: return memo['content'], None
decrypted = decrypt_content(memo['content'], password)
if decrypted is None: return None, "Invalid password"
return decrypted, None
@staticmethod
def get_heatmap_stats(days=365):
return MemoRepository.get_heatmap(days)
+3 -3
View File
@@ -17,9 +17,9 @@ def parse_metadata(text, default_group=GROUP_DEFAULT):
if group_match: if group_match:
group_name = group_match.group(1) group_name = group_match.group(1)
# #태그 추출 (마크다운 헤더 # , ## 및 내부 링크[[#ID]] 방지) # 태그 추출 (공백이나 줄 시작 뒤의 #만 인정하며, HTML 속성(: 또는 =) 뒤의 #은 무시)
# 태그는 반드시 # 바로 뒤에 영문/숫자/한글이 붙어 있어야 하며, 앞에 다른 문자가 없어야 함 tag_regex = r'(?<![:=])(?<![:=]\s)(?:(?<=\s)|(?<=^))#([^\s\#\d\W][\w가-힣-]*)'
tag_matches = re.finditer(r'(?<!#)(?<!\[\[)(?<!\w)#([^\s\#\d\W][^\s\#]*)', text) tag_matches = re.finditer(tag_regex, text)
for match in tag_matches: for match in tag_matches:
tags.append(match.group(1)) tags.append(match.group(1))
@@ -0,0 +1,17 @@
# [Bug Report] 초기 로딩 시 날짜 필터로 인한 무한 스크롤 중단
## 1. 버그 내용
- **현상**: 로그인 후 첫 화면에서 최근 5일 이내의 메모만 표시됨. 스크롤을 내려도 그 이전의 메모(5일 전 이전)들이 로드되지 않음.
- **원인**:
- `app/routes/memo.py``get_memos` API에서 `offset == 0`(첫 페이지)인 경우에만 강제로 `updated_at >= T-5 days` 필터를 적용하도록 설계되어 있었음.
- 만약 최근 5일 이내의 메모가 페이지당 제한 수량(20개)보다 적을 경우, API는 20개 미만의 결과를 반환하게 됨.
- 프론트엔드(`AppService.js`)는 반환된 개수가 제한 수량보다 적으면 `hasMore = false`로 설정하여 더 이상의 조회를 중단함. 이로 인해 5일 이전의 메모 데이터가 존재하더라도 조회 시도조차 하지 않게 됨.
## 2. 조치 사항
- `app/routes/memo.py`에서 `offset == 0` 일 때 적용되던 강제 날짜 필터 로직을 삭제함.
- 기본 정렬 순서가 `updated_at DESC`이므로, 필터 없이도 자연스럽게 최신 메모부터 로드되며 데이터 일관성이 유지됨.
- 무한 스크롤 시 `hasMore` 판정이 정상적으로 이루어져 모든 과거 메모를 순차적으로 불러올 수 있게 됨.
## 3. 향후 주의사항
- 무한 스크롤이 적용된 API에서는 특정 `offset` 값에 따라 `WHERE` 조건이 동적으로 변해서는 안 됨.
- 전체 데이터셋에 대한 페이징 일관성을 유지해야 데이터 누락이나 중복 로딩을 방지할 수 있음.
+18
View File
@@ -0,0 +1,18 @@
# [Bug Report] HTML 색상 코드 태그 오인 및 AI 태그 수정 불가 문제
## 1. 버그 내용
- **현상**:
1. HTML 코드를 본문에 붙여넣을 때, CSS 색상 코드(예: `#ffffff`)를 태그(`#태그`)로 잘못 인식하여 추출함.
2. AI 분석으로 생성된 태그가 편집 UI에는 노출되지 않으나 카드에는 표시되며, 저장 시 삭제되지 않고 계속 남아있음.
- **원인**:
1. 태그 추출 정규식이 전방 탐색 조건을 충실히 따지지 않아, 색상 앞에 공백이 있는 경우 이를 태그로 인식함.
2. `update_memo` API가 저장 시 `source = 'user'`인 태그만 삭제하고 교체하기 때문에, `source = 'ai'`인 태그는 사용자가 수동 저장해도 지워지지 않음.
## 2. 조치 사항
- **정규식 최종 강화**: `app/utils/__init__.py``parse_metadata` 함수에서 앞에 공백이나 줄 시작(`\s|^`)이 있더라도, **콜론(:)이나 등호(=) 직후**에 오는 `#`은 HTML 속성이나 CSS 색상값으로 간주하여 무시하도록 정규식을 고도화함.
- 최종 정규식: `(?<![:=])(?<![:=]\s)(?:(?<=\s)|(?<=^))#([^\s\#\d\W][\w가-힣-]*)`
- **저장 로직 강화**: `app/routes/memo.py``update_memo` 함수에서 수동 저장 시 해당 메모의 모든 태그(AI 태그 포함)를 일단 삭제한 뒤, 사용자가 편집창에서 보고 있던 최종 리스트로 갱신하도록 변경함.
## 3. 향후 주의사항
- 특수 문자가 포함된 외부 코드(HTML/JS 등)를 붙여넣을 때 메타데이터 추출기와 충돌할 수 있으므로, 정규식은 항상 보수적으로(Context-aware) 설계해야 함.
- 사용자 인터페이스(UI)와 데이터베이스 상태 간의 괴리(편집창에 안 보이는 데이터가 DB에 남는 경우)는 사용자에게 큰 혼란을 주므로 지양해야 함.
+18 -1
View File
@@ -16,7 +16,8 @@ export const AppService = {
offset: 0, offset: 0,
limit: 20, limit: 20,
hasMore: true, hasMore: true,
isLoading: false isLoading: false,
autoLoadCount: 0 // 💡 큰 화면 대응 자동 로딩 횟추 추적
}, },
/** /**
@@ -27,6 +28,7 @@ export const AppService = {
this.state.memosCache = []; this.state.memosCache = [];
this.state.hasMore = true; this.state.hasMore = true;
this.state.isLoading = false; this.state.isLoading = false;
this.state.autoLoadCount = 0; // 초기화
// 히트맵 데이터 새로고침 // 히트맵 데이터 새로고침
if (HeatmapManager && HeatmapManager.refresh) { if (HeatmapManager && HeatmapManager.refresh) {
@@ -78,6 +80,21 @@ export const AppService = {
UI.setHasMore(this.state.hasMore); UI.setHasMore(this.state.hasMore);
UI.renderMemos(newMemos, {}, window.memoEventHandlers, isAppend); UI.renderMemos(newMemos, {}, window.memoEventHandlers, isAppend);
// 💡 [개선] 큰 화면 대응: 렌더링 후에도 센티넬이 보이면(스크롤바가 아직 안 생겼으면) 추가 로드
// 사용자 요청에 따라 자동 로딩은 최대 3회(총 80개 분량)까지만 진행
if (this.state.hasMore && this.state.autoLoadCount < 3) {
setTimeout(() => {
if (UI.isSentinelVisible()) {
console.log(`[AppService] Auto-loading (${this.state.autoLoadCount + 1}/3)...`);
this.state.autoLoadCount++;
this.loadMore(onUpdateSidebar, true);
}
}, 100);
} else if (!UI.isSentinelVisible()) {
// 스크롤바가 생겼거나 센티넬이 가려지면 카운트 리셋 (다음번 수동 스크롤 트리거를 위해)
this.state.autoLoadCount = 0;
}
} catch (err) { } catch (err) {
console.error('[AppService] loadMore failed:', err); console.error('[AppService] loadMore failed:', err);
} finally { } finally {
+12
View File
@@ -292,6 +292,18 @@ export const UI = {
DOM.scrollSentinel.style.visibility = hasMore ? 'visible' : 'hidden'; DOM.scrollSentinel.style.visibility = hasMore ? 'visible' : 'hidden';
DOM.scrollSentinel.innerText = hasMore ? I18nManager.t('msg_loading') : I18nManager.t('msg_last_memo'); DOM.scrollSentinel.innerText = hasMore ? I18nManager.t('msg_loading') : I18nManager.t('msg_last_memo');
} }
},
/**
* 💡 무한 스크롤 보조: 센티넬이 현재 화면에 보이는지 확인
*/
isSentinelVisible() {
if (!DOM.scrollSentinel) return false;
const rect = DOM.scrollSentinel.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.top <= (window.innerHeight || document.documentElement.clientHeight)
);
} }
}; };