diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..273a7ba --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +# E701: 한 줄에 콜론(:) 사용 (Multiple statements on one line (colon)) +# E702: 한 줄에 세미콜론(;) 사용 (Multiple statements on one line (semicolon)) +# 위 항목들은 가독성을 위한 스타일 가이드이므로, 개발 효율을 위해 영구 무시 설정함. +ignore = E701, E702 +max-line-length = 120 +exclude = .git,__pycache__,docs,old,build,dist,venv diff --git a/.gitignore b/.gitignore index eddbc12..be21b41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,34 @@ -# Brain Dogfood Public Repo Gitignore +# [뇌사료] Git 배포용 .gitignore + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +build/ +dist/ + +# Configuration & Secrets .env config.json -memos.db -__pycache__/ -*.pyc -logs/ + +# Data & Databases data/ -.vscode/ +*.db +*.sqlite3 + +# Media & Uploads +static/uploads/* +!static/uploads/.gitkeep + +# Logs & Temporary +logs/ +*.log +scratch/ +.gemini/ .idea/ +.vscode/ + +# System +.DS_Store +Thumbs.db diff --git a/app/__init__.py b/app/__init__.py index f7ffbf9..b7822c7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 ) diff --git a/app/auth.py b/app/auth.py index b5ce1ec..c0a8eab 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 diff --git a/app/database.py b/app/database.py index be6f4b4..d385f05 100644 --- a/app/database.py +++ b/app/database.py @@ -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(''' diff --git a/app/routes/auth.py b/app/routes/auth.py index 7fd1a4d..ad65e6c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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') diff --git a/app/routes/main.py b/app/routes/main.py index 04d4cb2..6e4f2fc 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -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) diff --git a/app/routes/memo.py b/app/routes/memo.py index aaee8aa..7c76f11 100644 --- a/app/routes/memo.py +++ b/app/routes/memo.py @@ -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) diff --git a/app/routes/settings.py b/app/routes/settings.py index ebf0774..56140d6 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -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']) diff --git a/app/utils/__init__.py b/app/utils/__init__.py index cc92bc3..c261078 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -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'(? **"지식은 기록될 때 힘을 얻고, 연결될 때 생명을 얻는다."** @@ -12,10 +12,11 @@ - **이중 보안**: 메모별 개별 암호화 및 미디어 보안 실드. - **AI 구조화**: Gemini 2.0 Flash 기반 자동 요약 및 지능형 태깅. -## ✨ What's New in v5.0 -- **Heatmap**: 최근 1년/6개월/3개월/1개월 활동량 지표 추가. -- **Performance**: 대량 데이터 로딩 최적화(Bulk Fetch) 및 무한 스크롤 도입. -- **Editor**: 중요 지식 강조를 위한 글자 색상(Color Syntax) 기능 통합. +## ✨ What's New in v1.5 +- **Advanced Category Toggle**: 라이트 유저를 위해 카테고리 기능을 숨기거나 켤 수 있는 옵션 도입. +- **i18n Stabilization**: 한국어/영어 전환 시 히트맵, 달력 등 동적 컴포넌트까지 완벽하게 자가 교정 및 번역 반영. +- **V5 Metadata Shield**: 정규식 엔진 고도화를 통해 시스템 메타데이터 중복 및 푸터 손상 원천 차단. +- **Data Integrity**: 다국어 환경의 정합성을 위해 내부 그룹명을 영문 상수로 통일. ## 📂 문서 인덱스 1. [**사용자 매뉴얼 (User Manual)**](user_manual.md): **[최초 사용자 필독]** 사용법 및 연결 문법 @@ -27,4 +28,4 @@ 7. [**단축키 가이드 (Shortcuts Guide)**](shortcuts.md): **[업무 효율 극대화]** 탐색 및 편집 단축키 총정리 --- -*Last Updated: 2026-04-15* +*Last Updated: 2026-04-16 (v1.5 Milestone Release)* diff --git a/docs/api_reference.md b/docs/api_reference.md index 4dc5366..588763a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -1,4 +1,4 @@ -# 📡 데이터베이스 및 API 명세서 (v13.5) +# 📡 데이터베이스 및 API 명세서 (v1.5) 본 문서는 `뇌사료` 프로젝트의 데이터 저장 구조(Schema)와 모든 외부 통신 인터페이스(API)를 상세히 기술합니다. @@ -10,10 +10,36 @@ | :--- | :--- | :--- | :--- | | `id` | INTEGER | PRIMARY KEY | 자동 증가 고유 아이디 | | `title` | TEXT | - | 메모 제목 | -| `content` | TEXT | - | 메모 본문 (암호화 시 바이너리 텍스트) | +| `content` | TEXT | - | 메모 본문 (마크다운) | +| `summary` | TEXT | - | AI 생성 요약문 | +| `color` | TEXT | `#2c3e50` | 메모 카드 테마 색상 | +| `is_pinned` | BOOLEAN | 0 | 상단 고정 여부 | +| `status` | TEXT | `'active'` | 상태 (`active`, `done`, `archived`) | +| `group_name` | TEXT | `'default'` | 그룹 ID (영문 상수 권장) | +| `category` | TEXT | - | (v1.5) 소속 카테고리 명 | | `is_encrypted` | BOOLEAN | 0 | 암호화 여부 | +| `created_at` | TIMESTAMP | - | 생성 일시 | +| `updated_at` | TIMESTAMP | - | 수정 일시 | -### 1.2 `memo_links` 테이블 (v7.0 추가) +### 1.2 `tags` 테이블 +메모와 태그 간의 관계를 저장합니다. +| 컬럼명 | 타입 | 설명 | +| :--- | :--- | :--- | +| `memo_id` | INTEGER | 소속 메모 ID | +| `name` | TEXT | 태그 이름 | +| `source` | TEXT | 생성 주체 (`user`, `ai`) | + +### 1.3 `attachments` 테이블 +메모에 첨부된 미디어 자산을 관리합니다. +| 컬럼명 | 타입 | 설명 | +| :--- | :--- | :--- | +| `memo_id` | INTEGER | 소속 메모 ID | +| `filename` | TEXT | 저장된 파일명 (UUID 기반) | +| `original_name`| TEXT | 원본 파일명 | +| `file_type` | TEXT | MIME 타입 | +| `size` | INTEGER | 파일 크기 (Bytes) | + +### 1.4 `memo_links` 테이블 메모 간의 `[[#ID]]` 링크 및 시각화 인력을 관리합니다. | 컬럼명 | 타입 | 설명 | | :--- | :--- | :--- | @@ -22,23 +48,17 @@ --- -## 🌐 2. API 엔드포인트 전수 명세 +## 🌐 2. API 엔드포인트 명세 (주요 항목) -### 2.1 Memos & Analysis -| Method | URL | Description | -| :--- | :--- | :--- | -| `GET` | `/api/memos` | 전체 메모 목록, 태그, 첨부파일, **백링크** 정보 통합 조회 | -| `POST` | `/api/memos//decrypt` | 비밀번호 검증 및 본문 일시 복호화 | -| `GET` | `/api/stats/heatmap` | 최근 N일간의 일자별 메모 작성 수(통계) 조회 (`days` 파라미터 지원) | +### 2.1 Memos & Search +- `GET /api/memos`: 필터링된 메모 목록 및 메타데이터 통합 조회. +- `POST /api/memos//decrypt`: 암호화된 메모 복호화 요청. +- `GET /api/stats/heatmap`: 히트맵 렌더링을 위한 통계 데이터 조회. -### 2.2 Assets (제한적 접근) -| Method | URL | Security Policy | Description | +### 2.2 Settings & Configuration (v1.5 업데이트) +| Method | URL | Parameters | Description | | :--- | :--- | :--- | :--- | -| `GET` | `/api/download/` | **세션 필수(로그인 상호작용)** | 이미지/파일 다운로드. 이미지인 경우 `inline` 처리 및 암호화 메모 관련 파일은 로그인 미달 시 403 차단. | -| `POST` | `/api/upload` | `login_required` | 파일 업로드 및 서버 측 마스터 키 암호화 저장. | +| `GET` | `/api/settings` | - | 테마, 언어, 고급 기능 활성화 상태 조회 | +| `POST` | `/api/settings` | `lang`, `enable_categories`, `bg_color` 등 | 서버 설정을 영구 업데이트 | -### 2.3 Settings & Ops (v11.0 추가) -| Method | URL | Description | -| :--- | :--- | :--- | -| `GET` | `/api/settings` | 서버 사이드 테마 및 전역 설정 조회 | -| `POST` | `/api/settings` | UI 테마 설정을 서버에 영구 기록 | +> **v1.5 변경점**: `lang`(언어), `enable_categories`(고급 카테고리 사용 여부) 필드가 추가되었습니다. diff --git a/docs/architecture.md b/docs/architecture.md index c6e1b06..b464a53 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,4 @@ -# 🏢 시스템 아키텍처 및 폴더 구조 (v5.0+) +# 🏢 시스템 아키텍처 및 폴더 구조 (v1.5) 본 문서는 `뇌사료` 프로젝트의 물리적 파일 구조와 논리적 설계 아키텍처를 상세히 기술합니다. @@ -11,11 +11,9 @@ | `/data` | **Database Box** | SQLite3 DB 파일 (`memos.db`) 저장 위치 | | `/docs` | **Documentation** | 시스템 기술 문서 및 가이드 | | `/logs` | **Log Box** | 시스템 작동 및 접근 로그 (`app.log`) | -| `/static` | **Static Assets** | CSS, 이미지, 파비코 및 프론트엔드 JS | +| `/static` | **Static Assets** | CSS, 이미지 및 프론트엔드 리소스 | | `/static/js/components` | **UI Components** | D3.js 시각화 모듈 및 UI 핵심 로직 | | `/templates` | **HTML Templates** | Jinja2 기반 레이아웃 및 페이지 | -| `deploy.py` | **Ops Tool** | 수술적 정밀 배포 도구 (Surgical Deployment) | -| `backup.py` | **Disaster Recovery** | 핵심 데이터(DB, .env, 첨부파일) 증분 백업 도구 | --- @@ -23,16 +21,16 @@ ### 2.1 Backend: Blueprint-based Modular Flask - **패키지 구조**: `app/__init__.py`에서 중앙 집중식으로 앱을 생성하고, `routes/` 아래의 각 기능을 Blueprint로 등록합니다. -- **보안 실드 (Security Shield)**: `before_request` 단계에서 비정상적인 트래픽 및 파라미터를 필터링하는 로깅 시스템이 선제적으로 작동합니다. -- **성능 최적화 (Bulk Fetch)**: 다량의 메모리 조회 시 발생하는 N+1 문제를 방지하기 위해 태그, 첨부파일, 백링크 정보를 한꺼번에 Fetch하는 벌크 조회 로직이 적용되었습니다. +- **다국어 엔진 (v1.5)**: 서버 사이드에서도 `i18n.py`를 통해 클라이언트 언어 환경에 맞춤화된 응답(에러 메시지 등)을 제공합니다. -### 2.2 Frontend: Modular Component Architecture -- **지식 네뷸라 (Knowledge Nebula)**: D3.js의 물리 시뮬레이션 엔진을 도입하여 유기적인 성단 구조를 시각화합니다. -- **컴포넌트 중심 설계**: `HeatmapManager.js` (활동 시각화), `CalendarManager.js` (달력), `Visualizer.js` (그래프), `DrawerManager.js` (탐색기) 등으로 독립된 모듈 구조를 채택하여 유지보수성을 극대화했습니다. -- **레이아웃 혁명**: **무한 스크롤(Infinite Scroll)** 페이징 기법을 도입하여 수천 개의 지식 파편도 성능 저하 없이 탐색할 수 있습니다. -- **State Management**: `AppService.js`를 중앙 상태 관리 엔진으로 활용하여 데이터 요청과 UI 업데이트의 정합성을 유지합니다. +### 2.2 Frontend: State-Driven UI +- **컴포넌트 중심 설계**: `HeatmapManager.js`, `CalendarManager.js`, `ComposerCategoryUI.js` 등으로 독립된 모듈 구조를 채택했습니다. +- **State Management**: `AppService.js`를 통해 전역 상태를 관리하며, 설정 변경(언어, 테마) 시 `ThemeManager.js`가 시스템 전반의 정합성을 동기화합니다. -### 2.3 Ops & Reliability -- **Merged Configuration**: 개발/운영 환경의 환경변수를 한곳에서 관리하며, 배포 시 `.env` 파일을 통해 보안 설정이 주입됩니다. -- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 수술적 배포 방식을 채택했습니다. -- **Disaster Recovery**: `backup.py`를 통해 서버 침해나 시스템 붕괴 시에도 3대 핵심 자산(.env, DB, Uploads)만으로 즉시 복구가 가능한 구조를 갖췄습니다. +### 2.3 Data Policy: English Constant Policy (v1.5 정책) +- **데이터 정합성**: 다국어 환경에서 그룹 필터링 등이 오작동하는 것을 방지하기 위해, 데이터베이스의 `group_name` 필드에는 **영문 상수**(`default`, `files`, `done` 등)를 저장하는 것을 원칙으로 합니다. +- **매핑 방식**: 화면에 노출되는 텍스트는 프론트엔드 i18n 매니저를 통해 사용자의 현재 언어 설정에 맞춰 동적으로 번역되어 표기됩니다. + +### 2.4 Ops & Reliability +- **Surgical Cleanup**: 배포 시 운영 데이터(DB, Uploads)는 보존하고 코드 영역만 정밀하게 교체하는 방식을 채택했습니다. +- **Disaster Recovery**: `backup.py`를 통해 핵심 자산(.env, DB, Uploads)을 증분 백업하여 언제든 즉시 복구가 가능합니다. diff --git a/docs/features.md b/docs/features.md index 0b853d6..c474067 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ -# 💎 핵심 기능 가이드 (v13.3) +# 💎 핵심 기능 가이드 (v1.5) -본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석**에 대한 상세 명세를 담고 있습니다. +본 문서는 `뇌사료` 프로젝트를 상징하는 핵심 기능들인 **지식 시각화**, **암호화**, **AI 분석** 및 **고객 맞춤형 설정**에 대한 상세 명세를 담고 있습니다. ## 🌌 1. 지식 네뷸라 (Knowledge Nebula) D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유기적인 우주 성단 구조로 시각화합니다. @@ -16,7 +16,7 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유 ### 2.1 메모 및 파일 보안 - **개별 암호화**: 메모마다 고유한 비밀번호를 사용하여 `Fernet (AES-128 CBC/HMAC)` 방식으로 본문을 암호화합니다. -- **미디어 실드 (v10.1)**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다. +- **미디어 실드**: 모든 첨부파일은 서버 마스터 키로 암호화되어 저장됩니다. 암호화된 메모의 이미지는 **로그인된 세션**에서만 정밀하게 렌더링을 허용하여 데이터 유출을 원천 차단합니다. ## 🧠 3. Gemini AI 기반 지식 구조화 (AI Insight) @@ -29,20 +29,15 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유 ### 4.1 연결 문법 (`[[#ID]]`) - **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다. - **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다. -27: -32: ## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) - v14.0 -33: -34: ### 5.1 활동 시각화 -35: - **기록 습관 형성**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다. -36: - **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다. -37: - **상태 보존**: 선택한 보기 설정은 `localStorage`에 저장되어 재접속 시에도 유지됩니다. -38: -39: ### 5.2 지능형 히트맵 알고리즘 -40: - **단계별 농도**: 해당 일의 메모 작성 수에 따라 5단계(`lvl-0`~`lvl-4`)의 색상 농도가 적용됩니다. -41: - **프리미엄 그라데이션**: 뇌사료 특유의 Cyan(시안)에서 Purple(보라)로 이어지는 네온 그라데이션 테마를 따릅니다. -42: -43: ## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor) -44: -45: ### 6.1 컬러 텍스트 (Color Syntax) -46: - **시각적 강조**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 본문 중 중요한 지식 키워드를 다양한 색상으로 강조할 수 있습니다. -47: - **지각적 설계**: 다크 모드 환경에서도 가독성이 뛰어난 색상 팔레트를 우선적으로 제공합니다. + +## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap) +- **활동 시각화**: 최근 365일간의 활동량을 GitHub 스타일의 그리드로 시각화하여 지식 축적의 꾸준함을 독려합니다. +- **동적 범위 필터링**: 사용자의 필요에 따라 **1개월 / 3개월 / 6개월 / 1년** 단위를 자유롭게 선택하여 볼 수 있습니다. + +## 🎨 6. 확장된 에디터 스타일링 (Enhanced Editor) +- **컬러 텍스트 (Color Syntax)**: Toast UI Editor의 컬러 신택스 플러그인을 통합하여, 중요 키워드를 다양한 색상으로 강조할 수 있습니다. +- **V5 메타데이터 쉴드**: 정밀한 정규식 엔진을 도입하여 메모 하단의 시스템 메타데이터가 중복되거나 파손되는 것을 방지하고 항상 깔끔한 상태를 유지합니다. + +## ⚙️ 7. 맞춤형 사용자 환경 (v1.5 신규) +- **고급 설정 (Advanced Categories)**: 라이트 유저를 위해 복잡한 카테고리 기능을 숨길 수 있습니다. 설정에서 활성화 시에만 작성기 칩과 사이드바 섹션이 정밀한 레이아웃으로 노출됩니다. +- **글로벌 인텔리전스 (i18n Stabilization)**: 한국어와 영어를 완벽하게 지원하며, 언어 설정을 변경할 경우 히트맵과 달력 등 동적 컴포넌트까지 실시간으로(자동 새로고침) 완벽하게 번역이 적용됩니다. diff --git a/docs/obsidian_plugin_plan.md b/docs/obsidian_plugin_plan.md new file mode 100644 index 0000000..2b83f8d --- /dev/null +++ b/docs/obsidian_plugin_plan.md @@ -0,0 +1,376 @@ +# 뇌사료 ↔ 옵시디언 플러그인 연동 구현 계획 + +> 작성일: 2026-04-16 +> 작업 경로: `c:\project\my_util\memo_server` +> 서버 주소: `your-server-ip:5093` +> 원격 경로: `/home/your-username/Script/memo_server` + +--- + +## 1. 제품 컨셉 (확정) + +``` +뇌사료 단독: 웹 기반 빠른 캡처 메모 + AI 태깅/요약 +뇌사료 + 옵시디언: 뇌사료(INPUT/캡처) → 옵시디언(PROCESS/정리/아카이브/그래프) +``` + +**포지셔닝 핵심:** +- 뇌사료는 옵시디언의 "웹 프론트엔드 + AI 레이어" 역할 +- 옵시디언 사용자의 고질적 불편(로컬 파일 기반 → 모바일/웹 접근 어려움)을 해결 +- 뇌사료 → 옵시디언은 **단방향 동기화** (뇌사료가 Single Source of Truth) +- 옵시디언은 읽기/정리 전용 뷰로 사용 + +--- + +## 2. 현재 아키텍처 현황 + +### 2-1. 서버 구조 (Flask + Blueprint) +``` +app/ +├── __init__.py # create_app(), Blueprint 등록, 보안 미들웨어 +├── constants.py # GROUP_DEFAULT="default", GROUP_FILES="files", GROUP_DONE="done" +├── database.py # SQLite, DB_PATH=data/memos.db +├── auth.py # @login_required 데코레이터 (세션 기반) +├── security.py # Fernet 암호화/복호화 (PBKDF2 + ENCRYPTION_SEED) +├── ai.py # Gemini AI 태깅/요약 +└── routes/ + ├── __init__.py # register_blueprints(app) ← 여기에 새 Blueprint 추가 + ├── memo.py # /api/memos CRUD + ├── file.py # /api/files 업로드/다운로드 + ├── ai.py # /api/ai + ├── auth.py # /login /logout + ├── settings.py # /api/settings + └── main.py # / 메인 페이지 +``` + +### 2-2. DB 스키마 (SQLite: data/memos.db) +```sql +memos( + id, title, content, summary, color, + is_pinned, status, -- status: 'active' | 'done' + group_name, -- 'default' | 'files' | 'done' | 사용자 정의 + is_encrypted, -- 0 | 1 + created_at, updated_at +) +tags(id, memo_id, name, source) -- source: 'user' | 'ai' +attachments(id, memo_id, filename, original_name, file_type, size, created_at) +memo_links(id, source_id, target_id) -- 백링크/전방링크 +``` + +### 2-3. 현재 인증 방식 +- **세션 기반** (`session['logged_in']`) — 브라우저 전용 +- API Token 인증 **없음** → Phase 1에서 추가 필요 + +### 2-4. 암호화 방식 +```python +# app/security.py +# PBKDF2(password + ENCRYPTION_SEED) → Fernet 키 → 본문 암호화 +# .env의 ENCRYPTION_SEED 필수 +encrypt_content(content, password) +decrypt_content(encrypted_data, password) # 실패 시 None 반환 +``` +암호화 메모는 `is_encrypted=1`, 복호화는 `/api/memos/{id}/decrypt` POST로 처리. + +--- + +## 3. 구현 계획 (3단계) + +--- + +### Phase 1. API Token 인증 추가 (뇌사료 서버) + +**목적:** 세션 없이 외부에서 API를 호출할 수 있도록 Token 인증 추가 +**원칙:** 기존 `@login_required` 데코레이터를 건드리지 않고 새 데코레이터 추가 + +#### 1-1. `.env`에 토큰 추가 +```env +# .env (기존 항목 유지, 아래 추가) +OBSIDIAN_API_TOKEN=your_secret_token_here +``` + +#### 1-2. `app/auth.py`에 토큰 인증 데코레이터 추가 +```python +def token_required(view): + """API Token 기반 인증 데코레이터 (Obsidian 플러그인 전용)""" + @functools.wraps(view) + def wrapped_view(**kwargs): + token = request.headers.get('X-API-Token') or request.args.get('token') + expected = os.getenv('OBSIDIAN_API_TOKEN', '') + if not expected or token != expected: + return jsonify({'error': 'Unauthorized'}), 401 + return view(**kwargs) + return wrapped_view +``` + +#### 1-3. 새 Blueprint 파일 생성: `app/routes/sync.py` +```python +# app/routes/sync.py +# 옵시디언 동기화 전용 Blueprint + +sync_bp = Blueprint('sync', __name__) + +# GET /api/sync/export +# 전체 메모 목록 반환 (암호화 메모는 플레이스홀더 처리) +# 쿼리파라미터: since=2024-01-01T00:00:00 (증분 동기화용) + +# POST /api/sync/decrypt/ +# 단일 암호화 메모 복호화 반환 +# 헤더: X-Memo-Password: 비밀번호 + +# GET /api/sync/groups +# 그룹 목록 반환 +``` + +#### 1-4. `app/routes/__init__.py`에 Blueprint 등록 +```python +from .sync import sync_bp +app.register_blueprint(sync_bp) +``` + +**완료 기준:** `curl -H "X-API-Token: xxx" http://서버/api/sync/export` 가 JSON 반환 + +--- + +### Phase 2. Python 동기화 스크립트 (로컬 실행) + +**목적:** 뇌사료 API를 호출해서 옵시디언 Vault에 .md 파일로 저장 +**실행 방식:** Windows Task Scheduler 또는 cron으로 주기 실행 (5~10분) +**위치:** 프로젝트 루트의 `obsidian_sync/` 폴더 + +#### 2-1. 파일 구조 +``` +obsidian_sync/ +├── obsidian_sync.py # 메인 동기화 스크립트 +├── config.json # 설정 파일 +└── last_sync.txt # 마지막 동기화 시각 저장 (증분 동기화용) +``` + +#### 2-2. `obsidian_sync/config.json` +```json +{ + "server_url": "http://your-server-ip:5093", + "api_token": "your_secret_token_here", + "vault_path": "C:/Users/your-username/Documents/ObsidianVault/뇌사료", + "sync_interval_minutes": 10, + "encrypted_memo_handling": "placeholder", + "frontmatter": true, + "group_to_folder": { + "default": "inbox", + "files": "files", + "done": "archive" + } +} +``` + +#### 2-3. 변환 규칙 (뇌사료 → .md) + +| 뇌사료 필드 | 옵시디언 변환 | +|---|---| +| `title` | 파일명 + H1 헤더 | +| `content` | 본문 (HTML → Markdown 변환) | +| `tags` | frontmatter `tags:` + 본문 `#태그` | +| `group_name` | 하위 폴더 분류 | +| `backlinks` | 본문 하단 `[[링크제목]]` | +| `is_encrypted=1` | `[🔒 암호화된 메모 — 뇌사료에서 확인]` | +| `created_at` | frontmatter `date:` | +| `updated_at` | frontmatter `updated:` | +| `summary` | frontmatter `summary:` | + +#### 2-4. 생성될 .md 예시 +```markdown +--- +id: 42 +date: 2026-04-15 +updated: 2026-04-16 +tags: [python, flask, ai] +source: 뇌사료 +group: default +--- + +# 메모 제목 + +본문 내용... + +--- +**Tags:** #python #flask #ai +**Links:** [[관련 메모 제목]] +**Source:** [뇌사료에서 열기](http://your-server-ip:5093) +``` + +**완료 기준:** 스크립트 실행 후 Vault 폴더에 .md 파일 생성 확인 + +--- + +### Phase 3. TypeScript 옵시디언 플러그인 (선택적 고도화) + +**목적:** Phase 2가 안정화된 후, 옵시디언 내에서 직접 UI 제공 +**전제조건:** Phase 1, 2 완료 후 진행 +**개발 언어:** TypeScript (옵시디언 플러그인 필수) + +#### 3-1. 플러그인 저장소 구조 +``` +obsidian-brainsryo-plugin/ # 별도 Git 저장소 권장 +├── src/ +│ ├── main.ts # 플러그인 진입점 +│ ├── api.ts # 뇌사료 API 클라이언트 +│ ├── converter.ts # 뇌사료 JSON → Obsidian .md 변환 +│ ├── settings.ts # 플러그인 설정 UI +│ └── modal.ts # 암호화 메모 비밀번호 입력 모달 +├── manifest.json +├── package.json +└── tsconfig.json +``` + +#### 3-2. 플러그인 설정 항목 (UI) +``` +뇌사료 서버 URL: [http://your-server-ip:5093] +API Token: [***************] +저장 폴더: [뇌사료/] +동기화 주기: [10분] +암호화 메모 처리: [플레이스홀더 ▼] ← 또는 "비밀번호 입력" +자동 동기화: [ON/OFF] +``` + +#### 3-3. 암호화 메모 처리 흐름 (플러그인 버전) +``` +옵시디언에서 암호화 메모 클릭 + → 비밀번호 입력 모달 표시 (Obsidian Modal API) + → POST /api/sync/decrypt/{id} (헤더: X-Memo-Password) + → 복호화 성공 시 임시 .md 생성 후 표시 + → 닫으면 임시 파일 삭제 (디스크에 평문 저장 안 함) +``` + +#### 3-4. 개발 우선순위 +1. 단방향 동기화 (뇌사료 → 옵시디언) 기본 버전 +2. 설정 UI +3. 증분 동기화 (마지막 동기화 이후 변경분만) +4. 암호화 메모 지원 (비밀번호 모달) +5. (미래) 옵시디언 → 뇌사료 import 기능 + +--- + +## 4. 구현 순서 체크리스트 + +### Phase 1 — API Token (뇌사료 서버 수정) +- [ ] `.env`에 `OBSIDIAN_API_TOKEN` 추가 +- [ ] `.env.example`에도 항목 추가 (값 없이) +- [ ] `app/auth.py`에 `token_required` 데코레이터 추가 +- [ ] `app/routes/sync.py` 파일 생성 + - [ ] `GET /api/sync/export` 엔드포인트 (`since` 파라미터 지원) + - [ ] `POST /api/sync/decrypt/` 엔드포인트 + - [ ] `GET /api/sync/groups` 엔드포인트 +- [ ] `app/routes/__init__.py`에 `sync_bp` 등록 +- [ ] 서버 배포 (`python deploy.py` — 사용자 승인 필수) + +### Phase 2 — Python 동기화 스크립트 +- [ ] `obsidian_sync/` 폴더 생성 +- [ ] `obsidian_sync/config.json` 작성 +- [ ] `obsidian_sync/obsidian_sync.py` 작성 + - [ ] API 호출 (Token 인증) + - [ ] HTML → Markdown 변환 (`markdownify` 라이브러리) + - [ ] frontmatter 생성 + - [ ] 태그/백링크 변환 + - [ ] 그룹 → 폴더 분류 + - [ ] 증분 동기화 (`last_sync.txt`) + - [ ] 암호화 메모 플레이스홀더 처리 +- [ ] 스크립트 테스트 (실제 Vault에 파일 생성 확인) +- [ ] Windows Task Scheduler 등록 + +### Phase 3 — TypeScript 옵시디언 플러그인 (나중에) +- [ ] Node.js 개발 환경 세팅 +- [ ] obsidian-sample-plugin 템플릿 클론 +- [ ] API 클라이언트 구현 +- [ ] 설정 UI 구현 +- [ ] 동기화 로직 구현 +- [ ] 암호화 메모 모달 구현 +- [ ] 로컬 설치 테스트 (`.obsidian/plugins/` 복사) + +--- + +## 5. API 스펙 (Phase 1에서 구현할 엔드포인트) + +### `GET /api/sync/export` +``` +Headers: X-API-Token: {OBSIDIAN_API_TOKEN} +Params: + - since (optional): ISO 8601 datetime, 이 시각 이후 수정된 메모만 반환 + - limit (optional): 기본 1000 +Response: +[ + { + "id": 42, + "title": "메모 제목", + "content": "

