mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-25 03:48:38 +09:00
Refactor: Modularize memo.py to 3-layer architecture, Fix tag extraction regex, and Enhance infinite scroll for large screens
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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})
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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` 조건이 동적으로 변해서는 안 됨.
|
||||||
|
- 전체 데이터셋에 대한 페이징 일관성을 유지해야 데이터 누락이나 중복 로딩을 방지할 수 있음.
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user