mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
v1.5: Integrated optional category feature, i18n stabilization, and documentation update
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 목록(정수)을 반환합니다.
|
||||
|
||||
Reference in New Issue
Block a user