HTML 본문

", + "summary": "AI 요약", + "tags": [{"name": "python", "source": "ai"}, ...], + "group_name": "default", + "is_encrypted": false, + "is_pinned": false, + "backlinks": [{"source_id": 10, "title": "다른 메모"}], + "links": [{"target_id": 20, "title": "링크된 메모"}], + "created_at": "2026-04-15T10:00:00", + "updated_at": "2026-04-16T08:00:00" + }, + // is_encrypted=true인 경우: + { + "id": 99, + "title": "암호화된 메모", + "content": null, + "is_encrypted": true, + ... + } +] +``` + +### `POST /api/sync/decrypt/` +``` +Headers: + X-API-Token: {OBSIDIAN_API_TOKEN} + X-Memo-Password: {메모_비밀번호} +Response (성공): {"content": "복호화된 본문"} +Response (실패): {"error": "Invalid password"}, 403 +``` + +### `GET /api/sync/groups` +``` +Headers: X-API-Token: {OBSIDIAN_API_TOKEN} +Response: {"groups": ["default", "files", "done", "custom_group1", ...]} +``` + +--- + +## 6. 주의사항 및 설계 원칙 + +> **단방향 원칙:** 뇌사료 → 옵시디언 방향만 동기화. +> 옵시디언에서 .md를 수정해도 뇌사료 DB에는 반영되지 않음. +> 양방향 동기화는 충돌 처리 복잡도가 높아 Phase 3 이후 별도 검토. + +> **암호화 메모 보안:** +> 복호화된 본문은 절대 디스크에 저장하지 않음. +> Phase 2 스크립트는 기본적으로 `encrypted_memo_handling: "placeholder"` 유지. + +> **파일명 규칙:** 옵시디언 파일명 특수문자 금지 (`/ \ : * ? " < > |`) +> 뇌사료 제목의 해당 문자는 `_`로 치환. 중복 시 `제목_id42.md` 형식. + +--- + +## 7. 작업 이어받기 안내 (다른 AI 인스턴스용) + +1. **먼저 읽을 파일들:** + - `app/auth.py` — 현재 인증 구조 확인 + - `app/routes/__init__.py` — Blueprint 등록 방식 확인 + - `app/routes/memo.py` — 기존 API 패턴 참고 + - `app/security.py` — 암호화/복호화 함수 확인 + - `.env` — `OBSIDIAN_API_TOKEN` 추가 여부 확인 + +2. **시작점:** Phase 1 체크리스트 항목 순서대로 진행 + +3. **배포 방법:** 수정 완료 후 `python deploy.py` 실행 + (반드시 사용자 승인 후 실행 — 사용자 규칙) + +4. **테스트:** + ```bash + curl -H "X-API-Token: {토큰}" http://your-server-ip:5093/api/sync/export + ``` + +5. **추가 의존성:** `pip install markdownify` (Phase 2 스크립트에서 필요) diff --git a/docs/user_manual.md b/docs/user_manual.md index 66e551c..9da33b1 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -1,4 +1,4 @@ -# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v5.0+) +# 📔 뇌사료 (Brain Dogfood) 사용자 매뉴얼 (v1.5) '뇌사료' 프로젝트에 오신 것을 환영합니다! 본 매뉴얼은 파편화된 영감을 체계적인 지식 성단(Nebula)으로 구축하는 데 필요한 모든 가이드를 제공합니다. @@ -8,105 +8,56 @@ 메인 전면에 펼쳐진 **지식 네뷸라**는 단순한 목록이 아닌 지식의 유기적인 지도를 보여줍니다. -- **노드(Node)**: 각각의 메모를 상징합니다. - - **크기**: 내용이 많거나 연결이 많을수록 노드가 거대해집니다. - - **색상**: 각 메모에 설정된 고유한 그룹 색상을 따릅니다. - - **🔒 아이콘**: 암호화된 메모임을 나타내며, 제목만 미리보기로 제공됩니다. -- **링크(Link)**: - - **실선**: `[[#ID]]` 문법으로 명시적으로 연결된 관계입니다. - - **인력(Gravity)**: 같은 그룹이나 공통 태그를 가진 메모들은 서로를 끌어당겨 가까이 배치됩니다. +- **노드(Node)**: 각각의 메모를 상징하며, 그룹 색상을 따릅니다. +- **링크(Link)**: `[[#ID]]` 문법으로 명시적으로 연결된 관계를 시선과 인력으로 표현합니다. --- -## 🌡️ 3. 지식 성장 히트맵 (Heatmap) 사용법 +## 🌡️ 2. 지식 성장 히트맵 (Heatmap) 사용법 -사이드바에 위치한 히트맵은 사용자의 기록 강도를 시각적으로 보여줍니다. +사이드바의 히트맵은 최근 1년간의 지식 축적 활동량을 시각적으로 보여줍니다. -- **기간 전환**: 히트맵 상단 제목 옆의 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다. -- **상태 유지**: 한 번 선택한 기간은 브라우저에 저장되어 다음 접속 시에도 그대로 유지됩니다. -- **활동량 확인**: 각 칸에 마우스를 올리면 해당 날짜에 작성된 메모의 개수를 확인할 수 있습니다. 색상이 진해질수록(Cyan -> Purple) 더 많은 지식을 축적했음을 의미합니다. +- **기간 전환**: 상단 드롭다운을 통해 **1개월 / 3개월 / 6개월 / 1년** 단위를 선택할 수 있습니다. +- **언어 연동**: 시스템 언어 설정에 따라 제목과 범례("Less"/"More")가 자동으로 번역됩니다. --- -## ✍️ 4. 메모 작성 및 스타일링 +## ✍️ 3. 메모 작성 및 스타일링 -### 4.1 지식 연결 문법 (`[[#ID]]`) +### 3.1 지식 연결 문법 (`[[#ID]]`) 메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요. -> 예: "이 개념은 `[[#12]]`에서 다룬 내용과 상충됩니다." - **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다. -### 4.2 컬러 텍스트 (Color Syntax) -에디터 상단 툴바의 **색상 선택 아이콘**을 사용하여 텍스트에 색상을 입힐 수 있습니다. 중요 키워드를 강조하여 지식의 가독성을 높이세요. +### 3.2 컬러 텍스트 (Color Syntax) +에디터 툴바의 색상 아이콘으로 지식의 가독성을 높입니다. -### 4.3 개별 메모 암호화 -중요한 개인 정보나 아이디어는 암호화하여 보호할 수 있습니다. -- **사용법**: 편집기 하단의 **[암호화 사용]** 체크 -> 비밀번호 설정. -- **특약**: 암호화된 메모는 서버 측에서도 해독이 불가능하며, 비밀번호 분실 시 복구가 절대 불가능하므로 주의하세요. -- **복호화**: 작성된 암호화 메모 옆의 **🔓 해독** 버튼을 눌러 비밀번호 입력 시 일시적으로 내용을 확인할 수 있습니다. +### 3.3 V5 메타데이터 쉴드 +시스템이 생성하는 하단 메타데이터는 자동으로 관리되므로 본문 작성에만 집중하면 됩니다. --- ---- +## ⚙️ 4. 설정 및 커스터마이징 (v1.5 신규) -## 🧠 5. AI 인텔리전스 (AI Insights) +우측 상단의 **[⚙️ 설정]** 버튼을 통해 나만의 지식 환경을 구축할 수 있습니다. -### 5.1 AI 활성화 및 API 키 설정 (초보자 가이드) -'뇌사료'의 지능형 기능을 사용하려면 Google의 Gemini API 키가 필요합니다. 다음 단계에 따라 **1분 만에 무료로** 설정을 마칠 수 있습니다. +### 4.1 고급 설정 (카테고리 활성화) +- **라이트 모드 (기본)**: 카테고리 기능이 숨겨져 있어 태그와 그룹만으로 심플하게 기록할 수 있습니다. +- **고급 모드**: 설정에서 **"카테고리 기능(고급) 활성화"**를 체크하면, 작성기 하단에 카테고리 선택 칩이 나타나며 사이드바에서도 카테고리별 분류가 활성화됩니다. -1. **키 발급**: [Google AI Studio (https://aistudio.google.com/app/apikey)](https://aistudio.google.com/app/apikey)에 접속합니다. -2. **프로젝트 생성**: "Create API key in new project" 버튼을 누릅니다. -3. **키 복사**: 생성된 `AIza...`로 시작하는 긴 문자열을 복사합니다. -4. **서버 적용**: 본 프로젝트 폴더의 `.env` 파일을 열고 `GEMINI_API_KEY=` 뒤에 복사한 키를 붙여넣고 저장합니다. -5. **활성화**: 앱 우상단 **[⚙️ 설정]** -> **AI 기능 활성화** 체크박스를 켜고 저장합니다. - -> [!TIP] -> - API 키 발급은 완전히 무료이며, 개인적인 용도로는 충분한 할당량이 제공됩니다. -> - 키가 없더라도 메모 작성 및 시각화 등 기본적인 기능은 "NO AI" 모드로 완벽하게 작동합니다. - -### 5.2 주요 기능 -- **자동 요약**: 방대한 내용을 AI가 1~2문장의 핵심 문장으로 압축해줍니다. -- **스마트 태그**: 본문을 분석하여 자동으로 추천 태그를 생성합니다. -- **추론형 배치**: AI가 생성한 태그를 기반으로 지식 네뷸라 상에서 비슷한 맥락의 메모들이 자동으로 성단을 형성합니다. +### 4.2 다국어 설정 (Language) +- **한국어 / English**: 선호하는 언어를 선택하고 [Save]를 누르면 즉시 전체 UI와 달력, 히트맵 등의 도구가 해당 언어로 재초기화됩니다. --- ---- - -## ⌨️ 6. 단축키 및 작업 효율 (Shortcuts) - -'뇌사료'는 마우스 없이도 거의 모든 작업을 수행할 수 있도록 강력한 **Ctrl 기반** 단축키를 지원합니다. - -### 6.1 전역 내비게이션 -- **`Alt + `** (Backtick): ⚡ **Quake 스타일 새 메모** (영감을 즉시 기록) -- **`Ctrl + Shift + N`**: 새 메모 작성기 열기 📝 -- **`Ctrl + Shift + G`**: 지식 네뷸라(시각화) 열기 🕸️ -- **`Ctrl + Shift + E`**: 지식 탐색기(사이드바) 열기 🔍 -- **`Ctrl + Shift + C`**: 사이드바 캘린더 토글 📅 -- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 모든 모달 및 드로어 닫기 - -### 6.2 에디터 작업 -- **`Ctrl + Enter`** 또는 **`Ctrl + S`**: **현재 메모 저장 및 게시** 💾 -- **`/` (Slash)**: 슬래시 명령 오픈 (AI 요약, 서식 등) 🪄 -- **`Shift + ESC`**: 작성 취소 및 닫기 - -### 6.3 마우스 팁 -- **`Alt + 클릭`**: 메인 그리드에서 메모를 즉시 수정 ✏️ +## 🔒 5. 보안 및 백업 +- **개별 암호화**: 편집기 하단 [암호화 사용]을 통해 메모를 안전하게 잠글 수 있습니다. +- **미디어 보안**: 모든 첨부파일은 서버 측 암호화를 통해 세션이 유효한 사용자에게만 안전하게 제공됩니다. --- -## 🚀 7. 운영 및 관리 (Ops & Backup) - -### 7.1 정밀 배포 (`deploy.py`) -개발 환경에서 작업한 코드를 서버로 안전하게 배포합니다. -```bash -python deploy.py -``` - -### 7.2 재난 복구 백업 (`backup.py`) -서버의 모든 핵심 데이터를 압축하여 안전하게 보관합니다. -```bash -python backup.py -``` +## 🚀 6. 운영 및 관리 (Ops) +- **`deploy.py`**: 개발 환경의 코드를 서버로 안전하게 배포합니다. +- **`backup.py`**: 서버의 핵심 데이터를 정기적으로 백업합니다. --- diff --git a/docs/v2_mobile_roadmap.md b/docs/v2_mobile_roadmap.md deleted file mode 100644 index 7ae256a..0000000 --- a/docs/v2_mobile_roadmap.md +++ /dev/null @@ -1,49 +0,0 @@ -# 📱 v2.0 모바일 최적화 로드맵 (Roadmap) - -본 문서는 `뇌사료` 프로젝트 v2.0에서 다룰 모바일 해상도 지원 및 UX 최적화 범위에 대한 정밀 분석 내역을 담고 있습니다. - -## 1. 레이아웃 및 응답성 (Responsive Layout) - -### 1.1 오프캔버스 사이드바 (Off-canvas Sidebar) -- **현황**: 사이드바가 가로 공간을 상시 점유하여 모바일에서 가독성 저해. -- **v2.0 목표**: 768px 이하에서 사이드바를 기본적으로 숨기고, 상단 햄버거 메뉴 버튼 클릭 시 왼쪽에서 슬라이드되는 드로어(Drawer) 방식으로 전환. -- **기술적 구현**: `transform: translateX(-100%)` 및 `z-index`를 활용한 오버레이 처리. - -### 1.2 가변형 메모 그리드 (Fluid Masonry Grid) -- **현황**: 열 개수가 고정되거나 모바일에서 너무 좁게 보임. -- **v2.0 목표**: 모바일 해상도에서 메모 카드를 1열(100%)로 강제하여 텍스트 가독성 극대화. 패딩 값을 `3rem`에서 `1rem`으로 축소. - ---- - -## 2. 입력 및 작성 경험 (Composer UX) - -### 2.1 인터페이스 수직 스태킹 (Vertical Stacking) -- **현황**: 제목, 그룹, 태그 입력창이 가로로 배치되어 모바일에서 잘림. -- **v2.0 목표**: `flex-direction: column`을 적용하여 제목 -> 메타 정보(그룹/태그) -> 에디터 순서로 자연스럽게 흐르도록 재배치. - -### 2.2 모바일 에디터 최적화 -- **v2.0 목표**: Toast UI 에디터의 불필요한 툴바를 숨기고, 모바일 자판이 올라와도 입력 영역이 충분히 확보되도록 높이 자동 조절 기능 추가. - ---- - -## 3. 터치 및 제스처 (Touch & Gestures) - -### 3.1 터치 타겟(Touch Target) 확장 -- **목표**: 모든 버튼 및 상호작용 요소의 크기를 최소 44x44px 이상으로 확보하여 오클릭 방지. -- **세부 사항**: 삭제, 수정, 암호화 토글 버튼의 여백 조정. - -### 3.2 제스처 지원 -- **목표**: 화면 왼쪽 가장자리를 스와이프하여 사이드바를 여는 제스처 로직 검토. - ---- - -## 4. 기술적 체크리스트 (Technical Checklist) - -- [ ] `@media (max-width: 768px)` 기준점 확립. -- [ ] 터치 이벤트(`touchstart`, `touchend`) 처리 최적화. -- [ ] `safe-area-inset-bottom` 등 최신 모바일 기기의 노치 및 하단 바 대응. -- [ ] 모바일 데이터 환경을 고려한 이미지 지연 로딩(Lazy Loading) 적용. -- [ ] 모달 창의 `width: 100%; height: 100%;` 전체 화면 모드 지원. - ---- -*Generated for Brain Dogfood v2.0 Development* diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..b015744 --- /dev/null +++ b/run.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# --- 🧠 뇌사료 서버 관리 스크립트 --- + +APP_NAME="brain.py" +PID_FILE="server.pid" +LOG_FILE="logs/console.log" + +# 로그 디렉토리 생성 확인 +mkdir -p logs + +start() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + if ps -p $PID > /dev/null; then + echo "⚠️ 서버가 이미 실행 중입니다. (PID: $PID)" + return + fi + fi + + echo "🚀 서버를 백그라운드에서 시작합니다... (Port: 5093)" + # nohup으로 실행하여 세션 종료 후에도 유지 + nohup python3 $APP_NAME > $LOG_FILE 2>&1 & + + # PID 저장 + echo $! > $PID_FILE + echo "✅ 서버 기동 완료! (PID: $!)" + echo "📝 콘솔 로그: $LOG_FILE" +} + +stop() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + echo "🛑 서버를 중지하는 중... (PID: $PID)" + kill $PID + rm $PID_FILE + echo "✅ 서버가 중지되었습니다." + else + echo "⚠️ 실행 중인 서버의 PID 파일을 찾을 수 없습니다." + fi +} + +status() { + if [ -f $PID_FILE ]; then + PID=$(cat $PID_FILE) + if ps -p $PID > /dev/null; then + echo "🟢 서버가 현재 실행 중입니다. (PID: $PID)" + else + echo "🔴 PID 파일은 있으나 프로세스가 존재하지 않습니다." + fi + else + echo "⚪ 서버가 중지 상태입니다." + fi +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + sleep 2 + start + ;; + status) + status + ;; + *) + echo "사용법: $0 {start|stop|restart|status}" + exit 1 +esac + +exit 0 diff --git a/static/app.js b/static/app.js index 6e12720..1c9eb8e 100644 --- a/static/app.js +++ b/static/app.js @@ -10,6 +10,7 @@ import { CalendarManager } from './js/components/CalendarManager.js'; import { Visualizer } from './js/components/Visualizer.js'; import { HeatmapManager } from './js/components/HeatmapManager.js'; import { DrawerManager } from './js/components/DrawerManager.js'; +import { CategoryManager } from './js/components/CategoryManager.js'; import { ModalManager } from './js/components/ModalManager.js'; import { I18nManager } from './js/utils/I18nManager.js'; import { Constants } from './js/utils/Constants.js'; @@ -27,17 +28,20 @@ document.addEventListener('DOMContentLoaded', async () => { AppService.setFilter({ date }, updateSidebarCallback); }); DrawerManager.init(); + CategoryManager.init(() => AppService.refreshData(updateSidebarCallback)); Visualizer.init('graphContainer'); UI.initSidebarToggle(); // --- 🔹 Callbacks --- - const updateSidebarCallback = (memos, activeGroup) => { - UI.updateSidebar(memos, activeGroup, (newFilter) => { + const updateSidebarCallback = (memos, activeGroup, activeCategory) => { + UI.updateSidebar(memos, activeGroup, activeCategory, (newFilter) => { if (newFilter === Constants.GROUPS.FILES) { ModalManager.openAssetLibrary((id, ms) => UI.openMemoModal(id, ms)); } else { AppService.setFilter({ group: newFilter }, updateSidebarCallback); } + }, (newCat) => { + AppService.setFilter({ category: newCat }, updateSidebarCallback); }); }; @@ -138,6 +142,13 @@ document.addEventListener('DOMContentLoaded', async () => { }); }; + // --- 🔹 Category Management --- + document.getElementById('manageCategoryBtn').onclick = () => { + CategoryManager.open(); + }; + + // --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) --- + // --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) --- document.addEventListener('keydown', (e) => { const isCtrl = e.ctrlKey || e.metaKey; @@ -189,6 +200,16 @@ document.addEventListener('DOMContentLoaded', async () => { if (isAlt && key === '`') { e.preventDefault(); ComposerManager.openEmpty(); + return; + } + + // 5. Category Slots: Alt + 1~4 + if (isAlt && (key >= '1' && key <= '4')) { + if (ComposerManager.DOM.composer.style.display === 'block') { + e.preventDefault(); + const slotIndex = parseInt(key) - 1; // 1->0 (Done), 2->1 (Cat1)... + ComposerManager.toggleCategoryBySlot(slotIndex); + } } }); diff --git a/static/css/layout.css b/static/css/layout.css index cfb4c4a..8a6fa42 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -105,3 +105,155 @@ .action-btn:hover { background: rgba(184, 59, 94, 0.8); } .ai-btn:hover { background: var(--ai-accent); color: white; } + +/* Memo Footer Metadata Styling */ +.memo-metadata-footer { + margin-top: 20px; + padding-top: 15px; + border-top: 1px dashed rgba(255, 255, 255, 0.1); + color: var(--muted); + font-size: 0.85rem; + opacity: 0.7; +} + +.memo-metadata-footer p { + margin: 5px 0; +} + +/* === Composer Category Chips === */ +#composerCategoryBar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + flex-wrap: wrap; +} + +.cat-chip { + padding: 6px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + user-select: none; +} + +.cat-chip:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--accent-light); + transform: translateY(-1px); + color: white; +} + +.cat-chip.active { + background: var(--accent-light); + color: white; + border-color: var(--accent-light); + box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3); + font-weight: 600; +} + +.cat-chip.done-chip.active { + background: #10b981; /* Success Green */ + border-color: #10b981; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +/* 💡 외부 카테고리 강조칩 (핀에 없지만 지정된 경우) */ +.cat-chip.external-active { + border: 1px dashed #ff4d4d !important; + background: rgba(255, 77, 77, 0.05); + color: #ff4d4d !important; +} + +.cat-chip.external-active:hover { + background: rgba(255, 77, 77, 0.12); +} + +.cat-chip kbd { + font-size: 10px; + opacity: 0.6; + margin-left: 4px; + background: rgba(0, 0, 0, 0.2); + padding: 1px 4px; + border-radius: 3px; +} + +/* Category Management List */ +.cat-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + margin-bottom: 6px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.cat-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); +} + +.cat-item .cat-name { + flex: 1; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); +} + +.cat-actions { + display: flex; + gap: 15px; +} + +.cat-action-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: 1.1rem; + padding: 2px; + opacity: 0.4; + transition: all 0.2s; +} + +.cat-action-btn:hover { + opacity: 1; + transform: scale(1.2); +} + +.cat-action-btn.pin.active { + opacity: 1; + color: var(--accent); + filter: drop-shadow(0 0 5px var(--accent)); +} + +.cat-action-btn.delete:hover { + color: #f87171; +} + +/* Custom Scrollbar for Category List */ +#categoryListContainer::-webkit-scrollbar { + width: 6px; +} +#categoryListContainer::-webkit-scrollbar-track { + background: transparent; +} +#categoryListContainer::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; +} +#categoryListContainer::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} diff --git a/static/js/AppService.js b/static/js/AppService.js index fdc2d1f..9014eb5 100644 --- a/static/js/AppService.js +++ b/static/js/AppService.js @@ -10,6 +10,7 @@ export const AppService = { state: { memosCache: [], currentFilterGroup: 'all', + currentFilterCategory: null, // NEW: 카테고리 필터 currentFilterDate: null, currentSearchQuery: '', offset: 0, @@ -42,11 +43,11 @@ export const AppService = { if (this.state.isLoading || !this.state.hasMore) return; this.state.isLoading = true; - // UI.showLoading(true)는 호출부에서 관리하거나 여기서 직접 호출 가능 try { const filters = { group: this.state.currentFilterGroup, + category: this.state.currentFilterCategory, // NEW date: this.state.currentFilterDate, query: this.state.currentSearchQuery, offset: this.state.offset, @@ -68,13 +69,10 @@ export const AppService = { this.state.offset += newMemos.length; - // 캘린더 점 표시는 첫 로드 시에면 하면 부족할 수 있으므로, - // 필요 시 전체 데이터를 새로 고침하는 별도 API가 필요할 수 있음. - // 여기서는 현재 캐시된 데이터 기반으로 업데이트. CalendarManager.updateMemoDates(this.state.memosCache); if (onUpdateSidebar) { - onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup); + onUpdateSidebar(this.state.memosCache, this.state.currentFilterGroup, this.state.currentFilterCategory); } UI.setHasMore(this.state.hasMore); @@ -90,17 +88,34 @@ export const AppService = { /** * 필터 상태를 변경하고 데이터 초기화 후 다시 로딩 */ - async setFilter({ group, date, query }, onUpdateSidebar) { + async setFilter({ group, category, date, query }, onUpdateSidebar) { let changed = false; - if (group !== undefined && this.state.currentFilterGroup !== group) { - this.state.currentFilterGroup = group; + + // 1. 그룹 선택 처리 + if (group !== undefined) { + // 그룹이 바뀌거나, 혹은 카테고리가 켜져있는 상태에서 그룹을 누르면 카테고리 해제 + if (this.state.currentFilterGroup !== group || this.state.currentFilterCategory !== null) { + this.state.currentFilterGroup = group; + this.state.currentFilterCategory = null; + changed = true; + } + } + + // 2. 카테고리 선택 처리 + if (category !== undefined) { + if (this.state.currentFilterCategory === category) { + // 이미 선택된 카테고리 재클릭 시 해제 (Toggle) + this.state.currentFilterCategory = null; + } else { + this.state.currentFilterCategory = category; + } + this.state.currentFilterGroup = 'all'; // 카테고리 필터 적용/변경 시 그룹 초기화 changed = true; } if (date !== undefined && this.state.currentFilterDate !== date) { this.state.currentFilterDate = date; changed = true; - // UI 동기화 CalendarManager.setSelectedDate(date); if (HeatmapManager.setSelectedDate) { HeatmapManager.setSelectedDate(date); diff --git a/static/js/api.js b/static/js/api.js index 3c21ae6..8ffed93 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -18,12 +18,22 @@ export const API = { async fetchMemos(filters = {}) { const { limit = 20, offset = 0, group = 'all', query = '' } = filters; - const date = filters.date || ''; // null이나 undefined를 빈 문자열로 변환 - const params = new URLSearchParams({ limit, offset, group, query, date }); + const date = filters.date || ''; + const category = (filters.category === null || filters.category === undefined) ? '' : filters.category; + + const params = new URLSearchParams({ + limit, + offset, + group, + query, + category, + date, + _t: Date.now() // 브라우저 캐시 방지용 타임스탬프 + }); return await this.request(`/api/memos?${params.toString()}`); }, async fetchHeatmapData(days = 365) { - return await this.request(`/api/stats/heatmap?days=${days}`); + return await this.request(`/api/stats/heatmap?days=${days}&_t=${Date.now()}`); }, async saveMemo(payload, id = null) { @@ -52,7 +62,7 @@ export const API = { }, async fetchAssets() { - return await this.request('/api/assets'); + return await this.request(`/api/assets?_t=${Date.now()}`); }, async uploadFile(file) { diff --git a/static/js/components/CategoryManager.js b/static/js/components/CategoryManager.js new file mode 100644 index 0000000..b83ce83 --- /dev/null +++ b/static/js/components/CategoryManager.js @@ -0,0 +1,144 @@ +/** + * 카테고리 관리 모달 (Category Management Modal) + */ +import { API } from '../api.js'; +import { I18nManager } from '../utils/I18nManager.js'; +import { ThemeManager } from './ThemeManager.js'; + +export const CategoryManager = { + DOM: {}, + onUpdateCallback: null, + + init(onUpdateCallback) { + this.onUpdateCallback = onUpdateCallback; + this.DOM = { + modal: document.getElementById('categoryModal'), + closeBtn: document.getElementById('closeCategoryBtn'), + container: document.getElementById('categoryListContainer'), + input: document.getElementById('newCategoryInput'), + addBtn: document.getElementById('addCategoryBtn') + }; + + if (!this.DOM.modal) return; + + this.DOM.closeBtn.onclick = () => this.close(); + this.DOM.addBtn.onclick = () => this.handleAdd(); + this.DOM.input.onkeydown = (e) => { if (e.key === 'Enter') this.handleAdd(); }; + + window.addEventListener('click', (e) => { + if (e.target === this.DOM.modal) this.close(); + }); + }, + + open() { + this.render(); + this.DOM.modal.classList.add('active'); + this.DOM.input.focus(); + }, + + close() { + this.DOM.modal.classList.remove('active'); + }, + + async render() { + const settings = ThemeManager.settings || {}; + const categories = settings.categories || []; + const pinned = settings.pinned_categories || []; + + this.DOM.container.innerHTML = ''; + + categories.forEach(cat => { + const isPinned = pinned.includes(cat); + const item = document.createElement('div'); + item.className = 'cat-item'; + + item.innerHTML = ` +
${cat}
+
+ + +
+ `; + + // 핀 토글 + item.querySelector('.pin').onclick = () => this.togglePin(cat); + + // 삭제 + item.querySelector('.delete').onclick = () => this.deleteCategory(cat); + + this.DOM.container.appendChild(item); + }); + + if (categories.length === 0) { + this.DOM.container.innerHTML = `

${I18nManager.t('label_no_category')}

`; + } + }, + + async handleAdd() { + const name = this.DOM.input.value.trim(); + if (!name) return; + if (name.length > 20) { + alert("Name too long (max 20)"); + return; + } + + const settings = { ...ThemeManager.settings }; + if (settings.categories.includes(name)) { + alert("Already exists"); + return; + } + + settings.categories.push(name); + // 공간이 있으면 자동 핀 고정 + if (settings.pinned_categories.length < 3) { + settings.pinned_categories.push(name); + } + + await this.save(settings); + this.DOM.input.value = ''; + this.render(); + }, + + async togglePin(cat) { + const settings = { ...ThemeManager.settings }; + const idx = settings.pinned_categories.indexOf(cat); + + if (idx > -1) { + settings.pinned_categories.splice(idx, 1); + } else { + if (settings.pinned_categories.length >= 3) { + alert(I18nManager.t('msg_category_limit')); + return; + } + settings.pinned_categories.push(cat); + } + + await this.save(settings); + this.render(); + }, + + async deleteCategory(cat) { + if (!confirm(I18nManager.t('msg_confirm_delete_category'))) return; + + const settings = { ...ThemeManager.settings }; + settings.categories = settings.categories.filter(c => c !== cat); + settings.pinned_categories = settings.pinned_categories.filter(c => c !== cat); + + await this.save(settings); + this.render(); + }, + + async save(settings) { + try { + await API.saveSettings(settings); + // 전역 세팅 업데이트 및 UI 리프레시 + ThemeManager.settings = settings; + if (window.UI) window.UI._updateSettingsCache(settings); + if (this.onUpdateCallback) this.onUpdateCallback(); + } catch (err) { + alert("Save failed: " + err.message); + } + } +}; diff --git a/static/js/components/ComposerManager.js b/static/js/components/ComposerManager.js index 70f2802..b89f92a 100644 --- a/static/js/components/ComposerManager.js +++ b/static/js/components/ComposerManager.js @@ -5,12 +5,17 @@ import { API } from '../api.js'; import { EditorManager } from '../editor.js'; import { I18nManager } from '../utils/I18nManager.js'; import { Constants } from '../utils/Constants.js'; +import { AppService } from '../AppService.js'; +import { ThemeManager } from './ThemeManager.js'; + +// --- NEW 서브 모듈 임포트 --- +import { ComposerDraft } from './composer/ComposerDraft.js'; +import { ComposerCategoryUI } from './composer/ComposerCategoryUI.js'; export const ComposerManager = { DOM: {}, init(onSaveSuccess) { - // 타이밍 이슈 방지를 위해 DOM 요소 지연 할당 this.DOM = { trigger: document.getElementById('composerTrigger'), composer: document.getElementById('composer'), @@ -21,11 +26,15 @@ export const ComposerManager = { encryptionToggle: document.getElementById('encryptionToggle'), password: document.getElementById('memoPassword'), foldBtn: document.getElementById('foldBtn'), - discardBtn: document.getElementById('discardBtn') + discardBtn: document.getElementById('discardBtn'), + categoryBar: document.getElementById('composerCategoryBar') }; if (!this.DOM.composer || !this.DOM.trigger) return; + this.selectedCategory = null; + this.isDoneStatus = false; + // 1. 이벤트 바인딩 this.DOM.trigger.onclick = () => this.openEmpty(); this.DOM.foldBtn.onclick = () => this.close(); @@ -44,28 +53,41 @@ export const ComposerManager = { }; this.DOM.encryptionToggle.onclick = () => this.toggleEncryption(); - - // 단축키 힌트 토글 바인딩 - const shortcutToggle = document.getElementById('shortcutToggle'); - const shortcutDetails = document.getElementById('shortcutDetails'); - if (shortcutToggle && shortcutDetails) { - shortcutToggle.onclick = () => { - const isVisible = shortcutDetails.style.display !== 'none'; - shortcutDetails.style.display = isVisible ? 'none' : 'flex'; - const label = I18nManager.t('shortcuts_label'); - shortcutToggle.textContent = isVisible ? label : `${label} ▲`; + this.initShortcutHint(); + + // 2. 자동 임시저장 및 키보드 리스너 등록 + this.draftTimer = setInterval(() => this.saveDraft(), 3000); + ComposerDraft.checkRestore((draft) => this.restoreDraft(draft)); + document.addEventListener('keydown', (e) => this.handleKeyDown(e)); + }, + + initShortcutHint() { + const toggle = document.getElementById('shortcutToggle'); + const details = document.getElementById('shortcutDetails'); + if (toggle && details) { + toggle.onclick = () => { + const isVisible = details.style.display !== 'none'; + details.style.display = isVisible ? 'none' : 'flex'; + toggle.textContent = isVisible ? I18nManager.t('shortcuts_label') : `${I18nManager.t('shortcuts_label')} ▲`; }; } - - // --- 자동 임시저장 (Auto-Draft) --- - this.draftTimer = setInterval(() => this.saveDraft(), 3000); - this.checkDraftRestore(); }, openEmpty() { this.clear(); + + // 컨텍스트 기반 그룹 자동 설정 (all, done, tag 제외) + const currentGroup = AppService.state.currentFilterGroup; + if (currentGroup && + currentGroup !== 'all' && + currentGroup !== Constants.GROUPS.DONE && + !currentGroup.startsWith('tag:')) { + this.DOM.group.value = currentGroup; + } + this.DOM.composer.style.display = 'block'; this.DOM.trigger.style.display = 'none'; + this.renderCategoryChips(); // 💡 초기화 후 칩 렌더링 this.DOM.title.focus(); }, @@ -77,6 +99,10 @@ export const ComposerManager = { this.DOM.group.value = memo.group_name || Constants.GROUPS.DEFAULT; this.DOM.tags.value = (memo.tags || []).filter(t => t.source === 'user').map(t => t.name).join(', '); + // 💡 분류 및 상태 복원 + this.selectedCategory = memo.category || null; + this.isDoneStatus = memo.status === 'done'; + EditorManager.setMarkdown(memo.content || ''); EditorManager.setAttachedFiles(memo.attachments || []); @@ -86,6 +112,7 @@ export const ComposerManager = { this.DOM.composer.style.display = 'block'; this.DOM.trigger.style.display = 'none'; + this.renderCategoryChips(); // 💡 렌더링 window.scrollTo({ top: 0, behavior: 'smooth' }); }, @@ -94,6 +121,8 @@ export const ComposerManager = { title: this.DOM.title.value.trim(), content: EditorManager.getMarkdown(), group_name: this.DOM.group.value.trim() || Constants.GROUPS.DEFAULT, + category: this.selectedCategory, + status: this.isDoneStatus ? 'done' : 'active', tags: this.DOM.tags.value.split(',').map(t => t.trim()).filter(t => t), is_encrypted: this.DOM.encryptionToggle.dataset.locked === 'true', password: this.DOM.password.value.trim(), @@ -106,7 +135,7 @@ export const ComposerManager = { try { await API.saveMemo(data, this.DOM.id.value); EditorManager.sessionFiles.clear(); - this.clearDraft(); + ComposerDraft.clear(); // 💡 서브 모듈 위임 if (callback) await callback(); this.clear(); this.close(); @@ -123,9 +152,12 @@ export const ComposerManager = { this.DOM.title.value = ''; this.DOM.group.value = Constants.GROUPS.DEFAULT; this.DOM.tags.value = ''; + this.selectedCategory = null; + this.isDoneStatus = false; EditorManager.setMarkdown(''); EditorManager.setAttachedFiles([]); this.setLocked(false); + this.renderCategoryChips(); }, toggleEncryption() { @@ -137,89 +169,70 @@ export const ComposerManager = { this.DOM.encryptionToggle.dataset.locked = locked; this.DOM.encryptionToggle.innerText = locked ? '🔒' : '🔓'; this.DOM.password.style.display = locked ? 'block' : 'none'; - - // 비밀번호가 명시적으로 전달된 경우에만 업데이트 (해제 시 기존 비번 유지) - if (password !== null) { - this.DOM.password.value = password; - } - - if (locked && !this.DOM.password.value) { - this.DOM.password.focus(); - } + if (password !== null) this.DOM.password.value = password; + if (locked && !this.DOM.password.value) this.DOM.password.focus(); }, - // === 자동 임시저장 (Auto-Draft) === + // --- 서브 모듈 위임 메서드들 --- - /** - * 현재 에디터 내용을 localStorage에 자동 저장 - */ saveDraft() { - // 컴포저가 닫혀있으면 저장하지 않음 if (this.DOM.composer.style.display !== 'block') return; - - const title = this.DOM.title.value; - const content = EditorManager.getMarkdown(); - - // 내용이 비어있으면 저장하지 않음 - if (!title && !content) return; - - const draft = { - title: title, - content: content, - group: this.DOM.group.value, - tags: this.DOM.tags.value, - editingId: this.DOM.id.value, - timestamp: Date.now() - }; - localStorage.setItem('memo_draft', JSON.stringify(draft)); + ComposerDraft.save( + this.DOM.id.value, + this.DOM.title.value, + this.DOM.group.value, + this.DOM.tags.value, + EditorManager.getMarkdown() + ); }, - /** - * 페이지 로드 시 임시저장된 내용이 있으면 복원 확인 - */ - checkDraftRestore() { - const raw = localStorage.getItem('memo_draft'); - if (!raw) return; + restoreDraft(draft) { + this.openEmpty(); + this.DOM.title.value = draft.title || ''; + this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT; + this.DOM.tags.value = draft.tags || ''; + if (draft.editingId) this.DOM.id.value = draft.editingId; + EditorManager.setMarkdown(draft.content || ''); + }, - try { - const draft = JSON.parse(raw); - - // 24시간 이상 된 임시저장은 자동 삭제 - if (Date.now() - draft.timestamp > 86400000) { - this.clearDraft(); - return; + renderCategoryChips() { + ComposerCategoryUI.render( + this.DOM.categoryBar, + this.selectedCategory, + this.isDoneStatus, + { + onSelect: (cat) => { + this.selectedCategory = (this.selectedCategory === cat) ? null : cat; + this.renderCategoryChips(); + }, + onToggleDone: () => { + this.isDoneStatus = !this.isDoneStatus; + this.renderCategoryChips(); + } } + ); + }, - // 내용이 실제로 있는 경우에만 복원 확인 - if (!draft.title && !draft.content) { - this.clearDraft(); - return; - } + handleKeyDown(e) { + if (this.DOM.composer.style.display !== 'block') return; + if (!e.altKey) return; - const titlePreview = draft.title || I18nManager.t('label_untitled'); - const confirmMsg = I18nManager.t('msg_draft_restore_confirm') - .replace('{title}', titlePreview); - - if (confirm(confirmMsg)) { - this.openEmpty(); - this.DOM.title.value = draft.title || ''; - this.DOM.group.value = draft.group || Constants.GROUPS.DEFAULT; - this.DOM.tags.value = draft.tags || ''; - if (draft.editingId) this.DOM.id.value = draft.editingId; - EditorManager.setMarkdown(draft.content || ''); - } else { - this.clearDraft(); + const key = e.key; + if (key === '1') { + e.preventDefault(); + this.isDoneStatus = !this.isDoneStatus; + this.renderCategoryChips(); + } else if (key === '2' || key === '3' || key === '4') { + e.preventDefault(); + const cat = ComposerCategoryUI.getCategoryBySlot(parseInt(key) - 1); + if (cat) { + this.selectedCategory = (this.selectedCategory === cat) ? null : cat; + this.renderCategoryChips(); } - } catch (e) { - console.warn('[Draft] Failed to parse draft, deleting:', e); - this.clearDraft(); + } else if (key === '5') { + e.preventDefault(); + this.selectedCategory = null; + this.renderCategoryChips(); } - }, - - /** - * 임시저장 데이터 삭제 - */ - clearDraft() { - localStorage.removeItem('memo_draft'); } }; diff --git a/static/js/components/MemoCard.js b/static/js/components/MemoCard.js index 67e0724..9f3ab07 100644 --- a/static/js/components/MemoCard.js +++ b/static/js/components/MemoCard.js @@ -36,8 +36,13 @@ export function createMemoCardHtml(memo, isDone) { `; } else { + // 본문에서 하단 메타데이터 블록(--- 이후)을 제외하고 렌더링 (중복 표시 방지) + let content = memo.content || ''; + const footerIndex = content.lastIndexOf('\n\n---\n'); + const displayContent = footerIndex !== -1 ? content.substring(0, footerIndex) : content; + // marked로 파싱한 후 DOMPurify로 살균하여 XSS 방지 - htmlContent = DOMPurify.sanitize(marked.parse(memo.content || '')); + htmlContent = DOMPurify.sanitize(marked.parse(displayContent)); htmlContent = parseInternalLinks(htmlContent); htmlContent = fixImagePaths(htmlContent); } diff --git a/static/js/components/ModalManager.js b/static/js/components/ModalManager.js index 2877692..e337077 100644 --- a/static/js/components/ModalManager.js +++ b/static/js/components/ModalManager.js @@ -165,8 +165,21 @@ export const ModalManager = { if (!memo) return; import('../utils.js').then(({ parseInternalLinks, fixImagePaths }) => { - // 마크다운 파싱 후 살균 처리 (marked, DOMPurify는 global 사용) - let html = DOMPurify.sanitize(marked.parse(memo.content)); + // 메모 본문과 메타데이터 푸터 분리 렌더링 + let content = memo.content || ''; + const footerIndex = content.lastIndexOf('\n\n---\n'); + let html; + + if (footerIndex !== -1) { + const mainBody = content.substring(0, footerIndex); + const footerPart = content.substring(footerIndex + 5).trim(); // '---' 이후 + + html = DOMPurify.sanitize(marked.parse(mainBody)); + html += ``; + } else { + html = DOMPurify.sanitize(marked.parse(content)); + } + html = parseInternalLinks(html); html = fixImagePaths(html); diff --git a/static/js/components/SidebarUI.js b/static/js/components/SidebarUI.js index 9d562a4..4959945 100644 --- a/static/js/components/SidebarUI.js +++ b/static/js/components/SidebarUI.js @@ -45,3 +45,27 @@ export function renderGroupList(container, groups, activeGroup, onGroupClick) { container.appendChild(li); }); } + +/** + * 카테고리 목록 HTML 렌더링 (Pinned Categories 전용) + */ +export function renderCategoryList(container, pinnedCategories, activeCategory, onCategoryClick) { + if (!container) return; + + container.innerHTML = ''; + pinnedCategories.forEach(cat => { + const li = document.createElement('li'); + li.className = (cat === activeCategory) ? 'active' : ''; + li.title = cat; // 💡 사이드바 축소 시 이름을 보여주기 위해 title 추가 + li.innerHTML = `🏷️ ${escapeHTML(cat)}`; + li.onclick = () => onCategoryClick(cat); + container.appendChild(li); + }); + + if (pinnedCategories.length === 0) { + const li = document.createElement('li'); + li.style.cssText = 'font-size: 0.8rem; color: var(--muted); padding: 5px 15px; cursor: default;'; + li.textContent = I18nManager.t('label_no_category'); + container.appendChild(li); + } +} diff --git a/static/js/components/ThemeManager.js b/static/js/components/ThemeManager.js index 8774683..edd5b09 100644 --- a/static/js/components/ThemeManager.js +++ b/static/js/components/ThemeManager.js @@ -27,7 +27,13 @@ export const ThemeManager = { } // ... 나머지 모달 제어 로직 유지 (기존 코드와 동일) - if (settingsBtn) settingsBtn.onclick = () => settingsModal.classList.add('active'); + if (settingsBtn) { + settingsBtn.onclick = () => { + const langSelect = document.getElementById('set-lang'); + if (langSelect) this.initialLang = langSelect.value; + settingsModal.classList.add('active'); + }; + } if (closeSettingsBtn) closeSettingsBtn.onclick = () => settingsModal.classList.remove('active'); window.addEventListener('click', (e) => { @@ -57,13 +63,23 @@ export const ThemeManager = { data[mapping[p.id]] = p.value; }); data['enable_ai'] = document.getElementById('set-enable-ai').checked; + data['enable_categories'] = document.getElementById('set-enable-categories').checked; // 언어 설정이 UI에 있다면 추가 (현재는 config.json 수동 명시 권장이나 대비책 마련) const langSelect = document.getElementById('set-lang'); - if (langSelect) data['lang'] = langSelect.value; + const newLang = langSelect ? langSelect.value : (this.initialLang || 'ko'); + if (langSelect) data['lang'] = newLang; try { await API.saveSettings(data); + + // 언어가 변경되었다면 페이지를 새로고침하여 모든 매니저들을 새로운 언어로 재초기화합니다. + if (this.initialLang && this.initialLang !== newLang) { + alert(I18nManager.t('msg_settings_saved')); + window.location.reload(); + return; + } + await this.applyTheme(data); alert(I18nManager.t('msg_settings_saved')); settingsModal.classList.remove('active'); @@ -80,7 +96,8 @@ export const ThemeManager = { card_color: "rgba(30, 41, 59, 0.85)", encrypted_border: "#00f3ff", ai_accent: "#8b5cf6", - lang: "ko" + lang: "ko", + enable_categories: false }; this.applyTheme(defaults); } @@ -92,6 +109,11 @@ export const ThemeManager = { * 테마 데이터를 실제 CSS 변수 및 UI 요소에 반영 */ async applyTheme(settings) { + this.settings = settings; // NEW: 설정 캐시 저장 + if (window.UI) { + window.UI._updateSettingsCache(settings); + } + const mapping = { 'bg_color': '--bg', 'sidebar_color': '--sidebar', @@ -117,7 +139,15 @@ export const ThemeManager = { const aiToggle = document.getElementById('set-enable-ai'); if (aiToggle) aiToggle.checked = enableAI; - // 3. i18n 적용 + // 3. 카테고리 활성화 상태 적용 (고급 옵션) + const enableCategories = (settings.enable_categories === true); + const catToggle = document.getElementById('set-enable-categories'); + if (catToggle) catToggle.checked = enableCategories; + if (window.UI && typeof window.UI.applyCategoryVisibility === 'function') { + window.UI.applyCategoryVisibility(enableCategories); + } + + // 4. i18n 적용 const lang = settings.lang || 'ko'; await I18nManager.init(lang); const langSelect = document.getElementById('set-lang'); diff --git a/static/js/components/composer/ComposerCategoryUI.js b/static/js/components/composer/ComposerCategoryUI.js new file mode 100644 index 0000000..ae39012 --- /dev/null +++ b/static/js/components/composer/ComposerCategoryUI.js @@ -0,0 +1,73 @@ +/** + * 작성기 카테고리/핀 UI 렌더링 엔진 + */ +import { I18nManager } from '../../utils/I18nManager.js'; +import { ThemeManager } from '../ThemeManager.js'; + +export const ComposerCategoryUI = { + /** + * 카테고리 칩 및 상태 UI 렌더링 + */ + render(container, selectedCategory, isDoneStatus, handlers) { + if (!container) return; + + const settings = ThemeManager.settings || {}; + const slots = settings.pinned_categories || []; + + container.innerHTML = ''; + // 💡 인라인 스타일 대신 클래스로 관리하거나 layout.css의 #composerCategoryBar 설정을 따름 + + // 1. 완료 칩 (Alt + 1) + const doneChip = document.createElement('div'); + doneChip.className = `cat-chip done-chip ${isDoneStatus ? 'active' : ''}`; + doneChip.innerHTML = ` ${I18nManager.t('label_category_done')} Alt+1`; + doneChip.onclick = () => handlers.onToggleDone(); + container.appendChild(doneChip); + + // 2. 외부 카테고리 강조칩 (핀에 없지만 지정된 경우) + const isExternal = selectedCategory && !slots.includes(selectedCategory); + if (isExternal) { + const extChip = document.createElement('div'); + extChip.className = 'cat-chip external-active active'; + extChip.innerHTML = `📍 ${selectedCategory}`; + extChip.title = `Current: ${selectedCategory}`; + extChip.onclick = () => handlers.onSelect(selectedCategory); + container.appendChild(extChip); + + const divider = document.createElement('div'); + divider.className = 'chip-divider'; + // 구분선 스타일은 CSS에서 관리하거나 최소한으로 유지 + divider.style.cssText = 'width: 1px; height: 12px; background: var(--muted); opacity: 0.3; margin: 0 5px;'; + container.appendChild(divider); + } + + // 3. 핀 슬롯(1~3번) 렌더링 + slots.forEach((cat, idx) => { + const slotNum = idx + 2; // 완료(1) 다음부터 시작 + const key = `shortcut_cat_${idx + 1}`; + const label = I18nManager.t(key).replace('%s', cat); + + const chip = document.createElement('div'); + chip.className = `cat-chip ${selectedCategory === cat ? 'active' : ''}`; + chip.innerHTML = `🏷️ ${cat} Alt+${slotNum}`; + chip.title = label; + chip.onclick = () => handlers.onSelect(cat); + container.appendChild(chip); + }); + + // 4. Alt+5: 분류 해제 힌트 + const clearHint = document.createElement('div'); + clearHint.className = 'shortcut-hint'; + clearHint.textContent = I18nManager.t('shortcut_cat_clear'); + container.appendChild(clearHint); + }, + + /** + * 슬롯 인덱스 기반으로 어떤 카테고리를 토글할지 결정 + */ + getCategoryBySlot(index) { + const settings = ThemeManager.settings || {}; + const slots = settings.pinned_categories || []; + return slots[index - 1] || null; + } +}; diff --git a/static/js/components/composer/ComposerDraft.js b/static/js/components/composer/ComposerDraft.js new file mode 100644 index 0000000..b133466 --- /dev/null +++ b/static/js/components/composer/ComposerDraft.js @@ -0,0 +1,69 @@ +/** + * 작성기 임시저장(Draft) 관리 모듈 + */ +import { I18nManager } from '../../utils/I18nManager.js'; +import { Constants } from '../../utils/Constants.js'; + +export const ComposerDraft = { + /** + * 현재 에디터 내용을 localStorage에 자동 저장 + */ + save(id, title, group, tags, content) { + // 내용이 비어있으면 저장하지 않음 + if (!title && !content) return; + + const draft = { + title, + content, + group: group || Constants.GROUPS.DEFAULT, + tags: tags || '', + editingId: id || '', + timestamp: Date.now() + }; + localStorage.setItem('memo_draft', JSON.stringify(draft)); + }, + + /** + * 임시저장된 내용이 있는지 확인하고 복원 처리 + */ + checkRestore(onRestore) { + const raw = localStorage.getItem('memo_draft'); + if (!raw) return; + + try { + const draft = JSON.parse(raw); + + // 24시간 이상 된 임시저장은 자동 삭제 + if (Date.now() - draft.timestamp > 86400000) { + this.clear(); + return; + } + + // 내용이 실제로 있는 경우에만 복원 확인 + if (!draft.title && !draft.content) { + this.clear(); + return; + } + + const titlePreview = draft.title || I18nManager.t('label_untitled'); + const confirmMsg = I18nManager.t('msg_draft_restore_confirm') + .replace('{title}', titlePreview); + + if (confirm(confirmMsg)) { + onRestore(draft); + } else { + this.clear(); + } + } catch (e) { + console.warn('[Draft] Failed to parse draft, deleting:', e); + this.clear(); + } + }, + + /** + * 임시저장 데이터 삭제 + */ + clear() { + localStorage.removeItem('memo_draft'); + } +}; diff --git a/static/js/editor.js b/static/js/editor.js index 5f5aac2..e948803 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -9,6 +9,13 @@ export const EditorManager = { sessionFiles: new Set(), // 이번 세션에 새로 추가된 파일 트래킹 (취소 시 삭제용) init(elSelector, onCtrlEnter) { + // 이미 초기화된 경우 기존 에디터 인스턴스 반환 및 중복 방지 + const container = document.querySelector(elSelector); + if (this.editor && container && container.querySelector('.toastui-editor-defaultUI')) { + console.log('[Editor] Already initialized, skipping init.'); + return this.editor; + } + const isMobile = window.innerWidth <= 768; // --- 플러그인 설정 (글자 색상) --- diff --git a/static/js/ui.js b/static/js/ui.js index 23e0a6f..f319478 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -19,6 +19,9 @@ const DOM = { scrollSentinel: document.getElementById('scrollSentinel') }; +// 모듈 레벨의 설정 캐시 관리 (this 바인딩 문제 해결) +let settingsCache = {}; + export const UI = { /** * 사이드바 및 로그아웃 버튼 초기화 @@ -98,14 +101,51 @@ export const UI = { /** * 사이드바 시스템 고정 메뉴 상태 갱신 */ - updateSidebar(memos, activeGroup, onGroupClick) { + updateSidebar(memos, activeGroup, activeCategory, onGroupClick, onCategoryClick) { if (!DOM.systemNav) return; + // 1. 시스템 그룹 동기화 DOM.systemNav.querySelectorAll('li').forEach(li => { const group = li.dataset.group; li.className = (group === activeGroup) ? 'active' : ''; li.onclick = () => onGroupClick(group); }); + + // 2. 카테고리 동기화 (Pinned Categories) + import('./components/SidebarUI.js').then(({ renderCategoryList }) => { + const categoryNav = document.getElementById('categoryNav'); + + // 💡 settingsCache가 비어있을 경우 ThemeManager에서 직접 복구 시도 + const pinned = settingsCache.pinned_categories || (ThemeManager.settings ? ThemeManager.settings.pinned_categories : []); + + renderCategoryList(categoryNav, pinned, activeCategory, onCategoryClick); + }); + }, + + /** + * 카테고리 기능 활성화 여부에 따라 UI 요소 노출 제어 + */ + applyCategoryVisibility(enabled) { + const composerBar = document.getElementById('composerCategoryBar'); + const sidebarSection = document.getElementById('categorySidebarSection'); + + if (composerBar) { + // 작성기 칩 영역은 가로 정렬을 위해 flex 레이아웃이 필수입니다. + composerBar.style.display = enabled ? 'flex' : 'none'; + } + if (sidebarSection) { + // 사이드바 섹션은 기본 블록 레이아웃을 사용합니다. + sidebarSection.style.display = enabled ? 'block' : 'none'; + } + + console.log(`Category UI visibility updated: ${enabled ? 'VISIBLE' : 'HIDDEN'}`); + }, + + /** + * 설정 캐시 업데이트 (내부용) + */ + _updateSettingsCache(settings) { + settingsCache = settings; }, /** @@ -201,6 +241,9 @@ export const UI = { } }; +// 전역 동기화를 위해 window 객체에 할당 +window.UI = UI; + /** * 전역 파일 다운로드 함수 (항상 전역 스코프 유지) */ diff --git a/static/locales/en.json b/static/locales/en.json index 0d0ec1d..cef7f8d 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -11,6 +11,8 @@ "nav_logout": "Logout", "nav_settings": "Settings", "nav_toggle": "Toggle Sidebar", + "nav_categories": "Categories", + "nav_category_manage": "Manage Categories", "search_placeholder": "Search memos... (Title, Content, Tag)", "composer_placeholder": "Leave a fragment of knowledge...", @@ -31,6 +33,7 @@ "settings_security": "Security Border Color", "settings_ai_accent": "AI Accent Color", "settings_ai_enable": "Enable AI Features", + "settings_category_enable": "Enable Category Feature (Advanced)", "settings_lang": "Language", "settings_save": "Save Settings", "settings_reset": "Reset", @@ -90,6 +93,12 @@ "label_group_explorer": "📁 Group Explorer", "label_tag_explorer": "🏷️ Tag Explorer", "label_last_updated": "Last updated: ", + "label_category_done": "Done", + "label_no_category": "No Category", + "tooltip_add_category": "Add/Edit Category", + "prompt_category_name": "Enter category name (max 10 chars):", + "msg_confirm_delete_category": "Delete this category?", + "msg_category_limit": "Maximum 3 categories can be pinned.", "shortcuts_label": "⌨️ Shortcuts", "shortcut_save": "Save", @@ -109,7 +118,9 @@ "h2": "Heading 2", "h3": "Heading 3", "ai_summary": "AI Summary", - "ai_tags": "AI Tags" + "ai_tags": "AI Tags", + "shortcut_cat_3": "Alt+4: %s", + "shortcut_cat_clear": "Alt+5: Clear Category" }, "calendar_months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], diff --git a/static/locales/ko.json b/static/locales/ko.json index 47724c3..3d6385a 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -11,6 +11,8 @@ "nav_logout": "로그아웃", "nav_settings": "환경 설정", "nav_toggle": "사이드바 토글", + "nav_categories": "카테고리", + "nav_category_manage": "카테고리 관리", "search_placeholder": "메모 검색... (제목, 내용, 태그)", "composer_placeholder": "지식의 파편을 남겨주세요...", @@ -31,6 +33,7 @@ "settings_security": "보안 테두리색", "settings_ai_accent": "AI 분석 강조색", "settings_ai_enable": "AI 기능 활성화", + "settings_category_enable": "카테고리 기능 활성화 (고급)", "settings_lang": "언어 설정", "settings_save": "저장", "settings_reset": "초기화", @@ -89,6 +92,12 @@ "label_group_explorer": "📁 그룹별 탐색", "label_tag_explorer": "🏷️ 태그별 탐색", "label_last_updated": "마지막 수정: ", + "label_category_done": "완료", + "label_no_category": "카테고리 없음", + "tooltip_add_category": "카테고리 추가/편집", + "prompt_category_name": "새 카테고리 이름을 입력하세요 (최대 10자):", + "msg_confirm_delete_category": "이 카테고리를 삭제할까요?", + "msg_category_limit": "카테고리는 최대 3개까지만 핀(Pin) 고정 가능합니다.", "shortcuts_label": "⌨️ 단축키", "shortcut_save": "저장", @@ -96,6 +105,8 @@ "shortcut_nebula": "네뷸라", "shortcut_slash": "슬래시 명령", "shortcut_edit": "즉시 수정", + "shortcut_cat_3": "Alt+4: %s", + "shortcut_cat_clear": "Alt+5: 분류 해제", "slash": { "task": "체크박스", diff --git a/templates/components/composer.html b/templates/components/composer.html new file mode 100644 index 0000000..33cf3d7 --- /dev/null +++ b/templates/components/composer.html @@ -0,0 +1,51 @@ +
+ +
+ Capture knowledge or drop files... +
+ + + +
diff --git a/templates/components/memo_grid.html b/templates/components/memo_grid.html new file mode 100644 index 0000000..3f315dc --- /dev/null +++ b/templates/components/memo_grid.html @@ -0,0 +1,5 @@ +
+ +
+
+
diff --git a/templates/components/sidebar.html b/templates/components/sidebar.html new file mode 100644 index 0000000..3573860 --- /dev/null +++ b/templates/components/sidebar.html @@ -0,0 +1,60 @@ + diff --git a/templates/components/topbar.html b/templates/components/topbar.html new file mode 100644 index 0000000..61ec999 --- /dev/null +++ b/templates/components/topbar.html @@ -0,0 +1,7 @@ +
+ + +
diff --git a/templates/index.html b/templates/index.html index b9f8fdd..7d94df2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,193 +26,20 @@ - + {% include 'components/sidebar.html' %}
-
- - -
- -
- -
- Capture knowledge or drop files... -
- - - -
- -
- -
-
-
+ {% include 'components/topbar.html' %} + {% include 'components/composer.html' %} + {% include 'components/memo_grid.html' %}
- - - - - - - - - - - - - - - - -
-
-

🔍 Knowledge Explorer

- -
-
- -
-
+ {% include 'modals/memo_detail.html' %} + {% include 'modals/settings.html' %} + {% include 'modals/category.html' %} + {% include 'modals/graph.html' %} + {% include 'modals/explorer.html' %} + {% include 'modals/overlays.html' %} diff --git a/templates/modals/category.html b/templates/modals/category.html new file mode 100644 index 0000000..ea1f2d1 --- /dev/null +++ b/templates/modals/category.html @@ -0,0 +1,19 @@ + + diff --git a/templates/modals/explorer.html b/templates/modals/explorer.html new file mode 100644 index 0000000..b34fc6f --- /dev/null +++ b/templates/modals/explorer.html @@ -0,0 +1,10 @@ + +
+
+

🔍 Knowledge Explorer

+ +
+
+ +
+
diff --git a/templates/modals/graph.html b/templates/modals/graph.html new file mode 100644 index 0000000..d925641 --- /dev/null +++ b/templates/modals/graph.html @@ -0,0 +1,7 @@ + + diff --git a/templates/modals/memo_detail.html b/templates/modals/memo_detail.html new file mode 100644 index 0000000..7db333f --- /dev/null +++ b/templates/modals/memo_detail.html @@ -0,0 +1,4 @@ + + diff --git a/templates/modals/overlays.html b/templates/modals/overlays.html new file mode 100644 index 0000000..bccdf9d --- /dev/null +++ b/templates/modals/overlays.html @@ -0,0 +1,8 @@ + + + + + diff --git a/templates/modals/settings.html b/templates/modals/settings.html new file mode 100644 index 0000000..ad0112d --- /dev/null +++ b/templates/modals/settings.html @@ -0,0 +1,43 @@ + +