commit cfb9485d4f6318688594581678fd00036342c4ca Author: hyungi Date: Fri Sep 5 07:13:49 2025 +0900 ๐Ÿš€ ๋ฐฐํฌ์šฉ: PDF ๋ทฐ์–ด ๊ฐœ์„  ๋ฐ ์„œ์ ๋ณ„ UI ๋ฐ๋ณธ์”ฝํฌ ์Šคํƒ€์ผ ์ ์šฉ โœจ ์ฃผ์š” ๊ฐœ์„ ์‚ฌํ•ญ: - PDF API 500 ์—๋Ÿฌ ์ˆ˜์ • (ํ•œ๊ธ€ ํŒŒ์ผ๋ช… UTF-8 ์ธ์ฝ”๋”ฉ ์ฒ˜๋ฆฌ) - PDF ๋ทฐ์–ด ๊ธฐ๋Šฅ ์™„์ „ ๊ตฌํ˜„ (PDF.js ํ†ตํ•ฉ, ๋„ค๋น„๊ฒŒ์ด์…˜, ํ™•๋Œ€/์ถ•์†Œ) - ์„œ์ ๋ณ„ ๋ฌธ์„œ ๊ทธ๋ฃนํ™” UI ๋ฐ๋ณธ์”ฝํฌ ์Šคํƒ€์ผ๋กœ ๊ฐœ์„  - PDF Manager ํŽ˜์ด์ง€ ์„œ์ ๋ณ„ ๋ณด๊ธฐ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ - Alpine.js ๋กœ๋“œ ์ˆœ์„œ ์ตœ์ ํ™”๋กœ JavaScript ์—๋Ÿฌ ํ•ด๊ฒฐ ๐ŸŽจ UI/UX ๊ฐœ์„ : - ํ™•์žฅ/์ถ•์†Œ ๊ฐ€๋Šฅํ•œ ์•„์ฝ”๋””์–ธ ์Šคํƒ€์ผ ์„œ์  ๋ชฉ๋ก - ๊ฐ„๊ฒฐํ•˜๊ณ  ์ง๊ด€์ ์ธ ๋ฐ๋ณธ์”ฝํฌ ์Šคํƒ€์ผ ์ธํ„ฐํŽ˜์ด์Šค - PDF ์ƒํƒœ ํ‘œ์‹œ (HTML ์—ฐ๊ฒฐ, ์„œ์  ๋ถ„๋ฅ˜) - ๋ฐ˜์‘ํ˜• ๋””์ž์ธ ๋ฐ ๋ถ€๋“œ๋Ÿฌ์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ ๐Ÿ”ง ๊ธฐ์ˆ ์  ๊ฐœ์„ : - PDF.js ์›Œ์ปค ์„ค์ • ๋ฐ ํ† ํฐ ์ธ์ฆ ์ฒ˜๋ฆฌ - ์„œ์ ๋ณ„ PDF ์ž๋™ ๊ทธ๋ฃนํ™” ๋กœ์ง - Alpine.js ์ปดํฌ๋„ŒํŠธ ์ดˆ๊ธฐํ™” ์ตœ์ ํ™” diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..628f60b --- /dev/null +++ b/.gitignore @@ -0,0 +1,113 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# FastAPI +.pytest_cache/ +.coverage +htmlcov/ + +# Database +*.db +*.sqlite3 + +# Docker +.dockerignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Uploads (์‹ค์ œ ํŒŒ์ผ๋“ค) +uploads/documents/ +uploads/thumbnails/ + +# Poetry +poetry.lock + +# Temporary files +*.tmp +*.temp +# ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ๋“ค (๊ฐœ๋ฐœ์šฉ) +backend/uploads/documents/*.html diff --git a/QUICK-START.md b/QUICK-START.md new file mode 100644 index 0000000..6f3c6ce --- /dev/null +++ b/QUICK-START.md @@ -0,0 +1,276 @@ +# ๐Ÿš€ ๋น ๋ฅธ ์‹œ์ž‘ ๊ฐ€์ด๋“œ + +Document Server๋ฅผ Synology DS1525+์— ๋ฐฐํฌํ•˜๋Š” ๊ฐ€์žฅ ๊ฐ„๋‹จํ•œ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค. + +## โš ๏ธ ์‹ค์ œ ์„œ๋น„์Šค ํ™˜๊ฒฝ ์ฃผ์˜์‚ฌํ•ญ + +### ๐Ÿšจ ์ค‘์š”: ์ด ์‹œ์Šคํ…œ์€ ์‹ค์ œ ์„œ๋น„์Šค ์ค‘์ž…๋‹ˆ๋‹ค! +์ด Document Server๋Š” **์‹ค์ œ ์šด์˜ ์ค‘์ธ ๋งฅ๋ฏธ๋‹ˆ**์— ์„ค์น˜๋˜์–ด ์‚ฌ์šฉ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +**์ ˆ๋Œ€ ์‚ญ์ œํ•˜๋ฉด ์•ˆ ๋˜๋Š” ์›๋ณธ ๋ฐ์ดํ„ฐ:** +- ๋ชจ๋“  ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ ํŒŒ์ผ๋“ค +- ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๋ฉ”๋ชจ, ํ•˜์ด๋ผ์ดํŠธ, ๋…ธํŠธ๋ถ +- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ +- ์„ค์ • ํŒŒ์ผ ๋ฐ ์‚ฌ์šฉ์ž ํ™˜๊ฒฝ์„ค์ • + +### ๐Ÿ›ก๏ธ ์—…๋ฐ์ดํŠธ ์‹œ ํ•„์ˆ˜ ์›์น™ +1. **๋ฐฑ์—… ์šฐ์„ **: ๋ชจ๋“  ์ž‘์—… ์ „ ๋ฐ˜๋“œ์‹œ ๋ฐฑ์—… +2. **๋ฐ์ดํ„ฐ ๋ณด์กด**: ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ๋Š” ์ ˆ๋Œ€ ์‚ญ์ œ ๊ธˆ์ง€ +3. **์ ์ง„์  ์ ์šฉ**: ์ฝ”๋“œ๋งŒ ์—…๋ฐ์ดํŠธํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋Š” ๋ณด์กด +4. **์ฆ‰์‹œ ๋กค๋ฐฑ**: ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ๋ฐ”๋กœ ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋ณต๊ตฌ + +## ๐Ÿ“‹ ์ค€๋น„์‚ฌํ•ญ + +- Synology DS1525+ (32GB RAM, SSD ์บ์‹œ ํ™œ์„ฑํ™”) ๋˜๋Š” Mac Mini +- Docker ํŒจํ‚ค์ง€ ์„ค์น˜ (Package Center์—์„œ ์„ค์น˜) +- SSH ์ ‘์† ๊ฐ€๋Šฅ +- Git ์„ค์น˜ (์„ ํƒ์‚ฌํ•ญ) +- **์ค‘์š”**: ๊ธฐ์กด ์„œ๋น„์Šค ์ค‘๋‹จ ์ตœ์†Œํ™”๋ฅผ ์œ„ํ•œ ๊ณ„ํš + +## ๐ŸŽฏ ํ•œ ๋ฒˆ์— ๋ฐฐํฌํ•˜๊ธฐ + +### ๋ฐฉ๋ฒ• 1: Git ํด๋ก  (๊ถŒ์žฅ) โญ + +```bash +# 1. NAS์— SSH ์ ‘์† +ssh admin@your-nas-ip + +# 2. ํ”„๋กœ์ ํŠธ ํด๋ก  +cd /volume1/docker/ +git clone https://git.hyungi.net/hyungi/document-server.git +cd document-server + +# 3. ์ž๋™ ๋ฐฐํฌ (ํ™˜๊ฒฝ ์„ค์ • + ๋ฐฐํฌ) +./scripts/deploy-synology.sh +``` + +### ๋ฐฉ๋ฒ• 2: ํŒŒ์ผ ์—…๋กœ๋“œ + +```bash +# 1. ๋กœ์ปฌ์—์„œ NAS๋กœ ํŒŒ์ผ ์ „์†ก +scp -r ./document-server admin@your-nas-ip:/volume1/docker/ + +# 2. NAS์— SSH ์ ‘์† +ssh admin@your-nas-ip +cd /volume1/docker/document-server + +# 3. ์ž๋™ ๋ฐฐํฌ +./scripts/deploy-synology.sh +``` + +## โš™๏ธ ํ™˜๊ฒฝ ์„ค์ • (์ž๋™) + +๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ ์‹œ ์ž๋™์œผ๋กœ ํ™˜๊ฒฝ ์„ค์ •์ด ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค: + +``` +=== ๐Ÿ”ง Document Server ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • === + +1. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋น„๋ฐ€๋ฒˆํ˜ธ + ๊ธฐ๋ณธ๊ฐ’: AbC123XyZ (์ž๋™์ƒ์„ฑ) + ์ž…๋ ฅ: [์—”ํ„ฐ = ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ] + +2. JWT ์‹œํฌ๋ฆฟ ํ‚ค (๋ณด์•ˆ์šฉ) + ๊ธฐ๋ณธ๊ฐ’: kL9mN2pQ... (์ž๋™์ƒ์„ฑ) + ์ž…๋ ฅ: [์—”ํ„ฐ = ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ] + +3. ๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ + ๊ธฐ๋ณธ๊ฐ’: admin@document-server.local + ์ž…๋ ฅ: admin@mydomain.com + +4. ๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ + ๊ธฐ๋ณธ๊ฐ’: MyPass123 (์ž๋™์ƒ์„ฑ) + ์ž…๋ ฅ: [์—”ํ„ฐ = ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ] + +5. ๋„๋ฉ”์ธ ์ด๋ฆ„ (์™ธ๋ถ€ ์ ‘์†์šฉ) + ๊ธฐ๋ณธ๊ฐ’: localhost + ์ž…๋ ฅ: nas.mydomain.com +``` + +**๐Ÿ’ก ํŒ**: ๋Œ€๋ถ€๋ถ„ ์—”ํ„ฐ๋งŒ ๋ˆŒ๋Ÿฌ๋„ ์•ˆ์ „ํ•œ ๊ธฐ๋ณธ๊ฐ’์ด ์ž๋™ ์„ค์ •๋ฉ๋‹ˆ๋‹ค! + +## ๐ŸŽ‰ ๋ฐฐํฌ ์™„๋ฃŒ ํ›„ + +### ์ ‘์† ํ™•์ธ +``` +๐ŸŒ ์›น ์ธํ„ฐํŽ˜์ด์Šค: http://your-nas-ip:24100 +๐Ÿ“ง ๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ: admin@document-server.local +๐Ÿ”‘ ๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ: (์„ค์ • ์‹œ ํ‘œ์‹œ๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ) +``` + +### ์ฃผ์š” ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ +1. **ํ• ์ผ๊ด€๋ฆฌ**: `http://your-nas-ip:24100/todos.html` +2. **๋ฉ”๋ชจ ํŠธ๋ฆฌ**: `http://your-nas-ip:24100/memo-tree.html` +3. **๋…ธํŠธ๋ถ**: `http://your-nas-ip:24100/notebooks.html` +4. **๋ฌธ์„œ ์—…๋กœ๋“œ**: ๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ HTML ํŒŒ์ผ ๋“œ๋ž˜๊ทธ&๋“œ๋กญ + +## ๐Ÿ”„ ์•ˆ์ „ํ•œ ์—…๋ฐ์ดํŠธ ๋ฐฉ๋ฒ• + +### โš ๏ธ ์—…๋ฐ์ดํŠธ ์ „ ํ•„์ˆ˜ ์ฒดํฌ๋ฆฌ์ŠคํŠธ +```bash +# 1. ํ˜„์žฌ ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +docker-compose ps +./scripts/monitor-synology.sh + +# 2. ์ „์ฒด ๋ฐฑ์—… ์‹คํ–‰ (ํ•„์ˆ˜!) +./scripts/backup.sh + +# 3. ๋ฐฑ์—… ํŒŒ์ผ ํ™•์ธ +ls -la /volume2/document-storage/backups/ + +# 4. ์‚ฌ์šฉ์ž ์•Œ๋ฆผ (์„œ๋น„์Šค ์ค‘๋‹จ ์˜ˆ๊ณ ) +echo "โš ๏ธ ์‹œ์Šคํ…œ ์—…๋ฐ์ดํŠธ ์˜ˆ์ • - ์ž ์‹œ ์ค‘๋‹จ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค" +``` + +### Git ์‚ฌ์šฉ (๊ถŒ์žฅ) - ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ ํฌํ•จ +```bash +# NAS ๋˜๋Š” Mac Mini์— SSH ์ ‘์† +ssh admin@your-server-ip +cd /volume1/docker/document-server # ๋˜๋Š” ์‹ค์ œ ์„ค์น˜ ๊ฒฝ๋กœ + +# โš ๏ธ ์ค‘์š”: ์ž๋™ ์—…๋ฐ์ดํŠธ ์Šคํฌ๋ฆฝํŠธ๋Š” ๋ฐฑ์—…์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค +# ๋ฐฑ์—… + ์—…๋ฐ์ดํŠธ + ํ—ฌ์Šค์ฒดํฌ + ๋กค๋ฐฑ ๊ธฐ๋Šฅ +./scripts/update-synology.sh +``` + +### ์ˆ˜๋™ ์—…๋ฐ์ดํŠธ (๊ณ ๊ธ‰ ์‚ฌ์šฉ์ž์šฉ) +```bash +# 1. ํ•„์ˆ˜ ๋ฐฑ์—… ์‹คํ–‰ +./scripts/backup.sh + +# 2. ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ณด์กด +git stash # ์„ค์ • ํŒŒ์ผ ๋“ฑ ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ž„์‹œ ์ €์žฅ + +# 3. ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ (๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ ์ œ์™ธ) +git pull origin main + +# 4. ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ณต์› (ํ•„์š”์‹œ) +git stash pop + +# 5. ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ (๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ ๋ณด์กด) +docker-compose -f docker-compose.synology-optimized.yml restart + +# 6. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +./scripts/monitor-synology.sh +``` + +### ๐Ÿšจ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ ์›์น™ +- **๋ณผ๋ฅจ ๋งคํ•‘ ๋ณด์กด**: Docker ๋ณผ๋ฅจ์€ ์ ˆ๋Œ€ ์‚ญ์ œํ•˜์ง€ ์•Š์Œ +- **๋ฐฑ์—… ์šฐ์„ **: ๋ชจ๋“  ๋ณ€๊ฒฝ ์ „ ๋ฐ˜๋“œ์‹œ ๋ฐฑ์—… ์‹คํ–‰ +- **์ ์ง„์  ์—…๋ฐ์ดํŠธ**: ํ•œ ๋ฒˆ์— ํ•˜๋‚˜์”ฉ ์ปดํฌ๋„ŒํŠธ ์—…๋ฐ์ดํŠธ +- **๋กค๋ฐฑ ์ค€๋น„**: ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ์ฆ‰์‹œ ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋ณต๊ตฌ + +## ๐Ÿ“Š ๋ชจ๋‹ˆํ„ฐ๋ง + +```bash +# ์‹œ์Šคํ…œ ์ƒํƒœ ํ™•์ธ +./scripts/monitor-synology.sh + +# ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง (5์ดˆ ๊ฐ„๊ฒฉ) +watch -n 5 './scripts/monitor-synology.sh' + +# ๋กœ๊ทธ ํ™•์ธ +docker-compose -f docker-compose.synology-optimized.yml logs -f +``` + +## ๐Ÿšจ ๋ฌธ์ œ ํ•ด๊ฒฐ + +### ํฌํŠธ ์ถฉ๋Œ +```bash +# ํฌํŠธ ์‚ฌ์šฉ ํ™•์ธ +netstat -tuln | grep -E "(24100|24101|24102|24103)" + +# ๋‹ค๋ฅธ ํฌํŠธ ์‚ฌ์šฉ ์‹œ .env.synology ํŒŒ์ผ ์ˆ˜์ • +nano .env.synology +# EXTERNAL_PORT=24200 (์˜ˆ์‹œ) +``` + +### ๊ถŒํ•œ ๋ฌธ์ œ +```bash +# ๋””๋ ‰ํ† ๋ฆฌ ๊ถŒํ•œ ์ˆ˜์ • +sudo chown -R 1000:1000 /volume1/docker/document-server/ +sudo chown -R 1000:1000 /volume2/document-storage/ +``` + +### ์„œ๋น„์Šค ์žฌ์‹œ์ž‘ +```bash +# ์ „์ฒด ์žฌ์‹œ์ž‘ +docker-compose -f docker-compose.synology-optimized.yml restart + +# ํŠน์ • ์„œ๋น„์Šค๋งŒ ์žฌ์‹œ์ž‘ +docker-compose -f docker-compose.synology-optimized.yml restart backend +``` + +### ๋กค๋ฐฑ (์—…๋ฐ์ดํŠธ ์‹คํŒจ ์‹œ) +```bash +# ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋กค๋ฐฑ +./scripts/update-synology.sh rollback +``` + +## ๐Ÿ’พ ๋ฐฑ์—… + +### ์ž๋™ ๋ฐฑ์—… ์„ค์ • +```bash +# Synology ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ์—์„œ ์„ค์ • +# ์ œ์–ดํŒ > ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ > ์ƒ์„ฑ > ์‚ฌ์šฉ์ž ์ •์˜ ์Šคํฌ๋ฆฝํŠธ + +# ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ ๋ฐฑ์—… +0 2 * * * /volume1/docker/document-server/backup.sh +``` + +### ์ˆ˜๋™ ๋ฐฑ์—… +```bash +# ์ฆ‰์‹œ ๋ฐฑ์—… ์‹คํ–‰ +/volume1/docker/document-server/backup.sh + +# ๋ฐฑ์—… ํŒŒ์ผ ํ™•์ธ +ls -la /volume2/document-storage/backups/ +``` + +## ๐Ÿ”’ ๋ณด์•ˆ ์„ค์ • + +### ๋ฐฉํ™”๋ฒฝ (๊ถŒ์žฅ) +```bash +# Synology ์ œ์–ดํŒ > ๋ณด์•ˆ > ๋ฐฉํ™”๋ฒฝ +# ๊ทœ์น™ ์ถ”๊ฐ€: ํฌํŠธ 24100 ํ—ˆ์šฉ +``` + +### SSL ์ธ์ฆ์„œ (์™ธ๋ถ€ ์ ‘์† ์‹œ) +```bash +# Let's Encrypt ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ +certbot certonly --webroot -w /volume2/document-storage/documents -d your-domain.com +``` + +## ๐Ÿ“ž ๋„์›€๋ง + +### ๋กœ๊ทธ ์ˆ˜์ง‘ (๋ฌธ์ œ ๋ณด๊ณ  ์‹œ) +```bash +# ์‹œ์Šคํ…œ ๋ฆฌํฌํŠธ ์ƒ์„ฑ +./scripts/monitor-synology.sh > system-report.txt + +# ๋กœ๊ทธ ํŒŒ์ผ ์œ„์น˜ +/volume1/docker/document-server/logs/ +``` + +### ์œ ์šฉํ•œ ๋ช…๋ น์–ด +```bash +# Docker ์ƒํƒœ ํ™•์ธ +docker ps +docker stats + +# ๋””์Šคํฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ +df -h /volume1 /volume2 + +# ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ +free -h +``` + +--- + +## ๐ŸŽฏ ์š”์•ฝ + +1. **๋ฐฐํฌ**: `./scripts/deploy-synology.sh` (ํ•œ ๋ฒˆ๋งŒ) +2. **์—…๋ฐ์ดํŠธ**: `./scripts/update-synology.sh` (ํ•„์š”์‹œ) +3. **๋ชจ๋‹ˆํ„ฐ๋ง**: `./scripts/monitor-synology.sh` (์ƒํƒœ ํ™•์ธ) +4. **์ ‘์†**: `http://your-nas-ip:24100` + +**๐ŸŽ‰ ์ด์ œ Document Server๋ฅผ ์‚ฌ์šฉํ•  ์ค€๋น„๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!** diff --git a/README-DEPLOYMENT.md b/README-DEPLOYMENT.md new file mode 100644 index 0000000..bdd6f9c --- /dev/null +++ b/README-DEPLOYMENT.md @@ -0,0 +1,319 @@ +# ๐Ÿš€ Synology DS1525+ ๋ฐฐํฌ ๊ฐ€์ด๋“œ + +Document Server๋ฅผ Synology DS1525+ NAS์— ์ตœ์ ํ™”ํ•˜์—ฌ ๋ฐฐํฌํ•˜๋Š” ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. + +## โš ๏ธ ์‹ค์ œ ์„œ๋น„์Šค ํ™˜๊ฒฝ ๊ฒฝ๊ณ  + +### ๐Ÿšจ ์ค‘์š”: ์ด ์‹œ์Šคํ…œ์€ ์‹ค์ œ ์šด์˜ ์ค‘์ž…๋‹ˆ๋‹ค! +์ด Document Server๋Š” **์‹ค์ œ ์„œ๋น„์Šค ์ค‘์ธ ๋งฅ๋ฏธ๋‹ˆ**์— ์„ค์น˜๋˜์–ด ์šด์˜๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +**์ ˆ๋Œ€ ์‚ญ์ œํ•˜๋ฉด ์•ˆ ๋˜๋Š” ์›๋ณธ ๋ฐ์ดํ„ฐ:** +- ๋ชจ๋“  ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ ํŒŒ์ผ๋“ค (`/volume2/document-storage/uploads/`) +- ๋ณ€ํ™˜๋œ HTML ๋ฌธ์„œ๋“ค (`/volume2/document-storage/documents/`) +- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ๋“ค (`/volume1/docker/document-server/database/`) +- ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๋ชจ๋“  ๋ฉ”๋ชจ, ํ•˜์ด๋ผ์ดํŠธ, ๋…ธํŠธ๋ถ ๋ฐ์ดํ„ฐ +- Redis ์บ์‹œ ๋ฐ์ดํ„ฐ (`/volume1/docker/document-server/redis/`) +- ์„ค์ • ํŒŒ์ผ๋“ค (`/volume1/docker/document-server/config/`) + +### ๐Ÿ›ก๏ธ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ ํ•„์ˆ˜ ์›์น™ +1. **๋ฐฑ์—… ์šฐ์„ **: ๋ชจ๋“  ์ž‘์—… ์ „ ๋ฐ˜๋“œ์‹œ ์ „์ฒด ๋ฐฑ์—… ์‹คํ–‰ +2. **๋ณผ๋ฅจ ๋ณด์กด**: Docker ๋ณผ๋ฅจ ๋งคํ•‘์€ ์ ˆ๋Œ€ ๋ณ€๊ฒฝ/์‚ญ์ œ ๊ธˆ์ง€ +3. **์ ์ง„์  ์—…๋ฐ์ดํŠธ**: ์ฝ”๋“œ๋งŒ ์—…๋ฐ์ดํŠธํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋Š” ๋ณด์กด +4. **์ฆ‰์‹œ ๋กค๋ฐฑ**: ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ๋ฐ”๋กœ ์ด์ „ ์ƒํƒœ๋กœ ๋ณต๊ตฌ + +## ๐Ÿ—๏ธ ํ•˜๋“œ์›จ์–ด ์‚ฌ์–‘ + +### Synology DS1525+ ์ตœ์ ํ™” ๊ตฌ์„ฑ +- **CPU**: AMD Ryzen R1600 (4์ฝ”์–ด/8์Šค๋ ˆ๋“œ) +- **๋ฉ”๋ชจ๋ฆฌ**: 32GB DDR4 ECC +- **์Šคํ† ๋ฆฌ์ง€**: SSD ์ฝ๊ธฐ/์“ฐ๊ธฐ ์บ์‹œ ํ™œ์„ฑํ™” +- **๋ณผ๋ฅจ ๊ตฌ์„ฑ**: + - **Volume1 (SSD)**: ๊ณ ์„ฑ๋Šฅ ๋ฐ์ดํ„ฐ (๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค, ์บ์‹œ, ๋กœ๊ทธ) + - **Volume2 (HDD)**: ๋Œ€์šฉ๋Ÿ‰ ์ €์žฅ์†Œ (๋ฌธ์„œ, ์—…๋กœ๋“œ, ๋ฐฑ์—…) + +## ๐Ÿ“ ์Šคํ† ๋ฆฌ์ง€ ์ „๋žต + +### SSD ๋ณผ๋ฅจ (/volume1) - ์„ฑ๋Šฅ ์ตœ์šฐ์„  +``` +/volume1/docker/document-server/ +โ”œโ”€โ”€ database/ # PostgreSQL ๋ฐ์ดํ„ฐ (8GB shared_buffers) +โ”œโ”€โ”€ redis/ # Redis ์บ์‹œ (8GB maxmemory) +โ”œโ”€โ”€ logs/ # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ๊ทธ +โ”œโ”€โ”€ config/ # ์„ค์ • ํŒŒ์ผ +โ”œโ”€โ”€ nginx/ +โ”‚ โ”œโ”€โ”€ conf.d/ # Nginx ์„ค์ • +โ”‚ โ””โ”€โ”€ cache/ # Nginx ์บ์‹œ (2GB) +โ””โ”€โ”€ cache/ # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์บ์‹œ +``` + +### HDD ๋ณผ๋ฅจ (/volume2) - ๋Œ€์šฉ๋Ÿ‰ ์ €์žฅ +``` +/volume2/document-storage/ +โ”œโ”€โ”€ uploads/ # ์—…๋กœ๋“œ๋œ ํŒŒ์ผ (HTML, PDF) +โ”œโ”€โ”€ documents/ # ๋ณ€ํ™˜๋œ ๋ฌธ์„œ +โ”œโ”€โ”€ thumbnails/ # ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ +โ”œโ”€โ”€ backups/ # ์ž๋™ ๋ฐฑ์—… ํŒŒ์ผ +โ””โ”€โ”€ archives/ # ์•„์นด์ด๋ธŒ ๋ฐ์ดํ„ฐ +``` + +## ๐Ÿš€ ๋ฐฐํฌ ๋ฐฉ๋ฒ• + +### 1. ์ž๋™ ๋ฐฐํฌ (๊ถŒ์žฅ) +```bash +# ์ €์žฅ์†Œ ํด๋ก  +git clone +cd document-server + +# ์ž๋™ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ +./scripts/deploy-synology.sh +``` + +### 2. ์ˆ˜๋™ ๋ฐฐํฌ +```bash +# 1. ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx/conf.d,nginx/cache,cache} +sudo mkdir -p /volume2/document-storage/{uploads,documents,thumbnails,backups,archives} + +# 2. ๊ถŒํ•œ ์„ค์ • +sudo chown -R 1000:1000 /volume1/docker/document-server/ +sudo chown -R 1000:1000 /volume2/document-storage/ + +# 3. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • +cp .env.example .env.synology +# .env.synology ํŒŒ์ผ ํŽธ์ง‘ + +# 4. Docker Compose ์‹คํ–‰ +docker-compose -f docker-compose.synology-optimized.yml up -d +``` + +## โš™๏ธ ์„ฑ๋Šฅ ์ตœ์ ํ™” ์„ค์ • + +### PostgreSQL (32GB RAM ์ตœ์ ํ™”) +```ini +# /volume1/docker/document-server/config/postgresql.synology.conf +shared_buffers = 8GB # RAM์˜ 25% +effective_cache_size = 24GB # RAM์˜ 75% +work_mem = 512MB # ๋ณต์žกํ•œ ์ฟผ๋ฆฌ์šฉ +maintenance_work_mem = 4GB # ์ธ๋ฑ์Šค ๊ตฌ์ถ•์šฉ +max_worker_processes = 8 # 4์ฝ”์–ด/8์Šค๋ ˆ๋“œ ์ตœ์ ํ™” +max_parallel_workers_per_gather = 4 +random_page_cost = 1.1 # SSD ์ตœ์ ํ™” +effective_io_concurrency = 200 # SSD ๋™์‹œ I/O +``` + +### Redis (๋Œ€์šฉ๋Ÿ‰ ๋ฉ”๋ชจ๋ฆฌ ํ™œ์šฉ) +```conf +maxmemory 8gb # ์บ์‹œ ๋ฉ”๋ชจ๋ฆฌ ์ œํ•œ +maxmemory-policy allkeys-lru # LRU ์ •์ฑ… +appendonly yes # ๋ฐ์ดํ„ฐ ์ง€์†์„ฑ +auto-aof-rewrite-percentage 100 # AOF ์ตœ์ ํ™” +``` + +### Nginx (SSD ์บ์‹œ ์ตœ์ ํ™”) +```nginx +# ์บ์‹œ ์กด ์„ค์ • (SSD์— ์ €์žฅ) +proxy_cache_path /var/cache/nginx/documents + levels=1:2 + keys_zone=documents:100m + max_size=2g + inactive=60m; + +# Gzip ์••์ถ• +gzip on; +gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + +# ์ •์  ํŒŒ์ผ ์บ์‹œ +location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; +} +``` + +## ๐Ÿ“Š ๋ชจ๋‹ˆํ„ฐ๋ง + +### ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง +```bash +# ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค ๋ฐ ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +./scripts/monitor-synology.sh + +# ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง (5์ดˆ ๊ฐ„๊ฒฉ) +watch -n 5 './scripts/monitor-synology.sh' + +# Docker ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ +docker-compose -f docker-compose.synology-optimized.yml ps + +# ์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ +docker-compose -f docker-compose.synology-optimized.yml logs -f + +# ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ +docker stats +``` + +### ์ฃผ์š” ๋ฉ”ํŠธ๋ฆญ +- **CPU ์‚ฌ์šฉ๋ฅ **: ํ‰์ƒ์‹œ < 30%, ํ”ผํฌ < 70% +- **๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ **: < 80% (32GB ์ค‘ 25GB ์ดํ•˜) +- **๋””์Šคํฌ I/O**: SSD ์บ์‹œ ํšจ๊ณผ๋กœ ์‘๋‹ต ์‹œ๊ฐ„ < 100ms +- **๋„คํŠธ์›Œํฌ**: ๊ธฐ๊ฐ€๋น„ํŠธ ์ด๋”๋„ท ํ™œ์šฉ + +## ๐Ÿ’พ ๋ฐฑ์—… ๋ฐ ๋ณต๊ตฌ + +### ์ž๋™ ๋ฐฑ์—… ์„ค์ • +```bash +# Synology ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ์—์„œ ์„ค์ • +# ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ ์‹คํ–‰ +0 2 * * * /volume1/docker/document-server/backup.sh +``` + +### ๋ฐฑ์—… ๋‚ด์šฉ +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL ๋คํ”„ (๋งค์ผ) +- **์„ค์ • ํŒŒ์ผ**: ์••์ถ• ์•„์นด์ด๋ธŒ (๋งค์ผ) +- **๋ฌธ์„œ ํŒŒ์ผ**: ์ฆ๋ถ„ ๋ฐฑ์—… (์ฃผ๊ฐ„) +- **๋ณด๊ด€ ์ •์ฑ…**: 7์ผ๊ฐ„ ๋ณด๊ด€ ํ›„ ์ž๋™ ์‚ญ์ œ + +### ๋ณต๊ตฌ ๋ฐฉ๋ฒ• +```bash +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณต๊ตฌ +docker exec document-server-db psql -U docuser -d document_db < backup_file.sql + +# ์„ค์ • ํŒŒ์ผ ๋ณต๊ตฌ +tar -xzf config_backup_YYYYMMDD_HHMMSS.tar.gz -C /volume1/docker/document-server/ +``` + +## ๐Ÿ”ง ์œ ์ง€๋ณด์ˆ˜ + +### ์ •๊ธฐ ์ž‘์—… +1. **์ฃผ๊ฐ„**: ๋กœ๊ทธ ํŒŒ์ผ ์ •๋ฆฌ ๋ฐ ์••์ถ• +2. **์›”๊ฐ„**: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค VACUUM ๋ฐ REINDEX +3. **๋ถ„๊ธฐ**: ์ „์ฒด ์‹œ์Šคํ…œ ๋ฐฑ์—… ๋ฐ ๋ณต๊ตฌ ํ…Œ์ŠคํŠธ +4. **์—ฐ๊ฐ„**: ํ•˜๋“œ์›จ์–ด ์ ๊ฒ€ ๋ฐ ์—…๊ทธ๋ ˆ์ด๋“œ ๊ณ„ํš + +### ๋กœ๊ทธ ๊ด€๋ฆฌ +```bash +# ๋กœ๊ทธ ๋กœํ…Œ์ด์…˜ ์„ค์ • +/volume1/docker/document-server/logs/*.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 644 1000 1000 +} +``` + +### ์„ฑ๋Šฅ ํŠœ๋‹ +```bash +# PostgreSQL ํ†ต๊ณ„ ํ™•์ธ +docker exec document-server-db psql -U docuser -d document_db -c "SELECT * FROM pg_stat_activity;" + +# Redis ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ +docker exec document-server-redis redis-cli info memory + +# Nginx ์บ์‹œ ํšจ์œจ์„ฑ ํ™•์ธ +docker exec document-server-nginx nginx -T +``` + +## ๐Ÿšจ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… + +### ์ผ๋ฐ˜์ ์ธ ๋ฌธ์ œ + +#### 1. ๋ฉ”๋ชจ๋ฆฌ ๋ถ€์กฑ +```bash +# ์ฆ์ƒ: ์„œ๋น„์Šค ์‘๋‹ต ์ง€์—ฐ, OOM ํ‚ฌ +# ํ•ด๊ฒฐ: PostgreSQL/Redis ๋ฉ”๋ชจ๋ฆฌ ์„ค์ • ์กฐ์ • +shared_buffers = 6GB # 8GB์—์„œ ๊ฐ์†Œ +maxmemory 6gb # 8GB์—์„œ ๊ฐ์†Œ +``` + +#### 2. ๋””์Šคํฌ ๊ณต๊ฐ„ ๋ถ€์กฑ +```bash +# SSD ๊ณต๊ฐ„ ํ™•๋ณด +docker system prune -a +find /volume1/docker/document-server/logs -name "*.log" -mtime +7 -delete + +# HDD ๊ณต๊ฐ„ ํ™•๋ณด +find /volume2/document-storage/backups -name "*.sql" -mtime +30 -delete +``` + +#### 3. ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ ๋ฌธ์ œ +```bash +# ํฌํŠธ ํ™•์ธ +netstat -tuln | grep -E "(24100|24101|24102|24103)" + +# ๋ฐฉํ™”๋ฒฝ ์„ค์ • ํ™•์ธ +iptables -L | grep -E "(24100|24101|24102|24103)" +``` + +### ๋กœ๊ทธ ์œ„์น˜ +- **์• ํ”Œ๋ฆฌ์ผ€์ด์…˜**: `/volume1/docker/document-server/logs/` +- **Nginx**: `/volume1/docker/document-server/logs/nginx/` +- **PostgreSQL**: `docker logs document-server-db` +- **Redis**: `docker logs document-server-redis` + +## ๐Ÿ“ˆ ์„ฑ๋Šฅ ๋ฒค์น˜๋งˆํฌ + +### ์˜ˆ์ƒ ์„ฑ๋Šฅ (DS1525+ 32GB) +- **๋™์‹œ ์‚ฌ์šฉ์ž**: 50-100๋ช… +- **๋ฌธ์„œ ์ฒ˜๋ฆฌ**: 1000+ ๋ฌธ์„œ +- **์‘๋‹ต ์‹œ๊ฐ„**: < 200ms (ํ‰๊ท ) +- **์—…๋กœ๋“œ ์†๋„**: 100MB/s (๊ธฐ๊ฐ€๋น„ํŠธ ๋„คํŠธ์›Œํฌ) +- **๊ฒ€์ƒ‰ ์†๋„**: < 100ms (์ธ๋ฑ์Šค ๊ธฐ๋ฐ˜) + +### ํ™•์žฅ์„ฑ +- **์ˆ˜์ง ํ™•์žฅ**: RAM 64GB๊นŒ์ง€ ์ง€์› +- **์ˆ˜ํ‰ ํ™•์žฅ**: ๋กœ๋“œ ๋ฐธ๋Ÿฐ์„œ + ๋‹ค์ค‘ ๋ฐฑ์—”๋“œ +- **์Šคํ† ๋ฆฌ์ง€**: ์ถ”๊ฐ€ ๋ณผ๋ฅจ ๋งˆ์šดํŠธ ๊ฐ€๋Šฅ + +## ๐Ÿ”’ ๋ณด์•ˆ ์„ค์ • + +### ๋„คํŠธ์›Œํฌ ๋ณด์•ˆ +```bash +# ๋ฐฉํ™”๋ฒฝ ๊ทœ์น™ (ํ•„์š”ํ•œ ํฌํŠธ๋งŒ ๊ฐœ๋ฐฉ) +iptables -A INPUT -p tcp --dport 24100 -j ACCEPT # Nginx +iptables -A INPUT -p tcp --dport 22 -j ACCEPT # SSH +iptables -A INPUT -j DROP # ๊ธฐ๋ณธ ์ฐจ๋‹จ +``` + +### ๋ฐ์ดํ„ฐ ๋ณด์•ˆ +- **์•”ํ˜ธํ™”**: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐ Redis ์•”ํ˜ธ ์„ค์ • +- **๋ฐฑ์—… ์•”ํ˜ธํ™”**: GPG๋ฅผ ์ด์šฉํ•œ ๋ฐฑ์—… ํŒŒ์ผ ์•”ํ˜ธํ™” +- **์ ‘๊ทผ ์ œ์–ด**: ์‚ฌ์šฉ์ž๋ณ„ ๊ถŒํ•œ ๊ด€๋ฆฌ +- **SSL/TLS**: Let's Encrypt ์ธ์ฆ์„œ ์ ์šฉ + +## ๐Ÿ“ž ์ง€์› ๋ฐ ๋ฌธ์˜ + +### ๋ฌธ์ œ ๋ณด๊ณ  +1. **๋กœ๊ทธ ์ˆ˜์ง‘**: `./scripts/monitor-synology.sh > system-report.txt` +2. **ํ™˜๊ฒฝ ์ •๋ณด**: Docker ๋ฒ„์ „, ์‹œ์Šคํ…œ ์‚ฌ์–‘ +3. **์žฌํ˜„ ๋‹จ๊ณ„**: ๋ฌธ์ œ ๋ฐœ์ƒ ๊ณผ์ • ์ƒ์„ธ ๊ธฐ๋ก + +### ์•ˆ์ „ํ•œ ์—…๋ฐ์ดํŠธ ์ ˆ์ฐจ +```bash +# โš ๏ธ ์—…๋ฐ์ดํŠธ ์ „ ํ•„์ˆ˜ ๋ฐฑ์—… +./scripts/backup.sh + +# ํ˜„์žฌ ์ƒํƒœ ํ™•์ธ +docker-compose -f docker-compose.synology-optimized.yml ps + +# ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ณด์กด +git stash # ์„ค์ • ํŒŒ์ผ ๋“ฑ ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ž„์‹œ ์ €์žฅ + +# ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ (๋ฐ์ดํ„ฐ ๋ณด์กด) +git pull origin main + +# ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ณต์› (ํ•„์š”์‹œ) +git stash pop + +# ์ปจํ…Œ์ด๋„ˆ ์žฌ๋นŒ๋“œ (๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ ๋ณด์กด) +docker-compose -f docker-compose.synology-optimized.yml build --no-cache +docker-compose -f docker-compose.synology-optimized.yml up -d + +# ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +./scripts/monitor-synology.sh +``` + +### ๐Ÿšจ ์—…๋ฐ์ดํŠธ ์‹œ ์ฃผ์˜์‚ฌํ•ญ +- **๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ**: ์ ˆ๋Œ€ `docker-compose down -v` ์‚ฌ์šฉ ๊ธˆ์ง€ (๋ณผ๋ฅจ ์‚ญ์ œ๋จ) +- **๋ฐฑ์—… ํ™•์ธ**: ์—…๋ฐ์ดํŠธ ์ „ ๋ฐ˜๋“œ์‹œ ๋ฐฑ์—… ํŒŒ์ผ ์กด์žฌ ํ™•์ธ +- **๋กค๋ฐฑ ์ค€๋น„**: ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ์ฆ‰์‹œ ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋ณต๊ตฌ ๊ฐ€๋Šฅํ•ด์•ผ ํ•จ +- **์„œ๋น„์Šค ์ค‘๋‹จ**: ์ตœ์†Œํ•œ์˜ ์ค‘๋‹จ ์‹œ๊ฐ„์œผ๋กœ ์—…๋ฐ์ดํŠธ ์ง„ํ–‰ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b285ec --- /dev/null +++ b/README.md @@ -0,0 +1,630 @@ +# Document Server + +HTML ๋ฌธ์„œ ๊ด€๋ฆฌ ๋ฐ ๋ทฐ์–ด ์‹œ์Šคํ…œ + +## ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +PDF ๋ฌธ์„œ๋ฅผ OCR ์ฒ˜๋ฆฌํ•˜๊ณ  AI๋กœ HTML๋กœ ๋ณ€ํ™˜ํ•œ ํ›„, ์›น์—์„œ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ณ  ์—ด๋žŒํ•  ์ˆ˜ ์žˆ๋Š” ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค. + +## ๐Ÿ“ ์šฉ์–ด ์ •์˜ + +์‹œ์Šคํ…œ์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ฃผ์š” ์šฉ์–ด๋“ค์„ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค: + +### ํ•ต์‹ฌ ์šฉ์–ด +- **๋ฉ”๋ชจ (Memo)**: ํ•˜์ด๋ผ์ดํŠธ ๊ธฐ๋ฐ˜์˜ ๋ฉ”๋ชจ ๊ธฐ๋Šฅ + - ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ + - ๋ฌธ์„œ ๋ทฐ์–ด์—์„œ ํ…์ŠคํŠธ ์„ ํƒ โ†’ ํ•˜์ด๋ผ์ดํŠธ โ†’ ๋ฉ”๋ชจ ์ž‘์„ฑ + - API: `/api/notes/` (ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์ „์šฉ) + +- **๋…ธํŠธ (Note)**: ๋…๋ฆฝ์ ์ธ ๋ฌธ์„œ ์ž‘์„ฑ ๊ธฐ๋Šฅ + - HTML ๊ธฐ๋ฐ˜์˜ ์™„์ „ํ•œ ๋ฌธ์„œ + - ๊ธฐ๋ณธ ๋ทฐ์–ด ํŽ˜์ด์ง€์—์„œ ํ™•์ธ ๋ฐ ํŽธ์ง‘ + - ํ•˜์ด๋ผ์ดํŠธ, ๋ฉ”๋ชจ, ๋งํฌ ๋“ฑ ๋ชจ๋“  ๊ธฐ๋Šฅ ์‚ฌ์šฉ ๊ฐ€๋Šฅ + - ๋…ธํŠธ๋ถ์— ๊ทธ๋ฃนํ™” ๊ฐ€๋Šฅ + - API: `/api/note-documents/` + +- **๋…ธํŠธ๋ถ (Notebook)**: ๋…ธํŠธ ๋ฌธ์„œ๋“ค์„ ๊ทธ๋ฃนํ™”ํ•˜๋Š” ํด๋” + - ๋…ธํŠธ๋“ค์˜ ์ปจํ…Œ์ด๋„ˆ ์—ญํ•  + - ๊ณ„์ธต์  ๊ตฌ์กฐ ์ง€์› + - API: `/api/notebooks/` + +### ๊ธฐ๋Šฅ๋ณ„ ๊ตฌ๋ถ„ +| ๊ธฐ๋Šฅ | ์šฉ์–ด | ์„ค๋ช… | ์ฃผ์š” API | ๋ทฐ์–ด ์ง€์› | +|------|------|------|----------|----------| +| ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ | ๋ฉ”๋ชจ (Memo) | ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ | `/api/notes/` | โœ… ๋ฌธ์„œ ๋ทฐ์–ด | +| ๋…๋ฆฝ ๋ฌธ์„œ ์ž‘์„ฑ | ๋…ธํŠธ (Note) | HTML ๊ธฐ๋ฐ˜ ์™„์ „ํ•œ ๋ฌธ์„œ | `/api/note-documents/` | โœ… ๋™์ผ ๋ทฐ์–ด (๋ชจ๋“  ๊ธฐ๋Šฅ) | +| ๋ฌธ์„œ ๊ทธ๋ฃนํ™” | ๋…ธํŠธ๋ถ (Notebook) | ๋…ธํŠธ๋“ค์„ ๋‹ด๋Š” ํด๋” | `/api/notebooks/` | - | + +### ๋ฌธ์„œ ์ฒ˜๋ฆฌ ์›Œํฌํ”Œ๋กœ์šฐ +1. PDF ์Šค์บ” ํ›„ OCR ์ฒ˜๋ฆฌ +2. AI๋ฅผ ํ†ตํ•œ HTML ๋ณ€ํ™˜ (ํ•„์š”์‹œ ๋ฒˆ์—ญ ํฌํ•จ) +3. PDF ์›๋ณธ์€ Paperless์— ์—…๋กœ๋“œ +4. HTML ํŒŒ์ผ์€ Document Server์—์„œ ๊ด€๋ฆฌ + +## ์ฃผ์š” ๊ธฐ๋Šฅ + +### ํ•ต์‹ฌ ๊ธฐ๋Šฅ +- **์‚ฌ์šฉ์ž ์ธ์ฆ**: ๋กœ๊ทธ์ธ (๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ), JWT ๊ธฐ๋ฐ˜ ์„ธ์…˜ ๊ด€๋ฆฌ +- **HTML ๋ฌธ์„œ ๋ทฐ์–ด**: ๋ณ€ํ™˜๋œ HTML ๋ฌธ์„œ๋ฅผ ์›น์—์„œ ์—ด๋žŒ +- **์Šค๋งˆํŠธ ํ•˜์ด๋ผ์ดํŠธ**: ํ…์ŠคํŠธ ์„ ํƒ ํ›„ ๋ฐ‘์ค„/ํ•˜์ด๋ผ์ดํŠธ ํ‘œ์‹œ +- **์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ**: ํ•˜์ด๋ผ์ดํŠธ์— ์ง์ ‘ ๋ฉ”๋ชจ ์ถ”๊ฐ€ ๋ฐ ํŽธ์ง‘ +- **๋ฉ”๋ชจ ๊ด€๋ฆฌ**: ๋ฉ”๋ชจ๋งŒ ๋”ฐ๋กœ ๋ณด๊ธฐ, ๊ฒ€์ƒ‰, ์ •๋ ฌ ๊ธฐ๋Šฅ +- **๋น ๋ฅธ ๋„ค๋น„๊ฒŒ์ด์…˜**: ๋ฉ”๋ชจ์—์„œ ์›๋ฌธ ์œ„์น˜๋กœ ์ฆ‰์‹œ ์ด๋™ +- **์ฑ…๊ฐˆํ”ผ ๊ธฐ๋Šฅ**: ํŽ˜์ด์ง€ ๋ถ๋งˆํฌ ๋ฐ ๋น ๋ฅธ ์ด๋™ +- **ํ†ตํ•ฉ ๊ฒ€์ƒ‰**: ๋ฌธ์„œ ๋‚ด์šฉ + ๋ฉ”๋ชจ ๋‚ด์šฉ ํ†ตํ•ฉ ๊ฒ€์ƒ‰ + +### ์ถ”๊ฐ€ ๊ธฐ๋Šฅ +- **๋ฌธ์„œ ๊ด€๋ฆฌ**: HTML + PDF ์›๋ณธ ํ†ตํ•ฉ ๊ด€๋ฆฌ (Paperless ์Šคํƒ€์ผ) +- **ํƒœ๊ทธ ์‹œ์Šคํ…œ**: ๋ฌธ์„œ ๋ถ„๋ฅ˜ ๋ฐ ์กฐ์งํ™” +- **๋ฌธ์„œ ์—…๋กœ๋“œ**: ๋“œ๋ž˜๊ทธ&๋“œ๋กญ, ์ผ๊ด„ ์—…๋กœ๋“œ +- **์‚ฌ์šฉ์ž ๊ด€๋ฆฌ**: ๊ฐœ์ธ๋ณ„ ๋ฉ”๋ชจ, ๋ถ๋งˆํฌ, ๊ถŒํ•œ ๊ด€๋ฆฌ +- **๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ**: ์‚ฌ์šฉ์ž ์ƒ์„ฑ, ๋ฌธ์„œ ๊ด€๋ฆฌ, ์‹œ์Šคํ…œ ์„ค์ • +- **๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ**: ์ œ๋ชฉ, ๋‚ ์งœ, ์นดํ…Œ๊ณ ๋ฆฌ, ์ปค์Šคํ…€ ํ•„๋“œ + +## ๊ธฐ์ˆ  ์Šคํƒ + +### Backend +- **์–ธ์–ด**: Python 3.11+ +- **ํ”„๋ ˆ์ž„์›Œํฌ**: FastAPI 0.104+ +- **ORM**: SQLAlchemy 2.0+ +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL 15+ +- **์บ์‹ฑ**: Redis 7+ +- **๋น„๋™๊ธฐ**: asyncio, asyncpg +- **์ธ์ฆ**: JWT (python-jose) +- **ํŒŒ์ผ ์ฒ˜๋ฆฌ**: python-multipart, Pillow +- **๊ฒ€์ƒ‰**: Elasticsearch 8+ (๋˜๋Š” Whoosh) + +### Frontend +- **๊ธฐ๋ณธ**: HTML5, CSS3, JavaScript (ES6+) +- **CSS ํ”„๋ ˆ์ž„์›Œํฌ**: Tailwind CSS 3+ +- **UI ์ปดํฌ๋„ŒํŠธ**: Alpine.js 3+ (๊ฒฝ๋Ÿ‰ ๋ฐ˜์‘ํ˜•) +- **๊ฒ€์ƒ‰ UI**: Fuse.js (ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ๊ฒ€์ƒ‰) +- **์—๋””ํ„ฐ**: Quill.js 1.3+ (๋ฉ”๋ชจ ๊ธฐ๋Šฅ) +- **ํ•˜์ด๋ผ์ดํŠธ**: Rangy.js (ํ…์ŠคํŠธ ์„ ํƒ/ํ•˜์ด๋ผ์ดํŠธ) +- **์•„์ด์ฝ˜**: Heroicons / Lucide + +### ์›น์„œ๋ฒ„ & ํ”„๋ก์‹œ +- **๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ**: Nginx 1.24+ +- **ASGI ์„œ๋ฒ„**: Uvicorn 0.24+ +- **์ •์  ํŒŒ์ผ**: Nginx (์ง์ ‘ ์„œ๋น™) + +### ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค & ์ €์žฅ์†Œ +- **์ฃผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL 15+ (๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ, ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ) +- **์ „๋ฌธ ๊ฒ€์ƒ‰**: PostgreSQL Full-Text Search + Elasticsearch (์„ ํƒ) +- **์บ์‹ฑ**: Redis 7+ (์„ธ์…˜, ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์บ์‹ฑ) +- **ํŒŒ์ผ ์ €์žฅ์†Œ**: ๋กœ์ปฌ ํŒŒ์ผ์‹œ์Šคํ…œ (ํ–ฅํ›„ S3 ํ˜ธํ™˜ ์Šคํ† ๋ฆฌ์ง€) + +### ๊ฐœ๋ฐœ ๋„๊ตฌ +- **ํŒจํ‚ค์ง€ ๊ด€๋ฆฌ**: Poetry (Python ์˜์กด์„ฑ) +- **์ฝ”๋“œ ํฌ๋งทํŒ…**: Black, isort +- **๋ฆฐํŒ…**: Flake8, mypy (ํƒ€์ž… ์ฒดํ‚น) +- **ํ…Œ์ŠคํŒ…**: pytest, pytest-asyncio +- **API ๋ฌธ์„œ**: FastAPI ์ž๋™ ์ƒ์„ฑ (Swagger/OpenAPI) + +### ์ธํ”„๋ผ & ๋ฐฐํฌ +- **์ปจํ…Œ์ด๋„ˆ**: Docker 24+ & Docker Compose +- **์ฃผ ๋ฐฐํฌ ํ™˜๊ฒฝ**: Synology DS1525+ (32GB RAM, SSD ์บ์‹ฑ) +- **๋ณด์กฐ ๋ฐฐํฌ ํ™˜๊ฒฝ**: Mac Mini (๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ) +- **ํ”„๋กœ์„ธ์Šค ๊ด€๋ฆฌ**: Docker (์ปจํ…Œ์ด๋„ˆ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜) +- **๋กœ๊ทธ ๊ด€๋ฆฌ**: Python logging + ํŒŒ์ผ ๋กœํ…Œ์ด์…˜ +- **๋ชจ๋‹ˆํ„ฐ๋ง**: ๊ธฐ๋ณธ ํ—ฌ์Šค์ฒดํฌ (ํ–ฅํ›„ Prometheus + Grafana) + +### ์™ธ๋ถ€ ์—ฐ๋™ +- **Paperless-ngx**: REST API ์—ฐ๋™ (์›๋ณธ PDF ๋‹ค์šด๋กœ๋“œ) +- **OCR**: Tesseract (ํ•„์š”์‹œ ์ถ”๊ฐ€ OCR ์ฒ˜๋ฆฌ) +- **AI ๋ฒˆ์—ญ**: OpenAI API / Google Translate API (์„ ํƒ) + +## ํฌํŠธ ํ• ๋‹น + +- **24100**: Nginx (๋ฉ”์ธ ์›น์„œ๋ฒ„) +- **24101**: Database (PostgreSQL/SQLite) +- **24102**: Backend API ์„œ๋ฒ„ +- **24103**: ์ถ”๊ฐ€ ์„œ๋น„์Šค์šฉ ์˜ˆ์•ฝ + +## ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ + +``` +document-server/ +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ docker-compose.yml +โ”œโ”€โ”€ nginx/ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ””โ”€โ”€ nginx.conf +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ Dockerfile +โ”‚ โ”œโ”€โ”€ requirements.txt (Python) / package.json (Node.js) +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ main.py / app.js +โ”‚ โ”‚ โ”œโ”€โ”€ models/ +โ”‚ โ”‚ โ”œโ”€โ”€ routes/ +โ”‚ โ”‚ โ””โ”€โ”€ services/ +โ”‚ โ””โ”€โ”€ uploads/ +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ static/ +โ”‚ โ”‚ โ”œโ”€โ”€ css/ +โ”‚ โ”‚ โ”œโ”€โ”€ js/ +โ”‚ โ”‚ โ””โ”€โ”€ assets/ +โ”‚ โ””โ”€โ”€ templates/ +โ”œโ”€โ”€ database/ +โ”‚ โ”œโ”€โ”€ init/ +โ”‚ โ””โ”€โ”€ migrations/ +โ””โ”€โ”€ docs/ + โ””โ”€โ”€ api.md +``` + +## ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ (์˜ˆ์ƒ) + +### ์ฃผ์š” ํ…Œ์ด๋ธ” +- **users**: ์‚ฌ์šฉ์ž ์ •๋ณด (์ด๋ฉ”์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹œ, ๊ถŒํ•œ, ์ƒ์„ฑ์ผ) +- **documents**: ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์ œ๋ชฉ, HTML/PDF ๊ฒฝ๋กœ, ์—…๋กœ๋“œ์ž, ์ƒ์„ฑ์ผ) +- **document_tags**: ๋ฌธ์„œ ํƒœ๊ทธ (๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„) +- **tags**: ํƒœ๊ทธ ์ •๋ณด (์ด๋ฆ„, ์ƒ‰์ƒ, ์„ค๋ช…) +- **highlights**: ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด (์‚ฌ์šฉ์ž๋ณ„, ๋ฌธ์„œ๋ณ„, ํ…์ŠคํŠธ ๋ฒ”์œ„, ์ƒ‰์ƒ) +- **notes**: ๋ฉ”๋ชจ ์ •๋ณด (ํ•˜์ด๋ผ์ดํŠธ ์—ฐ๊ฒฐ, ๋ฉ”๋ชจ ๋‚ด์šฉ, ์ƒ์„ฑ/์ˆ˜์ •์ผ) +- **bookmarks**: ์ฑ…๊ฐˆํ”ผ ์ •๋ณด (์‚ฌ์šฉ์ž๋ณ„, ๋ฌธ์„œ๋ณ„, ํŽ˜์ด์ง€ ์œ„์น˜) +- **user_sessions**: ์‚ฌ์šฉ์ž ์„ธ์…˜ ๊ด€๋ฆฌ (JWT ํ† ํฐ, ๋งŒ๋ฃŒ์ผ) +- **user_preferences**: ์‚ฌ์šฉ์ž ์„ค์ • (ํ…Œ๋งˆ, ์–ธ์–ด, ๋ทฐ์–ด ์„ค์ •) + +### ํ•˜์ด๋ผ์ดํŠธ & ๋ฉ”๋ชจ ์Šคํ‚ค๋งˆ ์ƒ์„ธ +```sql +-- ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ด๋ธ” +highlights ( + id: UUID PRIMARY KEY, + user_id: UUID REFERENCES users(id), + document_id: UUID REFERENCES documents(id), + start_offset: INTEGER, -- ํ…์ŠคํŠธ ์‹œ์ž‘ ์œ„์น˜ + end_offset: INTEGER, -- ํ…์ŠคํŠธ ๋ ์œ„์น˜ + selected_text: TEXT, -- ์„ ํƒ๋œ ํ…์ŠคํŠธ (๊ฒ€์ƒ‰์šฉ) + highlight_color: VARCHAR(7), -- ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ (#FFFF00) + element_selector: TEXT, -- DOM ์š”์†Œ ์„ ํƒ์ž + created_at: TIMESTAMP, + updated_at: TIMESTAMP +) + +-- ๋ฉ”๋ชจ ํ…Œ์ด๋ธ” (ํ•˜์ด๋ผ์ดํŠธ์™€ 1:1 ๊ด€๊ณ„) +notes ( + id: UUID PRIMARY KEY, + highlight_id: UUID REFERENCES highlights(id) ON DELETE CASCADE, + content: TEXT NOT NULL, -- ๋ฉ”๋ชจ ๋‚ด์šฉ + is_private: BOOLEAN DEFAULT true, + tags: TEXT[], -- ๋ฉ”๋ชจ ํƒœ๊ทธ + created_at: TIMESTAMP, + updated_at: TIMESTAMP +) +``` + +## ๊ฐœ๋ฐœ ๋‹จ๊ณ„ + +### Phase 1: ๊ธฐ๋ณธ ๊ตฌ์กฐ โœ… +- [x] ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์„ค์ • +- [x] Docker ํ™˜๊ฒฝ ๊ตฌ์„ฑ +- [x] ๊ธฐ๋ณธ ์›น์„œ๋ฒ„ ์„ค์ • (Nginx + FastAPI) + +### Phase 2: ์ธ์ฆ ์‹œ์Šคํ…œ โœ… +- [x] ์‚ฌ์šฉ์ž ๋ชจ๋ธ ๋ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ +- [x] ๋กœ๊ทธ์ธ API (๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ) +- [x] JWT ํ† ํฐ ๊ด€๋ฆฌ +- [x] ๊ถŒํ•œ ๋ฏธ๋“ค์›จ์–ด + +### Phase 3: ํ•ต์‹ฌ ๊ธฐ๋Šฅ โœ… +- [x] HTML ๋ฌธ์„œ ๋ทฐ์–ด (ํ•˜์ด๋ผ์ดํŠธ, ๋ฉ”๋ชจ ๊ธฐ๋Šฅ ํฌํ•จ) +- [x] ๋ฌธ์„œ ์—…๋กœ๋“œ ๊ธฐ๋Šฅ +- [x] ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ (๋ฌธ์„œ + ๋ฉ”๋ชจ) + +### Phase 4: ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ โœ… +- [x] ์Šค๋งˆํŠธ ํ•˜์ด๋ผ์ดํŠธ (ํ…์ŠคํŠธ ์„ ํƒ โ†’ ํ•˜์ด๋ผ์ดํŠธ) +- [x] ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ (ํ•˜์ด๋ผ์ดํŠธ โ†” ๋ฉ”๋ชจ 1:1 ์—ฐ๊ฒฐ) +- [x] ์ฑ…๊ฐˆํ”ผ ์‹œ์Šคํ…œ (์œ„์น˜ ์ €์žฅ ๋ฐ ๋น ๋ฅธ ์ด๋™) +- [x] ๋ฉ”๋ชจ ๊ด€๋ฆฌ (๊ฒ€์ƒ‰, ํ•„ํ„ฐ๋ง, ํƒœ๊ทธ) +- [x] ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ (๋ฌธ์„œ + ๋ฉ”๋ชจ ํ†ตํ•ฉ ๊ฒ€์ƒ‰) + +### Phase 5: ๋ฌธ์„œ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ โœ… +- [x] ๋ฌธ์„œ ํƒœ๊ทธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ (ํƒœ๊ทธ ์ƒ์„ฑ, ํ•„ํ„ฐ๋ง) +- [x] ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ (์ œ๋ชฉ, ์„ค๋ช…, ๋‚ ์งœ, ์–ธ์–ด) +- [x] ์‚ฌ์šฉ์ž๋ณ„ ๊ถŒํ•œ ์‹œ์Šคํ…œ +- [x] ๊ด€๋ฆฌ์ž ๊ณ„์ • ๊ธฐ๋ฐ˜ ์‚ฌ์šฉ์ž ์ƒ์„ฑ +- [x] Paperless ์Šคํƒ€์ผ ๋ฌธ์„œ ๊ด€๋ฆฌ + +### Phase 6: ์‹œ์Šคํ…œ ์•ˆ์ •์„ฑ ๋ฐ ํ†ตํ•ฉ โœ… +- [x] ํ”„๋ก ํŠธ์—”๋“œ-๋ฐฑ์—”๋“œ ์™„์ „ ์—ฐ๋™ +- [x] Pydantic v2 ํ˜ธํ™˜์„ฑ ์ˆ˜์ • +- [x] Alpine.js ์ปดํฌ๋„ŒํŠธ ๊ฐ„ ์•ˆ์ „ํ•œ ํ†ต์‹  +- [x] API ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๋ฐ ์‚ฌ์šฉ์ž ํ”ผ๋“œ๋ฐฑ +- [x] ์‹ค์‹œ๊ฐ„ ๋ฌธ์„œ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + +### Phase 7: ์ตœ์šฐ์„  ๊ฐœ์„ ์‚ฌํ•ญ โœ… +- [x] **๋ฉ”๋ชจ-ํ•˜์ด๋ผ์ดํŠธ ํ†ตํ•ฉ**: ํ•˜์ด๋ผ์ดํŠธ ๊ธฐ๋ฐ˜ ๋ฉ”๋ชจ ๊ธฐ๋Šฅ ์™„์ „ ํ†ตํ•ฉ +- [x] **๋…ธํŠธ ๋ทฐ์–ด ๊ธฐ๋Šฅ**: ๋…ธํŠธ์—์„œ ํ•˜์ด๋ผ์ดํŠธ, ๋ฉ”๋ชจ, ๋งํฌ ๋“ฑ ๋ชจ๋“  ๊ธฐ๋Šฅ ์ง€์› +- [x] **API ๊ตฌ์กฐ ์ •๋ฆฌ**: ๋ฉ”๋ชจ(`/api/notes/`) vs ๋…ธํŠธ(`/api/note-documents/`) ๋ช…ํ™•ํ•œ ๋ถ„๋ฆฌ +- [x] **์šฉ์–ด ํ†ต์ผ**: ์ „์ฒด ์‹œ์Šคํ…œ์—์„œ ๋ฉ”๋ชจ/๋…ธํŠธ ์šฉ์–ด ์ผ๊ด€์„ฑ ํ™•๋ณด +- [x] **๋…ธํŠธ๋ถ-์„œ์  ๋งํฌ ์‹œ์Šคํ…œ**: ์–‘๋ฐฉํ–ฅ ๋งํฌ/๋ฐฑ๋งํฌ ์™„์ „ ๊ตฌํ˜„ + +### Phase 8: ๋ฏธ์™„์„ฑ ํ•ต์‹ฌ ๊ธฐ๋Šฅ (์šฐ์„ ์ˆœ์œ„) ๐Ÿšง +- [x] **๋…ธํŠธ ํŽธ์ง‘๊ธฐ**: ๋…ธํŠธ ์ƒ์„ฑ/ํŽธ์ง‘ UI ์™„์„ฑ (`/note-editor.html`) โœ… +- [x] **๋…ธํŠธ๋ถ ๊ด€๋ฆฌ API**: ๋…ธํŠธ๋ถ CRUD ๋ฐฑ์—”๋“œ ์™„์„ฑ โœ… +- [x] **๋…ธํŠธ๋ถ ๊ด€๋ฆฌ UI**: ํ”„๋ก ํŠธ์—”๋“œ CRUD ๊ธฐ๋Šฅ ์™„์„ฑ (`/notebooks.html`) โœ… + - ๋…ธํŠธ๋ถ ๋ชฉ๋ก ์กฐํšŒ/ํ‘œ์‹œ, ์ƒ์„ฑ/ํŽธ์ง‘/์‚ญ์ œ ๋ชจ๋‹ฌ + - ํ† ์ŠคํŠธ ์•Œ๋ฆผ ์‹œ์Šคํ…œ, ํ†ต๊ณ„ ๋Œ€์‹œ๋ณด๋“œ + - ๋…ธํŠธ๋ถ๋ณ„ ๋…ธํŠธ ๊ด€๋ฆฌ ๋ฐ ๋น ๋ฅธ ๋…ธํŠธ ์ƒ์„ฑ +- [x] **๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‹œ์Šคํ…œ**: ๊ณ„์ธต์  ๋ฉ”๋ชจ ๊ตฌ์กฐ ๋ฐ ๊ด€๋ฆฌ (`/memo-tree.html`) โœ… + - ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ ์ƒ์„ฑ/ํŽธ์ง‘/์‚ญ์ œ, Monaco ์—๋””ํ„ฐ ํ†ตํ•ฉ + - ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ๋…ธ๋“œ ์žฌ๋ฐฐ์น˜, ์ •์‚ฌ ๊ฒฝ๋กœ ์„ค์ • + - ๋‹ค์–‘ํ•œ ๋…ธ๋“œ ํƒ€์ž… (๋ฉ”๋ชจ, ํด๋”, ์ฑ•ํ„ฐ, ์บ๋ฆญํ„ฐ, ํ”Œ๋กฏ) + - ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ ๋ฐ ํ† ์ŠคํŠธ ์•Œ๋ฆผ +- [ ] **๊ณ ๊ธ‰ ๊ฒ€์ƒ‰**: ๋ฌธ์„œ/๋…ธํŠธ/๋ฉ”๋ชจ ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ํ•„ํ„ฐ๋ง +- [ ] **์‚ฌ์šฉ์ž ๊ด€๋ฆฌ**: ๋‹ค์ค‘ ์‚ฌ์šฉ์ž ์ง€์› ๋ฐ ๊ถŒํ•œ ๊ด€๋ฆฌ + +### Phase 9: ๊ด€๋ฆฌ ๋ฐ ์ตœ์ ํ™” (์˜ˆ์ •) +- [ ] ๊ด€๋ฆฌ์ž ๋Œ€์‹œ๋ณด๋“œ UI +- [ ] ๋ฌธ์„œ ํ†ต๊ณ„ ๋ฐ ๋ถ„์„ +- [ ] ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ์ตœ์ ํ™” +- [ ] ๋ฌธ์„œ ๋ฒ„์ „ ๊ด€๋ฆฌ +- [ ] ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ์บ์‹ฑ + +## ํ˜„์žฌ ์ƒํƒœ (2025-01-26) + +### โœ… ์™„๋ฃŒ๋œ ๊ธฐ๋Šฅ +- **์™„์ „ํ•œ ๋ฐฑ์—”๋“œ API**: FastAPI + SQLAlchemy + PostgreSQL +- **์‚ฌ์šฉ์ž ์ธ์ฆ**: JWT ๊ธฐ๋ฐ˜ ๋กœ๊ทธ์ธ/๋กœ๊ทธ์•„์›ƒ +- **๋ฌธ์„œ ๊ด€๋ฆฌ**: ์—…๋กœ๋“œ, ์กฐํšŒ, ๋ชฉ๋ก, ์‚ญ์ œ (๋“œ๋ž˜๊ทธ&๋“œ๋กญ ์ง€์›) +- **ํƒœ๊ทธ ์‹œ์Šคํ…œ**: ๋ฌธ์„œ ๋ถ„๋ฅ˜ ๋ฐ ํ•„ํ„ฐ๋ง +- **ํ•˜์ด๋ผ์ดํŠธ & ๋ฉ”๋ชจ**: ํ…์ŠคํŠธ ์„ ํƒ โ†’ ํ•˜์ด๋ผ์ดํŠธ โ†’ ๋ฉ”๋ชจ ์ถ”๊ฐ€ +- **์ฑ…๊ฐˆํ”ผ**: ํŽ˜์ด์ง€ ๋ถ๋งˆํฌ ๋ฐ ๋น ๋ฅธ ์ด๋™ +- **ํ†ตํ•ฉ ๊ฒ€์ƒ‰**: ๋ฌธ์„œ ๋‚ด์šฉ + ๋ฉ”๋ชจ ํ†ตํ•ฉ ๊ฒ€์ƒ‰ +- **์‹ค์‹œ๊ฐ„ UI**: ์—…๋กœ๋“œ ํ›„ ์ฆ‰์‹œ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ +- **ํ• ์ผ๊ด€๋ฆฌ ์‹œ์Šคํ…œ**: ๊ฒ€ํ† ํ•„์š”/TODO/์™„๋ฃŒ๋œ์ผ 3๋‹จ๊ณ„ ์›Œํฌํ”Œ๋กœ์šฐ +- **๋ฉ”๋ชจ ํŠธ๋ฆฌ**: ๊ณ„์ธต์  ๋ฉ”๋ชจ ๊ตฌ์กฐ ๋ฐ Monaco ์—๋””ํ„ฐ +- **๋…ธํŠธ๋ถ ์‹œ์Šคํ…œ**: ๋…ธํŠธ ๋ฌธ์„œ ๊ทธ๋ฃนํ™” ๋ฐ ๊ด€๋ฆฌ +- **๋ชจ๋ฐ”์ผ ์ตœ์ ํ™”**: ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ, ํ’€ํˆฌ๋ฆฌํ”„๋ ˆ์‹œ, ๋ฐ˜์‘ํ˜• ๋””์ž์ธ + +### ๐Ÿš€ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ธฐ๋Šฅ +1. **๋กœ๊ทธ์ธ**: `admin@test.com` / `admin123` +2. **๋ฌธ์„œ ์—…๋กœ๋“œ**: HTML ํŒŒ์ผ ๋“œ๋ž˜๊ทธ&๋“œ๋กญ ๋˜๋Š” ์„ ํƒ +3. **๋ฌธ์„œ ๋ทฐ์–ด**: ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ ํด๋ฆญํ•˜์—ฌ ๋ทฐ์–ด ํŽ˜์ด์ง€ ์ด๋™ +4. **ํƒœ๊ทธ ๊ด€๋ฆฌ**: ์—…๋กœ๋“œ ์‹œ ํƒœ๊ทธ ์ถ”๊ฐ€, ๋ชฉ๋ก์—์„œ ํƒœ๊ทธ๋ณ„ ํ•„ํ„ฐ๋ง +5. **ํ• ์ผ๊ด€๋ฆฌ**: `/todos.html` - ๊ฒ€ํ† ํ•„์š” โ†’ TODO โ†’ ์™„๋ฃŒ๋œ์ผ ์›Œํฌํ”Œ๋กœ์šฐ +6. **๋ฉ”๋ชจ ํŠธ๋ฆฌ**: `/memo-tree.html` - ๊ณ„์ธต์  ๋ฉ”๋ชจ ์ž‘์„ฑ ๋ฐ ๊ด€๋ฆฌ +7. **๋…ธํŠธ๋ถ**: `/notebooks.html` - ๋…ธํŠธ ๋ฌธ์„œ ๊ทธ๋ฃนํ™” ๋ฐ ํŽธ์ง‘ + +### ๐Ÿ”ง ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค +- **ํ”„๋ก ํŠธ์—”๋“œ**: http://localhost:24100 +- **๋ฐฑ์—”๋“œ API**: http://localhost:24102 +- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: PostgreSQL (ํฌํŠธ 24101) +- **์บ์‹œ**: Redis (ํฌํŠธ 24103) + +## โš ๏ธ ์‹ค์ œ ์„œ๋น„์Šค ํ™˜๊ฒฝ ์ฃผ์˜์‚ฌํ•ญ + +### ๐Ÿšจ ์ค‘์š”: ์›๋ณธ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ +์ด ์‹œ์Šคํ…œ์€ **์‹ค์ œ ์„œ๋น„์Šค ์ค‘์ธ ๋งฅ๋ฏธ๋‹ˆ**์— ์„ค์น˜๋˜์–ด ์šด์˜๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +**์ ˆ๋Œ€ ์‚ญ์ œํ•˜๋ฉด ์•ˆ ๋˜๋Š” ์›๋ณธ ๋ฐ์ดํ„ฐ:** +- `/Users/hyungi/document-server/uploads/` - ์—…๋กœ๋“œ๋œ ์›๋ณธ ๋ฌธ์„œ๋“ค +- `/Users/hyungi/document-server/frontend/uploads/` - ํ”„๋ก ํŠธ์—”๋“œ ์—…๋กœ๋“œ ํŒŒ์ผ๋“ค +- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ (๋ฉ”๋ชจ, ํ•˜์ด๋ผ์ดํŠธ, ๋…ธํŠธ๋ถ) +- ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๋ชจ๋“  ์ฝ˜ํ…์ธ  + +### ๐Ÿ“‹ ์—…๋ฐ์ดํŠธ/์ˆ˜์ • ์ „ ํ•„์ˆ˜ ์ฒดํฌ๋ฆฌ์ŠคํŠธ +```bash +# 1. ํ˜„์žฌ ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +docker-compose ps + +# 2. ์ „์ฒด ๋ฐฑ์—… ์‹คํ–‰ (ํ•„์ˆ˜!) +./scripts/backup.sh + +# 3. ๋ฐฑ์—… ํŒŒ์ผ ํ™•์ธ +ls -la ./backups/ + +# 4. ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ ๋ณด์กด ํ™•์ธ +ls -la uploads/ +ls -la frontend/uploads/ +``` + +### ๐Ÿ›ก๏ธ ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ ์›์น™ +1. **์›๋ณธ ๋ณด์กด**: ์—…๋ฐ์ดํŠธ ์‹œ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ๋Š” ์ ˆ๋Œ€ ์‚ญ์ œํ•˜์ง€ ์•Š์Œ +2. **๋ฐฑ์—… ์šฐ์„ **: ๋ชจ๋“  ๋ณ€๊ฒฝ ์ „ ๋ฐ˜๋“œ์‹œ ๋ฐฑ์—… ์‹คํ–‰ +3. **์ ์ง„์  ์—…๋ฐ์ดํŠธ**: ์ฝ”๋“œ๋งŒ ์—…๋ฐ์ดํŠธํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋Š” ๋ณด์กด +4. **๋กค๋ฐฑ ์ค€๋น„**: ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ์ฆ‰์‹œ ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋ณต๊ตฌ ๊ฐ€๋Šฅ + +### ๐Ÿ”„ ์•ˆ์ „ํ•œ ์—…๋ฐ์ดํŠธ ๋ฐฉ๋ฒ• +```bash +# 1. ๋ฐฑ์—… ์‹คํ–‰ +cp -r uploads/ uploads_backup_$(date +%Y%m%d_%H%M%S)/ +cp -r frontend/uploads/ frontend_uploads_backup_$(date +%Y%m%d_%H%M%S)/ + +# 2. ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ (๋ฐ์ดํ„ฐ ๋ณด์กด) +git stash # ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ž„์‹œ ์ €์žฅ +git pull origin main +git stash pop # ํ•„์š”์‹œ ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ณต์› + +# 3. ์„œ๋น„์Šค ์žฌ์‹œ์ž‘ (๋ฐ์ดํ„ฐ ๋ณผ๋ฅจ ๋ณด์กด) +docker-compose restart + +# 4. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +docker-compose ps +curl http://localhost:24100/health # ํ—ฌ์Šค์ฒดํฌ +``` + +## ์„ค์น˜ ๋ฐ ์‹คํ–‰ + +### ๊ฐœ๋ฐœ ํ™˜๊ฒฝ +```bash +# ํ”„๋กœ์ ํŠธ ํด๋ก  +git clone +cd document-server + +# Docker ํ™˜๊ฒฝ ์‹คํ–‰ +docker-compose up -d + +# ๊ฐœ๋ฐœ ๋ชจ๋“œ ์‹คํ–‰ +docker-compose -f docker-compose.dev.yml up +``` + +### ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ +```bash +# ์ผ๋ฐ˜ ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ +docker-compose -f docker-compose.prod.yml up -d + +# Synology DS1525+ ์ตœ์ ํ™” ๋ฐฐํฌ (๊ถŒ์žฅ) +./scripts/deploy-synology.sh +``` + +### ๐Ÿ“‹ Synology NAS ๋ฐฐํฌ +DS1525+ (32GB RAM, SSD ์บ์‹œ) ํ™˜๊ฒฝ์— ์ตœ์ ํ™”๋œ ๋ฐฐํฌ ๊ฐ€์ด๋“œ๋Š” [README-DEPLOYMENT.md](README-DEPLOYMENT.md)๋ฅผ ์ฐธ์กฐํ•˜์„ธ์š”. + +**์ฃผ์š” ํŠน์ง•:** +- 32GB RAM ์ตœ์ ํ™” (PostgreSQL 8GB, Redis 8GB) +- SSD/HDD ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์Šคํ† ๋ฆฌ์ง€ ์ „๋žต +- ์ž๋™ ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง ๋„๊ตฌ +- ์„ฑ๋Šฅ ์ตœ์ ํ™”๋œ ์„ค์ • (Nginx ์บ์‹œ, DB ํŠœ๋‹) + +## ๐Ÿข Synology DS1525+ ์ตœ์ ํ™” ๋ฐฐํฌ + +### ํ•˜๋“œ์›จ์–ด ์‚ฌ์–‘ +- **๋ชจ๋ธ**: Synology DS1525+ (5-Bay NAS) +- **CPU**: AMD Ryzen R1600 (4์ฝ”์–ด/8์Šค๋ ˆ๋“œ) +- **๋ฉ”๋ชจ๋ฆฌ**: 32GB DDR4 ECC +- **์Šคํ† ๋ฆฌ์ง€**: SSD ์ฝ๊ธฐ/์“ฐ๊ธฐ ์บ์‹ฑ ํ™œ์„ฑํ™” +- **๋„คํŠธ์›Œํฌ**: ๊ธฐ๊ฐ€๋น„ํŠธ ์ด๋”๋„ท + +### ์Šคํ† ๋ฆฌ์ง€ ์ตœ์ ํ™” ์ „๋žต + +#### SSD ๋ฐฐ์น˜ (๊ณ ์„ฑ๋Šฅ ์š”๊ตฌ) +```bash +# ์‹œ์Šคํ…œ ๋ฐ ๊ณ ๋นˆ๋„ ์•ก์„ธ์Šค ๋ฐ์ดํ„ฐ +/volume1/docker/document-server/ +โ”œโ”€โ”€ database/ # PostgreSQL ๋ฐ์ดํ„ฐ (SSD) +โ”œโ”€โ”€ redis/ # Redis ์บ์‹œ (SSD) +โ”œโ”€โ”€ logs/ # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ๊ทธ (SSD) +โ””โ”€โ”€ config/ # ์„ค์ • ํŒŒ์ผ (SSD) +``` + +#### HDD ๋ฐฐ์น˜ (๋Œ€์šฉ๋Ÿ‰ ์ €์žฅ) +```bash +# ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์ €์žฅ์†Œ +/volume2/document-storage/ +โ”œโ”€โ”€ documents/ # HTML ๋ฌธ์„œ ํŒŒ์ผ (HDD) +โ”œโ”€โ”€ uploads/ # ์—…๋กœ๋“œ๋œ ์›๋ณธ ํŒŒ์ผ (HDD) +โ”œโ”€โ”€ backups/ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… (HDD) +โ””โ”€โ”€ archives/ # ์•„์นด์ด๋ธŒ ํŒŒ์ผ (HDD) +``` + +### Docker Compose ์ตœ์ ํ™” ์„ค์ • + +#### ๋ณผ๋ฅจ ๋งคํ•‘ (docker-compose.synology.yml) +```yaml +version: '3.8' + +services: + database: + volumes: + # SSD: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ฑ๋Šฅ ์ตœ์ ํ™” + - /volume1/docker/document-server/database:/var/lib/postgresql/data + + redis: + volumes: + # SSD: ์บ์‹œ ์„ฑ๋Šฅ ์ตœ์ ํ™” + - /volume1/docker/document-server/redis:/data + + backend: + volumes: + # SSD: ๋กœ๊ทธ ๋ฐ ์„ค์ • + - /volume1/docker/document-server/logs:/app/logs + - /volume1/docker/document-server/config:/app/config + # HDD: ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์ €์žฅ + - /volume2/document-storage/uploads:/app/uploads + - /volume2/document-storage/documents:/app/documents + + nginx: + volumes: + # SSD: ์„ค์ • ๋ฐ ์บ์‹œ + - /volume1/docker/document-server/nginx:/etc/nginx/conf.d + # HDD: ์ •์  ํŒŒ์ผ ์„œ๋น™ + - /volume2/document-storage/documents:/usr/share/nginx/html/documents:ro +``` + +### ์‹œ๋†€๋กœ์ง€ ํ™˜๊ฒฝ ๋ฐฐํฌ ๋ช…๋ น์–ด + +```bash +# 1. ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +sudo mkdir -p /volume1/docker/document-server/{database,redis,logs,config,nginx} +sudo mkdir -p /volume2/document-storage/{documents,uploads,backups,archives} + +# 2. ๊ถŒํ•œ ์„ค์ • +sudo chown -R 1000:1000 /volume1/docker/document-server/ +sudo chown -R 1000:1000 /volume2/document-storage/ + +# 3. ์‹œ๋†€๋กœ์ง€ ์ตœ์ ํ™” ๋ฐฐํฌ +docker-compose -f docker-compose.synology.yml up -d + +# 4. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +docker-compose -f docker-compose.synology.yml ps +``` + +### ์„ฑ๋Šฅ ์ตœ์ ํ™” ์„ค์ • + +#### PostgreSQL ํŠœ๋‹ (32GB RAM ํ™˜๊ฒฝ) +```ini +# postgresql.conf +shared_buffers = 8GB # RAM์˜ 25% +effective_cache_size = 24GB # RAM์˜ 75% +work_mem = 256MB # ๋ณต์žกํ•œ ์ฟผ๋ฆฌ์šฉ +maintenance_work_mem = 2GB # ์ธ๋ฑ์Šค ๊ตฌ์ถ•์šฉ +checkpoint_completion_target = 0.9 # SSD ์ตœ์ ํ™” +wal_buffers = 64MB # WAL ๋ฒ„ํผ +random_page_cost = 1.1 # SSD ํ™˜๊ฒฝ ์ตœ์ ํ™” +``` + +#### Redis ์„ค์ • (์บ์‹ฑ ์ตœ์ ํ™”) +```conf +# redis.conf +maxmemory 4gb # ์บ์‹œ ๋ฉ”๋ชจ๋ฆฌ ์ œํ•œ +maxmemory-policy allkeys-lru # LRU ์ •์ฑ… +save 900 1 # ์ž๋™ ์ €์žฅ ์„ค์ • +save 300 10 +save 60 10000 +``` + +### ๋ฐฑ์—… ์ „๋žต + +#### ์ž๋™ ๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ +```bash +#!/bin/bash +# /volume1/docker/document-server/scripts/backup.sh + +BACKUP_DIR="/volume2/document-storage/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… +docker-compose -f docker-compose.synology.yml exec -T database \ + pg_dump -U postgres document_server > "$BACKUP_DIR/db_backup_$DATE.sql" + +# ์„ค์ • ํŒŒ์ผ ๋ฐฑ์—… +tar -czf "$BACKUP_DIR/config_backup_$DATE.tar.gz" \ + /volume1/docker/document-server/config/ + +# 7์ผ ์ด์ƒ ๋œ ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ +find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete +find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete + +echo "Backup completed: $DATE" +``` + +#### ์‹œ๋†€๋กœ์ง€ ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ ์„ค์ • +```bash +# ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ ์ž๋™ ๋ฐฑ์—… +# ์ œ์–ดํŒ > ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ > ์ƒ์„ฑ > ์‚ฌ์šฉ์ž ์ •์˜ ์Šคํฌ๋ฆฝํŠธ +0 2 * * * /volume1/docker/document-server/scripts/backup.sh +``` + +### ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ ์œ ์ง€๋ณด์ˆ˜ + +#### ๋ฆฌ์†Œ์Šค ๋ชจ๋‹ˆํ„ฐ๋ง +```bash +# ์ปจํ…Œ์ด๋„ˆ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ +docker stats + +# ๋””์Šคํฌ ์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ +df -h /volume1 /volume2 + +# ์‹œ๋†€๋กœ์ง€ ์‹œ์Šคํ…œ ์ƒํƒœ +cat /proc/meminfo | grep -E "MemTotal|MemAvailable" +``` + +#### ๋กœ๊ทธ ๋กœํ…Œ์ด์…˜ ์„ค์ • +```bash +# /etc/logrotate.d/document-server +/volume1/docker/document-server/logs/*.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 644 1000 1000 + postrotate + docker-compose -f docker-compose.synology.yml restart backend + endscript +} +``` + +### ๋„คํŠธ์›Œํฌ ์ตœ์ ํ™” + +#### ํฌํŠธ ํฌ์›Œ๋”ฉ ์„ค์ • +- **์™ธ๋ถ€ ํฌํŠธ**: 24100 (HTTPS ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ๊ถŒ์žฅ) +- **๋‚ด๋ถ€ ํฌํŠธ**: 24100 (Nginx) +- **๋ฐฉํ™”๋ฒฝ**: ํ•„์š”ํ•œ ํฌํŠธ๋งŒ ๊ฐœ๋ฐฉ + +#### SSL/TLS ์„ค์ • (Let's Encrypt) +```bash +# Certbot์„ ํ†ตํ•œ SSL ์ธ์ฆ์„œ ์ž๋™ ๊ฐฑ์‹  +docker run --rm -v /volume1/docker/document-server/ssl:/etc/letsencrypt \ + certbot/certbot certonly --webroot \ + -w /volume2/document-storage/documents \ + -d your-domain.com +``` + +## API ์—”๋“œํฌ์ธํŠธ (์˜ˆ์ƒ) + +### ์ธ์ฆ ๊ด€๋ฆฌ +- `POST /api/auth/register` - ํšŒ์›๊ฐ€์ž… +- `POST /api/auth/login` - ๋กœ๊ทธ์ธ +- `POST /api/auth/logout` - ๋กœ๊ทธ์•„์›ƒ +- `POST /api/auth/refresh` - ํ† ํฐ ๊ฐฑ์‹  +- `GET /api/auth/me` - ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด + +### ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ +- `GET /api/users/profile` - ํ”„๋กœํ•„ ์กฐํšŒ +- `PUT /api/users/profile` - ํ”„๋กœํ•„ ์ˆ˜์ • +- `PUT /api/users/password` - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ +- `GET /api/users/preferences` - ์‚ฌ์šฉ์ž ์„ค์ • +- `PUT /api/users/preferences` - ์‚ฌ์šฉ์ž ์„ค์ • ๋ณ€๊ฒฝ + +### ๋ฌธ์„œ ๊ด€๋ฆฌ +- `GET /api/documents` - ๋ฌธ์„œ ๋ชฉ๋ก (์‚ฌ์šฉ์ž๋ณ„ ๊ถŒํ•œ ์ ์šฉ) +- `POST /api/documents` - ๋ฌธ์„œ ์—…๋กœ๋“œ +- `GET /api/documents/:id` - ๋ฌธ์„œ ์ƒ์„ธ +- `DELETE /api/documents/:id` - ๋ฌธ์„œ ์‚ญ์ œ + +### ๊ฒ€์ƒ‰ +- `GET /api/search?q=keyword` - ๋ฌธ์„œ ๊ฒ€์ƒ‰ +- `GET /api/search/advanced` - ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ + +### ์‚ฌ์šฉ์ž ๊ธฐ๋Šฅ (์ธ์ฆ ํ•„์š”) +- `POST /api/annotations` - ๋ฐ‘์ค„/ํ•˜์ด๋ผ์ดํŠธ ์ €์žฅ +- `GET /api/annotations/:document_id` - ๋ฌธ์„œ๋ณ„ ์ฃผ์„ ์กฐํšŒ +- `GET /api/bookmarks` - ์ฑ…๊ฐˆํ”ผ ๋ชฉ๋ก +- `POST /api/bookmarks` - ์ฑ…๊ฐˆํ”ผ ์ถ”๊ฐ€ +- `POST /api/notes` - ๋ฉ”๋ชจ ์ €์žฅ +- `GET /api/notes/:document_id` - ๋ฌธ์„œ๋ณ„ ๋ฉ”๋ชจ ์กฐํšŒ + +### ๊ด€๋ฆฌ์ž ๊ธฐ๋Šฅ +- `GET /api/admin/users` - ์‚ฌ์šฉ์ž ๋ชฉ๋ก +- `PUT /api/admin/users/:id` - ์‚ฌ์šฉ์ž ๊ถŒํ•œ ๋ณ€๊ฒฝ +- `GET /api/admin/documents` - ์ „์ฒด ๋ฌธ์„œ ๊ด€๋ฆฌ + +### Paperless ์—ฐ๋™ +- `GET /api/paperless/download/:id` - ์›๋ณธ PDF ๋‹ค์šด๋กœ๋“œ +- `GET /api/paperless/sync` - Paperless ๋™๊ธฐํ™” + +## ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ + +- ํŒŒ์ผ ์—…๋กœ๋“œ ๊ฒ€์ฆ +- XSS ๋ฐฉ์ง€ +- CSRF ํ† ํฐ +- ์‚ฌ์šฉ์ž ์ธ์ฆ/๊ถŒํ•œ +- ํŒŒ์ผ ์ ‘๊ทผ ์ œ์–ด + +## ์„ฑ๋Šฅ ์ตœ์ ํ™” + +- HTML ๋ฌธ์„œ ์บ์‹ฑ +- ๊ฒ€์ƒ‰ ์ธ๋ฑ์‹ฑ +- ์ด๋ฏธ์ง€ ์ตœ์ ํ™” +- CDN ํ™œ์šฉ (ํ•„์š”์‹œ) + +## ํ–ฅํ›„ ๊ณ„ํš + +- ๋ชจ๋ฐ”์ผ ๋ฐ˜์‘ํ˜• ์ง€์› +- ๋‹ค๊ตญ์–ด ์ง€์› +- ํ˜‘์—… ๊ธฐ๋Šฅ (๊ณต์œ , ๋Œ“๊ธ€) +- AI ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ์š”์•ฝ +- ๋ฌธ์„œ ๋ฒ„์ „ ๊ด€๋ฆฌ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f74cf39 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,52 @@ +FROM python:3.11-slim + +# ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • +WORKDIR /app + +# ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜ +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# ์˜์กด์„ฑ ์ง์ ‘ ์„ค์น˜ (Poetry ๋Œ€์‹  pip ์‚ฌ์šฉ) +RUN pip install --no-cache-dir \ + fastapi==0.104.1 \ + uvicorn[standard]==0.24.0 \ + sqlalchemy==2.0.23 \ + asyncpg==0.29.0 \ + psycopg2-binary==2.9.7 \ + alembic==1.12.1 \ + python-jose[cryptography]==3.3.0 \ + passlib[bcrypt]==1.7.4 \ + python-multipart==0.0.6 \ + pillow==10.1.0 \ + redis==5.0.1 \ + pydantic[email]==2.5.0 \ + pydantic-settings==2.1.0 \ + python-dotenv==1.0.0 \ + httpx==0.25.2 \ + aiofiles==23.2.1 \ + jinja2==3.1.2 \ + greenlet==3.0.0 + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ +COPY src/ ./src/ + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /app/uploads + +# ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์ • +ENV PYTHONPATH=/app +ENV DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db +ENV SECRET_KEY=production-secret-key-change-this +ENV ADMIN_EMAIL=admin@test.com +ENV ADMIN_PASSWORD=admin123 + +# ํฌํŠธ ๋…ธ์ถœ +EXPOSE 8000 + +# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ (์ง์ ‘ uvicorn ์‹คํ–‰) +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..d5271da --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • +WORKDIR /app + +# ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์—…๋ฐ์ดํŠธ ๋ฐ ํ•„์š”ํ•œ ํŒจํ‚ค์ง€ ์„ค์น˜ +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Poetry ์„ค์น˜ +RUN pip install poetry + +# Poetry ์„ค์ • (๊ฐœ๋ฐœ ๋ชจ๋“œ) +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VENV_IN_PROJECT=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache + +# ์˜์กด์„ฑ ํŒŒ์ผ ๋ณต์‚ฌ +COPY pyproject.toml poetry.lock* ./ + +# ๊ฐœ๋ฐœ ์˜์กด์„ฑ ํฌํ•จํ•˜์—ฌ ์„ค์น˜ +RUN poetry install && rm -rf $POETRY_CACHE_DIR + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /app/uploads + +# ํฌํŠธ ๋…ธ์ถœ +EXPOSE 8000 + +# ๊ฐœ๋ฐœ ๋ชจ๋“œ๋กœ ์‹คํ–‰ (ํ•ซ ๋ฆฌ๋กœ๋“œ) +CMD ["poetry", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/database/migrations/005_create_memo_tree_tables.sql b/backend/database/migrations/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/backend/database/migrations/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- 005_create_memo_tree_tables.sql + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings JSONB DEFAULT '{}', -- ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- ๊ธฐ๋ณธ ์ •๋ณด + title VARCHAR(500) NOT NULL, + content TEXT, -- ์‹ค์ œ ๋ฉ”๋ชจ ๋‚ด์šฉ (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + -- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags TEXT[], -- ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata JSONB DEFAULT '{}', -- ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์บ๋ฆญํ„ฐ ์ •๋ณด, ํ”Œ๋กฏ ์ •๋ณด ๋“ฑ) + + -- ์ƒํƒœ ๊ด€๋ฆฌ + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- ์‹œ๊ฐ„ ์ •๋ณด + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- ์ œ์•ฝ ์กฐ๊ฑด + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ (์„ ํƒ์ ) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: ๊ฒฝ๋กœ ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- ๋ฃจํŠธ ๋…ธ๋“œ์ธ ๊ฒฝ์šฐ + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ๊ฒฝ๋กœ๋ฅผ ๊ฐ€์ ธ์™€์„œ ํ™•์žฅ + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ (๊ฐœ๋ฐœ์šฉ) +-- ์†Œ์„ค ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '๋‚ด ์ฒซ ๋ฒˆ์งธ ์†Œ์„ค', + 'ํŒํƒ€์ง€ ์†Œ์„ค ํ”„๋กœ์ ํŠธ', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/backend/database/migrations/006_add_canonical_path.sql b/backend/database/migrations/006_add_canonical_path.sql new file mode 100644 index 0000000..1474cda --- /dev/null +++ b/backend/database/migrations/006_add_canonical_path.sql @@ -0,0 +1,98 @@ +-- 006_add_canonical_path.sql +-- ์ •์‚ฌ ๊ฒฝ๋กœ ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ํ•„๋“œ ์ถ”๊ฐ€ + +-- memo_nodes ํ…Œ์ด๋ธ”์— ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ ์ถ”๊ฐ€ +ALTER TABLE memo_nodes +ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE, +ADD COLUMN canonical_order INTEGER DEFAULT NULL, +ADD COLUMN story_path TEXT DEFAULT NULL; -- ์ •์‚ฌ ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ ์œ„ํ•œ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ +CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE; + +-- ํŠธ๋ฆฌ๋ณ„ ์ •์‚ฌ ๊ฒฝ๋กœ ํ†ต๊ณ„๋ฅผ ์œ„ํ•œ ๋ทฐ ์ƒ์„ฑ +CREATE OR REPLACE VIEW memo_tree_canonical_stats AS +SELECT + t.id as tree_id, + t.title as tree_title, + COUNT(n.id) as total_nodes, + COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes, + MAX(n.canonical_order) as max_canonical_order, + STRING_AGG( + CASE WHEN n.is_canonical = TRUE THEN n.title END, + ' โ†’ ' + ORDER BY n.canonical_order + ) as canonical_story_path +FROM memo_trees t +LEFT JOIN memo_nodes n ON t.id = n.tree_id +GROUP BY t.id, t.title; + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ ์ž๋™ ์—…๋ฐ์ดํŠธ ํ•จ์ˆ˜ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ ๊ฐ€๋Šฅ) +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- ์ •์‚ฌ๋กœ ์„ค์ •๋  ๋•Œ + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- ๊ฐ™์€ ๋ถ€๋ชจ๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํ˜•์ œ ๋…ธ๋“œ๋“ค์˜ ์ •์‚ฌ ์ƒํƒœ ํ•ด์ œ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ์ˆœ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ˆœ์„œ ๊ณ„์‚ฐ + IF NEW.parent_id IS NULL THEN + -- ๋ฃจํŠธ ๋…ธ๋“œ๋Š” ํ•ญ์ƒ 1 + NEW.canonical_order = 1; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ์ˆœ์„œ + 1 + SELECT COALESCE(parent.canonical_order, 0) + 1 + INTO NEW.canonical_order + FROM memo_nodes parent + WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE; + + -- ๋ถ€๋ชจ๊ฐ€ ์ •์‚ฌ๊ฐ€ ์•„๋‹ˆ๋ฉด ์ˆœ์„œ ํ• ๋‹น ์•ˆํ•จ + IF NEW.canonical_order IS NULL THEN + NEW.canonical_order = NULL; + END IF; + END IF; + + -- ์ •์‚ฌ ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ + NEW.story_path = COALESCE(NEW.path, ''); + END IF; + + -- ์ •์‚ฌ์—์„œ ์ œ์™ธ๋  ๋•Œ ์ˆœ์„œ ์ œ๊ฑฐ + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- ๋’ค์˜ ์ˆœ์„œ๋“ค์„ ์•ž์œผ๋กœ ๋‹น๊ธฐ๊ธฐ + UPDATE memo_nodes + SET canonical_order = canonical_order - 1 + WHERE tree_id = NEW.tree_id + AND is_canonical = TRUE + AND canonical_order > OLD.canonical_order; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +CREATE TRIGGER trigger_update_canonical_order + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- ๊ธฐ์กด ๋ฃจํŠธ ๋…ธ๋“œ๋“ค์„ ์ •์‚ฌ๋กœ ์„ค์ • (๊ธฐ๋ณธ๊ฐ’) +UPDATE memo_nodes +SET is_canonical = TRUE, canonical_order = 1 +WHERE parent_id IS NULL AND is_canonical = FALSE; + +COMMENT ON COLUMN memo_nodes.is_canonical IS '์ •์‚ฌ ๊ฒฝ๋กœ ์—ฌ๋ถ€ (์†Œ์„ค์˜ ๋ฉ”์ธ ์Šคํ† ๋ฆฌ๋ผ์ธ)'; +COMMENT ON COLUMN memo_nodes.canonical_order IS '์ •์‚ฌ ๊ฒฝ๋กœ์—์„œ์˜ ์ˆœ์„œ (1๋ถ€ํ„ฐ ์‹œ์ž‘)'; +COMMENT ON COLUMN memo_nodes.story_path IS '์ •์‚ฌ ๊ฒฝ๋กœ ๋ฌธ์ž์—ด ํ‘œํ˜„'; diff --git a/backend/database/migrations/006_create_todo_tables.sql b/backend/database/migrations/006_create_todo_tables.sql new file mode 100644 index 0000000..7428e21 --- /dev/null +++ b/backend/database/migrations/006_create_todo_tables.sql @@ -0,0 +1,58 @@ +-- ํ• ์ผ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + +-- ํ• ์ผ ์•„์ดํ…œ ํ…Œ์ด๋ธ” +CREATE TABLE IF NOT EXISTS todo_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- ๊ธฐ๋ณธ ์ •๋ณด + content TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'active', 'completed', 'delayed', 'split')), + + -- ์‹œ๊ฐ„ ๊ด€๋ฆฌ + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + start_date TIMESTAMP WITH TIME ZONE, + estimated_minutes INTEGER CHECK (estimated_minutes > 0 AND estimated_minutes <= 120), + completed_at TIMESTAMP WITH TIME ZONE, + delayed_until TIMESTAMP WITH TIME ZONE, + + -- ๋ถ„ํ•  ๊ด€๋ฆฌ + parent_id UUID REFERENCES todo_items(id) ON DELETE CASCADE, + split_order INTEGER, + + -- ์ธ๋ฑ์Šค + CONSTRAINT unique_split_order UNIQUE (parent_id, split_order) +); + +-- ํ• ์ผ ๋Œ“๊ธ€ ํ…Œ์ด๋ธ” +CREATE TABLE IF NOT EXISTS todo_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + todo_item_id UUID NOT NULL REFERENCES todo_items(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_todo_items_user_id ON todo_items(user_id); +CREATE INDEX IF NOT EXISTS idx_todo_items_status ON todo_items(status); +CREATE INDEX IF NOT EXISTS idx_todo_items_start_date ON todo_items(start_date); +CREATE INDEX IF NOT EXISTS idx_todo_items_parent_id ON todo_items(parent_id); +CREATE INDEX IF NOT EXISTS idx_todo_comments_todo_item_id ON todo_comments(todo_item_id); +CREATE INDEX IF NOT EXISTS idx_todo_comments_user_id ON todo_comments(user_id); + +-- ํŠธ๋ฆฌ๊ฑฐ: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_todo_comments_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_todo_comments_updated_at + BEFORE UPDATE ON todo_comments + FOR EACH ROW + EXECUTE FUNCTION update_todo_comments_updated_at(); diff --git a/backend/database/migrations/007_fix_canonical_order.sql b/backend/database/migrations/007_fix_canonical_order.sql new file mode 100644 index 0000000..5be147d --- /dev/null +++ b/backend/database/migrations/007_fix_canonical_order.sql @@ -0,0 +1,97 @@ +-- 007_fix_canonical_order.sql +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ ๊ณ„์‚ฐ ๋กœ์ง ์ˆ˜์ • + +-- ๊ธฐ์กด ํŠธ๋ฆฌ๊ฑฐ ์‚ญ์ œ +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +DROP FUNCTION IF EXISTS update_canonical_order(); + +-- ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ณ„์‚ฐํ•˜๋Š” ํ•จ์ˆ˜ +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- ์ •์‚ฌ๋กœ ์„ค์ •๋  ๋•Œ + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- ๊ฐ™์€ ๋ถ€๋ชจ๋ฅผ ๊ฐ€์ง„ ๋‹ค๋ฅธ ํ˜•์ œ ๋…ธ๋“œ๋“ค์˜ ์ •์‚ฌ ์ƒํƒœ ํ•ด์ œ (๋ถ„๊ธฐ์ ์—์„œ ํ•˜๋‚˜๋งŒ ์„ ํƒ) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- ์ •์‚ฌ ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ + NEW.story_path = COALESCE(NEW.path, ''); + + -- ์ˆœ์„œ๋Š” ๋ณ„๋„ ํ•จ์ˆ˜์—์„œ ์ผ๊ด„ ๊ณ„์‚ฐ + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + -- ์ •์‚ฌ์—์„œ ์ œ์™ธ๋  ๋•Œ + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- ์ˆœ์„œ ์žฌ๊ณ„์‚ฐ + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๋ณ„ ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ๋ฅผ DFS๋กœ ์žฌ๊ณ„์‚ฐํ•˜๋Š” ํ•จ์ˆ˜ +CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID) +RETURNS VOID AS $$ +DECLARE + current_order INTEGER := 1; +BEGIN + -- ๋ชจ๋“  ์ •์‚ฌ ๋…ธ๋“œ์˜ ์ˆœ์„œ๋ฅผ NULL๋กœ ์ดˆ๊ธฐํ™” + UPDATE memo_nodes + SET canonical_order = NULL + WHERE tree_id = tree_uuid AND is_canonical = TRUE; + + -- DFS๋กœ ์ˆœ์„œ ํ• ๋‹น (์žฌ๊ท€ CTE ์‚ฌ์šฉ) + WITH RECURSIVE canonical_path AS ( + -- ๋ฃจํŠธ ๋…ธ๋“œ๋“ค (์ •์‚ฌ์ธ ๊ฒƒ๋งŒ) + SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path + FROM memo_nodes + WHERE tree_id = tree_uuid + AND parent_id IS NULL + AND is_canonical = TRUE + + UNION ALL + + -- ์ž์‹ ๋…ธ๋“œ๋“ค (์ •์‚ฌ์ธ ๊ฒƒ๋งŒ) + SELECT n.id, n.parent_id, n.title, + cp.order_num + 1 as order_num, + cp.path || n.id + FROM memo_nodes n + INNER JOIN canonical_path cp ON n.parent_id = cp.id + WHERE n.tree_id = tree_uuid + AND n.is_canonical = TRUE + ) + UPDATE memo_nodes + SET canonical_order = cp.order_num + FROM canonical_path cp + WHERE memo_nodes.id = cp.id; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ๋‹ค์‹œ ์ƒ์„ฑ +CREATE TRIGGER trigger_update_canonical_order + AFTER UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- ๊ธฐ์กด ๋ฐ์ดํ„ฐ์˜ ์ˆœ์„œ ์žฌ๊ณ„์‚ฐ +DO $$ +DECLARE + tree_rec RECORD; +BEGIN + FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE + LOOP + PERFORM recalculate_canonical_orders(tree_rec.tree_id); + END LOOP; +END $$; diff --git a/backend/migrations/004_add_books_table.sql b/backend/migrations/004_add_books_table.sql new file mode 100644 index 0000000..7b7165d --- /dev/null +++ b/backend/migrations/004_add_books_table.sql @@ -0,0 +1,50 @@ +-- ์„œ์  ํ…Œ์ด๋ธ” ๋ฐ ๊ด€๊ณ„ ์ถ”๊ฐ€ +-- 2025-08-22: ์„œ์  ๊ทธ๋ฃนํ™” ๊ธฐ๋Šฅ ์ถ”๊ฐ€ + +-- ์„œ์  ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE IF NOT EXISTS books ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + author VARCHAR(200), + publisher VARCHAR(200), + isbn VARCHAR(20) UNIQUE, + description TEXT, + language VARCHAR(10) DEFAULT 'ko', + total_pages INTEGER DEFAULT 0, + cover_image_path VARCHAR(500), + is_public BOOLEAN DEFAULT true, + tags VARCHAR(1000), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_books_title ON books(title); +CREATE INDEX IF NOT EXISTS idx_books_author ON books(author); +CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at); + +-- documents ํ…Œ์ด๋ธ”์— book_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +ALTER TABLE documents ADD COLUMN IF NOT EXISTS book_id UUID; + +-- ์™ธ๋ž˜ํ‚ค ์ œ์•ฝ์กฐ๊ฑด ์ถ”๊ฐ€ +ALTER TABLE documents ADD CONSTRAINT IF NOT EXISTS fk_documents_book_id + FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE SET NULL; + +-- book_id ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_documents_book_id ON documents(book_id); + +-- ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ (updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- books ํ…Œ์ด๋ธ”์— ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ ์ถ”๊ฐ€ +DROP TRIGGER IF EXISTS update_books_updated_at ON books; +CREATE TRIGGER update_books_updated_at + BEFORE UPDATE ON books + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/migrations/005_add_matched_pdf_id.sql b/backend/migrations/005_add_matched_pdf_id.sql new file mode 100644 index 0000000..921977c --- /dev/null +++ b/backend/migrations/005_add_matched_pdf_id.sql @@ -0,0 +1,12 @@ +-- ๋ฌธ์„œ์— PDF ๋งค์นญ ํ•„๋“œ ์ถ”๊ฐ€ +-- Migration: 005_add_matched_pdf_id.sql + +-- matched_pdf_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +ALTER TABLE documents +ADD COLUMN matched_pdf_id UUID REFERENCES documents(id); + +-- ์ธ๋ฑ์Šค ์ถ”๊ฐ€ (์„ฑ๋Šฅ ํ–ฅ์ƒ) +CREATE INDEX idx_documents_matched_pdf_id ON documents(matched_pdf_id); + +-- ์ฝ”๋ฉ˜ํŠธ ์ถ”๊ฐ€ +COMMENT ON COLUMN documents.matched_pdf_id IS '๋งค์นญ๋œ PDF ๋ฌธ์„œ ID (HTML ๋ฌธ์„œ์— ์—ฐ๊ฒฐ๋œ ์›๋ณธ PDF)'; diff --git a/backend/migrations/006_make_html_path_nullable.sql b/backend/migrations/006_make_html_path_nullable.sql new file mode 100644 index 0000000..e54b66d --- /dev/null +++ b/backend/migrations/006_make_html_path_nullable.sql @@ -0,0 +1,9 @@ +-- HTML ๊ฒฝ๋กœ๋ฅผ nullable๋กœ ๋ณ€๊ฒฝ (PDF๋งŒ ์—…๋กœ๋“œํ•˜๋Š” ๊ฒฝ์šฐ ๋Œ€์‘) +-- Migration: 006_make_html_path_nullable.sql + +-- html_path ์ปฌ๋Ÿผ์„ nullable๋กœ ๋ณ€๊ฒฝ +ALTER TABLE documents +ALTER COLUMN html_path DROP NOT NULL; + +-- ์ฝ”๋ฉ˜ํŠธ ์—…๋ฐ์ดํŠธ +COMMENT ON COLUMN documents.html_path IS 'HTML ํŒŒ์ผ ๊ฒฝ๋กœ (PDF๋งŒ ์—…๋กœ๋“œํ•˜๋Š” ๊ฒฝ์šฐ null ๊ฐ€๋Šฅ)'; diff --git a/backend/migrations/007_add_document_links.sql b/backend/migrations/007_add_document_links.sql new file mode 100644 index 0000000..5579fa9 --- /dev/null +++ b/backend/migrations/007_add_document_links.sql @@ -0,0 +1,34 @@ +-- ๋ฌธ์„œ ๋งํฌ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE document_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + target_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + selected_text TEXT NOT NULL, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + link_text VARCHAR(500), + description TEXT, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_document_links_source_document_id ON document_links(source_document_id); +CREATE INDEX idx_document_links_target_document_id ON document_links(target_document_id); +CREATE INDEX idx_document_links_created_by ON document_links(created_by); +CREATE INDEX idx_document_links_start_offset ON document_links(start_offset); + +-- ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +CREATE OR REPLACE FUNCTION update_document_links_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_document_links_updated_at + BEFORE UPDATE ON document_links + FOR EACH ROW + EXECUTE FUNCTION update_document_links_updated_at(); diff --git a/backend/migrations/008_enhance_document_links.sql b/backend/migrations/008_enhance_document_links.sql new file mode 100644 index 0000000..0f0bacf --- /dev/null +++ b/backend/migrations/008_enhance_document_links.sql @@ -0,0 +1,24 @@ +-- ๋ฌธ์„œ ๋งํฌ ํ…Œ์ด๋ธ”์— ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ์„ ์œ„ํ•œ ์ปฌ๋Ÿผ ์ถ”๊ฐ€ + +-- ๋„์ฐฉ์  ํ…์ŠคํŠธ ์ •๋ณด ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +ALTER TABLE document_links +ADD COLUMN target_text TEXT, +ADD COLUMN target_start_offset INTEGER, +ADD COLUMN target_end_offset INTEGER; + +-- ๋งํฌ ํƒ€์ž… ์ปฌ๋Ÿผ ์ถ”๊ฐ€ (๊ธฐ๋ณธ๊ฐ’: document) +ALTER TABLE document_links +ADD COLUMN link_type VARCHAR(20) DEFAULT 'document' NOT NULL; + +-- ๊ธฐ์กด ๋ฐ์ดํ„ฐ์˜ link_type์„ 'document'๋กœ ์„ค์ • (์ด๋ฏธ ๊ธฐ๋ณธ๊ฐ’์ด์ง€๋งŒ ๋ช…์‹œ์ ์œผ๋กœ) +UPDATE document_links SET link_type = 'document' WHERE link_type IS NULL; + +-- ์ธ๋ฑ์Šค ์ถ”๊ฐ€ (์„ฑ๋Šฅ ํ–ฅ์ƒ) +CREATE INDEX idx_document_links_link_type ON document_links(link_type); +CREATE INDEX idx_document_links_target_offset ON document_links(target_document_id, target_start_offset, target_end_offset); + +-- ์ฝ”๋ฉ˜ํŠธ ์ถ”๊ฐ€ +COMMENT ON COLUMN document_links.target_text IS '๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ์„ ํƒ๋œ ํ…์ŠคํŠธ'; +COMMENT ON COLUMN document_links.target_start_offset IS '๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ํ…์ŠคํŠธ ์‹œ์ž‘ ์œ„์น˜'; +COMMENT ON COLUMN document_links.target_end_offset IS '๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ํ…์ŠคํŠธ ๋ ์œ„์น˜'; +COMMENT ON COLUMN document_links.link_type IS '๋งํฌ ํƒ€์ž…: document(์ „์ฒด ๋ฌธ์„œ) ๋˜๋Š” text_fragment(ํŠน์ • ํ…์ŠคํŠธ ๋ถ€๋ถ„)'; diff --git a/backend/migrations/009_create_notes_system.sql b/backend/migrations/009_create_notes_system.sql new file mode 100644 index 0000000..333324b --- /dev/null +++ b/backend/migrations/009_create_notes_system.sql @@ -0,0 +1,81 @@ +-- ๋…ธํŠธ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ์ƒ์„ฑ +-- 009_create_notes_system.sql + +-- ๋…ธํŠธ ๋ฌธ์„œ ํ…Œ์ด๋ธ” +CREATE TABLE IF NOT EXISTS notes_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + content TEXT, -- ๋งˆํฌ๋‹ค์šด ๋‚ด์šฉ + html_content TEXT, -- ๋ณ€ํ™˜๋œ HTML ๋‚ด์šฉ + note_type VARCHAR(50) DEFAULT 'note', -- note, research, summary, idea ๋“ฑ + tags TEXT[] DEFAULT '{}', -- ํƒœ๊ทธ ๋ฐฐ์—ด + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL, + is_published BOOLEAN DEFAULT false, -- ๊ณต๊ฐœ ์—ฌ๋ถ€ + parent_note_id UUID REFERENCES notes_documents(id) ON DELETE SET NULL, -- ๊ณ„์ธต ๊ตฌ์กฐ + sort_order INTEGER DEFAULT 0, -- ์ •๋ ฌ ์ˆœ์„œ + word_count INTEGER DEFAULT 0, -- ๋‹จ์–ด ์ˆ˜ + reading_time INTEGER DEFAULT 0, -- ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„) + + -- ์ธ๋ฑ์Šค + CONSTRAINT notes_documents_title_check CHECK (char_length(title) > 0) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_notes_documents_created_by ON notes_documents(created_by); +CREATE INDEX IF NOT EXISTS idx_notes_documents_created_at ON notes_documents(created_at); +CREATE INDEX IF NOT EXISTS idx_notes_documents_note_type ON notes_documents(note_type); +CREATE INDEX IF NOT EXISTS idx_notes_documents_parent_note_id ON notes_documents(parent_note_id); +CREATE INDEX IF NOT EXISTS idx_notes_documents_tags ON notes_documents USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_notes_documents_is_published ON notes_documents(is_published); + +-- ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„ ์ž๋™ ๊ฐฑ์‹  ํŠธ๋ฆฌ๊ฑฐ +CREATE OR REPLACE FUNCTION update_notes_documents_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_notes_documents_updated_at + BEFORE UPDATE ON notes_documents + FOR EACH ROW + EXECUTE FUNCTION update_notes_documents_updated_at(); + +-- ๊ธฐ์กด document_links ํ…Œ์ด๋ธ”์— ๋…ธํŠธ ์ง€์› ์ถ”๊ฐ€ +-- (์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํ…Œ์ด๋ธ”์ด๋ฏ€๋กœ ALTER ์‚ฌ์šฉ) +DO $$ +BEGIN + -- source_type, target_type ์ปฌ๋Ÿผ์ด ์—†๋‹ค๋ฉด ์ถ”๊ฐ€ + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'document_links' AND column_name = 'source_type' + ) THEN + ALTER TABLE document_links + ADD COLUMN source_type VARCHAR(20) DEFAULT 'document', + ADD COLUMN target_type VARCHAR(20) DEFAULT 'document'; + + -- ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋Š” ๋ชจ๋‘ 'document' ํƒ€์ž…์œผ๋กœ ์„ค์ • + UPDATE document_links SET source_type = 'document', target_type = 'document'; + END IF; +END $$; + +-- ๋…ธํŠธ ๊ด€๋ จ ๋งํฌ๋ฅผ ์œ„ํ•œ ์ธ๋ฑ์Šค +CREATE INDEX IF NOT EXISTS idx_document_links_source_type ON document_links(source_type); +CREATE INDEX IF NOT EXISTS idx_document_links_target_type ON document_links(target_type); + +-- ์ƒ˜ํ”Œ ๋…ธํŠธ ํƒ€์ž… ๋ฐ์ดํ„ฐ +INSERT INTO notes_documents (title, content, html_content, note_type, tags, created_by, is_published) +VALUES + ('๋…ธํŠธ ์‹œ์Šคํ…œ ์‚ฌ์šฉ๋ฒ•', + '# ๋…ธํŠธ ์‹œ์Šคํ…œ ์‚ฌ์šฉ๋ฒ•\n\n## ๊ธฐ๋ณธ ๊ธฐ๋Šฅ\n- ๋งˆํฌ๋‹ค์šด์œผ๋กœ ๋…ธํŠธ ์ž‘์„ฑ\n- HTML๋กœ ์ž๋™ ๋ณ€ํ™˜\n- ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜\n\n## ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ\n- ์„œ์ ๊ณผ ๋งํฌ ์—ฐ๊ฒฐ\n- ๊ณ„์ธต ๊ตฌ์กฐ ์ง€์›\n- ๋‚ด๋ณด๋‚ด๊ธฐ ๊ธฐ๋Šฅ', + '

๋…ธํŠธ ์‹œ์Šคํ…œ ์‚ฌ์šฉ๋ฒ•

๊ธฐ๋ณธ ๊ธฐ๋Šฅ

  • ๋งˆํฌ๋‹ค์šด์œผ๋กœ ๋…ธํŠธ ์ž‘์„ฑ
  • HTML๋กœ ์ž๋™ ๋ณ€ํ™˜
  • ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜

๊ณ ๊ธ‰ ๊ธฐ๋Šฅ

  • ์„œ์ ๊ณผ ๋งํฌ ์—ฐ๊ฒฐ
  • ๊ณ„์ธต ๊ตฌ์กฐ ์ง€์›
  • ๋‚ด๋ณด๋‚ด๊ธฐ ๊ธฐ๋Šฅ
', + 'guide', + ARRAY['๊ฐ€์ด๋“œ', '์‚ฌ์šฉ๋ฒ•', '์‹œ์Šคํ…œ'], + 'Administrator', + true) +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/backend/migrations/010_create_notebooks.sql b/backend/migrations/010_create_notebooks.sql new file mode 100644 index 0000000..d4bdf18 --- /dev/null +++ b/backend/migrations/010_create_notebooks.sql @@ -0,0 +1,25 @@ +-- ๋…ธํŠธ๋ถ ์‹œ์Šคํ…œ ์ƒ์„ฑ +CREATE TABLE notebooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + description TEXT, + color VARCHAR(7) DEFAULT '#3B82F6', -- ํ—ฅ์Šค ์ปฌ๋Ÿฌ ์ฝ”๋“œ + icon VARCHAR(50) DEFAULT 'book', -- FontAwesome ์•„์ด์ฝ˜ ์ด๋ฆ„ + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0 +); + +-- ๋…ธํŠธ๋ถ-๋…ธํŠธ ๊ด€๊ณ„ ํ…Œ์ด๋ธ” (๊ธฐ์กด notes_documents์˜ parent_note_id ๋Œ€์‹  ์‚ฌ์šฉ) +ALTER TABLE notes_documents ADD COLUMN notebook_id UUID REFERENCES notebooks(id); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_notebooks_created_by ON notebooks(created_by); +CREATE INDEX idx_notebooks_created_at ON notebooks(created_at); +CREATE INDEX idx_notes_notebook_id ON notes_documents(notebook_id); + +-- ๊ธฐ๋ณธ ๋…ธํŠธ๋ถ ์ƒ์„ฑ (๊ธฐ์กด ๋…ธํŠธ๋“ค์„ ์œ„ํ•œ) +INSERT INTO notebooks (title, description, created_by, color, icon) +VALUES ('๊ธฐ๋ณธ ๋…ธํŠธ๋ถ', '๋ถ„๋ฅ˜๋˜์ง€ ์•Š์€ ๋…ธํŠธ๋“ค', 'admin@test.com', '#6B7280', 'sticky-note'); diff --git a/backend/migrations/011_create_note_highlights_and_notes.sql b/backend/migrations/011_create_note_highlights_and_notes.sql new file mode 100644 index 0000000..9da8458 --- /dev/null +++ b/backend/migrations/011_create_note_highlights_and_notes.sql @@ -0,0 +1,48 @@ +-- ๋…ธํŠธ์šฉ ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE note_highlights ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + selected_text TEXT NOT NULL, + highlight_color VARCHAR(50) NOT NULL DEFAULT '#FFFF00', + highlight_type VARCHAR(50) NOT NULL DEFAULT 'highlight', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +-- ๋…ธํŠธ์šฉ ๋ฉ”๋ชจ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE note_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE, + highlight_id UUID REFERENCES note_highlights(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX ix_note_highlights_note_id ON note_highlights (note_id); +CREATE INDEX ix_note_highlights_created_by ON note_highlights (created_by); +CREATE INDEX ix_note_notes_note_id ON note_notes (note_id); +CREATE INDEX ix_note_notes_highlight_id ON note_notes (highlight_id); +CREATE INDEX ix_note_notes_created_by ON note_notes (created_by); + +-- updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_note_highlights_updated_at + BEFORE UPDATE ON note_highlights + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_note_notes_updated_at + BEFORE UPDATE ON note_notes + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/migrations/011_create_note_links.sql b/backend/migrations/011_create_note_links.sql new file mode 100644 index 0000000..f65c78c --- /dev/null +++ b/backend/migrations/011_create_note_links.sql @@ -0,0 +1,75 @@ +-- ๋…ธํŠธ ๋งํฌ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- ๋…ธํŠธ ๋ฌธ์„œ ๊ฐ„ ๋˜๋Š” ๋…ธํŠธ-๋ฌธ์„œ ๊ฐ„ ๋งํฌ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ํ…Œ์ด๋ธ” + +CREATE TABLE IF NOT EXISTS note_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- ๋งํฌ ์ถœ๋ฐœ์  (๋…ธํŠธ ๋˜๋Š” ๋ฌธ์„œ ์ค‘ ํ•˜๋‚˜) + source_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE, + source_document_id UUID REFERENCES documents(id) ON DELETE CASCADE, + + -- ๋งํฌ ๋„์ฐฉ์  (๋…ธํŠธ ๋˜๋Š” ๋ฌธ์„œ ์ค‘ ํ•˜๋‚˜) + target_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE, + target_document_id UUID REFERENCES documents(id) ON DELETE CASCADE, + + -- ์ถœ๋ฐœ์  ํ…์ŠคํŠธ ์ •๋ณด + selected_text TEXT NOT NULL, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + + -- ๋„์ฐฉ์  ํ…์ŠคํŠธ ์ •๋ณด (์„ ํƒ์‚ฌํ•ญ) + target_text TEXT, + target_start_offset INTEGER, + target_end_offset INTEGER, + + -- ๋งํฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + link_text VARCHAR(500), + description TEXT, + link_type VARCHAR(20) DEFAULT 'note' NOT NULL, + + -- ์ƒ์„ฑ์ž ๋ฐ ์‹œ๊ฐ„ ์ •๋ณด + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE, + + -- ์ œ์•ฝ ์กฐ๊ฑด + CONSTRAINT note_links_source_check CHECK ( + (source_note_id IS NOT NULL AND source_document_id IS NULL) OR + (source_note_id IS NULL AND source_document_id IS NOT NULL) + ), + CONSTRAINT note_links_target_check CHECK ( + (target_note_id IS NOT NULL AND target_document_id IS NULL) OR + (target_note_id IS NULL AND target_document_id IS NOT NULL) + ) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX IF NOT EXISTS idx_note_links_source_note ON note_links(source_note_id); +CREATE INDEX IF NOT EXISTS idx_note_links_source_document ON note_links(source_document_id); +CREATE INDEX IF NOT EXISTS idx_note_links_target_note ON note_links(target_note_id); +CREATE INDEX IF NOT EXISTS idx_note_links_target_document ON note_links(target_document_id); +CREATE INDEX IF NOT EXISTS idx_note_links_created_by ON note_links(created_by); +CREATE INDEX IF NOT EXISTS idx_note_links_created_at ON note_links(created_at); + +-- updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE OR REPLACE FUNCTION update_note_links_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_note_links_updated_at + BEFORE UPDATE ON note_links + FOR EACH ROW + EXECUTE FUNCTION update_note_links_updated_at(); + +-- ์ฝ”๋ฉ˜ํŠธ ์ถ”๊ฐ€ +COMMENT ON TABLE note_links IS '๋…ธํŠธ ๋ฌธ์„œ ๊ฐ„ ๋งํฌ ๊ด€๋ฆฌ ํ…Œ์ด๋ธ”'; +COMMENT ON COLUMN note_links.source_note_id IS '์ถœ๋ฐœ์  ๋…ธํŠธ ID (๋…ธํŠธ์—์„œ ์‹œ์ž‘ํ•˜๋Š” ๋งํฌ)'; +COMMENT ON COLUMN note_links.source_document_id IS '์ถœ๋ฐœ์  ๋ฌธ์„œ ID (๋ฌธ์„œ์—์„œ ์‹œ์ž‘ํ•˜๋Š” ๋งํฌ)'; +COMMENT ON COLUMN note_links.target_note_id IS '๋„์ฐฉ์  ๋…ธํŠธ ID'; +COMMENT ON COLUMN note_links.target_document_id IS '๋„์ฐฉ์  ๋ฌธ์„œ ID'; +COMMENT ON COLUMN note_links.link_type IS '๋งํฌ ํƒ€์ž…: note, document, text_fragment'; + diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c884ef3 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,87 @@ +[tool.poetry] +name = "document-server" +version = "0.1.0" +description = "HTML Document Management and Viewer System" +authors = ["Your Name "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.0" +uvicorn = {extras = ["standard"], version = "^0.24.0"} +sqlalchemy = "^2.0.0" +asyncpg = "^0.29.0" +alembic = "^1.12.0" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} +passlib = {extras = ["bcrypt"], version = "^1.7.4"} +python-multipart = "^0.0.6" +pillow = "^10.0.0" +redis = "^5.0.0" +pydantic = {extras = ["email"], version = "^2.4.0"} +pydantic-settings = "^2.0.0" +python-dotenv = "^1.0.0" +httpx = "^0.25.0" +aiofiles = "^23.2.0" +jinja2 = "^3.1.0" +beautifulsoup4 = "^4.13.0" +pypdf2 = "^3.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-asyncio = "^0.21.0" +black = "^23.9.0" +isort = "^5.12.0" +flake8 = "^6.1.0" +mypy = "^1.6.0" +pre-commit = "^3.5.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["src"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ + "passlib.*", + "jose.*", + "redis.*", +] +ignore_missing_imports = true diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..f69247b --- /dev/null +++ b/backend/src/__init__.py @@ -0,0 +1,3 @@ +""" +Document Server Backend Package +""" diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py new file mode 100644 index 0000000..18e93ac --- /dev/null +++ b/backend/src/api/__init__.py @@ -0,0 +1,3 @@ +""" +API ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" diff --git a/backend/src/api/dependencies.py b/backend/src/api/dependencies.py new file mode 100644 index 0000000..5724bae --- /dev/null +++ b/backend/src/api/dependencies.py @@ -0,0 +1,149 @@ +""" +API ์˜์กด์„ฑ +""" +from fastapi import Depends, HTTPException, status, Query +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional + +from ..core.database import get_db +from ..core.security import verify_token, get_user_id_from_token +from ..models.user import User + + +# HTTP Bearer ํ† ํฐ ์Šคํ‚ค๋งˆ (์„ ํƒ์ ) +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db) +) -> User: + """ํ˜„์žฌ ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž ๊ฐ€์ ธ์˜ค๊ธฐ""" + try: + # ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ + user_id = get_user_id_from_token(credentials.credentials) + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + return user + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """ํ™œ์„ฑ ์‚ฌ์šฉ์ž ํ™•์ธ""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + return current_user + + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + """๊ด€๋ฆฌ์ž ๊ถŒํ•œ ํ™•์ธ""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user + + +async def get_optional_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db) +) -> Optional[User]: + """์„ ํƒ์  ์‚ฌ์šฉ์ž ์ธ์ฆ (ํ† ํฐ์ด ์—†์–ด๋„ ๋จ)""" + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None + + +async def get_current_user_with_token_param( + _token: Optional[str] = Query(None), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db) +) -> User: + """URL ํŒŒ๋ผ๋ฏธํ„ฐ ๋˜๋Š” ํ—ค๋”์—์„œ ํ† ํฐ์„ ๊ฐ€์ ธ์™€์„œ ์‚ฌ์šฉ์ž ์ธ์ฆ""" + print(f"๐Ÿ” ํ† ํฐ ์ธ์ฆ ์‹œ์ž‘ - URL ํŒŒ๋ผ๋ฏธํ„ฐ: {_token[:50] if _token else 'None'}...") + print(f"๐Ÿ” Authorization ํ—ค๋”: {credentials.credentials[:50] if credentials else 'None'}...") + + token = None + + # URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ ํ† ํฐ ํ™•์ธ + if _token: + token = _token + print("โœ… URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ ํ† ํฐ ์‚ฌ์šฉ") + # Authorization ํ—ค๋”์—์„œ ํ† ํฐ ํ™•์ธ + elif credentials: + token = credentials.credentials + print("โœ… Authorization ํ—ค๋”์—์„œ ํ† ํฐ ์‚ฌ์šฉ") + + if not token: + print("โŒ ํ† ํฐ์ด ์ œ๊ณต๋˜์ง€ ์•Š์Œ") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No authentication token provided" + ) + + try: + # ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ + user_id = get_user_id_from_token(token) + print(f"โœ… ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ: {user_id}") + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + print(f"โŒ ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: {user_id}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + if not user.is_active: + print(f"โŒ ๋น„ํ™œ์„ฑ ์‚ฌ์šฉ์ž: {user.email}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + print(f"โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ ์„ฑ๊ณต: {user.email}") + return user + + except Exception as e: + print(f"๐Ÿšซ ํ† ํฐ ์ธ์ฆ ์‹คํŒจ: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 0000000..c7db491 --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,3 @@ +""" +API ๋ผ์šฐํ„ฐ ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..45ec12e --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -0,0 +1,193 @@ +""" +์ธ์ฆ ๊ด€๋ จ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from datetime import datetime + +from ...core.database import get_db +from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash +from ...core.config import settings +from ...models.user import User +from ...schemas.auth import ( + LoginRequest, TokenResponse, RefreshTokenRequest, + UserInfo, ChangePasswordRequest, CreateUserRequest +) +from ..dependencies import get_current_active_user, get_current_admin_user + + +router = APIRouter() + + +@router.post("/login", response_model=TokenResponse) +async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ""" + # ์‚ฌ์šฉ์ž ์กฐํšŒ + result = await db.execute( + select(User).where(User.email == login_data.email) + ) + user = result.scalar_one_or_none() + + # ์‚ฌ์šฉ์ž ์กด์žฌ ๋ฐ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ + if not user or not verify_password(login_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + # ๋น„ํ™œ์„ฑ ์‚ฌ์šฉ์ž ํ™•์ธ + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user" + ) + + # ์‚ฌ์šฉ์ž๋ณ„ ์„ธ์…˜ ํƒ€์ž„์•„์›ƒ์„ ์ ์šฉํ•œ ํ† ํฐ ์ƒ์„ฑ + access_token = create_access_token( + data={"sub": str(user.id)}, + timeout_minutes=user.session_timeout_minutes + ) + refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + # ๋งˆ์ง€๋ง‰ ๋กœ๊ทธ์ธ ์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ + await db.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.utcnow()) + ) + await db.commit() + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db) +): + """ํ† ํฐ ๊ฐฑ์‹ """ + from ...core.security import verify_token + + try: + # ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ๊ฒ€์ฆ + payload = verify_token(refresh_data.refresh_token, token_type="refresh") + user_id = payload.get("sub") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # ์‚ฌ์šฉ์ž ์กด์žฌ ํ™•์ธ + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + # ์ƒˆ ํ† ํฐ ์ƒ์„ฑ + access_token = create_access_token(data={"sub": str(user.id)}) + new_refresh_token = create_refresh_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + +@router.get("/me", response_model=UserInfo) +async def get_current_user_info( + current_user: User = Depends(get_current_active_user) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ""" + return UserInfo.model_validate(current_user) + + +@router.put("/change-password") +async def change_password( + password_data: ChangePasswordRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ""" + # ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password" + ) + + # ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ ๋ฐ ์—…๋ฐ์ดํŠธ + new_hashed_password = get_password_hash(password_data.new_password) + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(hashed_password=new_hashed_password) + ) + await db.commit() + + return {"message": "Password changed successfully"} + + +@router.post("/create-user", response_model=UserInfo) +async def create_user( + user_data: CreateUserRequest, + admin_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + # ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ + result = await db.execute( + select(User).where(User.email == user_data.email) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # ์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ + new_user = User( + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + full_name=user_data.full_name, + is_admin=user_data.is_admin, + is_active=True + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return UserInfo.from_orm(new_user) + + +@router.post("/logout") +async def logout( + current_user: User = Depends(get_current_active_user) +): + """๋กœ๊ทธ์•„์›ƒ (ํด๋ผ์ด์–ธํŠธ์—์„œ ํ† ํฐ ์‚ญ์ œ)""" + # ์‹ค์ œ๋กœ๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ํ† ํฐ์„ ์‚ญ์ œํ•˜๋ฉด ๋จ + # ํ•„์š”์‹œ ํ† ํฐ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ๊ตฌํ˜„ ๊ฐ€๋Šฅ + return {"message": "Logged out successfully"} diff --git a/backend/src/api/routes/book_categories.py b/backend/src/api/routes/book_categories.py new file mode 100644 index 0000000..32d83a8 --- /dev/null +++ b/backend/src/api/routes/book_categories.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from typing import List +from uuid import UUID + +from ...core.database import get_db +from ..dependencies import get_current_active_user +from ...models.user import User +from ...models.book import Book +from ...models.book_category import BookCategory +from ...models.document import Document +from ...schemas.book_category import ( + CreateBookCategoryRequest, + UpdateBookCategoryRequest, + BookCategoryResponse, + UpdateDocumentOrderRequest +) + +router = APIRouter() + +@router.post("/", response_model=BookCategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_book_category( + category_data: CreateBookCategoryRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ๋กœ์šด ์„œ์  ์†Œ๋ถ„๋ฅ˜ ์ƒ์„ฑ""" + # ์„œ์  ์กด์žฌ ํ™•์ธ + book_result = await db.execute(select(Book).where(Book.id == category_data.book_id)) + book = book_result.scalar_one_or_none() + if not book: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found") + + # ๊ถŒํ•œ ํ™•์ธ (๊ด€๋ฆฌ์ž๋งŒ) + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories") + + new_category = BookCategory(**category_data.model_dump()) + db.add(new_category) + await db.commit() + await db.refresh(new_category) + + return await _get_category_response(db, new_category) + +@router.get("/book/{book_id}", response_model=List[BookCategoryResponse]) +async def get_book_categories( + book_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ์„œ์ ์˜ ์†Œ๋ถ„๋ฅ˜ ๋ชฉ๋ก ์กฐํšŒ""" + result = await db.execute( + select(BookCategory) + .where(BookCategory.book_id == book_id) + .order_by(BookCategory.sort_order, BookCategory.name) + ) + categories = result.scalars().all() + + response_categories = [] + for category in categories: + response_categories.append(await _get_category_response(db, category)) + return response_categories + +@router.put("/{category_id}", response_model=BookCategoryResponse) +async def update_book_category( + category_id: UUID, + category_data: UpdateBookCategoryRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์„œ์  ์†Œ๋ถ„๋ฅ˜ ์ˆ˜์ •""" + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories") + + result = await db.execute(select(BookCategory).where(BookCategory.id == category_id)) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + + for field, value in category_data.model_dump(exclude_unset=True).items(): + setattr(category, field, value) + + await db.commit() + await db.refresh(category) + return await _get_category_response(db, category) + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_book_category( + category_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์„œ์  ์†Œ๋ถ„๋ฅ˜ ์‚ญ์ œ (ํฌํ•จ๋œ ๋ฌธ์„œ๋“ค์€ ๋ฏธ๋ถ„๋ฅ˜๋กœ ์ด๋™)""" + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories") + + result = await db.execute(select(BookCategory).where(BookCategory.id == category_id)) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + + # ํฌํ•จ๋œ ๋ฌธ์„œ๋“ค์„ ๋ฏธ๋ถ„๋ฅ˜๋กœ ์ด๋™ (category_id๋ฅผ NULL๋กœ ์„ค์ •) + await db.execute( + update(Document) + .where(Document.category_id == category_id) + .values(category_id=None) + ) + + await db.delete(category) + await db.commit() + return {"message": "Category deleted successfully"} + +@router.put("/documents/reorder", status_code=status.HTTP_200_OK) +async def update_document_order( + order_data: UpdateDocumentOrderRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์ˆœ์„œ ๋ณ€๊ฒฝ""" + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reorder documents") + + # ๋ฌธ์„œ ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + for item in order_data.document_orders: + document_id = item.get("document_id") + sort_order = item.get("sort_order", 0) + + await db.execute( + update(Document) + .where(Document.id == document_id) + .values(sort_order=sort_order) + ) + + await db.commit() + return {"message": "Document order updated successfully"} + +# Helper function +async def _get_category_response(db: AsyncSession, category: BookCategory) -> BookCategoryResponse: + """BookCategory๋ฅผ BookCategoryResponse๋กœ ๋ณ€ํ™˜""" + document_count_result = await db.execute( + select(func.count(Document.id)).where(Document.category_id == category.id) + ) + document_count = document_count_result.scalar_one() + + return BookCategoryResponse( + id=category.id, + book_id=category.book_id, + name=category.name, + description=category.description, + sort_order=category.sort_order, + created_at=category.created_at, + updated_at=category.updated_at, + document_count=document_count + ) diff --git a/backend/src/api/routes/bookmarks.py b/backend/src/api/routes/bookmarks.py new file mode 100644 index 0000000..bd6e8c8 --- /dev/null +++ b/backend/src/api/routes/bookmarks.py @@ -0,0 +1,300 @@ +""" +์ฑ…๊ฐˆํ”ผ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_ +from sqlalchemy.orm import joinedload +from typing import List, Optional +from datetime import datetime + +from ...core.database import get_db +from ...models.user import User +from ...models.document import Document +from ...models.bookmark import Bookmark +from ..dependencies import get_current_active_user +from pydantic import BaseModel + + +class CreateBookmarkRequest(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ ์š”์ฒญ""" + document_id: str + title: str + description: Optional[str] = None + page_number: Optional[int] = None + scroll_position: int = 0 + element_id: Optional[str] = None + element_selector: Optional[str] = None + + +class UpdateBookmarkRequest(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = None + description: Optional[str] = None + page_number: Optional[int] = None + scroll_position: Optional[int] = None + element_id: Optional[str] = None + element_selector: Optional[str] = None + + +class BookmarkResponse(BaseModel): + """์ฑ…๊ฐˆํ”ผ ์‘๋‹ต""" + id: str + document_id: str + title: str + description: Optional[str] + page_number: Optional[int] + scroll_position: int + element_id: Optional[str] + element_selector: Optional[str] + created_at: datetime + updated_at: Optional[datetime] + document_title: str + + class Config: + from_attributes = True + + +router = APIRouter() + + +@router.post("/", response_model=BookmarkResponse) +async def create_bookmark( + bookmark_data: CreateBookmarkRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == bookmark_data.document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์ฑ…๊ฐˆํ”ผ ์ƒ์„ฑ + bookmark = Bookmark( + user_id=current_user.id, + document_id=bookmark_data.document_id, + title=bookmark_data.title, + description=bookmark_data.description, + page_number=bookmark_data.page_number, + scroll_position=bookmark_data.scroll_position, + element_id=bookmark_data.element_id, + element_selector=bookmark_data.element_selector + ) + + db.add(bookmark) + await db.commit() + await db.refresh(bookmark) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = document.title + + return response_data + + +@router.get("/", response_model=List[BookmarkResponse]) +async def list_user_bookmarks( + skip: int = 0, + limit: int = 50, + document_id: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ์ฑ…๊ฐˆํ”ผ ์กฐํšŒ""" + query = ( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.user_id == current_user.id) + ) + + if document_id: + query = query.where(Bookmark.document_id == document_id) + + query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + bookmarks = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for bookmark in bookmarks: + bookmark_data = BookmarkResponse.from_orm(bookmark) + bookmark_data.document_title = bookmark.document.title + response_data.append(bookmark_data) + + return response_data + + +@router.get("/document/{document_id}", response_model=List[BookmarkResponse]) +async def get_document_bookmarks( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ์ฑ…๊ฐˆํ”ผ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์‚ฌ์šฉ์ž์˜ ์ฑ…๊ฐˆํ”ผ๋งŒ ์กฐํšŒ + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where( + and_( + Bookmark.document_id == document_id, + Bookmark.user_id == current_user.id + ) + ) + .order_by(Bookmark.page_number, Bookmark.scroll_position) + ) + bookmarks = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for bookmark in bookmarks: + bookmark_data = BookmarkResponse.from_orm(bookmark) + bookmark_data.document_title = bookmark.document.title + response_data.append(bookmark_data) + + return response_data + + +@router.get("/{bookmark_id}", response_model=BookmarkResponse) +async def get_bookmark( + bookmark_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.id == bookmark_id) + ) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = bookmark.document.title + + return response_data + + +@router.put("/{bookmark_id}", response_model=BookmarkResponse) +async def update_bookmark( + bookmark_id: str, + bookmark_data: UpdateBookmarkRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์—…๋ฐ์ดํŠธ""" + result = await db.execute( + select(Bookmark) + .options(joinedload(Bookmark.document)) + .where(Bookmark.id == bookmark_id) + ) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์—…๋ฐ์ดํŠธ + if bookmark_data.title is not None: + bookmark.title = bookmark_data.title + if bookmark_data.description is not None: + bookmark.description = bookmark_data.description + if bookmark_data.page_number is not None: + bookmark.page_number = bookmark_data.page_number + if bookmark_data.scroll_position is not None: + bookmark.scroll_position = bookmark_data.scroll_position + if bookmark_data.element_id is not None: + bookmark.element_id = bookmark_data.element_id + if bookmark_data.element_selector is not None: + bookmark.element_selector = bookmark_data.element_selector + + await db.commit() + await db.refresh(bookmark) + + response_data = BookmarkResponse.from_orm(bookmark) + response_data.document_title = bookmark.document.title + + return response_data + + +@router.delete("/{bookmark_id}") +async def delete_bookmark( + bookmark_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ""" + result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id)) + bookmark = result.scalar_one_or_none() + + if not bookmark: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Bookmark not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if bookmark.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์ฑ…๊ฐˆํ”ผ ์‚ญ์ œ + await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id)) + await db.commit() + + return {"message": "Bookmark deleted successfully"} diff --git a/backend/src/api/routes/books.py b/backend/src/api/routes/books.py new file mode 100644 index 0000000..067a266 --- /dev/null +++ b/backend/src/api/routes/books.py @@ -0,0 +1,230 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, or_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from uuid import UUID +import difflib # For similarity suggestions + +from ...core.database import get_db +from ..dependencies import get_current_active_user +from ...models.user import User +from ...models.book import Book +from ...models.document import Document +from ...schemas.book import CreateBookRequest, UpdateBookRequest, BookResponse, BookSearchResponse, BookSuggestionResponse + +router = APIRouter() + +# Helper to convert Book ORM object to BookResponse +async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse: + document_count_result = await db.execute( + select(func.count(Document.id)).where(Document.book_id == book.id) + ) + document_count = document_count_result.scalar_one() + return BookResponse( + id=book.id, + title=book.title, + author=book.author, + description=book.description, + language=book.language, + is_public=book.is_public, + created_at=book.created_at, + updated_at=book.updated_at, + document_count=document_count + ) + +@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED) +async def create_book( + book_data: CreateBookRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ๋กœ์šด ์„œ์  ์ƒ์„ฑ""" + # Check if a book with the same title and author already exists for the user + existing_book_query = select(Book).where(Book.title == book_data.title) + if book_data.author: + existing_book_query = existing_book_query.where(Book.author == book_data.author) + + existing_book = await db.execute(existing_book_query) + if existing_book.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A book with this title and author already exists." + ) + + new_book = Book(**book_data.model_dump()) + db.add(new_book) + await db.commit() + await db.refresh(new_book) + return await _get_book_response(db, new_book) + +@router.get("", response_model=List[BookResponse]) +async def get_books( + skip: int = 0, + limit: int = 50, + search: Optional[str] = Query(None, description="Search by book title or author"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ชจ๋“  ์„œ์  ๋ชฉ๋ก ์กฐํšŒ""" + query = select(Book) + if search: + query = query.where( + or_( + Book.title.ilike(f"%{search}%"), + Book.author.ilike(f"%{search}%") + ) + ) + + # Only show public books or books owned by the current user/admin + if not current_user.is_admin: + query = query.where(Book.is_public == True) # For simplicity, assuming all books are public for now or user can only see public ones. + # In a real app, you'd link books to users. + + query = query.offset(skip).limit(limit).order_by(Book.title) + result = await db.execute(query) + books = result.scalars().all() + + response_books = [] + for book in books: + response_books.append(await _get_book_response(db, book)) + return response_books + +@router.get("/{book_id}", response_model=BookResponse) +async def get_book( + book_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ์„œ์  ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ""" + result = await db.execute( + select(Book).where(Book.id == book_id) + ) + book = result.scalar_one_or_none() + + if not book: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found") + + # Access control (simplified) + if not book.is_public and not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions to access this book") + + return await _get_book_response(db, book) + +@router.put("/{book_id}", response_model=BookResponse) +async def update_book( + book_id: UUID, + book_data: UpdateBookRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์„œ์  ์ •๋ณด ์—…๋ฐ์ดํŠธ""" + if not current_user.is_admin: # Only admin can update books for now + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update books") + + result = await db.execute( + select(Book).where(Book.id == book_id) + ) + book = result.scalar_one_or_none() + + if not book: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found") + + for field, value in book_data.model_dump(exclude_unset=True).items(): + setattr(book, field, value) + + await db.commit() + await db.refresh(book) + return await _get_book_response(db, book) + +@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_book( + book_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์„œ์  ์‚ญ์ œ""" + if not current_user.is_admin: # Only admin can delete books for now + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete books") + + result = await db.execute( + select(Book).where(Book.id == book_id) + ) + book = result.scalar_one_or_none() + + if not book: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Book not found") + + # Disassociate documents from this book before deleting + await db.execute( + select(Document).where(Document.book_id == book_id) + ) + documents_to_update = (await db.execute(select(Document).where(Document.book_id == book_id))).scalars().all() + for doc in documents_to_update: + doc.book_id = None + + await db.delete(book) + await db.commit() + return {"message": "Book deleted successfully"} + +@router.get("/search/", response_model=List[BookSearchResponse]) +async def search_books( + q: str = Query(..., min_length=1, description="Search query for book title or author"), + limit: int = Query(10, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์„œ์  ๊ฒ€์ƒ‰ (์ œ๋ชฉ ๋˜๋Š” ์ €์ž)""" + query = select(Book).where( + or_( + Book.title.ilike(f"%{q}%"), + Book.author.ilike(f"%{q}%") + ) + ) + if not current_user.is_admin: + query = query.where(Book.is_public == True) + + result = await db.execute(query.limit(limit)) + books = result.scalars().all() + + response_books = [] + for book in books: + response_books.append(await _get_book_response(db, book)) + return response_books + +@router.get("/suggestions/", response_model=List[BookSuggestionResponse]) +async def get_book_suggestions( + title: str = Query(..., min_length=1, description="Book title for suggestions"), + limit: int = Query(5, ge=1, le=10), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ œ๋ชฉ ์œ ์‚ฌ๋„ ๊ธฐ๋ฐ˜ ์„œ์  ์ถ”์ฒœ""" + all_books_query = select(Book) + if not current_user.is_admin: + all_books_query = all_books_query.where(Book.is_public == True) + + all_books_result = await db.execute(all_books_query) + all_books = all_books_result.scalars().all() + + suggestions = [] + for book in all_books: + # Calculate similarity score using difflib + score = difflib.SequenceMatcher(None, title.lower(), book.title.lower()).ratio() + if score > 0.1: # Only consider if there's some similarity + suggestions.append({ + "book": book, + "similarity_score": score + }) + + # Sort by similarity score in descending order + suggestions.sort(key=lambda x: x["similarity_score"], reverse=True) + + response_suggestions = [] + for s in suggestions[:limit]: + book_response = await _get_book_response(db, s["book"]) + response_suggestions.append(BookSuggestionResponse( + **book_response.model_dump(), + similarity_score=s["similarity_score"] + )) + return response_suggestions \ No newline at end of file diff --git a/backend/src/api/routes/document_links.py b/backend/src/api/routes/document_links.py new file mode 100644 index 0000000..bec63ed --- /dev/null +++ b/backend/src/api/routes/document_links.py @@ -0,0 +1,690 @@ +""" +๋ฌธ์„œ ๋งํฌ ๊ด€๋ จ API ์—”๋“œํฌ์ธํŠธ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import List, Optional +from pydantic import BaseModel +import uuid + +from ...core.database import get_db +from ..dependencies import get_current_active_user +from ...models import User, Document, DocumentLink + +router = APIRouter() + + +# Pydantic ๋ชจ๋ธ๋“ค +class DocumentLinkCreate(BaseModel): + target_document_id: str + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] = None + description: Optional[str] = None + + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ (๋ชจ๋‘ Optional๋กœ ์„ค์ •) + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: Optional[str] = "document" # "document" or "text_fragment" + + +class DocumentLinkUpdate(BaseModel): + target_document_id: Optional[str] = None + link_text: Optional[str] = None + description: Optional[str] = None + + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: Optional[str] = None + + +class DocumentLinkResponse(BaseModel): + id: str + source_document_id: str + target_document_id: str + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] + description: Optional[str] + created_at: str + updated_at: Optional[str] + + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ + target_text: Optional[str] + target_start_offset: Optional[int] + target_end_offset: Optional[int] + link_type: Optional[str] = "document" + + # ๋Œ€์ƒ ๋ฌธ์„œ ์ •๋ณด + target_document_title: str + target_document_book_id: Optional[str] + target_content_type: Optional[str] = "document" # "document" ๋˜๋Š” "note" + + class Config: + from_attributes = True + + +class LinkableDocumentResponse(BaseModel): + id: str + title: str + book_id: Optional[str] + book_title: Optional[str] + sort_order: int + + class Config: + from_attributes = True + + +@router.post("/{document_id}/links", response_model=DocumentLinkResponse) +async def create_document_link( + document_id: str, + link_data: DocumentLinkCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋งํฌ ์ƒ์„ฑ""" + print(f"๐Ÿ”— ๋งํฌ ์ƒ์„ฑ ์š”์ฒญ - ๋ฌธ์„œ ID: {document_id}") + print(f"๐Ÿ“‹ ๋งํฌ ๋ฐ์ดํ„ฐ: {link_data}") + print(f"๐ŸŽฏ target_text: '{link_data.target_text}'") + print(f"๐ŸŽฏ target_start_offset: {link_data.target_start_offset}") + print(f"๐ŸŽฏ target_end_offset: {link_data.target_end_offset}") + print(f"๐ŸŽฏ link_type: {link_data.link_type}") + + if link_data.link_type == 'text_fragment' and not link_data.target_text: + print("๐Ÿšจ CRITICAL: text_fragment ๋งํฌ์ธ๋ฐ target_text๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!") + + # ์ถœ๋ฐœ ๋ฌธ์„œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + source_doc = result.scalar_one_or_none() + + if not source_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Source document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to source document" + ) + + # ๋Œ€์ƒ ๋ฌธ์„œ ๋˜๋Š” ๋…ธํŠธ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == link_data.target_document_id)) + target_doc = result.scalar_one_or_none() + + target_note = None + if not target_doc: + # ๋ฌธ์„œ์—์„œ ์ฐพ์ง€ ๋ชปํ•˜๋ฉด ๋…ธํŠธ์—์„œ ์ฐพ๊ธฐ + print(f"๐Ÿ” ๋ฌธ์„œ์—์„œ ์ฐพ์ง€ ๋ชปํ•จ, ๋…ธํŠธ์—์„œ ๊ฒ€์ƒ‰: {link_data.target_document_id}") + from ...models.note_document import NoteDocument + result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id)) + target_note = result.scalar_one_or_none() + + if target_note: + print(f"โœ… ๋…ธํŠธ ์ฐพ์Œ: {target_note.title}") + else: + print(f"โŒ ๋…ธํŠธ๋„ ์ฐพ์ง€ ๋ชปํ•จ: {link_data.target_document_id}") + # ๋””๋ฒ„๊น…: ์‹ค์ œ ์กด์žฌํ•˜๋Š” ๋…ธํŠธ๋“ค ํ™•์ธ + all_notes_result = await db.execute(select(NoteDocument).limit(5)) + all_notes = all_notes_result.scalars().all() + print(f"๐Ÿ” ์กด์žฌํ•˜๋Š” ๋…ธํŠธ ์˜ˆ์‹œ (์ตœ๋Œ€ 5๊ฐœ):") + for note in all_notes: + print(f" - ID: {note.id}, ์ œ๋ชฉ: {note.title}") + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target document or note not found" + ) + + # ๋Œ€์ƒ ๋ฌธ์„œ/๋…ธํŠธ ๊ถŒํ•œ ํ™•์ธ + if target_doc: + if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to target document" + ) + + # HTML ๋ฌธ์„œ๋งŒ ๋งํฌ ๊ฐ€๋Šฅ + if not target_doc.html_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only link to HTML documents" + ) + elif target_note: + # ๋…ธํŠธ ๊ถŒํ•œ ํ™•์ธ (๋…ธํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ƒ์„ฑ์ž๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ) + if target_note.created_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to target note" + ) + + # ๋งํฌ ์ƒ์„ฑ + new_link = DocumentLink( + source_document_id=uuid.UUID(document_id), + target_document_id=uuid.UUID(link_data.target_document_id), + selected_text=link_data.selected_text, + start_offset=link_data.start_offset, + end_offset=link_data.end_offset, + link_text=link_data.link_text, + description=link_data.description, + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ + target_text=link_data.target_text, + target_start_offset=link_data.target_start_offset, + target_end_offset=link_data.target_end_offset, + link_type=link_data.link_type, + created_by=current_user.id + ) + + db.add(new_link) + await db.commit() + await db.refresh(new_link) + + target_title = target_doc.title if target_doc else target_note.title + target_type = "document" if target_doc else "note" + print(f"โœ… ๋งํฌ ์ƒ์„ฑ ์™„๋ฃŒ: {source_doc.title} -> {target_title} ({target_type})") + print(f" - ๋งํฌ ํƒ€์ž…: {new_link.link_type}") + print(f" - ์„ ํƒ๋œ ํ…์ŠคํŠธ: {new_link.selected_text}") + print(f" - ๋Œ€์ƒ ํ…์ŠคํŠธ: {new_link.target_text}") + + # ๋ฐฑ๋งํฌ๋Š” ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋˜์ง€ ์•Š์Œ - ๊ธฐ์กด ๋งํฌ๋ฅผ ์—ญ๋ฐฉํ–ฅ์œผ๋กœ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์‹ ์‚ฌ์šฉ + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + return DocumentLinkResponse( + id=str(new_link.id), + source_document_id=str(new_link.source_document_id), + target_document_id=str(new_link.target_document_id), + selected_text=new_link.selected_text, + start_offset=new_link.start_offset, + end_offset=new_link.end_offset, + link_text=new_link.link_text, + description=new_link.description, + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ + target_text=new_link.target_text, + target_start_offset=new_link.target_start_offset, + target_end_offset=new_link.target_end_offset, + link_type=new_link.link_type, + created_at=new_link.created_at.isoformat(), + updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None, + target_document_title=target_title, + target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_id else None) + ) + + +@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse]) +async def get_document_links( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ์˜ ๋ชจ๋“  ๋งํฌ ์กฐํšŒ""" + # ๋ฌธ์„œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # ๋ชจ๋“  ๋งํฌ ์กฐํšŒ (๋ฌธ์„œโ†’๋ฌธ์„œ + ๋ฌธ์„œโ†’๋…ธํŠธ) + result = await db.execute( + select(DocumentLink) + .where(DocumentLink.source_document_id == document_id) + .order_by(DocumentLink.start_offset.asc()) + ) + + all_links = result.scalars().all() + print(f"๐Ÿ” ๋ฌธ์„œ ๋งํฌ ์กฐํšŒ ์™„๋ฃŒ: {len(all_links)}๊ฐœ ๋ฐœ๊ฒฌ") + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_links = [] + for link in all_links: + print(f"๐Ÿ”— ๋งํฌ ์ฒ˜๋ฆฌ ์ค‘: {link.id} -> {link.target_document_id}") + + # ๋Œ€์ƒ์ด ๋ฌธ์„œ์ธ์ง€ ๋…ธํŠธ์ธ์ง€ ํ™•์ธ + target_doc = None + target_note = None + + # ๋จผ์ € Document ํ…Œ์ด๋ธ”์—์„œ ์ฐพ๊ธฐ + doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id)) + target_doc = doc_result.scalar_one_or_none() + + if target_doc: + print(f"โœ… ๋Œ€์ƒ ๋ฌธ์„œ ์ฐพ์Œ: {target_doc.title}") + target_title = target_doc.title + target_book_id = str(target_doc.book_id) if target_doc.book_id else None + target_content_type = "document" + else: + # Document์—์„œ ์ฐพ์ง€ ๋ชปํ•˜๋ฉด NoteDocument์—์„œ ์ฐพ๊ธฐ + from ...models.note_document import NoteDocument + note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id)) + target_note = note_result.scalar_one_or_none() + + if target_note: + print(f"โœ… ๋Œ€์ƒ ๋…ธํŠธ ์ฐพ์Œ: {target_note.title}") + target_title = f"๐Ÿ“ {target_note.title}" # ๋…ธํŠธ์ž„์„ ํ‘œ์‹œ + target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None + target_content_type = "note" + else: + print(f"โŒ ๋Œ€์ƒ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ: {link.target_document_id}") + target_title = "Unknown Target" + target_book_id = None + target_content_type = "document" # ๊ธฐ๋ณธ๊ฐ’ + + response_links.append(DocumentLinkResponse( + id=str(link.id), + source_document_id=str(link.source_document_id), + target_document_id=str(link.target_document_id), + selected_text=link.selected_text, + start_offset=link.start_offset, + end_offset=link.end_offset, + link_text=link.link_text, + description=link.description, + created_at=link.created_at.isoformat(), + updated_at=link.updated_at.isoformat() if link.updated_at else None, + # ๊ณ ๊ธ‰ ๋งํฌ ๊ธฐ๋Šฅ (๊ธฐ์กด ๋งํฌ๋Š” None์ผ ์ˆ˜ ์žˆ์Œ) + target_text=getattr(link, 'target_text', None), + target_start_offset=getattr(link, 'target_start_offset', None), + target_end_offset=getattr(link, 'target_end_offset', None), + link_type=getattr(link, 'link_type', 'document'), + # ๋Œ€์ƒ ๋ฌธ์„œ/๋…ธํŠธ ์ •๋ณด ์ถ”๊ฐ€ + target_document_title=target_title, + target_document_book_id=target_book_id, + target_content_type=target_content_type + )) + + return response_links + + +@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse]) +async def get_linkable_documents( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋งํฌ ๊ฐ€๋Šฅํ•œ ๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ (๊ฐ™์€ ์„œ์  ์šฐ์„ , ์ „์ฒด HTML ๋ฌธ์„œ)""" + # ํ˜„์žฌ ๋ฌธ์„œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + current_doc = result.scalar_one_or_none() + + if not current_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # ๋งํฌ ๊ฐ€๋Šฅํ•œ HTML ๋ฌธ์„œ๋“ค ์กฐํšŒ + # 1. ๊ฐ™์€ ์„œ์ ์˜ ๋ฌธ์„œ๋“ค (์šฐ์„ ์ˆœ์œ„) + # 2. ๋‹ค๋ฅธ ์„œ์ ์˜ ๋ฌธ์„œ๋“ค + from ...models import Book + + query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where( + and_( + Document.html_path.isnot(None), # HTML ๋ฌธ์„œ๋งŒ + Document.id != document_id, # ์ž๊ธฐ ์ž์‹  ์ œ์™ธ + # ๊ถŒํ•œ ํ™•์ธ: ๊ณต๊ฐœ ๋ฌธ์„œ์ด๊ฑฐ๋‚˜ ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ๋ฌธ์„œ + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by( + # ๊ฐ™์€ ์„œ์  ์šฐ์„ , ๊ทธ ๋‹ค์Œ ์ •๋ ฌ ์ˆœ์„œ + (Document.book_id == current_doc.book_id).desc(), + Document.sort_order.asc().nulls_last(), + Document.created_at.asc() + ) + + result = await db.execute(query) + documents_with_books = result.all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + linkable_docs = [] + for doc, book in documents_with_books: + linkable_docs.append(LinkableDocumentResponse( + id=str(doc.id), + title=doc.title, + book_id=str(doc.book_id) if doc.book_id else None, + book_title=book.title if book else None, + sort_order=doc.sort_order or 0 + )) + + return linkable_docs + + +@router.put("/links/{link_id}", response_model=DocumentLinkResponse) +async def update_document_link( + link_id: str, + link_data: DocumentLinkUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋งํฌ ์ˆ˜์ •""" + # ๋งํฌ ์กฐํšŒ + result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id)) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Link not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (์ƒ์„ฑ์ž๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ) + if link.created_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # ๋Œ€์ƒ ๋ฌธ์„œ ๋ณ€๊ฒฝ ์‹œ ๊ฒ€์ฆ + if link_data.target_document_id: + result = await db.execute(select(Document).where(Document.id == link_data.target_document_id)) + target_doc = result.scalar_one_or_none() + + if not target_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target document not found" + ) + + if not target_doc.html_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only link to HTML documents" + ) + + link.target_document_id = uuid.UUID(link_data.target_document_id) + + # ํ•„๋“œ ์—…๋ฐ์ดํŠธ + if link_data.link_text is not None: + link.link_text = link_data.link_text + if link_data.description is not None: + link.description = link_data.description + + await db.commit() + await db.refresh(link) + + # ๋Œ€์ƒ ๋ฌธ์„œ ์ •๋ณด ์กฐํšŒ + result = await db.execute(select(Document).where(Document.id == link.target_document_id)) + target_doc = result.scalar_one() + + return DocumentLinkResponse( + id=str(link.id), + source_document_id=str(link.source_document_id), + target_document_id=str(link.target_document_id), + selected_text=link.selected_text, + start_offset=link.start_offset, + end_offset=link.end_offset, + link_text=link.link_text, + description=link.description, + created_at=link.created_at.isoformat(), + updated_at=link.updated_at.isoformat() if link.updated_at else None, + target_document_title=target_doc.title, + target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None + ) + + +@router.delete("/links/{link_id}") +@router.delete("/document-links/{link_id}") # ํ”„๋ก ํŠธ์—”๋“œ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•œ ์ถ”๊ฐ€ ๊ฒฝ๋กœ +async def delete_document_link( + link_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋งํฌ ์‚ญ์ œ""" + # ๋งํฌ ์กฐํšŒ + result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id)) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Link not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (์ƒ์„ฑ์ž๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) + if link.created_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + await db.delete(link) + await db.commit() + + return {"message": "Link deleted successfully"} + + +# ๋ฐฑ๋งํฌ ๊ด€๋ จ ๋ชจ๋ธ +class BacklinkResponse(BaseModel): + id: str + source_document_id: str + source_document_title: str + source_document_book_id: Optional[str] + source_content_type: Optional[str] = "document" # "document" or "note" + target_document_id: str + target_document_title: str + selected_text: str # ์†Œ์Šค ๋ฌธ์„œ์—์„œ ์„ ํƒํ•œ ํ…์ŠคํŠธ + start_offset: int # ์†Œ์Šค ๋ฌธ์„œ ์˜คํ”„์…‹ + end_offset: int # ์†Œ์Šค ๋ฌธ์„œ ์˜คํ”„์…‹ + link_text: Optional[str] + description: Optional[str] + link_type: str + target_text: Optional[str] # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ์˜ ํ…์ŠคํŠธ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + target_start_offset: Optional[int] # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ ์˜คํ”„์…‹ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + target_end_offset: Optional[int] # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ ์˜คํ”„์…‹ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + created_at: str + + class Config: + from_attributes = True + + +@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse]) +async def get_document_backlinks( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ์˜ ๋ฐฑ๋งํฌ ์กฐํšŒ (์ด ๋ฌธ์„œ๋ฅผ ์ฐธ์กฐํ•˜๋Š” ๋ชจ๋“  ๋งํฌ)""" + print(f"๐Ÿ” ๋ฐฑ๋งํฌ API ํ˜ธ์ถœ๋จ - ๋ฌธ์„œ ID: {document_id}, ์‚ฌ์šฉ์ž: {current_user.email}") + + # ๋ฌธ์„œ ์กด์žฌ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + print(f"โŒ ๋ฌธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: {document_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + print(f"โœ… ๋ฌธ์„œ ์ฐพ์Œ: {document.title}") + + # ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # ์ด ๋ฌธ์„œ๋ฅผ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋Š” ๋ชจ๋“  ๋งํฌ ์กฐํšŒ (๋ฐฑ๋งํฌ) + from ...models import Book + from ...models.note_link import NoteLink + from ...models.note_document import NoteDocument + from ...models.notebook import Notebook + + # 1. ์ผ๋ฐ˜ ๋ฌธ์„œ์—์„œ ์˜ค๋Š” ๋ฐฑ๋งํฌ (DocumentLink) + doc_query = select(DocumentLink, Document, Book).join( + Document, DocumentLink.source_document_id == Document.id + ).outerjoin(Book, Document.book_id == Book.id).where( + and_( + DocumentLink.target_document_id == document_id, + # ๊ถŒํ•œ ํ™•์ธ: ๊ณต๊ฐœ ๋ฌธ์„œ์ด๊ฑฐ๋‚˜ ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ๋ฌธ์„œ + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by(DocumentLink.created_at.desc()) + + doc_result = await db.execute(doc_query) + backlinks = [] + + print(f"๐Ÿ” ๋ฌธ์„œ ๋ฐฑ๋งํฌ ์ฟผ๋ฆฌ ์‹คํ–‰ ์™„๋ฃŒ") + + # ์ผ๋ฐ˜ ๋ฌธ์„œ ๋ฐฑ๋งํฌ ์ฒ˜๋ฆฌ + for link, source_doc, book in doc_result.fetchall(): + print(f"๐Ÿ“‹ ๋ฐฑ๋งํฌ ๋ฐœ๊ฒฌ: {source_doc.title} -> {document.title}") + print(f" - ์†Œ์Šค ํ…์ŠคํŠธ (selected_text): {link.selected_text}") + print(f" - ํƒ€๊ฒŸ ํ…์ŠคํŠธ (target_text): {link.target_text}") + print(f" - ํƒ€๊ฒŸ ์˜คํ”„์…‹: {link.target_start_offset}-{link.target_end_offset}") + print(f" - ๋งํฌ ํƒ€์ž…: {link.link_type}") + + backlinks.append(BacklinkResponse( + id=str(link.id), + source_document_id=str(link.source_document_id), + source_document_title=source_doc.title, + source_document_book_id=str(book.id) if book else None, + source_content_type="document", # ์ผ๋ฐ˜ ๋ฌธ์„œ + target_document_id=str(link.target_document_id), + target_document_title=document.title, + selected_text=link.selected_text, # ์†Œ์Šค ๋ฌธ์„œ์—์„œ ์„ ํƒํ•œ ํ…์ŠคํŠธ (์ฐธ๊ณ ์šฉ) + start_offset=link.start_offset, # ์†Œ์Šค ๋ฌธ์„œ ์˜คํ”„์…‹ (์ฐธ๊ณ ์šฉ) + end_offset=link.end_offset, # ์†Œ์Šค ๋ฌธ์„œ ์˜คํ”„์…‹ (์ฐธ๊ณ ์šฉ) + link_text=link.link_text, + description=link.description, + link_type=link.link_type, + target_text=link.target_text, # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ์˜ ํ…์ŠคํŠธ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + target_start_offset=link.target_start_offset, # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ ์˜คํ”„์…‹ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + target_end_offset=link.target_end_offset, # ๐ŸŽฏ ํƒ€๊ฒŸ ๋ฌธ์„œ ์˜คํ”„์…‹ (๋ฐฑ๋งํฌ ๋ Œ๋”๋ง์šฉ) + created_at=link.created_at.isoformat() + )) + + # 2. ๋…ธํŠธ์—์„œ ์˜ค๋Š” ๋ฐฑ๋งํฌ (NoteLink) - ๋™๊ธฐ ์ฟผ๋ฆฌ ์‚ฌ์šฉ + try: + from ...core.database import get_sync_db + sync_db = next(get_sync_db()) + + # ๋…ธํŠธ์—์„œ ์ด ๋ฌธ์„œ๋ฅผ ๋Œ€์ƒ์œผ๋กœ ํ•˜๋Š” ๋งํฌ๋“ค ์กฐํšŒ + note_links = sync_db.query(NoteLink).join( + NoteDocument, NoteLink.source_note_id == NoteDocument.id + ).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter( + NoteLink.target_document_id == document_id + ).all() + + print(f"๐Ÿ” ๋…ธํŠธ ๋ฐฑ๋งํฌ ์ฟผ๋ฆฌ ์‹คํ–‰ ์™„๋ฃŒ: {len(note_links)}๊ฐœ ๋ฐœ๊ฒฌ") + + # ๋…ธํŠธ ๋ฐฑ๋งํฌ ์ฒ˜๋ฆฌ + for link in note_links: + source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first() + notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None + + if source_note: + print(f"๐Ÿ“‹ ๋…ธํŠธ ๋ฐฑ๋งํฌ ๋ฐœ๊ฒฌ: {source_note.title} -> {document.title}") + print(f" - ์†Œ์Šค ํ…์ŠคํŠธ (selected_text): {link.selected_text}") + print(f" - ํƒ€๊ฒŸ ํ…์ŠคํŠธ (target_text): {link.target_text}") + print(f" - ํƒ€๊ฒŸ ์˜คํ”„์…‹: {link.target_start_offset}-{link.target_end_offset}") + print(f" - ๋งํฌ ํƒ€์ž…: {link.link_type}") + + backlinks.append(BacklinkResponse( + id=str(link.id), + source_document_id=str(link.source_note_id), # ๋…ธํŠธ ID๋ฅผ ๋ฌธ์„œ ID๋กœ ์‚ฌ์šฉ + source_document_title=f"๐Ÿ“ {source_note.title}", # ๋…ธํŠธ์ž„์„ ํ‘œ์‹œ + source_document_book_id=str(notebook.id) if notebook else None, + source_content_type="note", # ๋…ธํŠธ ๋ฌธ์„œ + target_document_id=str(link.target_document_id) if link.target_document_id else document_id, + target_document_title=document.title, + selected_text=link.selected_text, + start_offset=link.start_offset, + end_offset=link.end_offset, + link_text=link.link_text, + description=link.description, + link_type=link.link_type, + target_text=link.target_text, + target_start_offset=link.target_start_offset, + target_end_offset=link.target_end_offset, + created_at=link.created_at.isoformat() if link.created_at else None + )) + + sync_db.close() + except Exception as e: + print(f"โŒ ๋…ธํŠธ ๋ฐฑ๋งํฌ ์กฐํšŒ ์‹คํŒจ: {e}") + + print(f"โœ… ์ด {len(backlinks)}๊ฐœ์˜ ๋ฐฑ๋งํฌ ๋ฐ˜ํ™˜ (๋ฌธ์„œ + ๋…ธํŠธ)") + return backlinks + + +@router.get("/{document_id}/link-fragments") +async def get_document_link_fragments( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋‚ด ๋ชจ๋“  ๋งํฌ๋œ ํ…์ŠคํŠธ ์กฐ๊ฐ ์กฐํšŒ (์ค‘๋ณต ๋งํฌ ๊ด€๋ฆฌ์šฉ)""" + # ๋ฌธ์„œ ์กด์žฌ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # ์ด ๋ฌธ์„œ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋ชจ๋“  ๋งํฌ ์กฐํšŒ + query = select(DocumentLink, Document).join( + Document, DocumentLink.target_document_id == Document.id + ).where( + and_( + DocumentLink.source_document_id == document_id, + # ๊ถŒํ•œ ํ™•์ธ: ๊ณต๊ฐœ ๋ฌธ์„œ์ด๊ฑฐ๋‚˜ ๋ณธ์ธ์ด ์—…๋กœ๋“œํ•œ ๋ฌธ์„œ + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by(DocumentLink.start_offset.asc()) + + result = await db.execute(query) + fragments = [] + + for link, target_doc in result.fetchall(): + fragments.append({ + "link_id": str(link.id), + "start_offset": link.start_offset, + "end_offset": link.end_offset, + "selected_text": link.selected_text, + "target_document_id": str(link.target_document_id), + "target_document_title": target_doc.title, + "link_text": link.link_text, + "description": link.description, + "link_type": link.link_type, + "target_text": link.target_text, + "target_start_offset": link.target_start_offset, + "target_end_offset": link.target_end_offset + }) + + return fragments diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py new file mode 100644 index 0000000..d43c4d0 --- /dev/null +++ b/backend/src/api/routes/documents.py @@ -0,0 +1,1140 @@ +""" +๋ฌธ์„œ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_, or_, update +from sqlalchemy.orm import selectinload +from typing import List, Optional +import os +import uuid +from uuid import UUID +import aiofiles +from pathlib import Path + +from ...core.database import get_db +from ...core.config import settings +from ...models.user import User +from ...models.document import Document, Tag +from ...models.book import Book +from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param +from pydantic import BaseModel +from datetime import datetime + + +class DocumentResponse(BaseModel): + """๋ฌธ์„œ ์‘๋‹ต""" + id: str + title: str + description: Optional[str] + html_path: Optional[str] # PDF๋งŒ ์—…๋กœ๋“œํ•˜๋Š” ๊ฒฝ์šฐ None ๊ฐ€๋Šฅ + pdf_path: Optional[str] + thumbnail_path: Optional[str] + file_size: Optional[int] + page_count: Optional[int] + language: str + is_public: bool + is_processed: bool + created_at: datetime + updated_at: Optional[datetime] + document_date: Optional[datetime] + uploader_name: Optional[str] + tags: List[str] = [] + + # ์„œ์  ์ •๋ณด + book_id: Optional[str] = None + book_title: Optional[str] = None + book_author: Optional[str] = None + + # ์†Œ๋ถ„๋ฅ˜ ์ •๋ณด + category_id: Optional[str] = None + category_name: Optional[str] = None + sort_order: int = 0 + + # PDF ๋งค์นญ ์ •๋ณด + matched_pdf_id: Optional[str] = None + + class Config: + from_attributes = True + + +class TagResponse(BaseModel): + """ํƒœ๊ทธ ์‘๋‹ต""" + id: str + name: str + color: str + description: Optional[str] + document_count: int = 0 + + class Config: + from_attributes = True + + +class CreateTagRequest(BaseModel): + """ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ""" + name: str + color: str = "#3B82F6" + description: Optional[str] = None + + +router = APIRouter() + + +@router.get("/", response_model=List[DocumentResponse]) +async def list_documents( + skip: int = 0, + limit: int = 50, + tag: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ""" + query = select(Document).options( + selectinload(Document.uploader), + selectinload(Document.tags), + selectinload(Document.book), # ์„œ์  ์ •๋ณด ์ถ”๊ฐ€ + selectinload(Document.category) # ์†Œ๋ถ„๋ฅ˜ ์ •๋ณด ์ถ”๊ฐ€ + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง (๊ด€๋ฆฌ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ๊ณต๊ฐœ ๋ฌธ์„œ + ์ž์‹ ์ด ์—…๋กœ๋“œํ•œ ๋ฌธ์„œ๋งŒ) + if not current_user.is_admin: + query = query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + # ํƒœ๊ทธ ํ•„ํ„ฐ๋ง + if tag: + query = query.join(Document.tags).where(Tag.name == tag) + + # ๊ฒ€์ƒ‰ ํ•„ํ„ฐ๋ง + if search: + query = query.where( + or_( + Document.title.ilike(f"%{search}%"), + Document.description.ilike(f"%{search}%") + ) + ) + + query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + documents = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for doc in documents: + doc_data = DocumentResponse( + id=str(doc.id), + title=doc.title, + description=doc.description, + html_path=doc.html_path, # None ๊ฐ€๋Šฅ (PDF๋งŒ ์—…๋กœ๋“œํ•œ ๊ฒฝ์šฐ) + pdf_path=doc.pdf_path, + thumbnail_path=doc.thumbnail_path, + file_size=doc.file_size, + page_count=doc.page_count, + language=doc.language, + is_public=doc.is_public, + is_processed=doc.is_processed, + created_at=doc.created_at, + updated_at=doc.updated_at, + document_date=doc.document_date, + uploader_name=doc.uploader.full_name or doc.uploader.email, + tags=[tag.name for tag in doc.tags], + # ์„œ์  ์ •๋ณด ์ถ”๊ฐ€ + book_id=str(doc.book.id) if doc.book else None, + book_title=doc.book.title if doc.book else None, + book_author=doc.book.author if doc.book else None, + # ์†Œ๋ถ„๋ฅ˜ ์ •๋ณด ์ถ”๊ฐ€ + category_id=str(doc.category.id) if doc.category else None, + category_name=doc.category.name if doc.category else None, + sort_order=doc.sort_order, + # PDF ๋งค์นญ ์ •๋ณด ์ถ”๊ฐ€ + matched_pdf_id=str(doc.matched_pdf_id) if doc.matched_pdf_id else None + ) + response_data.append(doc_data) + + return response_data + + +@router.get("/hierarchy/structured", response_model=dict) +async def get_documents_by_hierarchy( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๊ณ„์ธต๊ตฌ์กฐ๋ณ„ ๋ฌธ์„œ ์กฐํšŒ (์„œ์  > ์†Œ๋ถ„๋ฅ˜ > ๋ฌธ์„œ)""" + # ๋ชจ๋“  ๋ฌธ์„œ ์กฐํšŒ (๊ธฐ์กด ๋กœ์ง ์žฌ์‚ฌ์šฉ) + query = select(Document).options( + selectinload(Document.uploader), + selectinload(Document.tags), + selectinload(Document.book), + selectinload(Document.category) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง + if not current_user.is_admin: + query = query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + # ์ •๋ ฌ: ์„œ์ ๋ณ„ > ์†Œ๋ถ„๋ฅ˜๋ณ„ > ๋ฌธ์„œ ์ˆœ์„œ๋ณ„ + query = query.order_by( + Document.book_id.nulls_last(), # ์„œ์  ์žˆ๋Š” ๊ฒƒ ๋จผ์ € + Document.category_id.nulls_last(), # ์†Œ๋ถ„๋ฅ˜ ์žˆ๋Š” ๊ฒƒ ๋จผ์ € + Document.sort_order, + Document.created_at.desc() + ) + + result = await db.execute(query) + documents = result.scalars().all() + + # ๊ณ„์ธต๊ตฌ์กฐ๋กœ ๊ทธ๋ฃนํ™” + hierarchy = { + "books": {}, # ์„œ์ ๋ณ„ ๊ทธ๋ฃน + "uncategorized": [] # ๋ฏธ๋ถ„๋ฅ˜ ๋ฌธ์„œ๋“ค + } + + for doc in documents: + doc_data = { + "id": str(doc.id), + "title": doc.title, + "description": doc.description, + "created_at": doc.created_at.isoformat(), + "uploader_name": doc.uploader.full_name or doc.uploader.email, + "tags": [tag.name for tag in doc.tags], + "sort_order": doc.sort_order, + "book_id": str(doc.book.id) if doc.book else None, + "book_title": doc.book.title if doc.book else None, + "category_id": str(doc.category.id) if doc.category else None, + "category_name": doc.category.name if doc.category else None + } + + if doc.book: + # ์„œ์ ์ด ์žˆ๋Š” ๋ฌธ์„œ + book_id = str(doc.book.id) + if book_id not in hierarchy["books"]: + hierarchy["books"][book_id] = { + "id": book_id, + "title": doc.book.title, + "author": doc.book.author, + "categories": {}, + "uncategorized_documents": [] + } + + if doc.category: + # ์†Œ๋ถ„๋ฅ˜๊ฐ€ ์žˆ๋Š” ๋ฌธ์„œ + category_id = str(doc.category.id) + if category_id not in hierarchy["books"][book_id]["categories"]: + hierarchy["books"][book_id]["categories"][category_id] = { + "id": category_id, + "name": doc.category.name, + "documents": [] + } + hierarchy["books"][book_id]["categories"][category_id]["documents"].append(doc_data) + else: + # ์„œ์ ์€ ์žˆ์ง€๋งŒ ์†Œ๋ถ„๋ฅ˜๊ฐ€ ์—†๋Š” ๋ฌธ์„œ + hierarchy["books"][book_id]["uncategorized_documents"].append(doc_data) + else: + # ์„œ์ ์ด ์—†๋Š” ๋ฏธ๋ถ„๋ฅ˜ ๋ฌธ์„œ + hierarchy["uncategorized"].append(doc_data) + + return hierarchy + + +@router.post("/", response_model=DocumentResponse) +async def upload_document( + title: str = Form(...), + description: Optional[str] = Form(None), + document_date: Optional[str] = Form(None), + language: Optional[str] = Form("ko"), + is_public: bool = Form(False), + tags: Optional[List[str]] = Form(None), # ํƒœ๊ทธ ๋ฆฌ์ŠคํŠธ + book_id: Optional[str] = Form(None), # ์„œ์  ID ์ถ”๊ฐ€ + html_file: UploadFile = File(...), + pdf_file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์—…๋กœ๋“œ""" + # ํŒŒ์ผ ํ™•์žฅ์ž ํ™•์ธ (HTML ๋˜๋Š” PDF ํ—ˆ์šฉ) + file_extension = html_file.filename.lower() + is_pdf_file = file_extension.endswith('.pdf') + is_html_file = file_extension.endswith(('.html', '.htm')) + + if not (is_html_file or is_pdf_file): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only HTML and PDF files are allowed" + ) + + if pdf_file and not pdf_file.filename.lower().endswith('.pdf'): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only PDF files are allowed for the original document" + ) + + # ๊ณ ์œ  ํŒŒ์ผ๋ช… ์ƒ์„ฑ + doc_id = str(uuid.uuid4()) + + # ๋ฉ”์ธ ํŒŒ์ผ ์ฒ˜๋ฆฌ (HTML ๋˜๋Š” PDF) - ํด๋” ๋ถ„๋ฆฌ + if is_pdf_file: + main_filename = f"{doc_id}.pdf" + pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs") + os.makedirs(pdf_dir, exist_ok=True) # PDF ํด๋” ์ƒ์„ฑ + main_path = os.path.join(pdf_dir, main_filename) + html_path = None # PDF๋งŒ ์—…๋กœ๋“œํ•˜๋Š” ๊ฒฝ์šฐ html_path๋Š” None + pdf_path = main_path # PDF ํŒŒ์ผ์ธ ๊ฒฝ์šฐ pdf_path์— ์ €์žฅ + else: + main_filename = f"{doc_id}.html" + html_dir = os.path.join(settings.UPLOAD_DIR, "documents") + os.makedirs(html_dir, exist_ok=True) # HTML ํด๋” ์ƒ์„ฑ + main_path = os.path.join(html_dir, main_filename) + html_path = main_path + pdf_path = None + + # ์ถ”๊ฐ€ PDF ํŒŒ์ผ ์ฒ˜๋ฆฌ (HTML ํŒŒ์ผ๊ณผ ํ•จ๊ป˜ ์—…๋กœ๋“œ๋œ ๊ฒฝ์šฐ) + additional_pdf_path = None + if pdf_file: + additional_pdf_filename = f"{doc_id}_additional.pdf" + pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs") + os.makedirs(pdf_dir, exist_ok=True) # PDF ํด๋” ์ƒ์„ฑ + additional_pdf_path = os.path.join(pdf_dir, additional_pdf_filename) + + try: + # ๋ฉ”์ธ ํŒŒ์ผ ์ €์žฅ (HTML ๋˜๋Š” PDF) + async with aiofiles.open(main_path, 'wb') as f: + content = await html_file.read() + await f.write(content) + + # ์ถ”๊ฐ€ PDF ํŒŒ์ผ ์ €์žฅ (HTML๊ณผ ํ•จ๊ป˜ ์—…๋กœ๋“œ๋œ ๊ฒฝ์šฐ) + if pdf_file and additional_pdf_path: + async with aiofiles.open(additional_pdf_path, 'wb') as f: + additional_content = await pdf_file.read() + await f.write(additional_content) + # HTML ํŒŒ์ผ์ธ ๊ฒฝ์šฐ ์ถ”๊ฐ€ PDF๋ฅผ pdf_path๋กœ ์„ค์ • + if is_html_file: + pdf_path = additional_pdf_path + + # ์„œ์  ID ๊ฒ€์ฆ (์žˆ๋Š” ๊ฒฝ์šฐ) + validated_book_id = None + if book_id: + try: + # UUID ํ˜•์‹ ๊ฒ€์ฆ ๋ฐ ์„œ์  ์กด์žฌ ํ™•์ธ + from uuid import UUID + book_uuid = UUID(book_id) + book_result = await db.execute(select(Book).where(Book.id == book_uuid)) + book = book_result.scalar_one_or_none() + if book: + validated_book_id = book_uuid + except (ValueError, Exception): + # ์ž˜๋ชป๋œ UUID ํ˜•์‹์ด๊ฑฐ๋‚˜ ์„œ์ ์ด ์—†์œผ๋ฉด ๋ฌด์‹œ + pass + + # ๋ฌธ์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ + document = Document( + id=doc_id, + title=title, + description=description, + html_path=html_path, + pdf_path=pdf_path, + language=language, + file_size=len(content), # HTML ํŒŒ์ผ ํฌ๊ธฐ + uploaded_by=current_user.id, + original_filename=html_file.filename, + is_public=is_public, + document_date=datetime.fromisoformat(document_date) if document_date else None, + book_id=validated_book_id # ์„œ์  ID ์ถ”๊ฐ€ + ) + + db.add(document) + await db.flush() # ID ์ƒ์„ฑ์„ ์œ„ํ•ด + + # ํƒœ๊ทธ ์ฒ˜๋ฆฌ + if tags: + tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()] + for tag_name in tag_names: + # ๊ธฐ์กด ํƒœ๊ทธ ์ฐพ๊ธฐ ๋˜๋Š” ์ƒ์„ฑ + result = await db.execute(select(Tag).where(Tag.name == tag_name)) + tag = result.scalar_one_or_none() + + if not tag: + tag = Tag( + name=tag_name, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + document.tags.append(tag) + + await db.commit() + + # ๋ฌธ์„œ ์ •๋ณด๋ฅผ ๋‹ค์‹œ ๋กœ๋“œ (ํƒœ๊ทธ ํฌํ•จ) + result = await db.execute( + select(Document) + .options(selectinload(Document.tags)) + .where(Document.id == document.id) + ) + document_with_tags = result.scalar_one() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + return DocumentResponse( + id=str(document_with_tags.id), + title=document_with_tags.title, + description=document_with_tags.description, + html_path=document_with_tags.html_path, + pdf_path=document_with_tags.pdf_path, + thumbnail_path=document_with_tags.thumbnail_path, + file_size=document_with_tags.file_size, + page_count=document_with_tags.page_count, + language=document_with_tags.language, + is_public=document_with_tags.is_public, + is_processed=document_with_tags.is_processed, + created_at=document_with_tags.created_at, + updated_at=document_with_tags.updated_at, + document_date=document_with_tags.document_date, + uploader_name=current_user.full_name or current_user.email, + tags=[tag.name for tag in document_with_tags.tags], + matched_pdf_id=str(document_with_tags.matched_pdf_id) if document_with_tags.matched_pdf_id else None + ) + + except Exception as e: + # ํŒŒ์ผ ์ •๋ฆฌ + if os.path.exists(main_path): + os.remove(main_path) + if additional_pdf_path and os.path.exists(additional_pdf_path): + os.remove(additional_pdf_path) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to upload document: {str(e)}" + ) + + +@router.get("/{document_id}", response_model=DocumentResponse) +async def get_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Document) + .options(selectinload(Document.uploader), selectinload(Document.tags)) + .where(Document.id == document_id) + ) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + return DocumentResponse( + id=str(document.id), + title=document.title, + description=document.description, + html_path=document.html_path, + pdf_path=document.pdf_path, + thumbnail_path=document.thumbnail_path, + file_size=document.file_size, + page_count=document.page_count, + language=document.language, + is_public=document.is_public, + is_processed=document.is_processed, + created_at=document.created_at, + updated_at=document.updated_at, + document_date=document.document_date, + uploader_name=document.uploader.full_name or document.uploader.email, + tags=[tag.name for tag in document.tags], + matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None + ) + + +@router.get("/{document_id}/content") +async def get_document_content( + document_id: str, + _token: Optional[str] = Query(None), + current_user: User = Depends(get_current_user_with_token_param), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ HTML ์ฝ˜ํ…์ธ  ์กฐํšŒ""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # ๋ฌธ์„œ ์กฐํšŒ + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # ๊ถŒํ•œ ํ™•์ธ + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + # HTML ํŒŒ์ผ ์ฝ๊ธฐ + import os + from fastapi.responses import HTMLResponse + + # html_path๋Š” ์ด๋ฏธ ์ „์ฒด ๊ฒฝ๋กœ๋ฅผ ํฌํ•จํ•˜๊ณ  ์žˆ์Œ + file_path = document.html_path + + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Document file not found") + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + return HTMLResponse(content=content) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}") + + +@router.get("/{document_id}/pdf") +async def get_document_pdf( + document_id: str, + _token: Optional[str] = Query(None), + current_user: User = Depends(get_current_user_with_token_param), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ PDF ํŒŒ์ผ ์กฐํšŒ""" + print(f"๐Ÿ” PDF ์š”์ฒญ - ๋ฌธ์„œ ID: {document_id}") + print(f"๐Ÿ” ํ† ํฐ ํŒŒ๋ผ๋ฏธํ„ฐ: {_token[:50] if _token else 'None'}...") + print(f"๐Ÿ” ํ˜„์žฌ ์‚ฌ์šฉ์ž: {current_user.email if current_user else 'None'}") + + try: + doc_uuid = UUID(document_id) + except ValueError: + print(f"โŒ ์ž˜๋ชป๋œ ๋ฌธ์„œ ID ํ˜•์‹: {document_id}") + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # ๋ฌธ์„œ ์กฐํšŒ + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + print(f"โŒ ๋ฌธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: {document_id}") + raise HTTPException(status_code=404, detail="Document not found") + + print(f"๐Ÿ“„ ๋ฌธ์„œ ์ •๋ณด: {document.title}") + print(f"๐Ÿ” ๋ฌธ์„œ ๊ถŒํ•œ: is_public={document.is_public}, uploaded_by={document.uploaded_by}") + print(f"๐Ÿ‘ค ์‚ฌ์šฉ์ž ๊ถŒํ•œ: is_admin={current_user.is_admin}, user_id={current_user.id}") + + # ๊ถŒํ•œ ํ™•์ธ + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + print(f"โŒ ์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ - ๊ด€๋ฆฌ์ž: {current_user.is_admin}, ๊ณต๊ฐœ: {document.is_public}, ์†Œ์œ ์ž: {document.uploaded_by == current_user.id}") + raise HTTPException(status_code=403, detail="Access denied") + + # PDF ํŒŒ์ผ ํ™•์ธ + if not document.pdf_path: + print(f"๐Ÿšซ PDF ๊ฒฝ๋กœ๊ฐ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์—†์Œ: {document.title}") + raise HTTPException(status_code=404, detail="PDF file not found for this document") + + # PDF ํŒŒ์ผ ๊ฒฝ๋กœ ์ฒ˜๋ฆฌ + import os + from fastapi.responses import FileResponse + + if document.pdf_path.startswith('/'): + file_path = document.pdf_path + else: + # PDF ํŒŒ์ผ์€ /app/uploads์— ์ €์žฅ๋จ + file_path = os.path.join("/app", document.pdf_path) + + print(f"๐Ÿ” PDF ํŒŒ์ผ ๊ฒฝ๋กœ ํ™•์ธ: {file_path}") + print(f"๐Ÿ“ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค PDF ๊ฒฝ๋กœ: {document.pdf_path}") + + if not os.path.exists(file_path): + print(f"๐Ÿšซ PDF ํŒŒ์ผ์ด ๋””์Šคํฌ์— ์—†์Œ: {file_path}") + # ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด์šฉ ํ™•์ธ + dir_path = os.path.dirname(file_path) + if os.path.exists(dir_path): + files = os.listdir(dir_path) + print(f"๐Ÿ“‚ ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด์šฉ: {files[:10]}") + else: + print(f"๐Ÿ“‚ ๋””๋ ‰ํ† ๋ฆฌ๋„ ์—†์Œ: {dir_path}") + raise HTTPException(status_code=404, detail="PDF file not found on disk") + + # PDF ์ธ๋ผ์ธ ํ‘œ์‹œ๋ฅผ ์œ„ํ•œ ํ—ค๋” ์„ค์ • + from fastapi.responses import FileResponse + + response = FileResponse( + path=file_path, + media_type='application/pdf', + filename=f"{document.title}.pdf" + ) + + # ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ธ๋ผ์ธ์œผ๋กœ ํ‘œ์‹œํ•˜๋„๋ก ์„ค์ • (๋‹ค์šด๋กœ๋“œ ๋ฐฉ์ง€) + # ํ•œ๊ธ€ ํŒŒ์ผ๋ช…์„ URL ์ธ์ฝ”๋”ฉ์œผ๋กœ ์ฒ˜๋ฆฌ + import urllib.parse + encoded_filename = urllib.parse.quote(f"{document.title}.pdf", safe='') + response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{encoded_filename}" + response.headers["X-Frame-Options"] = "SAMEORIGIN" # iframe ํ—ˆ์šฉ + + return response + + +@router.get("/{document_id}/search-in-content") +async def search_in_document_content( + document_id: str, + q: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ ๋‚ด์—์„œ ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ ๋ฐ ํŽ˜์ด์ง€ ์œ„์น˜ ๋ฐ˜ํ™˜""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # ๋ฌธ์„œ ์กฐํšŒ + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # ๊ถŒํ•œ ํ™•์ธ + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + search_results = [] + + # HTML ํŒŒ์ผ์—์„œ ๊ฒ€์ƒ‰ (OCR ๊ฒฐ๊ณผ) + if document.html_path: + try: + import os + from bs4 import BeautifulSoup + import re + + # ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ฒ˜๋ฆฌ + if document.html_path.startswith('/'): + html_file_path = document.html_path + else: + html_file_path = os.path.join("/app/data/documents", document.html_path) + + if os.path.exists(html_file_path): + with open(html_file_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # HTML์—์„œ ํŽ˜์ด์ง€๋ณ„๋กœ ๊ฒ€์ƒ‰ + soup = BeautifulSoup(html_content, 'html.parser') + + # ํŽ˜์ด์ง€ ๊ตฌ๋ถ„์ž ์ฐพ๊ธฐ (OCR ๊ฒฐ๊ณผ์—์„œ ํŽ˜์ด์ง€ ์ •๋ณด) + pages = soup.find_all(['div', 'section'], class_=re.compile(r'page|Page')) + + if not pages: + # ํŽ˜์ด์ง€ ๊ตฌ๋ถ„์ด ์—†์œผ๋ฉด ์ „์ฒด ํ…์ŠคํŠธ์—์„œ ๊ฒ€์ƒ‰ + text_content = soup.get_text() + matches = [] + start = 0 + while True: + pos = text_content.lower().find(q.lower(), start) + if pos == -1: + break + + # ์ปจํ…์ŠคํŠธ ์ถ”์ถœ + context_start = max(0, pos - 100) + context_end = min(len(text_content), pos + len(q) + 100) + context = text_content[context_start:context_end] + + matches.append({ + "page": 1, + "position": pos, + "context": context, + "match_text": text_content[pos:pos + len(q)] + }) + + start = pos + 1 + if len(matches) >= 10: # ์ตœ๋Œ€ 10๊ฐœ ๊ฒฐ๊ณผ + break + + search_results.extend(matches) + else: + # ํŽ˜์ด์ง€๋ณ„๋กœ ๊ฒ€์ƒ‰ + for page_num, page_elem in enumerate(pages, 1): + page_text = page_elem.get_text() + matches = [] + start = 0 + + while True: + pos = page_text.lower().find(q.lower(), start) + if pos == -1: + break + + # ์ปจํ…์ŠคํŠธ ์ถ”์ถœ + context_start = max(0, pos - 100) + context_end = min(len(page_text), pos + len(q) + 100) + context = page_text[context_start:context_end] + + matches.append({ + "page": page_num, + "position": pos, + "context": context, + "match_text": page_text[pos:pos + len(q)] + }) + + start = pos + 1 + if len(matches) >= 5: # ํŽ˜์ด์ง€๋‹น ์ตœ๋Œ€ 5๊ฐœ + break + + search_results.extend(matches) + + except Exception as e: + print(f"HTML ๊ฒ€์ƒ‰ ์˜ค๋ฅ˜: {e}") + + return { + "document_id": document_id, + "query": q, + "total_matches": len(search_results), + "matches": search_results[:20], # ์ตœ๋Œ€ 20๊ฐœ ๊ฒฐ๊ณผ + "has_pdf": bool(document.pdf_path), + "has_html": bool(document.html_path) + } + + +class UpdateDocumentRequest(BaseModel): + """๋ฌธ์„œ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + matched_pdf_id: Optional[str] = None + is_public: Optional[bool] = None + tags: Optional[List[str]] = None + + +@router.put("/{document_id}", response_model=DocumentResponse) +async def update_document( + document_id: str, + update_data: UpdateDocumentRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์ •๋ณด ์—…๋ฐ์ดํŠธ""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # ๋ฌธ์„œ ์กฐํšŒ + result = await db.execute( + select(Document) + .options(selectinload(Document.tags), selectinload(Document.uploader), selectinload(Document.book)) + .where(Document.id == doc_uuid) + ) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (๊ด€๋ฆฌ์ž์ด๊ฑฐ๋‚˜ ๋ฌธ์„œ ์†Œ์œ ์ž) + if not current_user.is_admin and document.uploaded_by != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this document" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_fields = update_data.model_dump(exclude_unset=True) + + for field, value in update_fields.items(): + if field == "matched_pdf_id": + # PDF ๋งค์นญ ์ฒ˜๋ฆฌ + if value: + try: + pdf_uuid = UUID(value) + # PDF ๋ฌธ์„œ๊ฐ€ ์‹ค์ œ๋กœ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ + pdf_result = await db.execute(select(Document).where(Document.id == pdf_uuid)) + pdf_doc = pdf_result.scalar_one_or_none() + if pdf_doc: + setattr(document, field, pdf_uuid) + except ValueError: + # ์ž˜๋ชป๋œ UUID ํ˜•์‹์ด๋ฉด ๋ฌด์‹œ + pass + else: + # None์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ๋งค์นญ ํ•ด์ œ + setattr(document, field, None) + elif field == "tags": + # ํƒœ๊ทธ ์ฒ˜๋ฆฌ + if value is not None: + # ๊ธฐ์กด ํƒœ๊ทธ ๊ด€๊ณ„ ์ œ๊ฑฐ + document.tags.clear() + + # ์ƒˆ ํƒœ๊ทธ ์ถ”๊ฐ€ + for tag_name in value: + tag_name = tag_name.strip() + if tag_name: + # ๊ธฐ์กด ํƒœ๊ทธ ์ฐพ๊ธฐ ๋˜๋Š” ์ƒ์„ฑ + tag_result = await db.execute(select(Tag).where(Tag.name == tag_name)) + tag = tag_result.scalar_one_or_none() + + if not tag: + tag = Tag( + name=tag_name, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + document.tags.append(tag) + else: + # ์ผ๋ฐ˜ ํ•„๋“œ ์—…๋ฐ์ดํŠธ + setattr(document, field, value) + + # ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„ ๊ฐฑ์‹  + document.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(document) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + return DocumentResponse( + id=str(document.id), + title=document.title, + description=document.description, + html_path=document.html_path, + pdf_path=document.pdf_path, + thumbnail_path=document.thumbnail_path, + file_size=document.file_size, + page_count=document.page_count, + language=document.language, + is_public=document.is_public, + is_processed=document.is_processed, + created_at=document.created_at, + updated_at=document.updated_at, + document_date=document.document_date, + uploader_name=document.uploader.full_name or document.uploader.email if document.uploader else "Unknown", + tags=[tag.name for tag in document.tags], + book_id=str(document.book.id) if document.book else None, + book_title=document.book.title if document.book else None, + book_author=document.book.author if document.book else None, + sort_order=document.sort_order, + matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None, + original_filename=document.original_filename + ) + + +@router.get("/{document_id}/download") +async def download_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # ๋ฌธ์„œ ์กฐํšŒ + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # ๊ถŒํ•œ ํ™•์ธ + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + # ๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ ๊ฒฝ๋กœ ๊ฒฐ์ • (PDF ์šฐ์„ , ์—†์œผ๋ฉด HTML) + file_path = document.pdf_path if document.pdf_path else document.html_path + + if not file_path or not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Document file not found") + + # ํŒŒ์ผ ์‘๋‹ต + from fastapi.responses import FileResponse + + # ํŒŒ์ผ๋ช… ์„ค์ • + filename = document.original_filename + if not filename: + extension = '.pdf' if document.pdf_path else '.html' + filename = f"{document.title}{extension}" + + return FileResponse( + path=file_path, + filename=filename, + media_type='application/octet-stream' + ) + + +@router.get("/{document_id}/navigation") +async def get_document_navigation( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ์กฐํšŒ (์ด์ „/๋‹ค์Œ ๋ฌธ์„œ)""" + # ํ˜„์žฌ ๋ฌธ์„œ ์กฐํšŒ + result = await db.execute(select(Document).where(Document.id == document_id)) + current_doc = result.scalar_one_or_none() + + if not current_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ + if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + navigation_info = { + "current": { + "id": str(current_doc.id), + "title": current_doc.title, + "sort_order": current_doc.sort_order + }, + "previous": None, + "next": None, + "book_info": None + } + + # ์„œ์ ์— ์†ํ•œ ๋ฌธ์„œ์ธ ๊ฒฝ์šฐ ์ด์ „/๋‹ค์Œ ๋ฌธ์„œ ์กฐํšŒ + if current_doc.book_id: + # ๊ฐ™์€ ์„œ์ ์˜ HTML ๋ฌธ์„œ๋“ค๋งŒ ์กฐํšŒ (PDF ์ œ์™ธ) + book_docs_result = await db.execute( + select(Document) + .where( + and_( + Document.book_id == current_doc.book_id, + Document.html_path.isnot(None), # HTML ๋ฌธ์„œ๋งŒ + or_(Document.is_public == True, Document.uploaded_by == current_user.id, current_user.is_admin == True) + ) + ) + .order_by(Document.sort_order.asc().nulls_last(), Document.created_at.asc()) + ) + book_docs = book_docs_result.scalars().all() + + # ํ˜„์žฌ ๋ฌธ์„œ์˜ ์ธ๋ฑ์Šค ์ฐพ๊ธฐ + current_index = None + for i, doc in enumerate(book_docs): + if doc.id == current_doc.id: + current_index = i + break + + if current_index is not None: + # ์ด์ „ ๋ฌธ์„œ + if current_index > 0: + prev_doc = book_docs[current_index - 1] + navigation_info["previous"] = { + "id": str(prev_doc.id), + "title": prev_doc.title, + "sort_order": prev_doc.sort_order + } + + # ๋‹ค์Œ ๋ฌธ์„œ + if current_index < len(book_docs) - 1: + next_doc = book_docs[current_index + 1] + navigation_info["next"] = { + "id": str(next_doc.id), + "title": next_doc.title, + "sort_order": next_doc.sort_order + } + + # ์„œ์  ์ •๋ณด ์ถ”๊ฐ€ + from ...models.book import Book + book_result = await db.execute(select(Book).where(Book.id == current_doc.book_id)) + book = book_result.scalar_one_or_none() + if book: + navigation_info["book_info"] = { + "id": str(book.id), + "title": book.title, + "author": book.author + } + + return navigation_info + + +@router.delete("/{document_id}") +async def delete_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฌธ์„œ ์‚ญ์ œ""" + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (๊ด€๋ฆฌ์ž๋งŒ) + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can delete documents" + ) + + # ํŒŒ์ผ ์‚ญ์ œ + if document.html_path and os.path.exists(document.html_path): + os.remove(document.html_path) + if document.pdf_path and os.path.exists(document.pdf_path): + os.remove(document.pdf_path) + if document.thumbnail_path and os.path.exists(document.thumbnail_path): + os.remove(document.thumbnail_path) + + # ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์•ˆ์ „ํ•˜๊ฒŒ ์‚ญ์ œ (์™ธ๋ž˜ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด ํ•ด๊ฒฐ) + from ...models.highlight import Highlight + from ...models.note import Note + from ...models.bookmark import Bookmark + + try: + print(f"DEBUG: Starting deletion of document {document_id}") + + # 0. PDF ์ฐธ์กฐ ํ•ด์ œ (์™ธ๋ž˜ํ‚ค ์ œ์•ฝ์กฐ๊ฑด ํ•ด๊ฒฐ) + # ์ด ๋ฌธ์„œ๋ฅผ matched_pdf_id๋กœ ์ฐธ์กฐํ•˜๋Š” ๋ชจ๋“  ๋ฌธ์„œ์˜ ์ฐธ์กฐ๋ฅผ NULL๋กœ ์„ค์ • + await db.execute( + update(Document) + .where(Document.matched_pdf_id == document_id) + .values(matched_pdf_id=None) + ) + print(f"DEBUG: Cleared matched_pdf_id references to document {document_id}") + + # 1. ๋จผ์ € ํ•ด๋‹น ๋ฌธ์„œ์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ID ์กฐํšŒ + highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id)) + highlight_ids = [row[0] for row in highlight_ids_result.fetchall()] + print(f"DEBUG: Found {len(highlight_ids)} highlights to delete") + + # 2. ํ•˜์ด๋ผ์ดํŠธ์— ์—ฐ๊ฒฐ๋œ ๋ชจ๋“  ๋ฉ”๋ชจ ์‚ญ์ œ + total_notes_deleted = 0 + for highlight_id in highlight_ids: + note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id)) + total_notes_deleted += note_result.rowcount + print(f"DEBUG: Deleted {total_notes_deleted} notes by highlight_id") + + # 3. document_id๋กœ ์ง์ ‘ ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ๋„ ์‚ญ์ œ (ํ˜น์‹œ ์žˆ๋‹ค๋ฉด) + direct_note_result = await db.execute(delete(Note).where(Note.document_id == document_id)) + print(f"DEBUG: Deleted {direct_note_result.rowcount} notes by document_id") + + # 4. ๋ถ๋งˆํฌ ์‚ญ์ œ + bookmark_result = await db.execute(delete(Bookmark).where(Bookmark.document_id == document_id)) + print(f"DEBUG: Deleted {bookmark_result.rowcount} bookmarks") + + # 5. ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ (์ด์ œ ๋ฉ”๋ชจ๊ฐ€ ๋ชจ๋‘ ์‚ญ์ œ๋˜์—ˆ์œผ๋ฏ€๋กœ ์•ˆ์ „) + highlight_result = await db.execute(delete(Highlight).where(Highlight.document_id == document_id)) + print(f"DEBUG: Deleted {highlight_result.rowcount} highlights") + + # 6. ๋ฌธ์„œ-ํƒœ๊ทธ ๊ด€๊ณ„๋Š” SQLAlchemy๊ฐ€ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ + + # 7. ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ฌธ์„œ ์‚ญ์ œ + doc_result = await db.execute(delete(Document).where(Document.id == document_id)) + print(f"DEBUG: Deleted {doc_result.rowcount} documents") + + # 8. ์ปค๋ฐ‹ + await db.commit() + print(f"DEBUG: Successfully deleted document {document_id}") + + except Exception as e: + print(f"ERROR: Failed to delete document {document_id}: {e}") + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete document: {str(e)}" + ) + + return {"message": "Document deleted successfully"} + + +@router.get("/tags/", response_model=List[TagResponse]) +async def list_tags( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ""" + result = await db.execute(select(Tag).order_by(Tag.name)) + tags = result.scalars().all() + + # ๊ฐ ํƒœ๊ทธ์˜ ๋ฌธ์„œ ์ˆ˜ ๊ณ„์‚ฐ + response_data = [] + for tag in tags: + # ๋ฌธ์„œ ์ˆ˜ ๊ณ„์‚ฐ (๊ถŒํ•œ ๊ณ ๋ ค) + doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id) + if not current_user.is_admin: + doc_query = doc_query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + doc_result = await db.execute(doc_query) + document_count = len(doc_result.scalars().all()) + + tag_data = TagResponse( + id=str(tag.id), + name=tag.name, + color=tag.color, + description=tag.description, + document_count=document_count + ) + response_data.append(tag_data) + + return response_data + + +@router.post("/tags/", response_model=TagResponse) +async def create_tag( + tag_data: CreateTagRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํƒœ๊ทธ ์ƒ์„ฑ""" + # ์ค‘๋ณต ํ™•์ธ + result = await db.execute(select(Tag).where(Tag.name == tag_data.name)) + existing_tag = result.scalar_one_or_none() + + if existing_tag: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tag already exists" + ) + + # ํƒœ๊ทธ ์ƒ์„ฑ + tag = Tag( + name=tag_data.name, + color=tag_data.color, + description=tag_data.description, + created_by=current_user.id + ) + + db.add(tag) + await db.commit() + await db.refresh(tag) + + return TagResponse( + id=str(tag.id), + name=tag.name, + color=tag.color, + description=tag.description, + document_count=0 + ) diff --git a/backend/src/api/routes/highlights.py b/backend/src/api/routes/highlights.py new file mode 100644 index 0000000..11db165 --- /dev/null +++ b/backend/src/api/routes/highlights.py @@ -0,0 +1,471 @@ +""" +ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ฆฌ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, and_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime +from uuid import UUID + +from ...core.database import get_db +from ...models.user import User +from ...models.document import Document +from ...models.highlight import Highlight +from ...models.note import Note +from ..dependencies import get_current_active_user +from pydantic import BaseModel + + +class CreateHighlightRequest(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์š”์ฒญ""" + document_id: str + start_offset: int + end_offset: int + selected_text: str + element_selector: Optional[str] = None + start_container_xpath: Optional[str] = None + end_container_xpath: Optional[str] = None + highlight_color: str = "#FFFF00" + highlight_type: str = "highlight" + note_content: Optional[str] = None # ๋ฐ”๋กœ ๋ฉ”๋ชจ ์ถ”๊ฐ€ + + +class UpdateHighlightRequest(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + highlight_color: Optional[str] = None + highlight_type: Optional[str] = None + note: Optional[str] = None # ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ์ง€์› + + +class HighlightResponse(BaseModel): + """ํ•˜์ด๋ผ์ดํŠธ ์‘๋‹ต""" + id: str + user_id: str + document_id: str + start_offset: int + end_offset: int + selected_text: str + element_selector: Optional[str] + start_container_xpath: Optional[str] + end_container_xpath: Optional[str] + highlight_color: str + highlight_type: str + created_at: datetime + updated_at: Optional[datetime] + note: Optional[dict] = None # ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ ์ •๋ณด + + class Config: + from_attributes = True + + +router = APIRouter() + + + + + + +@router.post("/", response_model=HighlightResponse) +async def create_highlight( + highlight_data: CreateHighlightRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (๋ฉ”๋ชจ ํฌํ•จ ๊ฐ€๋Šฅ)""" + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == highlight_data.document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + highlight = Highlight( + user_id=current_user.id, + document_id=highlight_data.document_id, + start_offset=highlight_data.start_offset, + end_offset=highlight_data.end_offset, + selected_text=highlight_data.selected_text, + element_selector=highlight_data.element_selector, + start_container_xpath=highlight_data.start_container_xpath, + end_container_xpath=highlight_data.end_container_xpath, + highlight_color=highlight_data.highlight_color, + highlight_type=highlight_data.highlight_type + ) + + db.add(highlight) + await db.flush() # ID ์ƒ์„ฑ์„ ์œ„ํ•ด + + # ๋ฉ”๋ชจ๊ฐ€ ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์ƒ์„ฑ + note = None + if highlight_data.note_content: + note = Note( + highlight_id=highlight.id, + content=highlight_data.note_content + ) + db.add(note) + + await db.commit() + await db.refresh(highlight) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (Pydantic v2 ํ˜ธํ™˜) + response_data = HighlightResponse( + id=str(highlight.id), + user_id=str(highlight.user_id), + document_id=str(highlight.document_id), + start_offset=highlight.start_offset, + end_offset=highlight.end_offset, + selected_text=highlight.selected_text, + element_selector=highlight.element_selector, + start_container_xpath=highlight.start_container_xpath, + end_container_xpath=highlight.end_container_xpath, + highlight_color=highlight.highlight_color, + highlight_type=highlight.highlight_type, + created_at=highlight.created_at, + updated_at=highlight.updated_at, + note=None + ) + + if note: + response_data.note = { + "id": str(note.id), + "content": note.content, + "tags": note.tags, + "created_at": note.created_at.isoformat(), + "updated_at": note.updated_at.isoformat() if note.updated_at else None + } + + return response_data + + +@router.get("/document/{document_id}", response_model=List[HighlightResponse]) +async def get_document_highlights( + document_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ชฉ๋ก ์กฐํšŒ""" + try: + print(f"DEBUG: Getting highlights for document {document_id}, user {current_user.id}") + + # ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # ๋ฌธ์„œ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this document" + ) + + # ์‚ฌ์šฉ์ž์˜ ํ•˜์ด๋ผ์ดํŠธ๋งŒ ์กฐํšŒ (์—ฐ๊ด€๋œ ๋ฉ”๋ชจ๋„ ํ•จ๊ป˜ ๋กœ๋“œ) + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.notes)) # ๋ฉ”๋ชจ ๊ด€๊ณ„ ๋กœ๋“œ + .where( + and_( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ) + ) + .order_by(Highlight.start_offset) + ) + highlights = result.scalars().all() + + print(f"DEBUG: Found {len(highlights)} highlights for user {current_user.id}") + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for highlight in highlights: + # ์—ฐ๊ด€๋œ ๋ฉ”๋ชจ ์ •๋ณด ํฌํ•จ (notes๋Š” ๋ฆฌ์ŠคํŠธ์ด๋ฏ€๋กœ ์ฒซ ๋ฒˆ์งธ ๋ฉ”๋ชจ ์‚ฌ์šฉ) + note_data = None + if highlight.notes and len(highlight.notes) > 0: + first_note = highlight.notes[0] # ์ฒซ ๋ฒˆ์งธ ๋ฉ”๋ชจ ์‚ฌ์šฉ + note_data = { + "id": str(first_note.id), + "content": first_note.content, + "created_at": first_note.created_at.isoformat(), + "updated_at": first_note.updated_at.isoformat() if first_note.updated_at else None + } + + highlight_data = HighlightResponse( + id=str(highlight.id), + user_id=str(highlight.user_id), + document_id=str(highlight.document_id), + start_offset=highlight.start_offset, + end_offset=highlight.end_offset, + selected_text=highlight.selected_text, + element_selector=highlight.element_selector, + start_container_xpath=highlight.start_container_xpath, + end_container_xpath=highlight.end_container_xpath, + highlight_color=highlight.highlight_color, + highlight_type=highlight.highlight_type, + created_at=highlight.created_at, + updated_at=highlight.updated_at, + note=note_data + ) + response_data.append(highlight_data) + + return response_data + + except Exception as e: + print(f"ERROR in get_document_highlights: {e}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) + + +@router.get("/{highlight_id}", response_model=HighlightResponse) +async def get_highlight( + highlight_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ธ ์กฐํšŒ""" + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.user)) + .where(Highlight.id == highlight_id) + ) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + response_data = HighlightResponse( + id=str(highlight.id), + user_id=str(highlight.user_id), + document_id=str(highlight.document_id), + start_offset=highlight.start_offset, + end_offset=highlight.end_offset, + selected_text=highlight.selected_text, + element_selector=highlight.element_selector, + start_container_xpath=highlight.start_container_xpath, + end_container_xpath=highlight.end_container_xpath, + highlight_color=highlight.highlight_color, + highlight_type=highlight.highlight_type, + created_at=highlight.created_at, + updated_at=highlight.updated_at, + note=None + ) + if highlight.notes: + response_data.note = { + "id": str(highlight.notes.id), + "content": highlight.notes.content, + "tags": highlight.notes.tags, + "created_at": highlight.notes.created_at.isoformat(), + "updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None + } + + return response_data + + +@router.put("/{highlight_id}", response_model=HighlightResponse) +async def update_highlight( + highlight_id: str, + highlight_data: UpdateHighlightRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์—…๋ฐ์ดํŠธ""" + result = await db.execute( + select(Highlight) + .options(selectinload(Highlight.user), selectinload(Highlight.notes)) + .where(Highlight.id == highlight_id) + ) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์—…๋ฐ์ดํŠธ + if highlight_data.highlight_color: + highlight.highlight_color = highlight_data.highlight_color + if highlight_data.highlight_type: + highlight.highlight_type = highlight_data.highlight_type + + # ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ + if highlight_data.note is not None: + if highlight.notes: + # ๊ธฐ์กด ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ + highlight.notes.content = highlight_data.note + highlight.notes.updated_at = datetime.utcnow() + else: + # ์ƒˆ ๋ฉ”๋ชจ ์ƒ์„ฑ + new_note = Note( + user_id=current_user.id, + document_id=highlight.document_id, + highlight_id=highlight.id, + content=highlight_data.note, + tags="" + ) + db.add(new_note) + + await db.commit() + await db.refresh(highlight) + + response_data = HighlightResponse( + id=str(highlight.id), + user_id=str(highlight.user_id), + document_id=str(highlight.document_id), + start_offset=highlight.start_offset, + end_offset=highlight.end_offset, + selected_text=highlight.selected_text, + element_selector=highlight.element_selector, + start_container_xpath=highlight.start_container_xpath, + end_container_xpath=highlight.end_container_xpath, + highlight_color=highlight.highlight_color, + highlight_type=highlight.highlight_type, + created_at=highlight.created_at, + updated_at=highlight.updated_at, + note=None + ) + if highlight.notes: + response_data.note = { + "id": str(highlight.notes.id), + "content": highlight.notes.content, + "tags": highlight.notes.tags, + "created_at": highlight.notes.created_at.isoformat(), + "updated_at": highlight.notes.updated_at.isoformat() if highlight.notes.updated_at else None + } + + return response_data + + +@router.delete("/{highlight_id}") +async def delete_highlight( + highlight_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ (์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ๋„ ํ•จ๊ป˜ ์‚ญ์ œ)""" + result = await db.execute(select(Highlight).where(Highlight.id == highlight_id)) + highlight = result.scalar_one_or_none() + + if not highlight: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Highlight not found" + ) + + # ์†Œ์œ ์ž ํ™•์ธ + if highlight.user_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + # ์•ˆ์ „ํ•œ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ (์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ ๋จผ์ € ์‚ญ์ œ) + try: + print(f"DEBUG: Starting deletion of highlight {highlight_id}") + + # 1. ๋จผ์ € ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ ์‚ญ์ œ + from ...models.note import Note + note_result = await db.execute(delete(Note).where(Note.highlight_id == highlight_id)) + print(f"DEBUG: Deleted {note_result.rowcount} notes for highlight {highlight_id}") + + # 2. ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ + highlight_result = await db.execute(delete(Highlight).where(Highlight.id == highlight_id)) + print(f"DEBUG: Deleted {highlight_result.rowcount} highlights") + + # 3. ์ปค๋ฐ‹ + await db.commit() + print(f"DEBUG: Successfully deleted highlight {highlight_id}") + + except Exception as e: + print(f"ERROR: Failed to delete highlight {highlight_id}: {e}") + await db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete highlight: {str(e)}" + ) + + return {"message": "Highlight deleted successfully"} + + +@router.get("/", response_model=List[HighlightResponse]) +async def list_user_highlights( + skip: int = 0, + limit: int = 50, + document_id: Optional[str] = None, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์กฐํšŒ""" + query = select(Highlight).options(selectinload(Highlight.user)).where( + Highlight.user_id == current_user.id + ) + + if document_id: + query = query.where(Highlight.document_id == document_id) + + query = query.order_by(Highlight.created_at.desc()).offset(skip).limit(limit) + + result = await db.execute(query) + highlights = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + response_data = [] + for highlight in highlights: + highlight_data = HighlightResponse( + id=str(highlight.id), + user_id=str(highlight.user_id), + document_id=str(highlight.document_id), + start_offset=highlight.start_offset, + end_offset=highlight.end_offset, + selected_text=highlight.selected_text, + element_selector=highlight.element_selector, + start_container_xpath=highlight.start_container_xpath, + end_container_xpath=highlight.end_container_xpath, + highlight_color=highlight.highlight_color, + highlight_type=highlight.highlight_type, + created_at=highlight.created_at, + updated_at=highlight.updated_at, + note=None + ) + # ๋ฉ”๋ชจ๋Š” ๋ณ„๋„ API์—์„œ ์กฐํšŒํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Œ + response_data.append(highlight_data) + + return response_data diff --git a/backend/src/api/routes/memo_trees.py b/backend/src/api/routes/memo_trees.py new file mode 100644 index 0000000..7716bc9 --- /dev/null +++ b/backend/src/api/routes/memo_trees.py @@ -0,0 +1,700 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, func, and_, or_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from uuid import UUID + +from ...core.database import get_db +from ...models.user import User +from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare +from ...schemas.memo_tree import ( + MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes, + MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove, + MemoTreeStats, MemoSearchRequest, MemoSearchResult +) +from ..dependencies import get_current_active_user + +router = APIRouter(prefix="/memo-trees", tags=["memo-trees"]) + + +# ============================================================================ +# ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ด€๋ฆฌ +# ============================================================================ + +@router.get("/", response_model=List[MemoTreeResponse]) +async def get_user_memo_trees( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), + include_archived: bool = Query(False, description="๋ณด๊ด€๋œ ํŠธ๋ฆฌ ํฌํ•จ ์—ฌ๋ถ€") +): + """์‚ฌ์šฉ์ž์˜ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ""" + try: + query = select(MemoTree).where(MemoTree.user_id == current_user.id) + + if not include_archived: + query = query.where(MemoTree.is_archived == False) + + query = query.order_by(MemoTree.updated_at.desc()) + + result = await db.execute(query) + trees = result.scalars().all() + + # ๊ฐ ํŠธ๋ฆฌ์˜ ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + tree_responses = [] + for tree in trees: + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + tree_responses.append(MemoTreeResponse(**tree_dict)) + + return tree_responses + + except Exception as e: + print(f"ERROR in get_user_memo_trees: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo trees: {str(e)}" + ) + + +@router.post("/", response_model=MemoTreeResponse) +async def create_memo_tree( + tree_data: MemoTreeCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ฑ""" + try: + new_tree = MemoTree( + user_id=current_user.id, + title=tree_data.title, + description=tree_data.description, + tree_type=tree_data.tree_type, + template_data=tree_data.template_data or {}, + settings=tree_data.settings or {}, + is_public=tree_data.is_public + ) + + db.add(new_tree) + await db.commit() + await db.refresh(new_tree) + + tree_dict = { + "id": str(new_tree.id), + "user_id": str(new_tree.user_id), + "title": new_tree.title, + "description": new_tree.description, + "tree_type": new_tree.tree_type, + "template_data": new_tree.template_data, + "settings": new_tree.settings, + "created_at": new_tree.created_at, + "updated_at": new_tree.updated_at, + "is_public": new_tree.is_public, + "is_archived": new_tree.is_archived, + "node_count": 0 + } + + return MemoTreeResponse(**tree_dict) + + except Exception as e: + await db.rollback() + print(f"ERROR in create_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create memo tree: {str(e)}" + ) + + +@router.get("/{tree_id}", response_model=MemoTreeResponse) +async def get_memo_tree( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ธ ์กฐํšŒ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + or_( + MemoTree.user_id == current_user.id, + MemoTree.is_public == True + ) + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + + return MemoTreeResponse(**tree_dict) + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo tree: {str(e)}" + ) + + +@router.put("/{tree_id}", response_model=MemoTreeResponse) +async def update_memo_tree( + tree_id: UUID, + tree_data: MemoTreeUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์—…๋ฐ์ดํŠธ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_data = tree_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(tree, field, value) + + await db.commit() + await db.refresh(tree) + + # ๋…ธ๋“œ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id) + ) + node_count = node_count_result.scalar() or 0 + + tree_dict = { + "id": str(tree.id), + "user_id": str(tree.user_id), + "title": tree.title, + "description": tree.description, + "tree_type": tree.tree_type, + "template_data": tree.template_data, + "settings": tree.settings, + "created_at": tree.created_at, + "updated_at": tree.updated_at, + "is_public": tree.is_public, + "is_archived": tree.is_archived, + "node_count": node_count + } + + return MemoTreeResponse(**tree_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in update_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update memo tree: {str(e)}" + ) + + +@router.delete("/{tree_id}") +async def delete_memo_tree( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‚ญ์ œ""" + try: + result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ํŠธ๋ฆฌ ์‚ญ์ œ (CASCADE๋กœ ๊ด€๋ จ ๋…ธ๋“œ๋“ค๋„ ์ž๋™ ์‚ญ์ œ๋จ) + await db.delete(tree) + await db.commit() + + return {"message": "Memo tree deleted successfully"} + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in delete_memo_tree: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete memo tree: {str(e)}" + ) + + +# ============================================================================ +# ๋ฉ”๋ชจ ๋…ธ๋“œ ๊ด€๋ฆฌ +# ============================================================================ + +@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse]) +async def get_memo_tree_nodes( + tree_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ์˜ ๋ชจ๋“  ๋…ธ๋“œ ์กฐํšŒ""" + try: + # ํŠธ๋ฆฌ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + tree_result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + or_( + MemoTree.user_id == current_user.id, + MemoTree.is_public == True + ) + ) + ) + ) + tree = tree_result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋…ธ๋“œ๋“ค ์กฐํšŒ + result = await db.execute( + select(MemoNode) + .where(MemoNode.tree_id == tree_id) + .order_by(MemoNode.path, MemoNode.sort_order) + ) + nodes = result.scalars().all() + + # ๊ฐ ๋…ธ๋“œ์˜ ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + node_responses = [] + for node in nodes: + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + node_responses.append(MemoNodeResponse(**node_dict)) + + return node_responses + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_tree_nodes: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo tree nodes: {str(e)}" + ) + + +@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse) +async def create_memo_node( + tree_id: UUID, + node_data: MemoNodeCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ฑ""" + try: + # ํŠธ๋ฆฌ ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + tree_result = await db.execute( + select(MemoTree).where( + and_( + MemoTree.id == tree_id, + MemoTree.user_id == current_user.id + ) + ) + ) + tree = tree_result.scalar_one_or_none() + + if not tree: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo tree not found" + ) + + # ๋ถ€๋ชจ ๋…ธ๋“œ ํ™•์ธ (์žˆ๋‹ค๋ฉด) + if node_data.parent_id: + parent_result = await db.execute( + select(MemoNode).where( + and_( + MemoNode.id == UUID(node_data.parent_id), + MemoNode.tree_id == tree_id + ) + ) + ) + parent_node = parent_result.scalar_one_or_none() + if not parent_node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent node not found" + ) + + # ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ + word_count = 0 + if node_data.content: + word_count = len(node_data.content.replace('\n', ' ').split()) + + new_node = MemoNode( + tree_id=tree_id, + parent_id=UUID(node_data.parent_id) if node_data.parent_id else None, + user_id=current_user.id, + title=node_data.title, + content=node_data.content, + node_type=node_data.node_type, + sort_order=node_data.sort_order, + tags=node_data.tags or [], + node_metadata=node_data.node_metadata or {}, + status=node_data.status, + word_count=word_count, + is_canonical=node_data.is_canonical or False + ) + + db.add(new_node) + await db.commit() + await db.refresh(new_node) + + node_dict = { + "id": str(new_node.id), + "tree_id": str(new_node.tree_id), + "parent_id": str(new_node.parent_id) if new_node.parent_id else None, + "user_id": str(new_node.user_id), + "title": new_node.title, + "content": new_node.content, + "node_type": new_node.node_type, + "sort_order": new_node.sort_order, + "depth_level": new_node.depth_level, + "path": new_node.path, + "tags": new_node.tags or [], + "node_metadata": new_node.node_metadata or {}, + "status": new_node.status, + "word_count": new_node.word_count, + "is_canonical": new_node.is_canonical, + "canonical_order": new_node.canonical_order, + "story_path": new_node.story_path, + "created_at": new_node.created_at, + "updated_at": new_node.updated_at, + "children_count": 0 + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in create_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create memo node: {str(e)}" + ) + + +@router.get("/nodes/{node_id}", response_model=MemoNodeResponse) +async def get_memo_node( + node_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ธ ์กฐํšŒ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ + if node.tree.user_id != current_user.id and not node.tree.is_public: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this node" + ) + + # ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get memo node: {str(e)}" + ) + + +@router.put("/nodes/{node_id}", response_model=MemoNodeResponse) +async def update_memo_node( + node_id: UUID, + node_data: MemoNodeUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์—…๋ฐ์ดํŠธ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ (์†Œ์œ ์ž๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ) + if node.tree.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this node" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_data = node_data.dict(exclude_unset=True) + for field, value in update_data.items(): + if field == "parent_id" and value: + # ๋ถ€๋ชจ ๋…ธ๋“œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + parent_result = await db.execute( + select(MemoNode).where( + and_( + MemoNode.id == UUID(value), + MemoNode.tree_id == node.tree_id + ) + ) + ) + parent_node = parent_result.scalar_one_or_none() + if not parent_node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent node not found" + ) + setattr(node, field, UUID(value)) + elif field == "parent_id" and value is None: + setattr(node, field, None) + else: + setattr(node, field, value) + + # ๋‚ด์šฉ์ด ์—…๋ฐ์ดํŠธ๋˜๋ฉด ๋‹จ์–ด ์ˆ˜ ์žฌ๊ณ„์‚ฐ + if "content" in update_data: + word_count = 0 + if node.content: + word_count = len(node.content.replace('\n', ' ').split()) + node.word_count = word_count + + await db.commit() + await db.refresh(node) + + # ์ž์‹ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + children_count_result = await db.execute( + select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id) + ) + children_count = children_count_result.scalar() or 0 + + node_dict = { + "id": str(node.id), + "tree_id": str(node.tree_id), + "parent_id": str(node.parent_id) if node.parent_id else None, + "user_id": str(node.user_id), + "title": node.title, + "content": node.content, + "node_type": node.node_type, + "sort_order": node.sort_order, + "depth_level": node.depth_level, + "path": node.path, + "tags": node.tags or [], + "node_metadata": node.node_metadata or {}, + "status": node.status, + "word_count": node.word_count, + "is_canonical": node.is_canonical, + "canonical_order": node.canonical_order, + "story_path": node.story_path, + "created_at": node.created_at, + "updated_at": node.updated_at, + "children_count": children_count + } + + return MemoNodeResponse(**node_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in update_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update memo node: {str(e)}" + ) + + +@router.delete("/nodes/{node_id}") +async def delete_memo_node( + node_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์‚ญ์ œ""" + try: + result = await db.execute( + select(MemoNode) + .options(selectinload(MemoNode.tree)) + .where(MemoNode.id == node_id) + ) + node = result.scalar_one_or_none() + + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Memo node not found" + ) + + # ์ ‘๊ทผ ๊ถŒํ•œ ํ™•์ธ (์†Œ์œ ์ž๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) + if node.tree.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to delete this node" + ) + + # ๋…ธ๋“œ ์‚ญ์ œ (CASCADE๋กœ ์ž์‹ ๋…ธ๋“œ๋“ค๋„ ์ž๋™ ์‚ญ์ œ๋จ) + await db.delete(node) + await db.commit() + + return {"message": "Memo node deleted successfully"} + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in delete_memo_node: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete memo node: {str(e)}" + ) diff --git a/backend/src/api/routes/note_documents.py b/backend/src/api/routes/note_documents.py new file mode 100644 index 0000000..89da0fa --- /dev/null +++ b/backend/src/api/routes/note_documents.py @@ -0,0 +1,271 @@ +""" +๋…ธํŠธ ๋ฌธ์„œ ๊ด€๋ จ API ์—”๋“œํฌ์ธํŠธ +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, desc, asc +from typing import List, Optional +import html + +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_document import ( + NoteDocument, + NoteDocumentCreate, + NoteDocumentUpdate, + NoteDocumentResponse, + NoteDocumentListItem, + NoteStats +) +from ...models.notebook import Notebook + +router = APIRouter() + + +def calculate_reading_time(content: str) -> int: + """HTML ๋‚ด์šฉ์—์„œ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (๋ถ„)""" + if not content: + return 0 + + # HTML ํƒœ๊ทธ ์ œ๊ฑฐ + text_content = html.unescape(content) + # ๊ฐ„๋‹จํ•œ HTML ํƒœ๊ทธ ์ œ๊ฑฐ (์ •ํ™•ํ•˜์ง€ ์•Š์ง€๋งŒ ๋Œ€๋žต์ ์ธ ๊ณ„์‚ฐ์šฉ) + import re + text_content = re.sub(r'<[^>]+>', '', text_content) + + # ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ (ํ•œ๊ตญ์–ด + ์˜์–ด) + words = len(text_content.split()) + korean_chars = len([c for c in text_content if '\uac00' <= c <= '\ud7af']) + + # ๋Œ€๋žต์ ์ธ ์ฝ๊ธฐ ์†๋„: ์˜์–ด 200๋‹จ์–ด/๋ถ„, ํ•œ๊ตญ์–ด 300์ž/๋ถ„ + english_time = words / 200 + korean_time = korean_chars / 300 + + return max(1, int(english_time + korean_time)) + + +@router.get("/", response_model=List[NoteDocumentListItem]) +def get_note_documents( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + search: Optional[str] = Query(None), + note_type: Optional[str] = Query(None), + published_only: bool = Query(False), + notebook_id: Optional[str] = Query(None), + sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|word_count)$"), + order: str = Query("desc", regex="^(asc|desc)$"), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ""" + query = db.query(NoteDocument) + + # ํ•„ํ„ฐ๋ง + if search: + search_term = f"%{search}%" + query = query.filter( + (NoteDocument.title.ilike(search_term)) | + (NoteDocument.content.ilike(search_term)) + ) + + if note_type: + query = query.filter(NoteDocument.note_type == note_type) + + if published_only: + query = query.filter(NoteDocument.is_published == True) + + if notebook_id: + query = query.filter(NoteDocument.notebook_id == notebook_id) + + # ์ •๋ ฌ + if sort_by == 'title': + query = query.order_by(asc(NoteDocument.title) if order == 'asc' else desc(NoteDocument.title)) + elif sort_by == 'created_at': + query = query.order_by(asc(NoteDocument.created_at) if order == 'asc' else desc(NoteDocument.created_at)) + elif sort_by == 'word_count': + query = query.order_by(asc(NoteDocument.word_count) if order == 'asc' else desc(NoteDocument.word_count)) + else: + query = query.order_by(desc(NoteDocument.updated_at)) + + # ํŽ˜์ด์ง€๋„ค์ด์…˜ + notes = query.offset(skip).limit(limit).all() + + # ์ž์‹ ๋…ธํŠธ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + result = [] + for note in notes: + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + note_item = NoteDocumentListItem.from_orm(note, child_count) + result.append(note_item) + + return result + + +@router.get("/stats", response_model=NoteStats) +def get_note_stats( + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ํ†ต๊ณ„ ์ •๋ณด""" + total_notes = db.query(func.count(NoteDocument.id)).scalar() + published_notes = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.is_published == True + ).scalar() + draft_notes = total_notes - published_notes + + # ๋…ธํŠธ ํƒ€์ž…๋ณ„ ํ†ต๊ณ„ + type_stats = db.query( + NoteDocument.note_type, + func.count(NoteDocument.id) + ).group_by(NoteDocument.note_type).all() + note_types = {note_type: count for note_type, count in type_stats} + + # ์ด ๋‹จ์–ด ์ˆ˜์™€ ์ฝ๊ธฐ ์‹œ๊ฐ„ + total_words = db.query(func.sum(NoteDocument.word_count)).scalar() or 0 + total_reading_time = db.query(func.sum(NoteDocument.reading_time)).scalar() or 0 + + # ์ตœ๊ทผ ๋…ธํŠธ๋“ค + recent_notes_query = db.query(NoteDocument).order_by( + desc(NoteDocument.updated_at) + ).limit(5).all() + + recent_notes = [NoteDocumentListItem.from_orm(note) for note in recent_notes_query] + + return NoteStats( + total_notes=total_notes, + published_notes=published_notes, + draft_notes=draft_notes, + note_types=note_types, + total_words=total_words, + total_reading_time=total_reading_time, + recent_notes=recent_notes + ) + + +@router.get("/{note_id}", response_model=NoteDocumentResponse) +def get_note_document( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋…ธํŠธ ๋ฌธ์„œ ์กฐํšŒ""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + return NoteDocumentResponse.from_orm(note) + + +@router.post("/", response_model=NoteDocumentResponse) +def create_note_document( + note_data: NoteDocumentCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """์ƒˆ ๋…ธํŠธ ๋ฌธ์„œ ์ƒ์„ฑ""" + # ๋‹จ์–ด ์ˆ˜ ๋ฐ ์ฝ๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ + word_count = len(note_data.content or '') if note_data.content else 0 + reading_time = calculate_reading_time(note_data.content or '') + + note = NoteDocument( + title=note_data.title, + content=note_data.content, + note_type=note_data.note_type, + tags=note_data.tags, + is_published=note_data.is_published, + parent_note_id=note_data.parent_note_id, + sort_order=note_data.sort_order, + notebook_id=note_data.notebook_id, + created_by=current_user.email, + word_count=word_count, + reading_time=reading_time + ) + + db.add(note) + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) + + +@router.put("/{note_id}", response_model=NoteDocumentResponse) +def update_note_document( + note_id: str, + note_data: NoteDocumentUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฌธ์„œ ์—…๋ฐ์ดํŠธ""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๊ถŒํ•œ ํ™•์ธ + if note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋งŒ ์ ์šฉ + update_data = note_data.dict(exclude_unset=True) + + # ๋‚ด์šฉ์ด ๋ณ€๊ฒฝ๋˜๋ฉด ๋‹จ์–ด ์ˆ˜์™€ ์ฝ๊ธฐ ์‹œ๊ฐ„ ์žฌ๊ณ„์‚ฐ + if 'content' in update_data: + update_data['word_count'] = len(update_data['content'] or '') + update_data['reading_time'] = calculate_reading_time(update_data['content'] or '') + + for field, value in update_data.items(): + setattr(note, field, value) + + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) + + +@router.delete("/{note_id}") +def delete_note_document( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฌธ์„œ ์‚ญ์ œ""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๊ถŒํ•œ ํ™•์ธ + if note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ์ž์‹ ๋…ธํŠธ๋“ค์ด ์žˆ๋Š”์ง€ ํ™•์ธ + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + if child_count > 0: + raise HTTPException( + status_code=400, + detail=f"Cannot delete note with {child_count} child notes" + ) + + db.delete(note) + db.commit() + + return {"message": "Note deleted successfully"} + + +@router.get("/{note_id}/content") +def get_note_document_content( + note_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_sync_db) +): + """๋…ธํŠธ ๋ฌธ์„œ์˜ HTML ์ฝ˜ํ…์ธ ๋งŒ ๋ฐ˜ํ™˜""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + + if not note: + raise HTTPException(status_code=404, detail="Note document not found") + + return note.content or "" diff --git a/backend/src/api/routes/note_highlights.py b/backend/src/api/routes/note_highlights.py new file mode 100644 index 0000000..db0d75e --- /dev/null +++ b/backend/src/api/routes/note_highlights.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_highlight import NoteHighlight, NoteHighlightCreate, NoteHighlightUpdate, NoteHighlightResponse +from ...models.note_document import NoteDocument + +router = APIRouter() + +@router.get("/note/{note_id}/highlights", response_model=List[NoteHighlightResponse]) +def get_note_highlights( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋…ธํŠธ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ํ•˜์ด๋ผ์ดํŠธ ์กฐํšŒ + highlights = db.query(NoteHighlight).filter( + NoteHighlight.note_id == note_id + ).order_by(NoteHighlight.start_offset).all() + + return [NoteHighlightResponse.from_orm(highlight) for highlight in highlights] + +@router.post("/note-highlights/", response_model=NoteHighlightResponse) +def create_note_highlight( + highlight_data: NoteHighlightCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == highlight_data.note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + highlight = NoteHighlight( + note_id=highlight_data.note_id, + start_offset=highlight_data.start_offset, + end_offset=highlight_data.end_offset, + selected_text=highlight_data.selected_text, + highlight_color=highlight_data.highlight_color, + highlight_type=highlight_data.highlight_type, + created_by=current_user.email + ) + + db.add(highlight) + db.commit() + db.refresh(highlight) + + return NoteHighlightResponse.from_orm(highlight) + +@router.put("/note-highlights/{highlight_id}", response_model=NoteHighlightResponse) +def update_note_highlight( + highlight_id: str, + highlight_data: NoteHighlightUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ ์ˆ˜์ •""" + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # ๊ถŒํ•œ ํ™•์ธ + if highlight.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ์—…๋ฐ์ดํŠธ + for field, value in highlight_data.dict(exclude_unset=True).items(): + setattr(highlight, field, value) + + db.commit() + db.refresh(highlight) + + return NoteHighlightResponse.from_orm(highlight) + +@router.delete("/note-highlights/{highlight_id}") +def delete_note_highlight( + highlight_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ""" + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # ๊ถŒํ•œ ํ™•์ธ + if highlight.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + db.delete(highlight) + db.commit() + + return {"message": "Highlight deleted successfully"} diff --git a/backend/src/api/routes/note_links.py b/backend/src/api/routes/note_links.py new file mode 100644 index 0000000..125b11e --- /dev/null +++ b/backend/src/api/routes/note_links.py @@ -0,0 +1,291 @@ +""" +๋…ธํŠธ ๋ฌธ์„œ ๋งํฌ ๊ด€๋ จ API ์—”๋“œํฌ์ธํŠธ +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +from typing import List, Optional +from pydantic import BaseModel +import uuid + +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_document import NoteDocument +from ...models.document import Document +from ...models.note_link import NoteLink + +router = APIRouter() + + +# Pydantic ๋ชจ๋ธ๋“ค +class NoteLinkCreate(BaseModel): + target_note_id: Optional[str] = None + target_document_id: Optional[str] = None + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] = None + description: Optional[str] = None + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: Optional[str] = "note" + + +class NoteLinkUpdate(BaseModel): + target_note_id: Optional[str] = None + target_document_id: Optional[str] = None + link_text: Optional[str] = None + description: Optional[str] = None + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: Optional[str] = None + + +class NoteLinkResponse(BaseModel): + id: str + source_note_id: Optional[str] = None + source_document_id: Optional[str] = None + target_note_id: Optional[str] = None + target_document_id: Optional[str] = None + target_content_type: Optional[str] = None # "document" or "note" + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] = None + description: Optional[str] = None + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: str + created_at: str + updated_at: Optional[str] = None + + # ์ถ”๊ฐ€ ์ •๋ณด + target_note_title: Optional[str] = None + target_document_title: Optional[str] = None + source_note_title: Optional[str] = None + source_document_title: Optional[str] = None + + class Config: + from_attributes = True + + +@router.get("/note-documents/{note_id}/links", response_model=List[NoteLinkResponse]) +def get_note_links( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ์—์„œ ๋‚˜๊ฐ€๋Š” ๋งํฌ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๋…ธํŠธ์—์„œ ๋‚˜๊ฐ€๋Š” ๋งํฌ๋“ค ์กฐํšŒ + links = db.query(NoteLink).filter( + NoteLink.source_note_id == note_id + ).all() + + result = [] + for link in links: + link_data = { + "id": str(link.id), + "source_note_id": str(link.source_note_id) if link.source_note_id else None, + "source_document_id": str(link.source_document_id) if link.source_document_id else None, + "target_note_id": str(link.target_note_id) if link.target_note_id else None, + "target_document_id": str(link.target_document_id) if link.target_document_id else None, + "selected_text": link.selected_text, + "start_offset": link.start_offset, + "end_offset": link.end_offset, + "link_text": link.link_text, + "description": link.description, + "target_text": link.target_text, + "target_start_offset": link.target_start_offset, + "target_end_offset": link.target_end_offset, + "link_type": link.link_type, + "created_at": link.created_at.isoformat() if link.created_at else None, + "updated_at": link.updated_at.isoformat() if link.updated_at else None, + } + + # ๋Œ€์ƒ ์ œ๋ชฉ ๋ฐ ํƒ€์ž… ์ถ”๊ฐ€ + if link.target_note_id: + target_note = db.query(NoteDocument).filter(NoteDocument.id == link.target_note_id).first() + if target_note: + link_data["target_note_title"] = target_note.title + link_data["target_content_type"] = "note" + elif link.target_document_id: + target_doc = db.query(Document).filter(Document.id == link.target_document_id).first() + if target_doc: + link_data["target_document_title"] = target_doc.title + link_data["target_content_type"] = "document" + + result.append(NoteLinkResponse(**link_data)) + + return result + + +@router.get("/note-documents/{note_id}/backlinks", response_model=List[NoteLinkResponse]) +def get_note_backlinks( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋กœ ๋“ค์–ด์˜ค๋Š” ๋ฐฑ๋งํฌ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๋…ธํŠธ๋กœ ๋“ค์–ด์˜ค๋Š” ๋ฐฑ๋งํฌ๋“ค ์กฐํšŒ + backlinks = db.query(NoteLink).filter( + NoteLink.target_note_id == note_id + ).all() + + result = [] + for link in backlinks: + link_data = { + "id": str(link.id), + "source_note_id": str(link.source_note_id) if link.source_note_id else None, + "source_document_id": str(link.source_document_id) if link.source_document_id else None, + "target_note_id": str(link.target_note_id) if link.target_note_id else None, + "target_document_id": str(link.target_document_id) if link.target_document_id else None, + "selected_text": link.selected_text, + "start_offset": link.start_offset, + "end_offset": link.end_offset, + "link_text": link.link_text, + "description": link.description, + "target_text": link.target_text, + "target_start_offset": link.target_start_offset, + "target_end_offset": link.target_end_offset, + "link_type": link.link_type, + "created_at": link.created_at.isoformat() if link.created_at else None, + "updated_at": link.updated_at.isoformat() if link.updated_at else None, + } + + # ์ถœ๋ฐœ์ง€ ์ œ๋ชฉ ์ถ”๊ฐ€ + if link.source_note_id: + source_note = db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first() + if source_note: + link_data["source_note_title"] = source_note.title + elif link.source_document_id: + source_doc = db.query(Document).filter(Document.id == link.source_document_id).first() + if source_doc: + link_data["source_document_title"] = source_doc.title + + result.append(NoteLinkResponse(**link_data)) + + return result + + +@router.post("/note-documents/{note_id}/links", response_model=NoteLinkResponse) +def create_note_link( + note_id: str, + link_data: NoteLinkCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ์—์„œ ๋‹ค๋ฅธ ๋…ธํŠธ/๋ฌธ์„œ๋กœ์˜ ๋งํฌ ์ƒ์„ฑ""" + # ์ถœ๋ฐœ์ง€ ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + source_note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not source_note: + raise HTTPException(status_code=404, detail="Source note not found") + + # ๋Œ€์ƒ ํ™•์ธ (๋…ธํŠธ ๋˜๋Š” ๋ฌธ์„œ ์ค‘ ํ•˜๋‚˜๋Š” ๋ฐ˜๋“œ์‹œ ์žˆ์–ด์•ผ ํ•จ) + if not link_data.target_note_id and not link_data.target_document_id: + raise HTTPException(status_code=400, detail="Either target_note_id or target_document_id is required") + + if link_data.target_note_id and link_data.target_document_id: + raise HTTPException(status_code=400, detail="Cannot specify both target_note_id and target_document_id") + + # ๋Œ€์ƒ ์กด์žฌ ํ™•์ธ + if link_data.target_note_id: + target_note = db.query(NoteDocument).filter(NoteDocument.id == link_data.target_note_id).first() + if not target_note: + raise HTTPException(status_code=404, detail="Target note not found") + + if link_data.target_document_id: + target_doc = db.query(Document).filter(Document.id == link_data.target_document_id).first() + if not target_doc: + raise HTTPException(status_code=404, detail="Target document not found") + + # ๋งํฌ ์ƒ์„ฑ + note_link = NoteLink( + source_note_id=note_id, + target_note_id=link_data.target_note_id, + target_document_id=link_data.target_document_id, + selected_text=link_data.selected_text, + start_offset=link_data.start_offset, + end_offset=link_data.end_offset, + link_text=link_data.link_text, + description=link_data.description, + target_text=link_data.target_text, + target_start_offset=link_data.target_start_offset, + target_end_offset=link_data.target_end_offset, + link_type=link_data.link_type or "note", + created_by=current_user.id + ) + + db.add(note_link) + db.commit() + db.refresh(note_link) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_data = { + "id": str(note_link.id), + "source_note_id": str(note_link.source_note_id) if note_link.source_note_id else None, + "source_document_id": str(note_link.source_document_id) if note_link.source_document_id else None, + "target_note_id": str(note_link.target_note_id) if note_link.target_note_id else None, + "target_document_id": str(note_link.target_document_id) if note_link.target_document_id else None, + "selected_text": note_link.selected_text, + "start_offset": note_link.start_offset, + "end_offset": note_link.end_offset, + "link_text": note_link.link_text, + "description": note_link.description, + "target_text": note_link.target_text, + "target_start_offset": note_link.target_start_offset, + "target_end_offset": note_link.target_end_offset, + "link_type": note_link.link_type, + "created_at": note_link.created_at.isoformat() if note_link.created_at else None, + "updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None, + } + + # ์†Œ์Šค ๋ฐ ํƒ€๊ฒŸ ํƒ€์ž… ์„ค์ • + response_data["source_content_type"] = "note" # ๋…ธํŠธ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋งํฌ + + if note_link.target_note_id: + target_note = db.query(NoteDocument).filter(NoteDocument.id == note_link.target_note_id).first() + if target_note: + response_data["target_note_title"] = target_note.title + response_data["target_content_type"] = "note" + elif note_link.target_document_id: + target_doc = db.query(Document).filter(Document.id == note_link.target_document_id).first() + if target_doc: + response_data["target_document_title"] = target_doc.title + response_data["target_content_type"] = "document" + + return NoteLinkResponse(**response_data) + + +@router.delete("/note-links/{link_id}") +def delete_note_link( + link_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋งํฌ ์‚ญ์ œ""" + link = db.query(NoteLink).filter(NoteLink.id == link_id).first() + if not link: + raise HTTPException(status_code=404, detail="Link not found") + + # ๊ถŒํ•œ ํ™•์ธ (๋งํฌ ์ƒ์„ฑ์ž ๋˜๋Š” ๊ด€๋ฆฌ์ž๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ) + if link.created_by != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + db.delete(link) + db.commit() + + return {"message": "Link deleted successfully"} diff --git a/backend/src/api/routes/note_notes.py b/backend/src/api/routes/note_notes.py new file mode 100644 index 0000000..89fc733 --- /dev/null +++ b/backend/src/api/routes/note_notes.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, selectinload +from typing import List +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_note import NoteNote, NoteNoteCreate, NoteNoteUpdate, NoteNoteResponse +from ...models.note_document import NoteDocument +from ...models.note_highlight import NoteHighlight + +router = APIRouter() + +@router.get("/note/{note_id}/notes", response_model=List[NoteNoteResponse]) +def get_note_notes( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋…ธํŠธ์˜ ๋ฉ”๋ชจ ๋ชฉ๋ก ์กฐํšŒ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๋ฉ”๋ชจ ์กฐํšŒ + notes = db.query(NoteNote).filter( + NoteNote.note_id == note_id + ).options( + selectinload(NoteNote.highlight) + ).order_by(NoteNote.created_at.desc()).all() + + return [NoteNoteResponse.from_orm(note) for note in notes] + +@router.get("/note-highlights/{highlight_id}/notes", response_model=List[NoteNoteResponse]) +def get_highlight_notes( + highlight_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ํ•˜์ด๋ผ์ดํŠธ์˜ ๋ฉ”๋ชจ ๋ชฉ๋ก ์กฐํšŒ""" + # ํ•˜์ด๋ผ์ดํŠธ ์กด์žฌ ํ™•์ธ + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # ๋ฉ”๋ชจ ์กฐํšŒ + notes = db.query(NoteNote).filter( + NoteNote.highlight_id == highlight_id + ).order_by(NoteNote.created_at.desc()).all() + + return [NoteNoteResponse.from_orm(note) for note in notes] + +@router.post("/note-notes/", response_model=NoteNoteResponse) +def create_note_note( + note_data: NoteNoteCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฉ”๋ชจ ์ƒ์„ฑ""" + # ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + note = db.query(NoteDocument).filter(NoteDocument.id == note_data.note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ํ•˜์ด๋ผ์ดํŠธ ์กด์žฌ ํ™•์ธ (์„ ํƒ์‚ฌํ•ญ) + if note_data.highlight_id: + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == note_data.highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # ๋ฉ”๋ชจ ์ƒ์„ฑ + note_note = NoteNote( + note_id=note_data.note_id, + highlight_id=note_data.highlight_id, + content=note_data.content, + created_by=current_user.email + ) + + db.add(note_note) + db.commit() + db.refresh(note_note) + + return NoteNoteResponse.from_orm(note_note) + +@router.put("/note-notes/{note_note_id}", response_model=NoteNoteResponse) +def update_note_note( + note_note_id: str, + note_data: NoteNoteUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฉ”๋ชจ ์ˆ˜์ •""" + note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first() + if not note_note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๊ถŒํ•œ ํ™•์ธ + if note_note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ์—…๋ฐ์ดํŠธ + for field, value in note_data.dict(exclude_unset=True).items(): + setattr(note_note, field, value) + + db.commit() + db.refresh(note_note) + + return NoteNoteResponse.from_orm(note_note) + +@router.delete("/note-notes/{note_note_id}") +def delete_note_note( + note_note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ฉ”๋ชจ ์‚ญ์ œ""" + note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first() + if not note_note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๊ถŒํ•œ ํ™•์ธ + if note_note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + db.delete(note_note) + db.commit() + + return {"message": "Note deleted successfully"} diff --git a/backend/src/api/routes/notebooks.py b/backend/src/api/routes/notebooks.py new file mode 100644 index 0000000..42bbf90 --- /dev/null +++ b/backend/src/api/routes/notebooks.py @@ -0,0 +1,270 @@ +""" +๋…ธํŠธ๋ถ (Notebook) ๊ด€๋ฆฌ API + +์šฉ์–ด ์ •์˜: +- ๋…ธํŠธ๋ถ (Notebook): ๋…ธํŠธ ๋ฌธ์„œ๋“ค์„ ๊ทธ๋ฃนํ™”ํ•˜๋Š” ํด๋” +- ๋…ธํŠธ (Note Document): ๋…๋ฆฝ์ ์ธ HTML ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ์ž‘์„ฑ +- ๋ฉ”๋ชจ (Memo): ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ (๋ณ„๋„ API) +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, desc, asc, select +from typing import List, Optional + +from ...core.database import get_sync_db +from ...models.notebook import ( + Notebook, + NotebookCreate, + NotebookUpdate, + NotebookResponse, + NotebookListItem, + NotebookStats +) +from ...models.note_document import NoteDocument +from ...models.user import User +from ..dependencies import get_current_user + +router = APIRouter() + +@router.get("/", response_model=List[NotebookListItem]) +def get_notebooks( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + search: Optional[str] = Query(None), + active_only: bool = Query(True), + sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|sort_order)$"), + order: str = Query("desc", regex="^(asc|desc)$"), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ถ ๋ชฉ๋ก ์กฐํšŒ""" + query = db.query(Notebook) + + # ํ•„ํ„ฐ๋ง + if search: + search_term = f"%{search}%" + query = query.filter( + (Notebook.title.ilike(search_term)) | + (Notebook.description.ilike(search_term)) + ) + + if active_only: + query = query.filter(Notebook.is_active == True) + + # ์ •๋ ฌ + if sort_by == 'title': + query = query.order_by(asc(Notebook.title) if order == 'asc' else desc(Notebook.title)) + elif sort_by == 'created_at': + query = query.order_by(asc(Notebook.created_at) if order == 'asc' else desc(Notebook.created_at)) + elif sort_by == 'sort_order': + query = query.order_by(asc(Notebook.sort_order) if order == 'asc' else desc(Notebook.sort_order)) + else: + query = query.order_by(desc(Notebook.updated_at)) + + # ํŽ˜์ด์ง€๋„ค์ด์…˜ + notebooks = query.offset(skip).limit(limit).all() + + # ๋…ธํŠธ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + result = [] + for notebook in notebooks: + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + notebook_item = NotebookListItem.from_orm(notebook, note_count) + result.append(notebook_item) + + return result + +@router.get("/stats", response_model=NotebookStats) +def get_notebook_stats( + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ถ ํ†ต๊ณ„ ์ •๋ณด""" + total_notebooks = db.query(func.count(Notebook.id)).scalar() + active_notebooks = db.query(func.count(Notebook.id)).filter( + Notebook.is_active == True + ).scalar() + + total_notes = db.query(func.count(NoteDocument.id)).scalar() + notes_without_notebook = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id.is_(None) + ).scalar() + + return NotebookStats( + total_notebooks=total_notebooks, + active_notebooks=active_notebooks, + total_notes=total_notes, + notes_without_notebook=notes_without_notebook + ) + +@router.get("/{notebook_id}", response_model=NotebookResponse) +def get_notebook( + notebook_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋…ธํŠธ๋ถ ์กฐํšŒ""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # ๋…ธํŠธ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + return NotebookResponse.from_orm(notebook, note_count) + +@router.post("/", response_model=NotebookResponse) +def create_notebook( + notebook_data: NotebookCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """์ƒˆ ๋…ธํŠธ๋ถ ์ƒ์„ฑ""" + notebook = Notebook( + title=notebook_data.title, + description=notebook_data.description, + color=notebook_data.color, + icon=notebook_data.icon, + is_active=notebook_data.is_active, + sort_order=notebook_data.sort_order, + created_by=current_user.email + ) + + db.add(notebook) + db.commit() + db.refresh(notebook) + + return NotebookResponse.from_orm(notebook, 0) + +@router.put("/{notebook_id}", response_model=NotebookResponse) +def update_notebook( + notebook_id: str, + notebook_data: NotebookUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ถ ์—…๋ฐ์ดํŠธ""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋งŒ ์ ์šฉ + update_data = notebook_data.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(notebook, field, value) + + db.commit() + db.refresh(notebook) + + # ๋…ธํŠธ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + return NotebookResponse.from_orm(notebook, note_count) + +@router.delete("/{notebook_id}") +def delete_notebook( + notebook_id: str, + force: bool = Query(False, description="๊ฐ•์ œ ์‚ญ์ œ (๋…ธํŠธ๊ฐ€ ์žˆ์–ด๋„ ์‚ญ์ œ)"), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ถ ์‚ญ์ œ""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # ๋…ธํŠธ๋ถ์— ํฌํ•จ๋œ ๋…ธํŠธ ํ™•์ธ + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + if note_count > 0 and not force: + raise HTTPException( + status_code=400, + detail=f"Cannot delete notebook with {note_count} notes. Use force=true to delete anyway." + ) + + if force and note_count > 0: + # ๋…ธํŠธ๋“ค์˜ notebook_id๋ฅผ NULL๋กœ ์„ค์ • (๊ธฐ๋ณธ ๋…ธํŠธ๋ถ์œผ๋กœ ์ด๋™) + db.query(NoteDocument).filter( + NoteDocument.notebook_id == notebook.id + ).update({NoteDocument.notebook_id: None}) + + db.delete(notebook) + db.commit() + + return {"message": "Notebook deleted successfully"} + +@router.get("/{notebook_id}/notes") +def get_notebook_notes( + notebook_id: str, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ถ์— ํฌํ•จ๋œ ๋…ธํŠธ๋“ค ์กฐํšŒ""" + # ๋…ธํŠธ๋ถ ์กด์žฌ ํ™•์ธ + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # ๋…ธํŠธ๋“ค ์กฐํšŒ + notes = db.query(NoteDocument).filter( + NoteDocument.notebook_id == notebook_id + ).order_by(desc(NoteDocument.updated_at)).offset(skip).limit(limit).all() + + return notes + +@router.post("/{notebook_id}/notes/{note_id}") +def add_note_to_notebook( + notebook_id: str, + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ฅผ ๋…ธํŠธ๋ถ์— ์ถ”๊ฐ€""" + # ๋…ธํŠธ๋ถ๊ณผ ๋…ธํŠธ ์กด์žฌ ํ™•์ธ + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๋…ธํŠธ๋ฅผ ๋…ธํŠธ๋ถ์— ํ• ๋‹น + note.notebook_id = notebook_id + db.commit() + + return {"message": "Note added to notebook successfully"} + +@router.delete("/{notebook_id}/notes/{note_id}") +def remove_note_from_notebook( + notebook_id: str, + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ฅผ ๋…ธํŠธ๋ถ์—์„œ ์ œ๊ฑฐ""" + note = db.query(NoteDocument).filter( + NoteDocument.id == note_id, + NoteDocument.notebook_id == notebook_id + ).first() + + if not note: + raise HTTPException(status_code=404, detail="Note not found in this notebook") + + # ๋…ธํŠธ๋ถ์—์„œ ์ œ๊ฑฐ (๊ธฐ๋ณธ ๋…ธํŠธ๋ถ์œผ๋กœ ์ด๋™) + note.notebook_id = None + db.commit() + + return {"message": "Note removed from notebook successfully"} diff --git a/backend/src/api/routes/notes.py b/backend/src/api/routes/notes.py new file mode 100644 index 0000000..f1be56a --- /dev/null +++ b/backend/src/api/routes/notes.py @@ -0,0 +1,532 @@ +""" +๋…ธํŠธ ๋ฌธ์„œ (Note Document) ๊ด€๋ฆฌ API + +์šฉ์–ด ์ •์˜: +- ๋…ธํŠธ (Note Document): ๋…๋ฆฝ์ ์ธ HTML ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ์ž‘์„ฑ +- ๋…ธํŠธ๋ถ (Notebook): ๋…ธํŠธ๋“ค์„ ๊ทธ๋ฃนํ™”ํ•˜๋Š” ํด๋” +- ๋ฉ”๋ชจ (Memo): ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ (๋ณ„๋„ API - highlights.py) +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, selectinload +from sqlalchemy import func, desc, asc, select +from typing import List, Optional +# import markdown # ์ž„์‹œ๋กœ ๋น„ํ™œ์„ฑํ™” +import re +from datetime import datetime, timedelta + +from ...core.database import get_sync_db +from ...models.note_document import ( + NoteDocument, + NoteDocumentCreate, + NoteDocumentUpdate, + NoteDocumentResponse, + NoteDocumentListItem, + NoteStats +) +from ...models.user import User +from ..dependencies import get_current_user + +router = APIRouter() + +# === ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ (Highlight Memo) API === +# ์šฉ์–ด ์ •์˜: ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ + +@router.post("/") +def create_note( + note_data: dict, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์ƒ์„ฑ""" + from ...models.note import Note + from ...models.highlight import Highlight + + # ํ•˜์ด๋ผ์ดํŠธ ์†Œ์œ ๊ถŒ ํ™•์ธ + highlight = db.query(Highlight).filter( + Highlight.id == note_data.get('highlight_id'), + Highlight.user_id == current_user.id + ).first() + + if not highlight: + raise HTTPException(status_code=404, detail="ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + # ๋ฉ”๋ชจ ์ƒ์„ฑ + note = Note( + highlight_id=note_data.get('highlight_id'), + content=note_data.get('content', ''), + is_private=note_data.get('is_private', False), + tags=note_data.get('tags', []) + ) + + db.add(note) + db.commit() + db.refresh(note) + + return note + +@router.put("/{note_id}") +def update_note( + note_id: str, + note_data: dict, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ""" + from ...models.note import Note + from ...models.highlight import Highlight + + # ๋ฉ”๋ชจ ์กด์žฌ ๋ฐ ์†Œ์œ ๊ถŒ ํ™•์ธ + note = db.query(Note).join(Highlight).filter( + Note.id == note_id, + Highlight.user_id == current_user.id + ).first() + + if not note: + raise HTTPException(status_code=404, detail="๋ฉ”๋ชจ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + # ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ + if 'content' in note_data: + note.content = note_data['content'] + if 'tags' in note_data: + note.tags = note_data['tags'] + + note.updated_at = datetime.utcnow() + + db.commit() + db.refresh(note) + + return note + +@router.delete("/{note_id}") +def delete_highlight_note( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์‚ญ์ œ""" + from ...models.note import Note + from ...models.highlight import Highlight + + note = db.query(Note).join(Highlight).filter( + Note.id == note_id, + Highlight.user_id == current_user.id + ).first() + + if not note: + raise HTTPException(status_code=404, detail="๋ฉ”๋ชจ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + db.delete(note) + db.commit() + + return {"message": "๋ฉ”๋ชจ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"} + +@router.get("/document/{document_id}") +async def get_document_notes( + document_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ""" + from ...models.note import Note + from ...models.highlight import Highlight + + notes = db.query(Note).join(Highlight).filter( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ).options( + selectinload(Note.highlight) + ).all() + + return notes + +def clean_html_content(content: str) -> str: + """HTML ๋‚ด์šฉ ์ •๋ฆฌ ๋ฐ ๊ฒ€์ฆ""" + if not content: + return "" + + # ๊ธฐ๋ณธ์ ์ธ HTML ์ •๋ฆฌ (๋‚˜์ค‘์— ๋” ์ •๊ตํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Œ) + return content.strip() + +def calculate_reading_time(content: str) -> int: + """์ฝ๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (๋ถ„ ๋‹จ์œ„)""" + if not content: + return 0 + + # ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ (ํ•œ๊ธ€, ์˜๋ฌธ ๋ชจ๋‘ ๊ณ ๋ ค) + korean_chars = len(re.findall(r'[๊ฐ€-ํžฃ]', content)) + english_words = len(re.findall(r'\b[a-zA-Z]+\b', content)) + + # ํ•œ๊ธ€: ๋ถ„๋‹น 500์ž, ์˜๋ฌธ: ๋ถ„๋‹น 200๋‹จ์–ด ๊ธฐ์ค€ + korean_time = korean_chars / 500 + english_time = english_words / 200 + + total_minutes = max(1, int(korean_time + english_time)) + return total_minutes + +def calculate_word_count(content: str) -> int: + """๋‹จ์–ด/๊ธ€์ž ์ˆ˜ ๊ณ„์‚ฐ""" + if not content: + return 0 + + korean_chars = len(re.findall(r'[๊ฐ€-ํžฃ]', content)) + english_words = len(re.findall(r'\b[a-zA-Z]+\b', content)) + + return korean_chars + english_words + +@router.get("/") +def get_notes( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + note_type: Optional[str] = Query(None), + tags: Optional[str] = Query(None), # ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„๋œ ํƒœ๊ทธ + search: Optional[str] = Query(None), + published_only: bool = Query(False), + parent_id: Optional[str] = Query(None), + notebook_id: Optional[str] = Query(None), # ๋…ธํŠธ๋ถ ํ•„ํ„ฐ + document_id: Optional[str] = Query(None), # ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ์šฉ + note_document_id: Optional[str] = Query(None), # ๋…ธํŠธ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ์šฉ + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ๋ชฉ๋ก ์กฐํšŒ ๋˜๋Š” ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ""" + + # ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ ์š”์ฒญ์ธ ๊ฒฝ์šฐ + if document_id or note_document_id: + from ...models.note import Note + from ...models.highlight import Highlight + + if document_id: + # ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ + notes = db.query(Note).join(Highlight).filter( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ).options( + selectinload(Note.highlight) + ).all() + else: + # ๋…ธํŠธ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ (note_document_id) + # ๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋ธ์ด ์žˆ๋‹ค๋ฉด ์‚ฌ์šฉ, ์—†๋‹ค๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ + notes = [] + + return notes + + # ์ผ๋ฐ˜ ๋…ธํŠธ ๋ฌธ์„œ ๋ชฉ๋ก ์กฐํšŒ + # ๋™๊ธฐ SQLAlchemy ์Šคํƒ€์ผ + query = db.query(NoteDocument) + + # ํ•„ํ„ฐ๋ง + if note_type: + query = query.filter(NoteDocument.note_type == note_type) + + if tags: + tag_list = [tag.strip() for tag in tags.split(',')] + query = query.filter(NoteDocument.tags.overlap(tag_list)) + + if search: + search_term = f"%{search}%" + query = query.filter( + (NoteDocument.title.ilike(search_term)) | + (NoteDocument.content.ilike(search_term)) + ) + + if published_only: + query = query.filter(NoteDocument.is_published == True) + + if notebook_id: + if notebook_id == 'null': + # ๋ฏธ๋ถ„๋ฅ˜ ๋…ธํŠธ (notebook_id๊ฐ€ None์ธ ๊ฒƒ๋“ค) + query = query.filter(NoteDocument.notebook_id.is_(None)) + else: + query = query.filter(NoteDocument.notebook_id == notebook_id) + + if parent_id: + query = query.filter(NoteDocument.parent_note_id == parent_id) + else: + # ์ตœ์ƒ์œ„ ๋…ธํŠธ๋งŒ (parent_id๊ฐ€ None์ธ ๊ฒƒ๋“ค) + query = query.filter(NoteDocument.parent_note_id.is_(None)) + + # ์ •๋ ฌ ๋ฐ ํŽ˜์ด์ง• + query = query.order_by(desc(NoteDocument.updated_at)) + notes = query.offset(skip).limit(limit).all() + + # ์ž์‹ ๋…ธํŠธ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + result = [] + for note in notes: + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + note_item = NoteDocumentListItem.from_orm(note, child_count) + result.append(note_item) + + return result + +@router.get("/stats", response_model=NoteStats) +def get_note_stats( + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ํ†ต๊ณ„ ์ •๋ณด""" + total_notes = db.query(func.count(NoteDocument.id)).scalar() + published_notes = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.is_published == True + ).scalar() + draft_notes = total_notes - published_notes + + # ๋…ธํŠธ ํƒ€์ž…๋ณ„ ํ†ต๊ณ„ + type_stats = db.query( + NoteDocument.note_type, + func.count(NoteDocument.id) + ).group_by(NoteDocument.note_type).all() + + note_types = {note_type: count for note_type, count in type_stats} + + # ์ด ๋‹จ์–ด ์ˆ˜์™€ ์ฝ๊ธฐ ์‹œ๊ฐ„ + totals = db.query( + func.sum(NoteDocument.word_count), + func.sum(NoteDocument.reading_time) + ).first() + + total_words = totals[0] or 0 + total_reading_time = totals[1] or 0 + + # ์ตœ๊ทผ ๋…ธํŠธ (5๊ฐœ) + recent_notes_query = db.query(NoteDocument).order_by( + desc(NoteDocument.updated_at) + ).limit(5) + + recent_notes = [] + for note in recent_notes_query.all(): + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + note_item = NoteDocumentListItem.from_orm(note, child_count) + recent_notes.append(note_item) + + return NoteStats( + total_notes=total_notes, + published_notes=published_notes, + draft_notes=draft_notes, + note_types=note_types, + total_words=total_words, + total_reading_time=total_reading_time, + recent_notes=recent_notes + ) + +@router.get("/{note_id}", response_model=NoteDocumentResponse) +def get_note( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """ํŠน์ • ๋…ธํŠธ ์กฐํšŒ""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + return NoteDocumentResponse.from_orm(note) + +@router.post("/", response_model=NoteDocumentResponse) +def create_note( + note_data: NoteDocumentCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """์ƒˆ ๋…ธํŠธ ์ƒ์„ฑ""" + # HTML ๋‚ด์šฉ ์ •๋ฆฌ + cleaned_content = clean_html_content(note_data.content or "") + + # ํ†ต๊ณ„ ๊ณ„์‚ฐ + word_count = calculate_word_count(note_data.content or "") + reading_time = calculate_reading_time(note_data.content or "") + + note = NoteDocument( + title=note_data.title, + content=cleaned_content, + note_type=note_data.note_type, + tags=note_data.tags, + is_published=note_data.is_published, + parent_note_id=note_data.parent_note_id, + sort_order=note_data.sort_order, + word_count=word_count, + reading_time=reading_time, + created_by=current_user.email + ) + + db.add(note) + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) + +@router.put("/{note_id}", response_model=NoteDocumentResponse) +def update_note( + note_id: str, + note_data: NoteDocumentUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ์ˆ˜์ •""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ์ˆ˜์ • ๊ถŒํ•œ ํ™•์ธ (์ž‘์„ฑ์ž๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ) + if note.created_by != current_user.username and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ํ•„๋“œ ์—…๋ฐ์ดํŠธ + update_data = note_data.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(note, field, value) + + # ๋‚ด์šฉ์ด ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ ํ†ต๊ณ„ ์žฌ๊ณ„์‚ฐ + if 'content' in update_data: + note.content = clean_html_content(note.content or "") + note.word_count = calculate_word_count(note.content or "") + note.reading_time = calculate_reading_time(note.content or "") + + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) + +@router.delete("/{note_id}") +def delete_note( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ ์‚ญ์ œ""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ์‚ญ์ œ ๊ถŒํ•œ ํ™•์ธ + if note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # ์ž์‹ ๋…ธํŠธ๋“ค์˜ parent_note_id๋ฅผ NULL๋กœ ์„ค์ • + db.query(NoteDocument).filter( + NoteDocument.parent_note_id == note_id + ).update({"parent_note_id": None}) + + db.delete(note) + db.commit() + + return {"message": "Note deleted successfully"} + +@router.get("/{note_id}/children", response_model=List[NoteDocumentListItem]) +async def get_note_children( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ์˜ ์ž์‹ ๋…ธํŠธ๋“ค ์กฐํšŒ""" + children = db.query(NoteDocument).filter( + NoteDocument.parent_note_id == note_id + ).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all() + + result = [] + for child in children: + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == child.id + ).scalar() + + child_item = NoteDocumentListItem.from_orm(child) + child_item.child_count = child_count + result.append(child_item) + + return result + +@router.get("/{note_id}/export/html") +async def export_note_html( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ฅผ HTML ํŒŒ์ผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ""" + from fastapi.responses import Response + + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # HTML ํ…œํ”Œ๋ฆฟ ์ƒ์„ฑ + html_template = f""" + + + + + {note.title} + + + +
+ ์ œ๋ชฉ: {note.title}
+ ํƒ€์ž…: {note.note_type}
+ ์ž‘์„ฑ์ž: {note.created_by}
+ ์ž‘์„ฑ์ผ: {note.created_at.strftime('%Y-%m-%d %H:%M')}
+ ํƒœ๊ทธ: {', '.join(note.tags) if note.tags else '์—†์Œ'} +
+
+ {note.content or ''} + +""" + + filename = f"{note.title.replace(' ', '_')}.html" + + return Response( + content=html_template, + media_type="text/html", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + +@router.get("/{note_id}/export/markdown") +async def export_note_markdown( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """๋…ธํŠธ๋ฅผ ๋งˆํฌ๋‹ค์šด ํŒŒ์ผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ""" + from fastapi.responses import Response + + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ํฌํ•จํ•œ ๋งˆํฌ๋‹ค์šด + markdown_content = f"""--- +title: {note.title} +type: {note.note_type} +author: {note.created_by} +created: {note.created_at.strftime('%Y-%m-%d %H:%M')} +tags: [{', '.join(note.tags) if note.tags else ''}] +--- + +# {note.title} + +{note.content or ''} +""" + + filename = f"{note.title.replace(' ', '_')}.md" + + return Response( + content=markdown_content, + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) \ No newline at end of file diff --git a/backend/src/api/routes/search.py b/backend/src/api/routes/search.py new file mode 100644 index 0000000..7948249 --- /dev/null +++ b/backend/src/api/routes/search.py @@ -0,0 +1,671 @@ +""" +๊ฒ€์ƒ‰ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_, and_, text +from sqlalchemy.orm import joinedload, selectinload +from typing import List, Optional, Dict, Any +from datetime import datetime + +from ...core.database import get_db +from ...models.user import User +from ...models.document import Document, Tag +from ...models.highlight import Highlight +from ...models.note import Note +from ...models.memo_tree import MemoTree, MemoNode +from ...models.note_document import NoteDocument +from ..dependencies import get_current_active_user +from pydantic import BaseModel + + +class SearchResult(BaseModel): + """๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ""" + type: str # "document", "note", "highlight" + id: str + title: str + content: str + document_id: str + document_title: str + created_at: datetime + relevance_score: float = 0.0 + highlight_info: Optional[Dict[str, Any]] = None + + class Config: + from_attributes = True + + +class SearchResponse(BaseModel): + """๊ฒ€์ƒ‰ ์‘๋‹ต""" + query: str + total_results: int + results: List[SearchResult] + facets: Dict[str, List[Dict[str, Any]]] = {} + + +router = APIRouter() + + +@router.get("/", response_model=SearchResponse) +async def search_all( + q: str = Query(..., description="๊ฒ€์ƒ‰์–ด"), + type_filter: Optional[str] = Query(None, description="๊ฒ€์ƒ‰ ํƒ€์ž… ํ•„ํ„ฐ: document, note, memo, highlight"), + document_id: Optional[str] = Query(None, description="ํŠน์ • ๋ฌธ์„œ ๋‚ด ๊ฒ€์ƒ‰"), + tag: Optional[str] = Query(None, description="ํƒœ๊ทธ ํ•„ํ„ฐ"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ†ตํ•ฉ ๊ฒ€์ƒ‰ (๋ฌธ์„œ + ๋ฉ”๋ชจ + ํ•˜์ด๋ผ์ดํŠธ)""" + results = [] + + # 1. ๋ฌธ์„œ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "document": + document_results = await search_documents(q, document_id, tag, current_user, db) + results.extend(document_results) + + # 2. ๋…ธํŠธ ๋ฌธ์„œ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "note": + note_results = await search_note_documents(q, current_user, db) + results.extend(note_results) + + # 3. ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๋…ธ๋“œ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "memo": + memo_results = await search_memo_nodes(q, current_user, db) + results.extend(memo_results) + + # 4. ๊ธฐ์กด ๋ฉ”๋ชจ ๊ฒ€์ƒ‰ (ํ•˜์œ„ ํ˜ธํ™˜์„ฑ) + if not type_filter or type_filter == "note": + old_note_results = await search_notes(q, document_id, tag, current_user, db) + results.extend(old_note_results) + + # 5. ํ•˜์ด๋ผ์ดํŠธ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "highlight": + highlight_results = await search_highlights(q, document_id, current_user, db) + results.extend(highlight_results) + + # 6. ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ๊ฒ€์ƒ‰ + if not type_filter or type_filter == "highlight_note": + highlight_note_results = await search_highlight_notes(q, document_id, current_user, db) + results.extend(highlight_note_results) + + # 7. ๋ฌธ์„œ ๋ณธ๋ฌธ ๊ฒ€์ƒ‰ (OCR ๋ฐ์ดํ„ฐ) + if not type_filter or type_filter == "document_content": + content_results = await search_document_content(q, document_id, current_user, db) + results.extend(content_results) + + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜๋กœ ์ •๋ ฌ + results.sort(key=lambda x: x.relevance_score, reverse=True) + + # ํŽ˜์ด์ง€๋„ค์ด์…˜ + total_results = len(results) + paginated_results = results[skip:skip + limit] + + # ํŒจ์‹ฏ ์ •๋ณด ์ƒ์„ฑ + facets = await generate_search_facets(results, current_user, db) + + return SearchResponse( + query=q, + total_results=total_results, + results=paginated_results, + facets=facets + ) + + +async def search_documents( + query: str, + document_id: Optional[str], + tag: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฌธ์„œ ๊ฒ€์ƒ‰""" + query_obj = select(Document).options( + selectinload(Document.uploader), + selectinload(Document.tags) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง + if not current_user.is_admin: + query_obj = query_obj.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Document.id == document_id) + + # ํƒœ๊ทธ ํ•„ํ„ฐ + if tag: + query_obj = query_obj.join(Document.tags).where(Tag.name == tag) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ + search_condition = or_( + Document.title.ilike(f"%{query}%"), + Document.description.ilike(f"%{query}%") + ) + query_obj = query_obj.where(search_condition) + + result = await db.execute(query_obj) + documents = result.scalars().all() + + search_results = [] + for doc in documents: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ (์ œ๋ชฉ ๋งค์น˜๊ฐ€ ๋” ๋†’์€ ์ ์ˆ˜) + score = 0.0 + if query.lower() in doc.title.lower(): + score += 2.0 + if doc.description and query.lower() in doc.description.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="document", + id=str(doc.id), + title=doc.title, + content=doc.description or "", + document_id=str(doc.id), + document_title=doc.title, + created_at=doc.created_at, + relevance_score=score + )) + + return search_results + + +async def search_notes( + query: str, + document_id: Optional[str], + tag: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰""" + query_obj = ( + select(Note) + .options( + joinedload(Note.highlight).joinedload(Highlight.document) + ) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Highlight.document_id == document_id) + + # ํƒœ๊ทธ ํ•„ํ„ฐ + if tag: + query_obj = query_obj.where(Note.tags.contains([tag])) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ (๋ฉ”๋ชจ ๋‚ด์šฉ + ํ•˜์ด๋ผ์ดํŠธ๋œ ํ…์ŠคํŠธ) + search_condition = or_( + Note.content.ilike(f"%{query}%"), + Highlight.selected_text.ilike(f"%{query}%") + ) + query_obj = query_obj.where(search_condition) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 0.0 + if query.lower() in note.content.lower(): + score += 2.0 + if query.lower() in note.highlight.selected_text.lower(): + score += 1.5 + + search_results.append(SearchResult( + type="note", + id=str(note.id), + title=f"๋ฉ”๋ชจ: {note.highlight.selected_text[:50]}...", + content=note.content, + document_id=str(note.highlight.document.id), + document_title=note.highlight.document.title, + created_at=note.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset + } + )) + + return search_results + + +async def search_highlights( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """ํ•˜์ด๋ผ์ดํŠธ ๊ฒ€์ƒ‰""" + query_obj = ( + select(Highlight) + .options(joinedload(Highlight.document)) + .where(Highlight.user_id == current_user.id) + ) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Highlight.document_id == document_id) + + # ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ + query_obj = query_obj.where(Highlight.selected_text.ilike(f"%{query}%")) + + result = await db.execute(query_obj) + highlights = result.scalars().all() + + search_results = [] + for highlight in highlights: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 1.0 if query.lower() in highlight.selected_text.lower() else 0.5 + + search_results.append(SearchResult( + type="highlight", + id=str(highlight.id), + title=f"ํ•˜์ด๋ผ์ดํŠธ: {highlight.selected_text[:50]}...", + content=highlight.selected_text, + document_id=str(highlight.document.id), + document_title=highlight.document.title, + created_at=highlight.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(highlight.id), + "selected_text": highlight.selected_text, + "start_offset": highlight.start_offset, + "end_offset": highlight.end_offset, + "highlight_color": highlight.highlight_color + } + )) + + return search_results + + +async def generate_search_facets( + results: List[SearchResult], + current_user: User, + db: AsyncSession +) -> Dict[str, List[Dict[str, Any]]]: + """๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํŒจ์‹ฏ ์ƒ์„ฑ""" + facets = {} + + # ํƒ€์ž…๋ณ„ ๊ฐœ์ˆ˜ + type_counts = {} + for result in results: + type_counts[result.type] = type_counts.get(result.type, 0) + 1 + + facets["types"] = [ + {"name": type_name, "count": count} + for type_name, count in type_counts.items() + ] + + # ๋ฌธ์„œ๋ณ„ ๊ฐœ์ˆ˜ + document_counts = {} + for result in results: + doc_title = result.document_title + document_counts[doc_title] = document_counts.get(doc_title, 0) + 1 + + facets["documents"] = [ + {"name": doc_title, "count": count} + for doc_title, count in sorted(document_counts.items(), key=lambda x: x[1], reverse=True)[:10] + ] + + return facets + + +@router.get("/suggestions") +async def get_search_suggestions( + q: str = Query(..., min_length=2, description="๊ฒ€์ƒ‰์–ด (์ตœ์†Œ 2๊ธ€์ž)"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๊ฒ€์ƒ‰์–ด ์ž๋™์™„์„ฑ ์ œ์•ˆ""" + suggestions = [] + + # ๋ฌธ์„œ ์ œ๋ชฉ์—์„œ ์ œ์•ˆ + doc_result = await db.execute( + select(Document.title) + .where( + and_( + Document.title.ilike(f"%{q}%"), + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) if not current_user.is_admin else text("true") + ) + ) + .limit(5) + ) + doc_titles = doc_result.scalars().all() + suggestions.extend([{"text": title, "type": "document"} for title in doc_titles]) + + # ํƒœ๊ทธ์—์„œ ์ œ์•ˆ + tag_result = await db.execute( + select(Tag.name) + .where(Tag.name.ilike(f"%{q}%")) + .limit(5) + ) + tag_names = tag_result.scalars().all() + suggestions.extend([{"text": name, "type": "tag"} for name in tag_names]) + + # ๋ฉ”๋ชจ ํƒœ๊ทธ์—์„œ ์ œ์•ˆ + note_result = await db.execute( + select(Note.tags) + .join(Highlight) + .where(Highlight.user_id == current_user.id) + ) + notes = note_result.scalars().all() + + note_tags = set() + for note in notes: + if note and isinstance(note, list): + for tag in note: + if q.lower() in tag.lower(): + note_tags.add(tag) + + suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]]) + + return {"suggestions": suggestions[:10]} + + +async def search_highlight_notes( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ๋‚ด์šฉ ๊ฒ€์ƒ‰""" + query_obj = select(Note).options( + selectinload(Note.highlight).selectinload(Highlight.document) + ) + + # ํ•˜์ด๋ผ์ดํŠธ๊ฐ€ ์žˆ๋Š” ๋…ธํŠธ๋งŒ + query_obj = query_obj.where(Note.highlight_id.isnot(None)) + + # Highlight์™€ ์กฐ์ธ (๊ถŒํ•œ ๋ฐ ๋ฌธ์„œ ํ•„ํ„ฐ๋ง์„ ์œ„ํ•ด) + query_obj = query_obj.join(Highlight) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง - ์‚ฌ์šฉ์ž์˜ ๋…ธํŠธ๋งŒ + query_obj = query_obj.where(Highlight.user_id == current_user.id) + + # ํŠน์ • ๋ฌธ์„œ ํ•„ํ„ฐ + if document_id: + query_obj = query_obj.where(Highlight.document_id == document_id) + + # ๋ฉ”๋ชจ ๋‚ด์šฉ์—์„œ ๊ฒ€์ƒ‰ + query_obj = query_obj.where(Note.content.ilike(f"%{query}%")) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + if not note.highlight or not note.highlight.document: + continue + + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 1.5 # ๋ฉ”๋ชจ ๋‚ด์šฉ ๋งค์น˜๋Š” ๋†’์€ ์ ์ˆ˜ + content_lower = (note.content or "").lower() + if query.lower() in content_lower: + score += 2.0 + + search_results.append(SearchResult( + type="highlight_note", + id=str(note.id), + title=f"ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ: {note.highlight.selected_text[:30]}...", + content=note.content or "", + document_id=str(note.highlight.document.id), + document_title=note.highlight.document.title, + created_at=note.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset, + "note_content": note.content + } + )) + + return search_results + + +async def search_note_documents( + query: str, + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋…ธํŠธ ๋ฌธ์„œ ๊ฒ€์ƒ‰""" + query_obj = select(NoteDocument).where( + or_( + NoteDocument.title.ilike(f"%{query}%"), + NoteDocument.content.ilike(f"%{query}%") + ) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง - ์‚ฌ์šฉ์ž์˜ ๋…ธํŠธ๋งŒ + query_obj = query_obj.where(NoteDocument.created_by == current_user.email) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 1.0 + if query.lower() in note.title.lower(): + score += 2.0 + if note.content and query.lower() in note.content.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="note", + id=str(note.id), + title=note.title, + content=note.content or "", + document_id=str(note.id), # ๋…ธํŠธ ์ž์ฒด๊ฐ€ ๋ฌธ์„œ + document_title=note.title, + created_at=note.created_at, + relevance_score=score + )) + + return search_results + + +async def search_memo_nodes( + query: str, + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๋…ธ๋“œ ๊ฒ€์ƒ‰""" + query_obj = select(MemoNode).options( + selectinload(MemoNode.tree) + ).where( + or_( + MemoNode.title.ilike(f"%{query}%"), + MemoNode.content.ilike(f"%{query}%") + ) + ) + + # ๊ถŒํ•œ ํ•„ํ„ฐ๋ง - ์‚ฌ์šฉ์ž์˜ ํŠธ๋ฆฌ์— ์†ํ•œ ๋…ธ๋“œ๋งŒ + query_obj = query_obj.join(MemoTree).where(MemoTree.user_id == current_user.id) + + result = await db.execute(query_obj) + nodes = result.scalars().all() + + search_results = [] + for node in nodes: + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 1.0 + if query.lower() in node.title.lower(): + score += 2.0 + if node.content and query.lower() in node.content.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="memo", + id=str(node.id), + title=node.title, + content=node.content or "", + document_id=str(node.tree.id), # ํŠธ๋ฆฌ ID๋ฅผ ๋ฌธ์„œ ID๋กœ ์‚ฌ์šฉ + document_title=f"๐Ÿ“š {node.tree.title}", + created_at=node.created_at, + relevance_score=score + )) + + return search_results + + +async def search_document_content( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """๋ฌธ์„œ ๋ณธ๋ฌธ ๋‚ด์šฉ ๊ฒ€์ƒ‰ (OCR ๋ฐ์ดํ„ฐ ํฌํ•จ)""" + # ๋ฌธ์„œ ๊ถŒํ•œ ํ™•์ธ + doc_query = select(Document) + if not current_user.is_admin: + doc_query = doc_query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + if document_id: + doc_query = doc_query.where(Document.id == document_id) + + result = await db.execute(doc_query) + documents = result.scalars().all() + + search_results = [] + + for doc in documents: + text_content = "" + file_type = "" + + # HTML ํŒŒ์ผ์—์„œ ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ (PDF OCR ๊ฒฐ๊ณผ ๋˜๋Š” ์„œ์  HTML) + if doc.html_path: + try: + import os + from bs4 import BeautifulSoup + + # ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ฒ˜๋ฆฌ + if doc.html_path.startswith('/'): + html_file_path = doc.html_path + else: + html_file_path = os.path.join("/app", doc.html_path) + + if os.path.exists(html_file_path): + with open(html_file_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # HTML์—์„œ ํ…์ŠคํŠธ ์ถ”์ถœ + soup = BeautifulSoup(html_content, 'html.parser') + text_content = soup.get_text() + + # PDF์ธ์ง€ ์„œ์ ์ธ์ง€ ๊ตฌ๋ถ„ + if doc.pdf_path: + file_type = "PDF" + else: + file_type = "HTML" + + except Exception as e: + print(f"HTML ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜ ({doc.html_path}): {e}") + continue + + # PDF ํŒŒ์ผ ์ง์ ‘ ํ…์ŠคํŠธ ์ถ”์ถœ (HTML์ด ์—†๋Š” ๊ฒฝ์šฐ) + elif doc.pdf_path: + try: + import os + import PyPDF2 + + # ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ฒ˜๋ฆฌ + if doc.pdf_path.startswith('/'): + pdf_file_path = doc.pdf_path + else: + pdf_file_path = os.path.join("/app", doc.pdf_path) + + if os.path.exists(pdf_file_path): + with open(pdf_file_path, 'rb') as f: + pdf_reader = PyPDF2.PdfReader(f) + text_pages = [] + + # ๋ชจ๋“  ํŽ˜์ด์ง€์—์„œ ํ…์ŠคํŠธ ์ถ”์ถœ + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + page_text = page.extract_text() + if page_text.strip(): + text_pages.append(f"[ํŽ˜์ด์ง€ {page_num + 1}]\n{page_text}") + + text_content = "\n\n".join(text_pages) + file_type = "PDF (์ง์ ‘์ถ”์ถœ)" + + except Exception as e: + print(f"PDF ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜ ({doc.pdf_path}): {e}") + continue + + # ๊ฒ€์ƒ‰์–ด๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ + if text_content and query.lower() in text_content.lower(): + # ๊ฒ€์ƒ‰์–ด ์ฃผ๋ณ€ ์ปจํ…์ŠคํŠธ ์ถ”์ถœ + context = extract_search_context(text_content, query, context_length=300) + + # ๊ด€๋ จ์„ฑ ์ ์ˆ˜ ๊ณ„์‚ฐ + score = 2.0 # ๋ณธ๋ฌธ ๋งค์น˜๋Š” ๋†’์€ ์ ์ˆ˜ + + # ๊ฒ€์ƒ‰์–ด ๋งค์น˜ ํšŸ์ˆ˜๋กœ ์ ์ˆ˜ ์กฐ์ • + match_count = text_content.lower().count(query.lower()) + score += min(match_count * 0.1, 1.0) # ์ตœ๋Œ€ 1์  ์ถ”๊ฐ€ + + search_results.append(SearchResult( + type="document_content", + id=str(doc.id), + title=f"๐Ÿ“„ {doc.title} ({file_type} ๋ณธ๋ฌธ)", + content=context, + document_id=str(doc.id), + document_title=doc.title, + created_at=doc.created_at, + relevance_score=score, + highlight_info={ + "file_type": file_type, + "match_count": match_count, + "has_pdf": bool(doc.pdf_path), + "has_html": bool(doc.html_path) + } + )) + + return search_results + + +def extract_search_context(text: str, query: str, context_length: int = 200) -> str: + """๊ฒ€์ƒ‰์–ด ์ฃผ๋ณ€ ์ปจํ…์ŠคํŠธ ์ถ”์ถœ""" + text_lower = text.lower() + query_lower = query.lower() + + # ์ฒซ ๋ฒˆ์งธ ๋งค์น˜ ์œ„์น˜ ์ฐพ๊ธฐ + match_pos = text_lower.find(query_lower) + if match_pos == -1: + return text[:context_length] + "..." + + # ์ปจํ…์ŠคํŠธ ์‹œ์ž‘/๋ ์œ„์น˜ ๊ณ„์‚ฐ + start = max(0, match_pos - context_length // 2) + end = min(len(text), match_pos + len(query) + context_length // 2) + + context = text[start:end] + + # ์•ž๋’ค์— ... ์ถ”๊ฐ€ + if start > 0: + context = "..." + context + if end < len(text): + context = context + "..." + + return context diff --git a/backend/src/api/routes/setup.py b/backend/src/api/routes/setup.py new file mode 100644 index 0000000..6b90ae0 --- /dev/null +++ b/backend/src/api/routes/setup.py @@ -0,0 +1,104 @@ +""" +์‹œ์Šคํ…œ ์ดˆ๊ธฐ ์„ค์ • API +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel, EmailStr +from typing import Optional + +from ...core.database import get_db +from ...core.security import get_password_hash +from ...models.user import User + +router = APIRouter() + + +class InitialSetupRequest(BaseModel): + """์ดˆ๊ธฐ ์„ค์ • ์š”์ฒญ""" + admin_email: EmailStr + admin_password: str + admin_full_name: Optional[str] = None + + +class SetupStatusResponse(BaseModel): + """์„ค์ • ์ƒํƒœ ์‘๋‹ต""" + is_setup_required: bool + has_admin_user: bool + total_users: int + + +@router.get("/status", response_model=SetupStatusResponse) +async def get_setup_status(db: AsyncSession = Depends(get_db)): + """์‹œ์Šคํ…œ ์„ค์ • ์ƒํƒœ ํ™•์ธ""" + # ์ „์ฒด ์‚ฌ์šฉ์ž ์ˆ˜ ์กฐํšŒ + total_users_result = await db.execute(select(func.count(User.id))) + total_users = total_users_result.scalar() + + # ๊ด€๋ฆฌ์ž ์‚ฌ์šฉ์ž ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ + admin_result = await db.execute( + select(User).where(User.role == "root") + ) + has_admin_user = admin_result.scalar_one_or_none() is not None + + return SetupStatusResponse( + is_setup_required=total_users == 0 or not has_admin_user, + has_admin_user=has_admin_user, + total_users=total_users + ) + + +@router.post("/initialize") +async def initialize_system( + setup_data: InitialSetupRequest, + db: AsyncSession = Depends(get_db) +): + """์‹œ์Šคํ…œ ์ดˆ๊ธฐ ์„ค์ • (root ๊ณ„์ • ์ƒ์„ฑ)""" + # ์ด๋ฏธ ์„ค์ •๋œ ์‹œ์Šคํ…œ์ธ์ง€ ํ™•์ธ + existing_admin = await db.execute( + select(User).where(User.role == "root") + ) + if existing_admin.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="System is already initialized" + ) + + # ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ + existing_user = await db.execute( + select(User).where(User.email == setup_data.admin_email) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Root ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + hashed_password = get_password_hash(setup_data.admin_password) + + admin_user = User( + email=setup_data.admin_email, + hashed_password=hashed_password, + full_name=setup_data.admin_full_name or "์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž", + is_active=True, + is_admin=True, + role="root", + can_manage_books=True, + can_manage_notes=True, + can_manage_novels=True + ) + + db.add(admin_user) + await db.commit() + await db.refresh(admin_user) + + return { + "message": "System initialized successfully", + "admin_user": { + "id": str(admin_user.id), + "email": admin_user.email, + "full_name": admin_user.full_name, + "role": admin_user.role + } + } diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py new file mode 100644 index 0000000..6ad323d --- /dev/null +++ b/backend/src/api/routes/todos.py @@ -0,0 +1,663 @@ +""" +ํ• ์ผ๊ด€๋ฆฌ ์‹œ์Šคํ…œ API ๋ผ์šฐํ„ฐ +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_ +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime, timedelta +from uuid import UUID + +from ...core.database import get_db +from ...models.user import User +from ...models.todo import TodoItem, TodoComment +from ...schemas.todo import ( + TodoItemCreate, TodoItemSchedule, TodoItemUpdate, TodoItemDelay, TodoItemSplit, + TodoItemResponse, TodoItemWithComments, TodoCommentCreate, TodoCommentUpdate, + TodoCommentResponse, TodoStats, TodoDashboard +) +from ..dependencies import get_current_active_user + +router = APIRouter(prefix="/todos", tags=["todos"]) + + +# ============================================================================ +# ํ• ์ผ ์•„์ดํ…œ ๊ด€๋ฆฌ +# ============================================================================ + +@router.post("/", response_model=TodoItemResponse) +async def create_todo_item( + todo_data: TodoItemCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์ƒˆ ํ• ์ผ ์ƒ์„ฑ (draft ์ƒํƒœ)""" + try: + new_todo = TodoItem( + user_id=current_user.id, + content=todo_data.content, + status="draft" + ) + + db.add(new_todo) + await db.commit() + await db.refresh(new_todo) + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_data = TodoItemResponse( + id=new_todo.id, + user_id=new_todo.user_id, + content=new_todo.content, + status=new_todo.status, + created_at=new_todo.created_at, + start_date=new_todo.start_date, + estimated_minutes=new_todo.estimated_minutes, + completed_at=new_todo.completed_at, + delayed_until=new_todo.delayed_until, + parent_id=new_todo.parent_id, + split_order=new_todo.split_order, + comment_count=0 + ) + + return response_data + + except Exception as e: + await db.rollback() + print(f"ERROR in create_todo_item: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create todo item: {str(e)}" + ) + + +@router.post("/{todo_id}/schedule", response_model=TodoItemResponse) +async def schedule_todo_item( + todo_id: UUID, + schedule_data: TodoItemSchedule, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ์ผ์ • ์„ค์ • (draft -> scheduled)""" + try: + # ํ• ์ผ ์กฐํšŒ + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id, + TodoItem.status == "draft" + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found or not in draft status" + ) + + # 2์‹œ๊ฐ„ ์ด์ƒ์ธ ๊ฒฝ์šฐ ๋ถ„ํ•  ์ œ์•ˆ + if schedule_data.estimated_minutes > 120: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tasks longer than 2 hours should be split into smaller tasks" + ) + + # ์ผ์ • ์„ค์ • + todo_item.start_date = schedule_data.start_date + todo_item.estimated_minutes = schedule_data.estimated_minutes + todo_item.status = "scheduled" + + await db.commit() + await db.refresh(todo_item) + + # ๋Œ“๊ธ€ ์ˆ˜ ๊ณ„์‚ฐ + comment_count_result = await db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id) + ) + comment_count = comment_count_result.scalar() or 0 + + response_data = TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + ) + + return response_data + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in schedule_todo_item: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to schedule todo item: {str(e)}" + ) + + +@router.post("/{todo_id}/split", response_model=List[TodoItemResponse]) +async def split_todo_item( + todo_id: UUID, + split_data: TodoItemSplit, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ๋ถ„ํ• """ + try: + # ์›๋ณธ ํ• ์ผ ์กฐํšŒ + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id, + TodoItem.status == "draft" + ) + ) + ) + original_todo = result.scalar_one_or_none() + + if not original_todo: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found or not in draft status" + ) + + # ๋ถ„ํ• ๋œ ํ• ์ผ๋“ค ์ƒ์„ฑ + subtasks = [] + for i, (subtask_content, estimated_minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)): + if estimated_minutes > 120: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Subtask {i+1} is longer than 2 hours" + ) + + subtask = TodoItem( + user_id=current_user.id, + content=subtask_content, + status="draft", + parent_id=original_todo.id, + split_order=i + 1 + ) + db.add(subtask) + subtasks.append(subtask) + + # ์›๋ณธ ํ• ์ผ ์ƒํƒœ ๋ณ€๊ฒฝ (๋ถ„ํ• ๋จ ํ‘œ์‹œ) + original_todo.status = "split" + + await db.commit() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_data = [] + for subtask in subtasks: + await db.refresh(subtask) + response_data.append(TodoItemResponse( + id=subtask.id, + user_id=subtask.user_id, + content=subtask.content, + status=subtask.status, + created_at=subtask.created_at, + start_date=subtask.start_date, + estimated_minutes=subtask.estimated_minutes, + completed_at=subtask.completed_at, + delayed_until=subtask.delayed_until, + parent_id=subtask.parent_id, + split_order=subtask.split_order, + comment_count=0 + )) + + return response_data + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in split_todo_item: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to split todo item: {str(e)}" + ) + + +@router.get("/", response_model=List[TodoItemResponse]) +async def get_todo_items( + status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ๋ชฉ๋ก ์กฐํšŒ""" + try: + query = select(TodoItem).where(TodoItem.user_id == current_user.id) + + if status: + query = query.where(TodoItem.status == status) + + query = query.order_by(TodoItem.created_at.desc()) + + result = await db.execute(query) + todo_items = result.scalars().all() + + # ๊ฐ ํ• ์ผ์˜ ๋Œ“๊ธ€ ์ˆ˜ ๊ณ„์‚ฐ + response_data = [] + for todo_item in todo_items: + comment_count_result = await db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id) + ) + comment_count = comment_count_result.scalar() or 0 + + response_data.append(TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + )) + + return response_data + + except Exception as e: + print(f"ERROR in get_todo_items: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get todo items: {str(e)}" + ) + + +@router.get("/active", response_model=List[TodoItemResponse]) +async def get_active_todos( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """์˜ค๋Š˜ ํ™œ์„ฑํ™”๋œ ํ• ์ผ๋“ค ์กฐํšŒ""" + try: + now = datetime.utcnow() + + # scheduled ์ƒํƒœ์ด๋ฉด์„œ ์‹œ์ž‘์ผ์ด ์ง€๋‚œ ๊ฒƒ๋“ค์„ active๋กœ ๋ณ€๊ฒฝ + update_result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.user_id == current_user.id, + TodoItem.status == "scheduled", + TodoItem.start_date <= now + ) + ) + ) + scheduled_items = update_result.scalars().all() + + for item in scheduled_items: + item.status = "active" + + if scheduled_items: + await db.commit() + + # active ์ƒํƒœ์ธ ํ• ์ผ๋“ค ์กฐํšŒ + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.user_id == current_user.id, + TodoItem.status == "active" + ) + ).order_by(TodoItem.start_date.asc()) + ) + active_todos = result.scalars().all() + + # ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + response_data = [] + for todo_item in active_todos: + comment_count_result = await db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id) + ) + comment_count = comment_count_result.scalar() or 0 + + response_data.append(TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + )) + + return response_data + + except Exception as e: + print(f"ERROR in get_active_todos: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get active todos: {str(e)}" + ) + + +@router.put("/{todo_id}/complete", response_model=TodoItemResponse) +async def complete_todo_item( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ์™„๋ฃŒ""" + try: + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id, + TodoItem.status == "active" + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found or not active" + ) + + todo_item.status = "completed" + todo_item.completed_at = datetime.utcnow() + + await db.commit() + await db.refresh(todo_item) + + # ๋Œ“๊ธ€ ์ˆ˜ ๊ณ„์‚ฐ + comment_count_result = await db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id) + ) + comment_count = comment_count_result.scalar() or 0 + + response_data = TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + ) + + return response_data + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in complete_todo_item: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to complete todo item: {str(e)}" + ) + + +@router.put("/{todo_id}/delay", response_model=TodoItemResponse) +async def delay_todo_item( + todo_id: UUID, + delay_data: TodoItemDelay, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ์ง€์—ฐ""" + try: + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id, + TodoItem.status == "active" + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found or not active" + ) + + todo_item.status = "delayed" + todo_item.delayed_until = delay_data.delayed_until + todo_item.start_date = delay_data.delayed_until # ์ƒˆ๋กœ์šด ์‹œ์ž‘์ผ๋กœ ์—…๋ฐ์ดํŠธ + + await db.commit() + await db.refresh(todo_item) + + # ๋Œ“๊ธ€ ์ˆ˜ ๊ณ„์‚ฐ + comment_count_result = await db.execute( + select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_item.id) + ) + comment_count = comment_count_result.scalar() or 0 + + response_data = TodoItemResponse( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=comment_count + ) + + return response_data + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in delay_todo_item: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delay todo item: {str(e)}" + ) + + +# ============================================================================ +# ๋Œ“๊ธ€ ๊ด€๋ฆฌ +# ============================================================================ + +@router.post("/{todo_id}/comments", response_model=TodoCommentResponse) +async def create_todo_comment( + todo_id: UUID, + comment_data: TodoCommentCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ์— ๋Œ“๊ธ€ ์ถ”๊ฐ€""" + try: + # ํ• ์ผ ์กด์žฌ ํ™•์ธ + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found" + ) + + new_comment = TodoComment( + todo_item_id=todo_id, + user_id=current_user.id, + content=comment_data.content + ) + + db.add(new_comment) + await db.commit() + await db.refresh(new_comment) + + return TodoCommentResponse( + id=new_comment.id, + todo_item_id=new_comment.todo_item_id, + user_id=new_comment.user_id, + content=new_comment.content, + created_at=new_comment.created_at, + updated_at=new_comment.updated_at + ) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + print(f"ERROR in create_todo_comment: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create todo comment: {str(e)}" + ) + + +@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse]) +async def get_todo_comments( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ• ์ผ ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ""" + try: + # ํ• ์ผ ์กด์žฌ ํ™•์ธ + result = await db.execute( + select(TodoItem).where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found" + ) + + # ๋Œ“๊ธ€ ์กฐํšŒ + result = await db.execute( + select(TodoComment).where(TodoComment.todo_item_id == todo_id) + .order_by(TodoComment.created_at.asc()) + ) + comments = result.scalars().all() + + return [ + TodoCommentResponse( + id=comment.id, + todo_item_id=comment.todo_item_id, + user_id=comment.user_id, + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at + ) + for comment in comments + ] + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_todo_comments: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get todo comments: {str(e)}" + ) + + +@router.get("/{todo_id}", response_model=TodoItemWithComments) +async def get_todo_item_with_comments( + todo_id: UUID, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """๋Œ“๊ธ€์ด ํฌํ•จ๋œ ํ• ์ผ ์ƒ์„ธ ์กฐํšŒ""" + try: + # ํ• ์ผ ์กฐํšŒ + result = await db.execute( + select(TodoItem).options(selectinload(TodoItem.comments)) + .where( + and_( + TodoItem.id == todo_id, + TodoItem.user_id == current_user.id + ) + ) + ) + todo_item = result.scalar_one_or_none() + + if not todo_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Todo item not found" + ) + + # ๋Œ“๊ธ€ ๋ฐ์ดํ„ฐ ๊ตฌ์„ฑ + comments = [ + TodoCommentResponse( + id=comment.id, + todo_item_id=comment.todo_item_id, + user_id=comment.user_id, + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at + ) + for comment in todo_item.comments + ] + + return TodoItemWithComments( + id=todo_item.id, + user_id=todo_item.user_id, + content=todo_item.content, + status=todo_item.status, + created_at=todo_item.created_at, + start_date=todo_item.start_date, + estimated_minutes=todo_item.estimated_minutes, + completed_at=todo_item.completed_at, + delayed_until=todo_item.delayed_until, + parent_id=todo_item.parent_id, + split_order=todo_item.split_order, + comment_count=len(comments), + comments=comments + ) + + except HTTPException: + raise + except Exception as e: + print(f"ERROR in get_todo_item_with_comments: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get todo item with comments: {str(e)}" + ) diff --git a/backend/src/api/routes/users.py b/backend/src/api/routes/users.py new file mode 100644 index 0000000..84f7737 --- /dev/null +++ b/backend/src/api/routes/users.py @@ -0,0 +1,402 @@ +""" +์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from sqlalchemy.orm import selectinload +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from datetime import datetime + +from ...core.database import get_db +from ...core.security import get_password_hash, verify_password +from ...models.user import User +from ..dependencies import get_current_active_user, get_current_admin_user + +router = APIRouter() + + +class UserResponse(BaseModel): + """์‚ฌ์šฉ์ž ์‘๋‹ต""" + id: str + email: str + full_name: Optional[str] + is_active: bool + is_admin: bool + role: str + can_manage_books: bool + can_manage_notes: bool + can_manage_novels: bool + session_timeout_minutes: int + theme: str + language: str + timezone: str + created_at: datetime + updated_at: Optional[datetime] + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +class CreateUserRequest(BaseModel): + """์‚ฌ์šฉ์ž ์ƒ์„ฑ ์š”์ฒญ""" + email: EmailStr + password: str + full_name: Optional[str] = None + role: str = "user" + can_manage_books: bool = True + can_manage_notes: bool = True + can_manage_novels: bool = True + session_timeout_minutes: int = 5 + + +class UpdateUserRequest(BaseModel): + """์‚ฌ์šฉ์ž ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + full_name: Optional[str] = None + is_active: Optional[bool] = None + role: Optional[str] = None + can_manage_books: Optional[bool] = None + can_manage_notes: Optional[bool] = None + can_manage_novels: Optional[bool] = None + session_timeout_minutes: Optional[int] = None + + +class UpdateProfileRequest(BaseModel): + """ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + full_name: Optional[str] = None + theme: Optional[str] = None + language: Optional[str] = None + timezone: Optional[str] = None + + +class ChangePasswordRequest(BaseModel): + """๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ""" + current_password: str + new_password: str + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_profile( + current_user: User = Depends(get_current_active_user) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ""" + return UserResponse( + id=str(current_user.id), + email=current_user.email, + full_name=current_user.full_name, + is_active=current_user.is_active, + is_admin=current_user.is_admin, + role=current_user.role, + can_manage_books=current_user.can_manage_books, + can_manage_notes=current_user.can_manage_notes, + can_manage_novels=current_user.can_manage_novels, + session_timeout_minutes=current_user.session_timeout_minutes, + theme=current_user.theme, + language=current_user.language, + timezone=current_user.timezone, + created_at=current_user.created_at, + updated_at=current_user.updated_at, + last_login=current_user.last_login + ) + + +@router.put("/me", response_model=UserResponse) +async def update_current_user_profile( + profile_data: UpdateProfileRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ""" + update_fields = profile_data.model_dump(exclude_unset=True) + + for field, value in update_fields.items(): + setattr(current_user, field, value) + + current_user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(current_user) + + return UserResponse( + id=str(current_user.id), + email=current_user.email, + full_name=current_user.full_name, + is_active=current_user.is_active, + is_admin=current_user.is_admin, + role=current_user.role, + can_manage_books=current_user.can_manage_books, + can_manage_notes=current_user.can_manage_notes, + can_manage_novels=current_user.can_manage_novels, + session_timeout_minutes=current_user.session_timeout_minutes, + theme=current_user.theme, + language=current_user.language, + timezone=current_user.timezone, + created_at=current_user.created_at, + updated_at=current_user.updated_at, + last_login=current_user.last_login + ) + + +@router.post("/me/change-password") +async def change_current_user_password( + password_data: ChangePasswordRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """ํ˜„์žฌ ์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ""" + # ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect" + ) + + # ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ์„ค์ • + current_user.hashed_password = get_password_hash(password_data.new_password) + current_user.updated_at = datetime.utcnow() + + await db.commit() + + return {"message": "Password changed successfully"} + + +@router.get("/", response_model=List[UserResponse]) +async def list_users( + skip: int = 0, + limit: int = 50, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute( + select(User) + .order_by(User.created_at.desc()) + .offset(skip) + .limit(limit) + ) + users = result.scalars().all() + + return [ + UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) + for user in users + ] + + +@router.post("/", response_model=UserResponse) +async def create_user( + user_data: CreateUserRequest, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์ƒ์„ฑ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + # ์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ + existing_user = await db.execute( + select(User).where(User.email == user_data.email) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # ๊ถŒํ•œ ํ™•์ธ (root๋งŒ admin/root ๊ณ„์ • ์ƒ์„ฑ ๊ฐ€๋Šฅ) + if user_data.role in ["admin", "root"] and current_user.role != "root": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can create admin accounts" + ) + + # ์‚ฌ์šฉ์ž ์ƒ์„ฑ + hashed_password = get_password_hash(user_data.password) + + new_user = User( + email=user_data.email, + hashed_password=hashed_password, + full_name=user_data.full_name, + is_active=True, + is_admin=user_data.role in ["admin", "root"], + role=user_data.role, + can_manage_books=user_data.can_manage_books, + can_manage_notes=user_data.can_manage_notes, + can_manage_novels=user_data.can_manage_novels, + session_timeout_minutes=user_data.session_timeout_minutes + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return UserResponse( + id=str(new_user.id), + email=new_user.email, + full_name=new_user.full_name, + is_active=new_user.is_active, + is_admin=new_user.is_admin, + role=new_user.role, + can_manage_books=new_user.can_manage_books, + can_manage_notes=new_user.can_manage_notes, + can_manage_novels=new_user.can_manage_novels, + session_timeout_minutes=new_user.session_timeout_minutes, + theme=new_user.theme, + language=new_user.language, + timezone=new_user.timezone, + created_at=new_user.created_at, + updated_at=new_user.updated_at, + last_login=new_user.last_login + ) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: str, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์ƒ์„ธ ์กฐํšŒ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) + + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + user_data: UpdateUserRequest, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์ •๋ณด ์—…๋ฐ์ดํŠธ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # ๊ถŒํ•œ ํ™•์ธ (root๋งŒ admin/root ๊ณ„์ • ์ˆ˜์ • ๊ฐ€๋Šฅ) + if user.role in ["admin", "root"] and current_user.role != "root": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can modify admin accounts" + ) + + # ์—…๋ฐ์ดํŠธํ•  ํ•„๋“œ๋“ค ์ ์šฉ + update_fields = user_data.model_dump(exclude_unset=True) + + for field, value in update_fields.items(): + if field == "role": + # ์—ญํ•  ๋ณ€๊ฒฝ ์‹œ is_admin๋„ ํ•จ๊ป˜ ์—…๋ฐ์ดํŠธ + setattr(user, field, value) + user.is_admin = value in ["admin", "root"] + else: + setattr(user, field, value) + + user.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(user) + + return UserResponse( + id=str(user.id), + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_admin=user.is_admin, + role=user.role, + can_manage_books=user.can_manage_books, + can_manage_notes=user.can_manage_notes, + can_manage_novels=user.can_manage_novels, + session_timeout_minutes=user.session_timeout_minutes, + theme=user.theme, + language=user.language, + timezone=user.timezone, + created_at=user.created_at, + updated_at=user.updated_at, + last_login=user.last_login + ) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + current_user: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_db) +): + """์‚ฌ์šฉ์ž ์‚ญ์ œ (๊ด€๋ฆฌ์ž ์ „์šฉ)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # ์ž๊ธฐ ์ž์‹  ์‚ญ์ œ ๋ฐฉ์ง€ + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account" + ) + + # root ๊ณ„์ • ์‚ญ์ œ ๋ฐฉ์ง€ + if user.role == "root": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete root account" + ) + + # ๊ถŒํ•œ ํ™•์ธ (root๋งŒ admin ๊ณ„์ • ์‚ญ์ œ ๊ฐ€๋Šฅ) + if user.role == "admin" and current_user.role != "root": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only root users can delete admin accounts" + ) + + await db.execute(delete(User).where(User.id == user_id)) + await db.commit() + + return {"message": "User deleted successfully"} \ No newline at end of file diff --git a/backend/src/core/config.py b/backend/src/core/config.py new file mode 100644 index 0000000..2c07451 --- /dev/null +++ b/backend/src/core/config.py @@ -0,0 +1,53 @@ +""" +์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • +""" +from pydantic_settings import BaseSettings +from typing import List +import os + + +class Settings(BaseSettings): + """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ • ํด๋ž˜์Šค""" + + # ๊ธฐ๋ณธ ์„ค์ • + APP_NAME: str = "Document Server" + DEBUG: bool = True + VERSION: str = "0.1.0" + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • + DATABASE_URL: str = "postgresql+asyncpg://docuser:docpass@localhost:24101/document_db" + + # Redis ์„ค์ • + REDIS_URL: str = "redis://localhost:24103/0" + + # JWT ์„ค์ • + SECRET_KEY: str = "your-secret-key-change-this-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS ์„ค์ • + ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"] + ALLOWED_ORIGINS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"] + + # ํŒŒ์ผ ์—…๋กœ๋“œ ์„ค์ • + UPLOAD_DIR: str = "uploads" + MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB + ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"] + + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์„ค์ • (์ดˆ๊ธฐ ์„ค์ •์šฉ) + ADMIN_EMAIL: str = "admin@document-server.local" + ADMIN_PASSWORD: str = "admin123" # ํ”„๋กœ๋•์…˜์—์„œ๋Š” ๋ฐ˜๋“œ์‹œ ๋ณ€๊ฒฝ + + class Config: + env_file = ".env" + case_sensitive = True + + +# ์„ค์ • ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ +settings = Settings() + +# ์—…๋กœ๋“œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +os.makedirs(settings.UPLOAD_DIR, exist_ok=True) +os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True) +os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True) diff --git a/backend/src/core/database.py b/backend/src/core/database.py new file mode 100644 index 0000000..46eecbc --- /dev/null +++ b/backend/src/core/database.py @@ -0,0 +1,122 @@ +""" +๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • ๋ฐ ์—ฐ๊ฒฐ +""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session +from sqlalchemy import MetaData, create_engine +from typing import AsyncGenerator, Generator + +from .config import settings + + +# SQLAlchemy ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์„ค์ • +metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + } +) + + +class Base(DeclarativeBase): + """SQLAlchemy Base ํด๋ž˜์Šค""" + metadata = metadata + + +# ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—”์ง„ ์ƒ์„ฑ +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + future=True, + pool_pre_ping=True, + pool_recycle=300, +) + +# ๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—”์ง„ ์ƒ์„ฑ (๋…ธํŠธ API์šฉ) +sync_database_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") +sync_engine = create_engine( + sync_database_url, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_recycle=300, +) + +# ๋น„๋™๊ธฐ ์„ธ์…˜ ํŒฉํ† ๋ฆฌ +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + +# ๋™๊ธฐ ์„ธ์…˜ ํŒฉํ† ๋ฆฌ +SyncSessionLocal = sessionmaker( + sync_engine, + class_=Session, + expire_on_commit=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ธ์…˜ ์˜์กด์„ฑ""" + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +def get_sync_db() -> Generator[Session, None, None]: + """๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ธ์…˜ ์˜์กด์„ฑ (๋…ธํŠธ API์šฉ)""" + session = SyncSessionLocal() + try: + yield session + except Exception: + session.rollback() + raise + finally: + session.close() + + +async def init_db() -> None: + """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™”""" + from ..models import user, document, highlight, note, bookmark + + async with engine.begin() as conn: + # ๋ชจ๋“  ํ…Œ์ด๋ธ” ์ƒ์„ฑ + await conn.run_sync(Base.metadata.create_all) + + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + await create_admin_user() + + +async def create_admin_user() -> None: + """๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ (์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ)""" + from ..models.user import User + from .security import get_password_hash + from sqlalchemy import select + + async with AsyncSessionLocal() as session: + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์กด์žฌ ํ™•์ธ + result = await session.execute( + select(User).where(User.email == settings.ADMIN_EMAIL) + ) + admin_user = result.scalar_one_or_none() + + if not admin_user: + # ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ + admin_user = User( + email=settings.ADMIN_EMAIL, + hashed_password=get_password_hash(settings.ADMIN_PASSWORD), + is_active=True, + is_admin=True, + full_name="Administrator" + ) + session.add(admin_user) + await session.commit() + print(f"๊ด€๋ฆฌ์ž ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {settings.ADMIN_EMAIL}") diff --git a/backend/src/core/security.py b/backend/src/core/security.py new file mode 100644 index 0000000..2f26036 --- /dev/null +++ b/backend/src/core/security.py @@ -0,0 +1,94 @@ +""" +๋ณด์•ˆ ๊ด€๋ จ ์œ ํ‹ธ๋ฆฌํ‹ฐ +""" +from datetime import datetime, timedelta +from typing import Optional, Union +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, status + +from .config import settings + + +# ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ ์ปจํ…์ŠคํŠธ +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str: + """์•ก์„ธ์Šค ํ† ํฐ ์ƒ์„ฑ""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + elif timeout_minutes is not None: + if timeout_minutes == 0: + # ๋ฌด์ œํ•œ ํ† ํฐ (1๋…„์œผ๋กœ ์„ค์ •) + expire = datetime.utcnow() + timedelta(days=365) + else: + expire = datetime.utcnow() + timedelta(minutes=timeout_minutes) + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict) -> str: + """๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์ƒ์„ฑ""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def verify_token(token: str, token_type: str = "access") -> dict: + """ํ† ํฐ ๊ฒ€์ฆ""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + # ํ† ํฐ ํƒ€์ž… ํ™•์ธ + if payload.get("type") != token_type: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type" + ) + + # ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ํ™•์ธ + exp = payload.get("exp") + if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired" + ) + + return payload + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + +def get_user_id_from_token(token: str) -> str: + """ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ID ์ถ”์ถœ""" + payload = verify_token(token) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + return user_id diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..9d32862 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,86 @@ +""" +Document Server - FastAPI Main Application +""" +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import uvicorn + +from .core.config import settings +from .core.database import init_db +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup, todos +from .api.routes import note_documents, note_links + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘/์ข…๋ฃŒ ์‹œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜""" + # ์‹œ์ž‘ ์‹œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” + await init_db() + yield + # ์ข…๋ฃŒ ์‹œ ์ •๋ฆฌ ์ž‘์—… (ํ•„์š”์‹œ) + + +# FastAPI ์•ฑ ์ƒ์„ฑ +app = FastAPI( + title="Document Server", + description="HTML Document Management and Viewer System", + version="0.1.0", + lifespan=lifespan, +) + +# CORS ์„ค์ • +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_ORIGINS if hasattr(settings, 'ALLOWED_ORIGINS') else ["*"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], +) + +# ์ •์  ํŒŒ์ผ ์„œ๋น™ (์—…๋กœ๋“œ๋œ ํŒŒ์ผ๋“ค) +app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") + +# API ๋ผ์šฐํ„ฐ ๋“ฑ๋ก +app.include_router(setup.router, prefix="/api/setup", tags=["์‹œ์Šคํ…œ ์„ค์ •"]) +app.include_router(auth.router, prefix="/api/auth", tags=["์ธ์ฆ"]) +app.include_router(users.router, prefix="/api/users", tags=["์‚ฌ์šฉ์ž"]) +app.include_router(documents.router, prefix="/api/documents", tags=["๋ฌธ์„œ"]) +app.include_router(highlights.router, prefix="/api/highlights", tags=["ํ•˜์ด๋ผ์ดํŠธ"]) +app.include_router(notes.router, prefix="/api/highlight-notes", tags=["ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ"]) +app.include_router(books.router, prefix="/api/books", tags=["์„œ์ "]) +app.include_router(book_categories.router, prefix="/api/book-categories", tags=["์„œ์  ์†Œ๋ถ„๋ฅ˜"]) +app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["์ฑ…๊ฐˆํ”ผ"]) +app.include_router(search.router, prefix="/api/search", tags=["๊ฒ€์ƒ‰"]) +app.include_router(memo_trees.router, prefix="/api", tags=["ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ"]) +app.include_router(document_links.router, prefix="/api/documents", tags=["๋ฌธ์„œ ๋งํฌ"]) +# ๋งํฌ ์‚ญ์ œ๋ฅผ ์œ„ํ•œ ์ถ”๊ฐ€ ๋ผ์šฐํ„ฐ (document-links ๊ฒฝ๋กœ ์ง€์›) +app.include_router(document_links.router, prefix="/api", tags=["๋ฌธ์„œ ๋งํฌ (ํ˜ธํ™˜์„ฑ)"]) +app.include_router(note_documents.router, prefix="/api/note-documents", tags=["๋…ธํŠธ ๋ฌธ์„œ"]) +app.include_router(note_links.router, prefix="/api", tags=["๋…ธํŠธ ๋งํฌ"]) +app.include_router(notebooks.router, prefix="/api/notebooks", tags=["๋…ธํŠธ๋ถ"]) +app.include_router(note_highlights.router, prefix="/api", tags=["๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ"]) +app.include_router(note_notes.router, prefix="/api", tags=["๋…ธํŠธ ๋ฉ”๋ชจ"]) +app.include_router(todos.router, prefix="/api", tags=["ํ• ์ผ๊ด€๋ฆฌ"]) + + +@app.get("/") +async def root(): + """๋ฃจํŠธ ์—”๋“œํฌ์ธํŠธ""" + return {"message": "Document Server API", "version": "0.1.0"} + + +@app.get("/health") +async def health_check(): + """ํ—ฌ์Šค์ฒดํฌ ์—”๋“œํฌ์ธํŠธ""" + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run( + "src.main:app", + host="0.0.0.0", + port=8000, + reload=True if settings.DEBUG else False, + ) diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..0d7090a --- /dev/null +++ b/backend/src/models/__init__.py @@ -0,0 +1,35 @@ +""" +๋ชจ๋ธ ํŒจํ‚ค์ง€ ์ดˆ๊ธฐํ™” +""" +from .user import User +from .document import Document, Tag +from .book import Book +from .highlight import Highlight +from .note import Note +from .bookmark import Bookmark +from .document_link import DocumentLink +from .note_document import NoteDocument +from .notebook import Notebook +from .note_highlight import NoteHighlight +from .note_note import NoteNote +from .note_link import NoteLink +from .memo_tree import MemoTree, MemoNode, MemoTreeShare + +__all__ = [ + "User", + "Document", + "Tag", + "Book", + "Highlight", + "Note", + "Bookmark", + "DocumentLink", + "NoteDocument", + "Notebook", + "NoteHighlight", + "NoteNote", + "NoteLink", + "MemoTree", + "MemoNode", + "MemoTreeShare" +] diff --git a/backend/src/models/book.py b/backend/src/models/book.py new file mode 100644 index 0000000..cb0c67a --- /dev/null +++ b/backend/src/models/book.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, String, DateTime, Text, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + +class Book(Base): + """์„œ์  ํ…Œ์ด๋ธ” (์—ฌ๋Ÿฌ ๋ฌธ์„œ๋ฅผ ๋ฌถ๋Š” ๋‹จ์œ„)""" + __tablename__ = "books" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False, index=True) + author = Column(String(255), nullable=True) + description = Column(Text, nullable=True) + language = Column(String(10), default="ko") + is_public = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + documents = relationship("Document", back_populates="book", cascade="all, delete-orphan") + categories = relationship("BookCategory", back_populates="book", cascade="all, delete-orphan", order_by="BookCategory.sort_order") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/src/models/book_category.py b/backend/src/models/book_category.py new file mode 100644 index 0000000..834ac4c --- /dev/null +++ b/backend/src/models/book_category.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + +class BookCategory(Base): + """์„œ์  ์†Œ๋ถ„๋ฅ˜ ํ…Œ์ด๋ธ” (์„œ์  ๋‚ด ๋ฌธ์„œ ๊ทธ๋ฃนํ™”)""" + __tablename__ = "book_categories" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + book_id = Column(UUID(as_uuid=True), ForeignKey('books.id', ondelete='CASCADE'), nullable=False, index=True) + name = Column(String(200), nullable=False) # ์†Œ๋ถ„๋ฅ˜ ์ด๋ฆ„ (์˜ˆ: "Chapter 1", "์„ค๊ณ„ ๊ธฐ์ค€", "๊ณ„์‚ฐ์„œ") + description = Column(Text, nullable=True) # ์†Œ๋ถ„๋ฅ˜ ์„ค๋ช… + sort_order = Column(Integer, default=0) # ์†Œ๋ถ„๋ฅ˜ ์ •๋ ฌ ์ˆœ์„œ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + book = relationship("Book", back_populates="categories") + documents = relationship("Document", back_populates="category", cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/backend/src/models/bookmark.py b/backend/src/models/bookmark.py new file mode 100644 index 0000000..329f95c --- /dev/null +++ b/backend/src/models/bookmark.py @@ -0,0 +1,42 @@ +""" +์ฑ…๊ฐˆํ”ผ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class Bookmark(Base): + """์ฑ…๊ฐˆํ”ผ ํ…Œ์ด๋ธ”""" + __tablename__ = "bookmarks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + + # ์ฑ…๊ฐˆํ”ผ ์ •๋ณด + title = Column(String(200), nullable=False) # ์ฑ…๊ฐˆํ”ผ ์ œ๋ชฉ + description = Column(Text, nullable=True) # ์„ค๋ช… + + # ์œ„์น˜ ์ •๋ณด + page_number = Column(Integer, nullable=True) # ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (์ถ”์ •) + scroll_position = Column(Integer, default=0) # ์Šคํฌ๋กค ์œ„์น˜ (ํ”ฝ์…€) + element_id = Column(String(100), nullable=True) # ํŠน์ • ์š”์†Œ ID + element_selector = Column(Text, nullable=True) # CSS ์„ ํƒ์ž + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + user = relationship("User", backref="bookmarks") + document = relationship("Document", back_populates="bookmarks") + + def __repr__(self): + return f"" diff --git a/backend/src/models/document.py b/backend/src/models/document.py new file mode 100644 index 0000000..5ba8f74 --- /dev/null +++ b/backend/src/models/document.py @@ -0,0 +1,87 @@ +""" +๋ฌธ์„œ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, Boolean, ForeignKey, Table +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +# ๋ฌธ์„œ-ํƒœ๊ทธ ๋‹ค๋Œ€๋‹ค ๊ด€๊ณ„ ํ…Œ์ด๋ธ” +document_tags = Table( + 'document_tags', + Base.metadata, + Column('document_id', UUID(as_uuid=True), ForeignKey('documents.id'), primary_key=True), + Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True) +) + + +class Document(Base): + """๋ฌธ์„œ ํ…Œ์ด๋ธ”""" + __tablename__ = "documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + book_id = Column(UUID(as_uuid=True), ForeignKey('books.id'), nullable=True, index=True) # ์„œ์  ID + category_id = Column(UUID(as_uuid=True), ForeignKey('book_categories.id'), nullable=True, index=True) # ์†Œ๋ถ„๋ฅ˜ ID + title = Column(String(500), nullable=False, index=True) + sort_order = Column(Integer, default=0) # ๋ฌธ์„œ ์ •๋ ฌ ์ˆœ์„œ (์†Œ๋ถ„๋ฅ˜ ๋‚ด์—์„œ) + description = Column(Text, nullable=True) + + # ํŒŒ์ผ ์ •๋ณด + html_path = Column(String(1000), nullable=True) # HTML ํŒŒ์ผ ๊ฒฝ๋กœ (PDF๋งŒ ์—…๋กœ๋“œํ•˜๋Š” ๊ฒฝ์šฐ null ๊ฐ€๋Šฅ) + pdf_path = Column(String(1000), nullable=True) # PDF ์›๋ณธ ๊ฒฝ๋กœ (์„ ํƒ) + thumbnail_path = Column(String(1000), nullable=True) # ์ธ๋„ค์ผ ๊ฒฝ๋กœ + matched_pdf_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True) # ๋งค์นญ๋œ PDF ๋ฌธ์„œ ID + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + file_size = Column(Integer, nullable=True) # ๋ฐ”์ดํŠธ ๋‹จ์œ„ + page_count = Column(Integer, nullable=True) # ํŽ˜์ด์ง€ ์ˆ˜ (์ถ”์ •) + language = Column(String(10), default="ko") # ๋ฌธ์„œ ์–ธ์–ด + + # ์—…๋กœ๋“œ ์ •๋ณด + uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + original_filename = Column(String(500), nullable=True) + + # ์ƒํƒœ + is_public = Column(Boolean, default=False) # ๊ณต๊ฐœ ์—ฌ๋ถ€ + is_processed = Column(Boolean, default=True) # ์ฒ˜๋ฆฌ ์™„๋ฃŒ ์—ฌ๋ถ€ + + # ์‹œ๊ฐ„ ์ •๋ณด + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + document_date = Column(DateTime(timezone=True), nullable=True) # ๋ฌธ์„œ ์ž‘์„ฑ์ผ (์‚ฌ์šฉ์ž ์ž…๋ ฅ) + + # ๊ด€๊ณ„ + book = relationship("Book", back_populates="documents") # ์„œ์  ๊ด€๊ณ„ + category = relationship("BookCategory", back_populates="documents") # ์†Œ๋ถ„๋ฅ˜ ๊ด€๊ณ„ + uploader = relationship("User", backref="uploaded_documents") + tags = relationship("Tag", secondary=document_tags, back_populates="documents") + highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan") + bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class Tag(Base): + """ํƒœ๊ทธ ํ…Œ์ด๋ธ”""" + __tablename__ = "tags" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(100), unique=True, nullable=False, index=True) + color = Column(String(7), default="#3B82F6") # HEX ์ƒ‰์ƒ ์ฝ”๋“œ + description = Column(Text, nullable=True) + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # ๊ด€๊ณ„ + creator = relationship("User", backref="created_tags") + documents = relationship("Document", secondary=document_tags, back_populates="tags") + + def __repr__(self): + return f"" diff --git a/backend/src/models/document_link.py b/backend/src/models/document_link.py new file mode 100644 index 0000000..f9c8f75 --- /dev/null +++ b/backend/src/models/document_link.py @@ -0,0 +1,53 @@ +""" +๋ฌธ์„œ ๋งํฌ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class DocumentLink(Base): + """๋ฌธ์„œ ๋งํฌ ํ…Œ์ด๋ธ”""" + __tablename__ = "document_links" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ๋งํฌ๊ฐ€ ์ƒ์„ฑ๋œ ๋ฌธ์„œ (์ถœ๋ฐœ์ ) + source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True) + + # ๋งํฌ ๋Œ€์ƒ ๋ฌธ์„œ ๋˜๋Š” ๋…ธํŠธ (๋„์ฐฉ์ ) - ์™ธ๋ž˜ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด ์ œ๊ฑฐํ•˜์—ฌ ๋…ธํŠธ ID๋„ ํ—ˆ์šฉ + target_document_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + # ์ถœ๋ฐœ์  ํ…์ŠคํŠธ ์ •๋ณด (๊ธฐ์กด) + selected_text = Column(Text, nullable=False) # ์„ ํƒ๋œ ํ…์ŠคํŠธ + start_offset = Column(Integer, nullable=False) # ์‹œ์ž‘ ์œ„์น˜ + end_offset = Column(Integer, nullable=False) # ๋ ์œ„์น˜ + + # ๋„์ฐฉ์  ํ…์ŠคํŠธ ์ •๋ณด (์ƒˆ๋กœ ์ถ”๊ฐ€) + target_text = Column(Text, nullable=True) # ๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ์„ ํƒ๋œ ํ…์ŠคํŠธ + target_start_offset = Column(Integer, nullable=True) # ๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ์‹œ์ž‘ ์œ„์น˜ + target_end_offset = Column(Integer, nullable=True) # ๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ๋ ์œ„์น˜ + + # ๋งํฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + link_text = Column(String(500), nullable=True) # ์‚ฌ์šฉ์ž ์ •์˜ ๋งํฌ ํ…์ŠคํŠธ (์„ ํƒ์‚ฌํ•ญ) + description = Column(Text, nullable=True) # ๋งํฌ ์„ค๋ช… (์„ ํƒ์‚ฌํ•ญ) + + # ๋งํฌ ํƒ€์ž… (์ „์ฒด ๋ฌธ์„œ vs ํŠน์ • ๋ถ€๋ถ„) + link_type = Column(String(20), default="document", nullable=False) # "document" or "text_fragment" + + # ์ƒ์„ฑ์ž ์ •๋ณด + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ - target_document๋Š” ์™ธ๋ž˜ํ‚ค ์ œ์•ฝ ์กฐ๊ฑด์ด ์—†์œผ๋ฏ€๋กœ relationship ์ œ๊ฑฐ + source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links") + # target_document relationship ์ œ๊ฑฐ (๋…ธํŠธ ID๋„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ) + creator = relationship("User", backref="created_links") + + def __repr__(self): + return f"" diff --git a/backend/src/models/highlight.py b/backend/src/models/highlight.py new file mode 100644 index 0000000..3ac50f1 --- /dev/null +++ b/backend/src/models/highlight.py @@ -0,0 +1,47 @@ +""" +ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class Highlight(Base): + """ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ด๋ธ”""" + __tablename__ = "highlights" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + + # ํ…์ŠคํŠธ ์œ„์น˜ ์ •๋ณด + start_offset = Column(Integer, nullable=False) # ์‹œ์ž‘ ์œ„์น˜ + end_offset = Column(Integer, nullable=False) # ๋ ์œ„์น˜ + selected_text = Column(Text, nullable=False) # ์„ ํƒ๋œ ํ…์ŠคํŠธ (๊ฒ€์ƒ‰์šฉ) + + # DOM ์œ„์น˜ ์ •๋ณด (์ •ํ™•ํ•œ ๋ณต์›์„ ์œ„ํ•ด) + element_selector = Column(Text, nullable=True) # CSS ์„ ํƒ์ž + start_container_xpath = Column(Text, nullable=True) # ์‹œ์ž‘ ์ปจํ…Œ์ด๋„ˆ XPath + end_container_xpath = Column(Text, nullable=True) # ๋ ์ปจํ…Œ์ด๋„ˆ XPath + + # ์Šคํƒ€์ผ ์ •๋ณด + highlight_color = Column(String(7), default="#FFFF00") # HEX ์ƒ‰์ƒ ์ฝ”๋“œ + highlight_type = Column(String(20), default="highlight") # highlight, underline, etc. + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + user = relationship("User", backref="highlights") + document = relationship("Document", back_populates="highlights") + notes = relationship("Note", back_populates="highlight", cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/backend/src/models/memo_tree.py b/backend/src/models/memo_tree.py new file mode 100644 index 0000000..04c323a --- /dev/null +++ b/backend/src/models/memo_tree.py @@ -0,0 +1,111 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class MemoTree(Base): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค)""" + __tablename__ = "memo_trees" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + title = Column(String(255), nullable=False) + description = Column(Text) + tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general' + template_data = Column(JSON) # ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings = Column(JSON, default={}) # ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + is_public = Column(Boolean, default=False) + is_archived = Column(Boolean, default=False) + + # ๊ด€๊ณ„ + user = relationship("User", back_populates="memo_trees") + nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan") + shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan") + + +class MemoNode(Base): + """๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ)""" + __tablename__ = "memo_nodes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False) + parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE")) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ธฐ๋ณธ ์ •๋ณด + title = Column(String(500), nullable=False) + content = Column(Text) # Markdown ํ˜•์‹ + node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot' + + # ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order = Column(Integer, default=0) + depth_level = Column(Integer, default=0) + path = Column(Text) # ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags = Column(ARRAY(String)) # ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata = Column(JSON, default={}) # ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + + # ์ƒํƒœ ๊ด€๋ฆฌ + status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete' + word_count = Column(Integer, default=0) + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical = Column(Boolean, default=False) # ์ •์‚ฌ ๊ฒฝ๋กœ ์—ฌ๋ถ€ + canonical_order = Column(Integer, nullable=True) # ์ •์‚ฌ ๊ฒฝ๋กœ ์ˆœ์„œ + story_path = Column(Text, nullable=True) # ์ •์‚ฌ ๊ฒฝ๋กœ ๋ฌธ์ž์—ด + + # ์‹œ๊ฐ„ ์ •๋ณด + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # ๊ด€๊ณ„ + tree = relationship("MemoTree", back_populates="nodes") + user = relationship("User", back_populates="memo_nodes") + parent = relationship("MemoNode", remote_side=[id], back_populates="children") + children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan") + versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan") + + +class MemoNodeVersion(Base): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ""" + __tablename__ = "memo_node_versions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False) + version_number = Column(Integer, nullable=False) + title = Column(String(500), nullable=False) + content = Column(Text) + node_metadata = Column(JSON, default={}) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ด€๊ณ„ + node = relationship("MemoNode", back_populates="versions") + creator = relationship("User") + + +class MemoTreeShare(Base): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ)""" + __tablename__ = "memo_tree_shares" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + permission_level = Column(String(20), default="read") # 'read', 'write', 'admin' + created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # ๊ด€๊ณ„ + tree = relationship("MemoTree", back_populates="shares") + shared_with_user = relationship("User", foreign_keys=[shared_with_user_id]) + creator = relationship("User", foreign_keys=[created_by]) diff --git a/backend/src/models/note.py b/backend/src/models/note.py new file mode 100644 index 0000000..6669cd5 --- /dev/null +++ b/backend/src/models/note.py @@ -0,0 +1,47 @@ +""" +๋ฉ”๋ชจ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID, ARRAY +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class Note(Base): + """๋ฉ”๋ชจ ํ…Œ์ด๋ธ” (ํ•˜์ด๋ผ์ดํŠธ์™€ 1:N ๊ด€๊ณ„)""" + __tablename__ = "notes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ์—ฐ๊ฒฐ ์ •๋ณด + highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False) + + # ๋ฉ”๋ชจ ๋‚ด์šฉ + content = Column(Text, nullable=False) + is_private = Column(Boolean, default=True) # ๊ฐœ์ธ ๋ฉ”๋ชจ ์—ฌ๋ถ€ + + # ํƒœ๊ทธ (๋ฉ”๋ชจ ๋ถ„๋ฅ˜์šฉ) + tags = Column(ARRAY(String), nullable=True) # ["์ค‘์š”", "์งˆ๋ฌธ", "์•„์ด๋””์–ด"] + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ + highlight = relationship("Highlight", back_populates="notes") + + @property + def user_id(self): + """ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ID ๊ฐ€์ ธ์˜ค๊ธฐ""" + return self.highlight.user_id if self.highlight else None + + @property + def document_id(self): + """ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ํ†ตํ•ด ๋ฌธ์„œ ID ๊ฐ€์ ธ์˜ค๊ธฐ""" + return self.highlight.document_id if self.highlight else None + + def __repr__(self): + return f"" diff --git a/backend/src/models/note_document.py b/backend/src/models/note_document.py new file mode 100644 index 0000000..fea8486 --- /dev/null +++ b/backend/src/models/note_document.py @@ -0,0 +1,151 @@ +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteDocument(Base): + """๋…ธํŠธ ๋ฌธ์„œ ๋ชจ๋ธ""" + __tablename__ = "notes_documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False) + content = Column(Text) # HTML ๋‚ด์šฉ (๊ธฐ๋ณธ) + markdown_content = Column(Text) # ๋งˆํฌ๋‹ค์šด ๋‚ด์šฉ (์„ ํƒ์‚ฌํ•ญ) + note_type = Column(String(50), default='note') # note, research, summary, idea ๋“ฑ + tags = Column(ARRAY(String), default=[]) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + is_published = Column(Boolean, default=False) + parent_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True) + notebook_id = Column(UUID(as_uuid=True), ForeignKey('notebooks.id'), nullable=True) + sort_order = Column(Integer, default=0) + + # ๊ด€๊ณ„ ์„ค์ • + notebook = relationship("Notebook", back_populates="notes") + highlights = relationship("NoteHighlight", back_populates="note", cascade="all, delete-orphan") + notes = relationship("NoteNote", back_populates="note", cascade="all, delete-orphan") + word_count = Column(Integer, default=0) + reading_time = Column(Integer, default=0) # ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„ (๋ถ„) + + # ๊ด€๊ณ„ + parent_note = relationship("NoteDocument", remote_side=[id], back_populates="child_notes") + child_notes = relationship("NoteDocument", back_populates="parent_note") + +# Pydantic ๋ชจ๋ธ๋“ค +class NoteDocumentBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + content: Optional[str] = None + note_type: str = Field(default='note', pattern='^(note|research|summary|idea|guide|reference)$') + tags: List[str] = Field(default=[]) + is_published: bool = Field(default=False) + parent_note_id: Optional[str] = None + notebook_id: Optional[str] = None + sort_order: int = Field(default=0) + +class NoteDocumentCreate(NoteDocumentBase): + pass + +class NoteDocumentUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=500) + content: Optional[str] = None + note_type: Optional[str] = Field(None, pattern='^(note|research|summary|idea|guide|reference)$') + tags: Optional[List[str]] = None + is_published: Optional[bool] = None + parent_note_id: Optional[str] = None + notebook_id: Optional[str] = None + sort_order: Optional[int] = None + +class NoteDocumentResponse(NoteDocumentBase): + id: str + markdown_content: Optional[str] = None + created_at: datetime + updated_at: datetime + created_by: str + word_count: int + reading_time: int + + # ๊ณ„์ธต ๊ตฌ์กฐ ์ •๋ณด + parent_note: Optional['NoteDocumentResponse'] = None + child_notes: List['NoteDocumentResponse'] = [] + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + """ORM ๊ฐ์ฒด์—์„œ Pydantic ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜""" + data = { + 'id': str(obj.id), # UUID๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ + 'title': obj.title, + 'content': obj.content, + 'note_type': obj.note_type, + 'tags': obj.tags or [], + 'is_published': obj.is_published, + 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, + 'notebook_id': str(obj.notebook_id) if obj.notebook_id else None, + 'sort_order': obj.sort_order, + 'markdown_content': obj.markdown_content, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'word_count': obj.word_count or 0, + 'reading_time': obj.reading_time or 0, + } + return cls(**data) + +# ์ž๊ธฐ ์ฐธ์กฐ ๊ด€๊ณ„๋ฅผ ์œ„ํ•œ ๋ชจ๋ธ ์—…๋ฐ์ดํŠธ +NoteDocumentResponse.model_rebuild() + +class NoteDocumentListItem(BaseModel): + """๋…ธํŠธ ๋ชฉ๋ก์šฉ ๊ฐ„์†Œํ™”๋œ ๋ชจ๋ธ""" + id: str + title: str + note_type: str + tags: List[str] + created_at: datetime + updated_at: datetime + created_by: str + is_published: bool + word_count: int + reading_time: int + parent_note_id: Optional[str] = None + child_count: int = 0 # ์ž์‹ ๋…ธํŠธ ๊ฐœ์ˆ˜ + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, child_count=0): + """ORM ๊ฐ์ฒด์—์„œ Pydantic ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜""" + data = { + 'id': str(obj.id), # UUID๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ + 'title': obj.title, + 'note_type': obj.note_type, + 'tags': obj.tags or [], + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_published': obj.is_published, + 'word_count': obj.word_count or 0, + 'reading_time': obj.reading_time or 0, + 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, + 'child_count': child_count, + } + return cls(**data) + +class NoteStats(BaseModel): + """๋…ธํŠธ ํ†ต๊ณ„ ์ •๋ณด""" + total_notes: int + published_notes: int + draft_notes: int + note_types: dict # {type: count} + total_words: int + total_reading_time: int + recent_notes: List[NoteDocumentListItem] diff --git a/backend/src/models/note_highlight.py b/backend/src/models/note_highlight.py new file mode 100644 index 0000000..9aacc97 --- /dev/null +++ b/backend/src/models/note_highlight.py @@ -0,0 +1,69 @@ +from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteHighlight(Base): + """๋…ธํŠธ ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋ธ""" + __tablename__ = "note_highlights" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False) + start_offset = Column(Integer, nullable=False) + end_offset = Column(Integer, nullable=False) + selected_text = Column(Text, nullable=False) + highlight_color = Column(String(50), nullable=False, default='#FFFF00') + highlight_type = Column(String(50), nullable=False, default='highlight') + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + + # ๊ด€๊ณ„ + note = relationship("NoteDocument", back_populates="highlights") + notes = relationship("NoteNote", back_populates="highlight", cascade="all, delete-orphan") + +# Pydantic ๋ชจ๋ธ๋“ค +class NoteHighlightBase(BaseModel): + note_id: str + start_offset: int + end_offset: int + selected_text: str + highlight_color: str = '#FFFF00' + highlight_type: str = 'highlight' + +class NoteHighlightCreate(NoteHighlightBase): + pass + +class NoteHighlightUpdate(BaseModel): + highlight_color: Optional[str] = None + highlight_type: Optional[str] = None + +class NoteHighlightResponse(NoteHighlightBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + return cls( + id=str(obj.id), + note_id=str(obj.note_id), + start_offset=obj.start_offset, + end_offset=obj.end_offset, + selected_text=obj.selected_text, + highlight_color=obj.highlight_color, + highlight_type=obj.highlight_type, + created_at=obj.created_at, + updated_at=obj.updated_at, + created_by=obj.created_by + ) diff --git a/backend/src/models/note_link.py b/backend/src/models/note_link.py new file mode 100644 index 0000000..7a3a66b --- /dev/null +++ b/backend/src/models/note_link.py @@ -0,0 +1,58 @@ +""" +๋…ธํŠธ ๋ฌธ์„œ ๋งํฌ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class NoteLink(Base): + """๋…ธํŠธ ๋ฌธ์„œ ๋งํฌ ํ…Œ์ด๋ธ”""" + __tablename__ = "note_links" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # ๋งํฌ๊ฐ€ ์ƒ์„ฑ๋œ ๋…ธํŠธ (์ถœ๋ฐœ์ ) - ๋…ธํŠธ ๋ฌธ์„œ ๋˜๋Š” ์ผ๋ฐ˜ ๋ฌธ์„œ ๊ฐ€๋Šฅ + source_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True) + source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True) + + # ๋งํฌ ๋Œ€์ƒ ๋…ธํŠธ (๋„์ฐฉ์ ) - ๋…ธํŠธ ๋ฌธ์„œ ๋˜๋Š” ์ผ๋ฐ˜ ๋ฌธ์„œ ๊ฐ€๋Šฅ + target_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True) + target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True) + + # ์ถœ๋ฐœ์  ํ…์ŠคํŠธ ์ •๋ณด + selected_text = Column(Text, nullable=False) # ์„ ํƒ๋œ ํ…์ŠคํŠธ + start_offset = Column(Integer, nullable=False) # ์‹œ์ž‘ ์œ„์น˜ + end_offset = Column(Integer, nullable=False) # ๋ ์œ„์น˜ + + # ๋„์ฐฉ์  ํ…์ŠคํŠธ ์ •๋ณด + target_text = Column(Text, nullable=True) # ๋Œ€์ƒ์—์„œ ์„ ํƒ๋œ ํ…์ŠคํŠธ + target_start_offset = Column(Integer, nullable=True) # ๋Œ€์ƒ์—์„œ ์‹œ์ž‘ ์œ„์น˜ + target_end_offset = Column(Integer, nullable=True) # ๋Œ€์ƒ์—์„œ ๋ ์œ„์น˜ + + # ๋งํฌ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + link_text = Column(String(500), nullable=True) # ์‚ฌ์šฉ์ž ์ •์˜ ๋งํฌ ํ…์ŠคํŠธ + description = Column(Text, nullable=True) # ๋งํฌ ์„ค๋ช… + + # ๋งํฌ ํƒ€์ž… + link_type = Column(String(20), default="note", nullable=False) # "note", "document", "text_fragment" + + # ์ƒ์„ฑ์ž ์ •๋ณด + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # ๊ด€๊ณ„ ์„ค์ • + source_note = relationship("NoteDocument", foreign_keys=[source_note_id], backref="outgoing_note_links") + source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_note_links") + target_note = relationship("NoteDocument", foreign_keys=[target_note_id], backref="incoming_note_links") + target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_note_links") + creator = relationship("User", backref="created_note_links") + + def __repr__(self): + return f"" + diff --git a/backend/src/models/note_note.py b/backend/src/models/note_note.py new file mode 100644 index 0000000..5861dfd --- /dev/null +++ b/backend/src/models/note_note.py @@ -0,0 +1,59 @@ +from sqlalchemy import Column, String, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteNote(Base): + """๋…ธํŠธ์˜ ๋ฉ”๋ชจ ๋ชจ๋ธ (๋…ธํŠธ ์•ˆ์˜ ํ•˜์ด๋ผ์ดํŠธ์— ๋Œ€ํ•œ ๋ฉ”๋ชจ)""" + __tablename__ = "note_notes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False) + highlight_id = Column(UUID(as_uuid=True), ForeignKey("note_highlights.id", ondelete="CASCADE"), nullable=True) + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + + # ๊ด€๊ณ„ + note = relationship("NoteDocument", back_populates="notes") + highlight = relationship("NoteHighlight", back_populates="notes") + +# Pydantic ๋ชจ๋ธ๋“ค +class NoteNoteBase(BaseModel): + note_id: str + highlight_id: Optional[str] = None + content: str + +class NoteNoteCreate(NoteNoteBase): + pass + +class NoteNoteUpdate(BaseModel): + content: Optional[str] = None + +class NoteNoteResponse(NoteNoteBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + return cls( + id=str(obj.id), + note_id=str(obj.note_id), + highlight_id=str(obj.highlight_id) if obj.highlight_id else None, + content=obj.content, + created_at=obj.created_at, + updated_at=obj.updated_at, + created_by=obj.created_by + ) diff --git a/backend/src/models/notebook.py b/backend/src/models/notebook.py new file mode 100644 index 0000000..2494caa --- /dev/null +++ b/backend/src/models/notebook.py @@ -0,0 +1,126 @@ +""" +๋…ธํŠธ๋ถ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +import uuid + +from ..core.database import Base + + +class Notebook(Base): + """๋…ธํŠธ๋ถ ํ…Œ์ด๋ธ”""" + __tablename__ = "notebooks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False) + description = Column(Text, nullable=True) + color = Column(String(7), default='#3B82F6') # ํ—ฅ์Šค ์ปฌ๋Ÿฌ ์ฝ”๋“œ + icon = Column(String(50), default='book') # FontAwesome ์•„์ด์ฝ˜ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + is_active = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + + # ๊ด€๊ณ„ ์„ค์ • (๋…ธํŠธ๋“ค) + notes = relationship("NoteDocument", back_populates="notebook") + + +# Pydantic ๋ชจ๋ธ๋“ค +class NotebookBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + description: Optional[str] = None + color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$') + icon: str = Field(default='book', min_length=1, max_length=50) + is_active: bool = True + sort_order: int = 0 + + +class NotebookCreate(NotebookBase): + pass + + +class NotebookUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=500) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + icon: Optional[str] = Field(None, min_length=1, max_length=50) + is_active: Optional[bool] = None + sort_order: Optional[int] = None + + +class NotebookResponse(NotebookBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + note_count: int = 0 # ํฌํ•จ๋œ ๋…ธํŠธ ๊ฐœ์ˆ˜ + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, note_count=0): + """ORM ๊ฐ์ฒด์—์„œ Pydantic ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜""" + data = { + 'id': str(obj.id), + 'title': obj.title, + 'description': obj.description, + 'color': obj.color, + 'icon': obj.icon, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_active': obj.is_active, + 'sort_order': obj.sort_order, + 'note_count': note_count, + } + return cls(**data) + + +class NotebookListItem(BaseModel): + """๋…ธํŠธ๋ถ ๋ชฉ๋ก์šฉ ๊ฐ„์†Œํ™”๋œ ๋ชจ๋ธ""" + id: str + title: str + description: Optional[str] + color: str + icon: str + created_at: datetime + updated_at: datetime + created_by: str + is_active: bool + note_count: int = 0 + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, note_count=0): + """ORM ๊ฐ์ฒด์—์„œ Pydantic ๋ชจ๋ธ๋กœ ๋ณ€ํ™˜""" + data = { + 'id': str(obj.id), + 'title': obj.title, + 'description': obj.description, + 'color': obj.color, + 'icon': obj.icon, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_active': obj.is_active, + 'note_count': note_count, + } + return cls(**data) + + +class NotebookStats(BaseModel): + """๋…ธํŠธ๋ถ ํ†ต๊ณ„ ์ •๋ณด""" + total_notebooks: int + active_notebooks: int + total_notes: int + notes_without_notebook: int diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py new file mode 100644 index 0000000..cae6ccb --- /dev/null +++ b/backend/src/models/todo.py @@ -0,0 +1,63 @@ +""" +ํ• ์ผ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from datetime import datetime +import uuid + +from ..core.database import Base + + +class TodoItem(Base): + """ํ• ์ผ ์•„์ดํ…œ""" + __tablename__ = "todo_items" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + # ๊ธฐ๋ณธ ์ •๋ณด + content = Column(Text, nullable=False) # ํ• ์ผ ๋‚ด์šฉ + status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed + + # ์‹œ๊ฐ„ ๊ด€๋ฆฌ + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + start_date = Column(DateTime(timezone=True), nullable=True) # ์‹œ์ž‘ ์˜ˆ์ •์ผ + estimated_minutes = Column(Integer, nullable=True) # ์˜ˆ์ƒ ์†Œ์š”์‹œ๊ฐ„ (๋ถ„) + completed_at = Column(DateTime(timezone=True), nullable=True) + delayed_until = Column(DateTime(timezone=True), nullable=True) # ์ง€์—ฐ๋œ ๊ฒฝ์šฐ ์ƒˆ๋กœ์šด ์‹œ์ž‘์ผ + + # ๋ถ„ํ•  ๊ด€๋ฆฌ + parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # ๋ถ„ํ• ๋œ ํ• ์ผ์˜ ๋ถ€๋ชจ + split_order = Column(Integer, nullable=True) # ๋ถ„ํ•  ์ˆœ์„œ + + # ๊ด€๊ณ„ + user = relationship("User", back_populates="todo_items") + comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan") + + # ์ž๊ธฐ ์ฐธ์กฐ ๊ด€๊ณ„ (๋ถ„ํ• ๋œ ํ• ์ผ๋“ค) + subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id]) + + def __repr__(self): + return f"" + + +class TodoComment(Base): + """ํ• ์ผ ๋Œ“๊ธ€/๋ฉ”๋ชจ""" + __tablename__ = "todo_comments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + # ๊ด€๊ณ„ + todo_item = relationship("TodoItem", back_populates="comments") + user = relationship("User") + + def __repr__(self): + return f"" diff --git a/backend/src/models/user.py b/backend/src/models/user.py new file mode 100644 index 0000000..9aed184 --- /dev/null +++ b/backend/src/models/user.py @@ -0,0 +1,51 @@ +""" +์‚ฌ์šฉ์ž ๋ชจ๋ธ +""" +from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from ..core.database import Base + + +class User(Base): + """์‚ฌ์šฉ์ž ํ…Œ์ด๋ธ”""" + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + # ๊ถŒํ•œ ์‹œ์Šคํ…œ (์„œ์ ๊ด€๋ฆฌ, ๋…ธํŠธ๊ด€๋ฆฌ, ์†Œ์„ค๊ด€๋ฆฌ) + can_manage_books = Column(Boolean, default=True) # ์„œ์  ๊ด€๋ฆฌ ๊ถŒํ•œ + can_manage_notes = Column(Boolean, default=True) # ๋…ธํŠธ ๊ด€๋ฆฌ ๊ถŒํ•œ + can_manage_novels = Column(Boolean, default=True) # ์†Œ์„ค ๊ด€๋ฆฌ ๊ถŒํ•œ + + # ์‚ฌ์šฉ์ž ์—ญํ•  (root, admin, user) + role = Column(String(20), default="user") # root, admin, user + + # ์„ธ์…˜ ํƒ€์ž„์•„์›ƒ ์„ค์ • (๋ถ„ ๋‹จ์œ„, 0 = ๋ฌด์ œํ•œ) + session_timeout_minutes = Column(Integer, default=5) # ๊ธฐ๋ณธ 5๋ถ„ + + # ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + last_login = Column(DateTime(timezone=True), nullable=True) + + # ์‚ฌ์šฉ์ž ์„ค์ • + theme = Column(String(50), default="light") # light, dark + language = Column(String(10), default="ko") # ko, en + timezone = Column(String(50), default="Asia/Seoul") + + # ๊ด€๊ณ„ (lazy loading์„ ์œ„ํ•ด ๋ฌธ์ž์—ด๋กœ ์ฐธ์กฐ) + memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic") + memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic") + todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic") + + def __repr__(self): + return f"" diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py new file mode 100644 index 0000000..5169111 --- /dev/null +++ b/backend/src/schemas/auth.py @@ -0,0 +1,63 @@ +""" +์ธ์ฆ ๊ด€๋ จ ์Šคํ‚ค๋งˆ +""" +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +from uuid import UUID + + +class LoginRequest(BaseModel): + """๋กœ๊ทธ์ธ ์š”์ฒญ""" + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + """ํ† ํฐ ์‘๋‹ต""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int # ์ดˆ ๋‹จ์œ„ + + +class RefreshTokenRequest(BaseModel): + """ํ† ํฐ ๊ฐฑ์‹  ์š”์ฒญ""" + refresh_token: str + + +class UserInfo(BaseModel): + """์‚ฌ์šฉ์ž ์ •๋ณด""" + id: UUID + email: str + full_name: Optional[str] = None + is_active: bool + is_admin: bool + role: str + can_manage_books: bool + can_manage_notes: bool + can_manage_novels: bool + session_timeout_minutes: int + theme: str + language: str + timezone: str + created_at: datetime + updated_at: Optional[datetime] = None + last_login: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ChangePasswordRequest(BaseModel): + """๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ์š”์ฒญ""" + current_password: str + new_password: str + + +class CreateUserRequest(BaseModel): + """์‚ฌ์šฉ์ž ์ƒ์„ฑ ์š”์ฒญ (๊ด€๋ฆฌ์ž์šฉ)""" + email: EmailStr + password: str + full_name: Optional[str] = None + is_admin: bool = False diff --git a/backend/src/schemas/book.py b/backend/src/schemas/book.py new file mode 100644 index 0000000..8c3c5f9 --- /dev/null +++ b/backend/src/schemas/book.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +class BookBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + author: Optional[str] = Field(None, max_length=255) + description: Optional[str] = None + language: str = Field("ko", max_length=10) + is_public: bool = False + +class CreateBookRequest(BookBase): + pass + +class UpdateBookRequest(BookBase): + pass + +class BookResponse(BookBase): + id: UUID + created_at: datetime + updated_at: Optional[datetime] + document_count: int = 0 # ๋ฌธ์„œ ๊ฐœ์ˆ˜ ์ถ”๊ฐ€ + + class Config: + from_attributes = True + +class BookSearchResponse(BookResponse): + pass + +class BookSuggestionResponse(BookResponse): + similarity_score: float = Field(..., ge=0.0, le=1.0) \ No newline at end of file diff --git a/backend/src/schemas/book_category.py b/backend/src/schemas/book_category.py new file mode 100644 index 0000000..cdcaf4c --- /dev/null +++ b/backend/src/schemas/book_category.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +class BookCategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + sort_order: int = Field(0, ge=0) + +class CreateBookCategoryRequest(BookCategoryBase): + book_id: UUID + +class UpdateBookCategoryRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + sort_order: Optional[int] = Field(None, ge=0) + +class BookCategoryResponse(BookCategoryBase): + id: UUID + book_id: UUID + created_at: datetime + updated_at: Optional[datetime] + document_count: int = 0 # ํฌํ•จ๋œ ๋ฌธ์„œ ์ˆ˜ + + class Config: + from_attributes = True + +class UpdateDocumentOrderRequest(BaseModel): + document_orders: List[dict] = Field(..., description="๋ฌธ์„œ ID์™€ ์ˆœ์„œ ์ •๋ณด") + # ์˜ˆ: [{"document_id": "uuid", "sort_order": 1}, ...] diff --git a/backend/src/schemas/memo_tree.py b/backend/src/schemas/memo_tree.py new file mode 100644 index 0000000..9c8731b --- /dev/null +++ b/backend/src/schemas/memo_tree.py @@ -0,0 +1,205 @@ +""" +ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ Pydantic ์Šคํ‚ค๋งˆ +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime +from uuid import UUID + + +# ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ๋“ค +class MemoTreeBase(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ""" + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$") + template_data: Optional[Dict[str, Any]] = None + settings: Optional[Dict[str, Any]] = None + is_public: bool = False + + +class MemoTreeCreate(MemoTreeBase): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ƒ์„ฑ ์š”์ฒญ""" + pass + + +class MemoTreeUpdate(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$") + template_data: Optional[Dict[str, Any]] = None + settings: Optional[Dict[str, Any]] = None + is_public: Optional[bool] = None + is_archived: Optional[bool] = None + + +class MemoTreeResponse(MemoTreeBase): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‘๋‹ต""" + id: str + user_id: str + created_at: datetime + updated_at: Optional[datetime] + is_archived: bool + node_count: Optional[int] = 0 # ๋…ธ๋“œ ๊ฐœ์ˆ˜ + + class Config: + from_attributes = True + + +# ๋ฉ”๋ชจ ๋…ธ๋“œ ์Šคํ‚ค๋งˆ๋“ค +class MemoNodeBase(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๊ธฐ๋ณธ ์Šคํ‚ค๋งˆ""" + title: str = Field(..., min_length=1, max_length=500) + content: Optional[str] = None + node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$") + tags: Optional[List[str]] = None + node_metadata: Optional[Dict[str, Any]] = None + status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$") + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: Optional[bool] = False + canonical_order: Optional[int] = None + + +class MemoNodeCreate(MemoNodeBase): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ฑ ์š”์ฒญ""" + tree_id: str + parent_id: Optional[str] = None + sort_order: Optional[int] = 0 + + +class MemoNodeUpdate(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์—…๋ฐ์ดํŠธ ์š”์ฒญ""" + title: Optional[str] = Field(None, min_length=1, max_length=500) + content: Optional[str] = None + node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$") + parent_id: Optional[str] = None + sort_order: Optional[int] = None + tags: Optional[List[str]] = None + node_metadata: Optional[Dict[str, Any]] = None + status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$") + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: Optional[bool] = None + canonical_order: Optional[int] = None + + +class MemoNodeMove(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์ด๋™ ์š”์ฒญ""" + parent_id: Optional[str] = None + sort_order: int = 0 + + +class MemoNodeResponse(MemoNodeBase): + """๋ฉ”๋ชจ ๋…ธ๋“œ ์‘๋‹ต""" + id: str + tree_id: str + parent_id: Optional[str] + user_id: str + sort_order: int + depth_level: int + path: Optional[str] + word_count: int + created_at: datetime + updated_at: Optional[datetime] + + # ์ •์‚ฌ ๊ฒฝ๋กœ ๊ด€๋ จ ํ•„๋“œ + is_canonical: bool + canonical_order: Optional[int] + story_path: Optional[str] + + # ๊ด€๊ณ„ ๋ฐ์ดํ„ฐ + children_count: Optional[int] = 0 + + class Config: + from_attributes = True + + +# ํŠธ๋ฆฌ ๊ตฌ์กฐ ์‘๋‹ต +class MemoTreeWithNodes(MemoTreeResponse): + """๋…ธ๋“œ๊ฐ€ ํฌํ•จ๋œ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ์‘๋‹ต""" + nodes: List[MemoNodeResponse] = [] + + +# ๋…ธ๋“œ ๋ฒ„์ „ ์Šคํ‚ค๋งˆ๋“ค +class MemoNodeVersionResponse(BaseModel): + """๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ์‘๋‹ต""" + id: str + node_id: str + version_number: int + title: str + content: Optional[str] + node_metadata: Optional[Dict[str, Any]] + created_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# ๊ณต์œ  ์Šคํ‚ค๋งˆ๋“ค +class MemoTreeShareCreate(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  ์ƒ์„ฑ ์š”์ฒญ""" + shared_with_user_email: str + permission_level: str = Field(default="read", pattern="^(read|write|admin)$") + + +class MemoTreeShareResponse(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  ์‘๋‹ต""" + id: str + tree_id: str + shared_with_user_id: str + shared_with_user_email: str + shared_with_user_name: str + permission_level: str + created_at: datetime + created_by: str + + class Config: + from_attributes = True + + +# ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ๋ง +class MemoSearchRequest(BaseModel): + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ์š”์ฒญ""" + query: str = Field(..., min_length=1) + tree_id: Optional[str] = None + node_types: Optional[List[str]] = None + tags: Optional[List[str]] = None + status: Optional[List[str]] = None + + +class MemoSearchResult(BaseModel): + """๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ""" + node: MemoNodeResponse + tree: MemoTreeResponse + matches: List[Dict[str, Any]] # ๋งค์น˜๋œ ๋ถ€๋ถ„๋“ค + relevance_score: float + + +# ํ†ต๊ณ„ ์Šคํ‚ค๋งˆ +class MemoTreeStats(BaseModel): + """๋ฉ”๋ชจ ํŠธ๋ฆฌ ํ†ต๊ณ„""" + total_nodes: int + nodes_by_type: Dict[str, int] + nodes_by_status: Dict[str, int] + total_words: int + last_updated: Optional[datetime] + + +# ๋‚ด๋ณด๋‚ด๊ธฐ ์Šคํ‚ค๋งˆ +class ExportRequest(BaseModel): + """๋‚ด๋ณด๋‚ด๊ธฐ ์š”์ฒญ""" + tree_id: str + format: str = Field(..., pattern="^(markdown|html|pdf|docx)$") + include_metadata: bool = True + node_types: Optional[List[str]] = None + + +class ExportResponse(BaseModel): + """๋‚ด๋ณด๋‚ด๊ธฐ ์‘๋‹ต""" + file_url: str + file_name: str + file_size: int + created_at: datetime diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py new file mode 100644 index 0000000..01339cc --- /dev/null +++ b/backend/src/schemas/todo.py @@ -0,0 +1,108 @@ +""" +ํ• ์ผ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ์Šคํ‚ค๋งˆ +""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +from uuid import UUID + + +class TodoCommentBase(BaseModel): + content: str = Field(..., min_length=1, max_length=1000) + + +class TodoCommentCreate(TodoCommentBase): + pass + + +class TodoCommentUpdate(BaseModel): + content: Optional[str] = Field(None, min_length=1, max_length=1000) + + +class TodoCommentResponse(TodoCommentBase): + id: UUID + todo_item_id: UUID + user_id: UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TodoItemBase(BaseModel): + content: str = Field(..., min_length=1, max_length=2000) + + +class TodoItemCreate(TodoItemBase): + """์ดˆ๊ธฐ ํ• ์ผ ์ƒ์„ฑ (draft ์ƒํƒœ)""" + pass + + +class TodoItemSchedule(BaseModel): + """ํ• ์ผ ์ผ์ • ์„ค์ •""" + start_date: datetime + estimated_minutes: int = Field(..., ge=1, le=120) # 1๋ถ„~2์‹œ๊ฐ„ + + +class TodoItemUpdate(BaseModel): + """ํ• ์ผ ์ˆ˜์ •""" + content: Optional[str] = Field(None, min_length=1, max_length=2000) + status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$") + start_date: Optional[datetime] = None + estimated_minutes: Optional[int] = Field(None, ge=1, le=120) + delayed_until: Optional[datetime] = None + + +class TodoItemDelay(BaseModel): + """ํ• ์ผ ์ง€์—ฐ""" + delayed_until: datetime + + +class TodoItemSplit(BaseModel): + """ํ• ์ผ ๋ถ„ํ• """ + subtasks: List[str] = Field(..., min_items=2, max_items=10) + estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10) + + +class TodoItemResponse(TodoItemBase): + id: UUID + user_id: UUID + status: str + created_at: datetime + start_date: Optional[datetime] + estimated_minutes: Optional[int] + completed_at: Optional[datetime] + delayed_until: Optional[datetime] + parent_id: Optional[UUID] + split_order: Optional[int] + + # ๋Œ“๊ธ€ ์ˆ˜ + comment_count: int = 0 + + class Config: + from_attributes = True + + +class TodoItemWithComments(TodoItemResponse): + """๋Œ“๊ธ€์ด ํฌํ•จ๋œ ํ• ์ผ ์‘๋‹ต""" + comments: List[TodoCommentResponse] = [] + + +class TodoStats(BaseModel): + """ํ• ์ผ ํ†ต๊ณ„""" + total_count: int + draft_count: int + scheduled_count: int + active_count: int + completed_count: int + delayed_count: int + completion_rate: float # ์™„๋ฃŒ์œจ (%) + + +class TodoDashboard(BaseModel): + """ํ• ์ผ ๋Œ€์‹œ๋ณด๋“œ""" + stats: TodoStats + today_todos: List[TodoItemResponse] + overdue_todos: List[TodoItemResponse] + upcoming_todos: List[TodoItemResponse] diff --git a/config/postgresql.synology.conf b/config/postgresql.synology.conf new file mode 100644 index 0000000..37feba9 --- /dev/null +++ b/config/postgresql.synology.conf @@ -0,0 +1,92 @@ +# PostgreSQL ์„ค์ • - Synology DS1525+ ์ตœ์ ํ™” (32GB RAM) +# /volume1/docker/document-server/config/postgresql.conf + +# ๋ฉ”๋ชจ๋ฆฌ ์„ค์ • (32GB RAM ํ™˜๊ฒฝ) +shared_buffers = 8GB # RAM์˜ 25% (8GB) +effective_cache_size = 24GB # RAM์˜ 75% (24GB) +work_mem = 256MB # ๋ณต์žกํ•œ ์ฟผ๋ฆฌ์šฉ (์ •๋ ฌ, ํ•ด์‹œ ์กฐ์ธ) +maintenance_work_mem = 2GB # ์ธ๋ฑ์Šค ๊ตฌ์ถ•, VACUUM์šฉ + +# ์ฒดํฌํฌ์ธํŠธ ์„ค์ • (SSD ์ตœ์ ํ™”) +checkpoint_completion_target = 0.9 # ์ฒดํฌํฌ์ธํŠธ ๋ถ„์‚ฐ (SSD ์ˆ˜๋ช… ์—ฐ์žฅ) +checkpoint_timeout = 15min # ์ฒดํฌํฌ์ธํŠธ ๊ฐ„๊ฒฉ +max_wal_size = 4GB # WAL ํŒŒ์ผ ์ตœ๋Œ€ ํฌ๊ธฐ +min_wal_size = 1GB # WAL ํŒŒ์ผ ์ตœ์†Œ ํฌ๊ธฐ + +# WAL ์„ค์ • +wal_buffers = 64MB # WAL ๋ฒ„ํผ ํฌ๊ธฐ +wal_writer_delay = 200ms # WAL ์“ฐ๊ธฐ ์ง€์—ฐ +commit_delay = 0 # ์ปค๋ฐ‹ ์ง€์—ฐ (SSD์—์„œ๋Š” 0) + +# ๋น„์šฉ ๊ธฐ๋ฐ˜ ์ตœ์ ํ™” (SSD ํ™˜๊ฒฝ) +random_page_cost = 1.1 # SSD๋Š” ๋žœ๋ค ์•ก์„ธ์Šค๊ฐ€ ๋น ๋ฆ„ +seq_page_cost = 1.0 # ์ˆœ์ฐจ ์•ก์„ธ์Šค ๊ธฐ์ค€๊ฐ’ +cpu_tuple_cost = 0.01 # CPU ํŠœํ”Œ ์ฒ˜๋ฆฌ ๋น„์šฉ +cpu_index_tuple_cost = 0.005 # ์ธ๋ฑ์Šค ํŠœํ”Œ ์ฒ˜๋ฆฌ ๋น„์šฉ +cpu_operator_cost = 0.0025 # ์—ฐ์‚ฐ์ž ์ฒ˜๋ฆฌ ๋น„์šฉ + +# ์—ฐ๊ฒฐ ์„ค์ • +max_connections = 200 # ์ตœ๋Œ€ ์—ฐ๊ฒฐ ์ˆ˜ +superuser_reserved_connections = 3 # ์Šˆํผ์œ ์ € ์˜ˆ์•ฝ ์—ฐ๊ฒฐ + +# ์ฟผ๋ฆฌ ํ”Œ๋ž˜๋„ˆ ์„ค์ • +default_statistics_target = 100 # ํ†ต๊ณ„ ์ •ํ™•๋„ +constraint_exclusion = partition # ํŒŒํ‹ฐ์…˜ ์ œ์•ฝ ์กฐ๊ฑด ์ตœ์ ํ™” +enable_partitionwise_join = on # ํŒŒํ‹ฐ์…˜๋ณ„ ์กฐ์ธ ์ตœ์ ํ™” +enable_partitionwise_aggregate = on # ํŒŒํ‹ฐ์…˜๋ณ„ ์ง‘๊ณ„ ์ตœ์ ํ™” + +# ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—…์ž ์„ค์ • +max_worker_processes = 8 # ์ตœ๋Œ€ ์›Œ์ปค ํ”„๋กœ์„ธ์Šค (CPU ์ฝ”์–ด ์ˆ˜) +max_parallel_workers_per_gather = 4 # ๋ณ‘๋ ฌ ์ฟผ๋ฆฌ ์›Œ์ปค +max_parallel_workers = 8 # ์ „์ฒด ๋ณ‘๋ ฌ ์›Œ์ปค +max_parallel_maintenance_workers = 4 # ๋ณ‘๋ ฌ ์œ ์ง€๋ณด์ˆ˜ ์›Œ์ปค + +# ์ž๋™ VACUUM ์„ค์ • +autovacuum = on # ์ž๋™ VACUUM ํ™œ์„ฑํ™” +autovacuum_max_workers = 3 # VACUUM ์›Œ์ปค ์ˆ˜ +autovacuum_naptime = 1min # VACUUM ์‹คํ–‰ ๊ฐ„๊ฒฉ +autovacuum_vacuum_threshold = 50 # VACUUM ์ž„๊ณ„๊ฐ’ +autovacuum_analyze_threshold = 50 # ANALYZE ์ž„๊ณ„๊ฐ’ +autovacuum_vacuum_scale_factor = 0.2 # VACUUM ์Šค์ผ€์ผ ํŒฉํ„ฐ +autovacuum_analyze_scale_factor = 0.1 # ANALYZE ์Šค์ผ€์ผ ํŒฉํ„ฐ + +# ๋กœ๊น… ์„ค์ • +log_destination = 'stderr' # ๋กœ๊ทธ ์ถœ๋ ฅ ๋Œ€์ƒ +logging_collector = off # Docker ํ™˜๊ฒฝ์—์„œ๋Š” off +log_min_messages = warning # ์ตœ์†Œ ๋กœ๊ทธ ๋ ˆ๋ฒจ +log_min_error_statement = error # ์—๋Ÿฌ ๋ฌธ์žฅ ๋กœ๊ทธ +log_min_duration_statement = 1000 # 1์ดˆ ์ด์ƒ ์ฟผ๋ฆฌ ๋กœ๊น… +log_checkpoints = on # ์ฒดํฌํฌ์ธํŠธ ๋กœ๊น… +log_connections = off # ์—ฐ๊ฒฐ ๋กœ๊น… (์„ฑ๋Šฅ์ƒ off) +log_disconnections = off # ์—ฐ๊ฒฐ ํ•ด์ œ ๋กœ๊น… (์„ฑ๋Šฅ์ƒ off) +log_lock_waits = on # ๋ฝ ๋Œ€๊ธฐ ๋กœ๊น… +log_temp_files = 10MB # ์ž„์‹œ ํŒŒ์ผ ๋กœ๊น… (10MB ์ด์ƒ) + +# ์ „๋ฌธ ๊ฒ€์ƒ‰ ์„ค์ • +default_text_search_config = 'pg_catalog.english' + +# ์‹œ๊ฐ„๋Œ€ ์„ค์ • +timezone = 'Asia/Seoul' +log_timezone = 'Asia/Seoul' + +# ๋ฌธ์ž ์ธ์ฝ”๋”ฉ +lc_messages = 'C' +lc_monetary = 'C' +lc_numeric = 'C' +lc_time = 'C' + +# ๊ธฐํƒ€ ์„ฑ๋Šฅ ์„ค์ • +effective_io_concurrency = 200 # SSD ๋™์‹œ I/O (SSD๋Š” ๋†’๊ฒŒ) +maintenance_io_concurrency = 10 # ์œ ์ง€๋ณด์ˆ˜ I/O ๋™์‹œ์„ฑ +wal_compression = on # WAL ์••์ถ• (๋””์Šคํฌ ์ ˆ์•ฝ) +full_page_writes = on # ์ „์ฒด ํŽ˜์ด์ง€ ์“ฐ๊ธฐ (์•ˆ์ •์„ฑ) + +# JIT ์ปดํŒŒ์ผ ์„ค์ • (PostgreSQL 11+) +jit = on # JIT ์ปดํŒŒ์ผ ํ™œ์„ฑํ™” +jit_above_cost = 100000 # JIT ํ™œ์„ฑํ™” ๋น„์šฉ ์ž„๊ณ„๊ฐ’ +jit_inline_above_cost = 500000 # ์ธ๋ผ์ธ JIT ๋น„์šฉ ์ž„๊ณ„๊ฐ’ +jit_optimize_above_cost = 500000 # ์ตœ์ ํ™” JIT ๋น„์šฉ ์ž„๊ณ„๊ฐ’ + +# ํ™•์žฅ ๋ชจ๋“ˆ ์„ค์ • +shared_preload_libraries = 'pg_stat_statements' # ์ฟผ๋ฆฌ ํ†ต๊ณ„ ๋ชจ๋“ˆ + diff --git a/database/init/005_create_memo_tree_tables.sql b/database/init/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/database/init/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +-- 005_create_memo_tree_tables.sql + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ (ํ”„๋กœ์ ํŠธ/์›Œํฌ์ŠคํŽ˜์ด์Šค) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- ํ…œํ”Œ๋ฆฟ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + settings JSONB DEFAULT '{}', -- ํŠธ๋ฆฌ๋ณ„ ์„ค์ • + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ (ํŠธ๋ฆฌ์˜ ๊ฐ ๋…ธ๋“œ) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- ๊ธฐ๋ณธ ์ •๋ณด + title VARCHAR(500) NOT NULL, + content TEXT, -- ์‹ค์ œ ๋ฉ”๋ชจ ๋‚ด์šฉ (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- ํŠธ๋ฆฌ ๊ตฌ์กฐ ๊ด€๋ฆฌ + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- ๊ฒฝ๋กœ ์ €์žฅ (์˜ˆ: /1/3/7) + + -- ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + tags TEXT[], -- ํƒœ๊ทธ ๋ฐฐ์—ด + node_metadata JSONB DEFAULT '{}', -- ๋…ธ๋“œ๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (์บ๋ฆญํ„ฐ ์ •๋ณด, ํ”Œ๋กฏ ์ •๋ณด ๋“ฑ) + + -- ์ƒํƒœ ๊ด€๋ฆฌ + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- ์‹œ๊ฐ„ ์ •๋ณด + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- ์ œ์•ฝ ์กฐ๊ฑด + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- ๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฒ„์ „ ๊ด€๋ฆฌ (์„ ํƒ์ ) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ณต์œ  (ํ˜‘์—… ๊ธฐ๋Šฅ) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- ์ธ๋ฑ์Šค ์ƒ์„ฑ +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: updated_at ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜: ๊ฒฝ๋กœ ์ž๋™ ์—…๋ฐ์ดํŠธ +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- ๋ฃจํŠธ ๋…ธ๋“œ์ธ ๊ฒฝ์šฐ + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- ๋ถ€๋ชจ ๋…ธ๋“œ์˜ ๊ฒฝ๋กœ๋ฅผ ๊ฐ€์ ธ์™€์„œ ํ™•์žฅ + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ํŠธ๋ฆฌ๊ฑฐ +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ (๊ฐœ๋ฐœ์šฉ) +-- ์†Œ์„ค ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '๋‚ด ์ฒซ ๋ฒˆ์งธ ์†Œ์„ค', + 'ํŒํƒ€์ง€ ์†Œ์„ค ํ”„๋กœ์ ํŠธ', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/database/init/01_init.sql b/database/init/01_init.sql new file mode 100644 index 0000000..5831ae3 --- /dev/null +++ b/database/init/01_init.sql @@ -0,0 +1,11 @@ +-- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” ์Šคํฌ๋ฆฝํŠธ +-- FastAPI๊ฐ€ ์ž๋™์œผ๋กœ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ๊ธฐ๋ณธ ์„ค์ •๋งŒ + +-- ํ™•์žฅ ๊ธฐ๋Šฅ ํ™œ์„ฑํ™” +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ์ „๋ฌธ ๊ฒ€์ƒ‰์„ ์œ„ํ•œ ์„ค์ • +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +ALTER DATABASE document_db SET timezone TO 'Asia/Seoul'; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..dfcc1cf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + # ๊ฐœ๋ฐœ์šฉ Nginx + nginx: + build: ./nginx + container_name: document-server-nginx-dev + ports: + - "24100:80" + volumes: + - ./frontend:/usr/share/nginx/html + - ./uploads:/usr/share/nginx/html/uploads + - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf + depends_on: + - backend + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ Backend (ํ•ซ ๋ฆฌ๋กœ๋“œ) + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: document-server-backend-dev + ports: + - "24102:8000" + volumes: + - ./uploads:/app/uploads + - ./backend:/app + environment: + - DATABASE_URL=postgresql://docuser:docpass@database:5432/document_db + - PAPERLESS_URL=${PAPERLESS_URL:-http://localhost:8000} + - PAPERLESS_TOKEN=${PAPERLESS_TOKEN:-} + - DEBUG=true + - RELOAD=true + depends_on: + - database + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (๋ฐ์ดํ„ฐ ์˜์†์„ฑ ์—†์Œ) + database: + image: postgres:15-alpine + container_name: document-server-db-dev + ports: + - "24101:5432" + environment: + - POSTGRES_DB=document_db + - POSTGRES_USER=docuser + - POSTGRES_PASSWORD=docpass + volumes: + - ./database/init:/docker-entrypoint-initdb.d + networks: + - document-network + + # ๊ฐœ๋ฐœ์šฉ Redis + redis: + image: redis:7-alpine + container_name: document-server-redis-dev + ports: + - "24103:6379" + networks: + - document-network + command: redis-server + +networks: + document-network: + driver: bridge diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml new file mode 100644 index 0000000..0fb928c --- /dev/null +++ b/docker-compose.synology.yml @@ -0,0 +1,178 @@ +version: '3.8' + +services: + # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (SSD ์ตœ์ ํ™” - 32GB RAM ํ™œ์šฉ) + database: + image: postgres:15-alpine + container_name: document-server-db + restart: unless-stopped + environment: + POSTGRES_DB: document_db + POSTGRES_USER: docuser + POSTGRES_PASSWORD: ${DB_PASSWORD:-docpass} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + # SSD: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (์„ฑ๋Šฅ ์ตœ์šฐ์„ ) + - /volume3/docker/document-server/database:/var/lib/postgresql/data + - /volume3/docker/document-server/config/postgresql.synology.conf:/etc/postgresql/postgresql.conf:ro + - ./database/init:/docker-entrypoint-initdb.d:ro + ports: + - "24101:5432" + command: > + postgres + -c config_file=/etc/postgresql/postgresql.conf + -c shared_buffers=8GB + -c effective_cache_size=24GB + -c work_mem=512MB + -c maintenance_work_mem=4GB + -c checkpoint_completion_target=0.9 + -c wal_buffers=128MB + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c max_worker_processes=8 + -c max_parallel_workers_per_gather=4 + -c max_parallel_workers=8 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U docuser -d document_db"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - document-network + deploy: + resources: + limits: + memory: 10G + reservations: + memory: 2G + + # Redis ์บ์‹œ (SSD ์ตœ์ ํ™” - ๋Œ€์šฉ๋Ÿ‰ ๋ฉ”๋ชจ๋ฆฌ ํ™œ์šฉ) + redis: + image: redis:7-alpine + container_name: document-server-redis + restart: unless-stopped + volumes: + # SSD: Redis ๋ฐ์ดํ„ฐ (๋น ๋ฅธ ์บ์‹œ) + - /volume3/docker/document-server/redis:/data + ports: + - "24103:6379" + command: > + redis-server + --maxmemory 8gb + --maxmemory-policy allkeys-lru + --save 900 1 + --save 300 10 + --save 60 10000 + --appendonly yes + --appendfsync everysec + --auto-aof-rewrite-percentage 100 + --auto-aof-rewrite-min-size 64mb + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - document-network + deploy: + resources: + limits: + memory: 10G + reservations: + memory: 1G + + # FastAPI ๋ฐฑ์—”๋“œ (SSD์—์„œ ์‹คํ–‰, HDD ์Šคํ† ๋ฆฌ์ง€ ์—ฐ๊ฒฐ) + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: document-server-backend + restart: unless-stopped + environment: + - DATABASE_URL=postgresql+asyncpg://docuser:${DB_PASSWORD:-docpass}@database:5432/document_db + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + - DEBUG=false + - ALLOWED_ORIGINS=http://localhost:24100,https://${DOMAIN_NAME:-localhost} + - UPLOAD_DIR=/app/uploads + - MAX_FILE_SIZE=500000000 + volumes: + # SSD: ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ๊ทธ ๋ฐ ์„ค์ • (๋น ๋ฅธ ์•ก์„ธ์Šค) + - /volume3/docker/document-server/logs:/app/logs + - /volume3/docker/document-server/config:/app/config + - /volume3/docker/document-server/cache:/app/cache + + # HDD: ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์ €์žฅ์†Œ (๋น„์šฉ ํšจ์œจ์ ) + - /volume1/document-storage/uploads:/app/uploads + - /volume1/document-storage/documents:/app/documents + - /volume1/document-storage/thumbnails:/app/thumbnails + - /volume1/document-storage/backups:/app/backups + ports: + - "24102:8000" + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - document-network + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 512M + + # Nginx ์›น์„œ๋ฒ„ (SSD ์บ์‹œ, HDD ์Šคํ† ๋ฆฌ์ง€) + nginx: + build: + context: ./nginx + dockerfile: Dockerfile + container_name: document-server-nginx + restart: unless-stopped + volumes: + # SSD: Nginx ์„ค์ •, ๋กœ๊ทธ, ์บ์‹œ (์„ฑ๋Šฅ ์ตœ์ ํ™”) + - /volume3/docker/document-server/nginx/conf.d:/etc/nginx/conf.d + - /volume3/docker/document-server/nginx/cache:/var/cache/nginx + - /volume3/docker/document-server/logs/nginx:/var/log/nginx + + # SSD: ํ”„๋ก ํŠธ์—”๋“œ ์ •์  ํŒŒ์ผ (๋น ๋ฅธ ์„œ๋น™) + - ./frontend:/usr/share/nginx/html:ro + + # HDD: ๋Œ€์šฉ๋Ÿ‰ ๋ฌธ์„œ ํŒŒ์ผ (์ฝ๊ธฐ ์ „์šฉ) + - /volume1/document-storage/uploads:/usr/share/nginx/html/uploads:ro + - /volume1/document-storage/documents:/usr/share/nginx/html/documents:ro + ports: + - "24100:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - document-network + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 128M + +networks: + document-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +# ๋ณผ๋ฅจ ์ •์˜๋Š” ์ œ๊ฑฐ (์ง์ ‘ ๊ฒฝ๋กœ ๋งคํ•‘ ์‚ฌ์šฉ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f7ed25 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +version: '3.8' + +services: + # Nginx ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ + nginx: + build: ./nginx + container_name: document-server-nginx + ports: + - "24100:80" + volumes: + - ./frontend:/usr/share/nginx/html + - ./uploads:/usr/share/nginx/html/uploads + depends_on: + - backend + networks: + - document-network + restart: unless-stopped + + # Backend API ์„œ๋ฒ„ + backend: + build: ./backend + container_name: document-server-backend + ports: + - "24102:8000" + volumes: + - ./uploads:/app/uploads + - ./backend/src:/app/src + environment: + - DATABASE_URL=postgresql+asyncpg://docuser:docpass@database:5432/document_db + - SECRET_KEY=${SECRET_KEY:-production-secret-key-change-this} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@test.com} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + - DEBUG=false + depends_on: + - database + networks: + - document-network + restart: unless-stopped + + # PostgreSQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค + database: + image: postgres:15-alpine + container_name: document-server-db + ports: + - "24101:5432" + environment: + - POSTGRES_DB=document_db + - POSTGRES_USER=docuser + - POSTGRES_PASSWORD=docpass + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - document-network + restart: unless-stopped + + # Redis (์บ์‹ฑ ๋ฐ ์„ธ์…˜) + redis: + image: redis:7-alpine + container_name: document-server-redis + ports: + - "24103:6379" + volumes: + - redis_data:/data + networks: + - document-network + restart: unless-stopped + command: redis-server --appendonly yes + +volumes: + postgres_data: + redis_data: + +networks: + document-network: + driver: bridge diff --git a/frontend/backup-restore.html b/frontend/backup-restore.html new file mode 100644 index 0000000..2f01797 --- /dev/null +++ b/frontend/backup-restore.html @@ -0,0 +1,317 @@ + + + + + + ๋ฐฑ์—…/๋ณต์› ๊ด€๋ฆฌ - Document Server + + + + + + + +
+ + +
+
+ +
+

๋ฐฑ์—…/๋ณต์› ๊ด€๋ฆฌ

+

์‹œ์Šคํ…œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐฑ์—…ํ•˜๊ณ  ๋ณต์›ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+
+ + +
+
+

+ + ๋ฐ์ดํ„ฐ ๋ฐฑ์—… +

+
+
+
+ +
+

์ „์ฒด ๋ฐฑ์—…

+

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ๋ชจ๋‘ ๋ฐฑ์—…ํ•ฉ๋‹ˆ๋‹ค.

+ +
+ + +
+

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—…

+

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋งŒ ๋ฐฑ์—…ํ•ฉ๋‹ˆ๋‹ค (ํŒŒ์ผ ์ œ์™ธ).

+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

+ + ๋ฐฑ์—… ๋ชฉ๋ก +

+
+
+
+ +

๋ฐฑ์—… ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ +
+ +
+
+
+ + +
+
+

+ + ๋ฐ์ดํ„ฐ ๋ณต์› +

+
+
+
+
+ + ์ฃผ์˜์‚ฌํ•ญ +
+

+ ๋ณต์› ์ž‘์—…์€ ํ˜„์žฌ ๋ฐ์ดํ„ฐ๋ฅผ ์™„์ „ํžˆ ๋ฎ์–ด์”๋‹ˆ๋‹ค. ๋ณต์› ์ „์— ๋ฐ˜๋“œ์‹œ ํ˜„์žฌ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐฑ์—…ํ•˜์„ธ์š”. +

+
+ +
+
+ + +
+ +
+
+

์„ ํƒ๋œ ํŒŒ์ผ

+

+

+
+
+ + +
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + + diff --git a/frontend/book-documents.html b/frontend/book-documents.html new file mode 100644 index 0000000..6dbe29c --- /dev/null +++ b/frontend/book-documents.html @@ -0,0 +1,160 @@ + + + + + + ์„œ์  ๋ฌธ์„œ ๋ชฉ๋ก - Document Server + + + + + + + + +
+ + +
+ +
+ +
+ + + +
+ + +
+
+
+
+ +
+
+

+
+ + โ€ข + +
+
+
+
+ + +
+

+
+
+
+ + +
+
+

๋ฌธ์„œ ๋ชฉ๋ก

+
+ + +
+ +

๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +
+ + +
+ +

๋ฌธ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

์ด ์„œ์ ์— ๋“ฑ๋ก๋œ ๋ฌธ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+
+
+
+ + + + + + + + diff --git a/frontend/book-editor.html b/frontend/book-editor.html new file mode 100644 index 0000000..6a86ed6 --- /dev/null +++ b/frontend/book-editor.html @@ -0,0 +1,199 @@ + + + + + + ์„œ์  ํŽธ์ง‘ - Document Server + + + + + + + + + +
+ + +
+ +
+
+ + + +
+ +
+
+
+ +
+
+

+

+

+ ๊ฐœ ๋ฌธ์„œ ํŽธ์ง‘ +

+
+
+
+
+ + +
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +
+
+

+ + ์„œ์  ์ •๋ณด +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+

+ + ๋ฌธ์„œ ์ˆœ์„œ ๋ฐ PDF ๋งค์นญ +

+
+ + +
+
+
+ +
+
+ +
+ + +
+ +

ํŽธ์ง‘ํ•  ๋ฌธ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+
+
+
+
+
+ + + + + + + + diff --git a/frontend/cache-buster.html b/frontend/cache-buster.html new file mode 100644 index 0000000..4fe5834 --- /dev/null +++ b/frontend/cache-buster.html @@ -0,0 +1,38 @@ + + + + + + ์บ์‹œ ๋ฌดํšจํ™” - Document Server + + + +
+

๐Ÿ”ง ์บ์‹œ ๋ฌดํšจํ™” ์ค‘...

+

์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”. 3์ดˆ ํ›„ ์—…๋กœ๋“œ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.

+
+
+
+
+ + + + diff --git a/frontend/components/header.html b/frontend/components/header.html new file mode 100644 index 0000000..5fe279f --- /dev/null +++ b/frontend/components/header.html @@ -0,0 +1,585 @@ + +
+ +
+ + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ab9c33 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,562 @@ + + + + + + Document Server + + + + + + + + + + + + + + + +
+ +
+
+
+

๋กœ๊ทธ์ธ

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ +
+ + +
+ + + + + +
+ + +
+
+
+

๋ฌธ์„œ ์—…๋กœ๋“œ

+ +
+ +
+
+ +
+ +
+ + +
+

+ + +

+
+
+
+ + +
+ +
+ + +
+

+ + +

+
+
+
+ + +
+ + + +
+ + + +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+ +
+
+
+ + +
+
+
+ +
+
+ +
+
+ + +
+
๐Ÿ’ก ์œ ์‚ฌํ•œ ์„œ์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค:
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+
+
+ + +
+ + + + + + + + + + + diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..56f9320 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,316 @@ + + + + + + ๋กœ๊ทธ์ธ - Document Server + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ + +
+ +
+ +
+
+ +
+

Document Server

+

์ง€์‹์„ ๊ด€๋ฆฌํ•˜๊ณ  ๊ณต์œ ํ•˜์„ธ์š”

+
+ + +
+
+

๋กœ๊ทธ์ธ

+

๊ณ„์ •์— ๋กœ๊ทธ์ธํ•˜์—ฌ ์‹œ์ž‘ํ•˜์„ธ์š”

+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+ + + ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์žŠ์œผ์…จ๋‚˜์š”? + +
+ + +
+ + +
+
+
+
+
+
+ ๋˜๋Š” +
+
+ +
+ +
+
+
+ + +
+

© 2024 Document Server. All rights reserved.

+

+ + ์•ˆ์ „ํ•˜๊ณ  ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ๋ฌธ์„œ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +

+
+
+
+ + + + + + + + + + + diff --git a/frontend/logs.html b/frontend/logs.html new file mode 100644 index 0000000..bea201b --- /dev/null +++ b/frontend/logs.html @@ -0,0 +1,331 @@ + + + + + + ์‹œ์Šคํ…œ ๋กœ๊ทธ - Document Server + + + + + + + +
+ + +
+
+ +
+

์‹œ์Šคํ…œ ๋กœ๊ทธ

+

์‹œ์Šคํ…œ ํ™œ๋™๊ณผ ์˜ค๋ฅ˜ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+
+ + +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+

์ •๋ณด

+

+
+
+
+ +
+
+
+ +
+
+

๊ฒฝ๊ณ 

+

+
+
+
+ +
+
+
+ +
+
+

์˜ค๋ฅ˜

+

+
+
+
+ +
+
+
+ +
+
+

๋””๋ฒ„๊ทธ

+

+
+
+
+
+ + +
+
+

+ + ๋กœ๊ทธ ๋ชฉ๋ก + (๊ฐœ) +

+
+ +
+ + + + + + + + + + + + +
์‹œ๊ฐ„๋ ˆ๋ฒจ์†Œ์Šค๋ฉ”์‹œ์ง€
+
+ + +
+
+ - / ๊ฐœ +
+
+ + +
+
+
+
+
+ + + + + + + diff --git a/frontend/memo-tree.html b/frontend/memo-tree.html new file mode 100644 index 0000000..f4b19f3 --- /dev/null +++ b/frontend/memo-tree.html @@ -0,0 +1,1281 @@ + + + + + + ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ - Document Server + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+

๋กœ๊ทธ์ธ

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+ +
+
+ +
+
+ + + +
+ + +
+ + +
+ + + + +
+
+
+ + +
+ +
+ +
+
+ +

ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์™ผ์ชฝ์—์„œ ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ์ƒˆ๋กœ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”

+
+
+ + +
+
+ +

์ฒซ ๋ฒˆ์งธ ๋…ธ๋“œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”

+ +
+
+ + +
+ +
+ + + + + + +
+ + +
+
+
+
+ + +
+ + + + +
+
+
+ + +
+
+ +

๋…ธ๋“œ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์™ผ์ชฝ ํŠธ๋ฆฌ์—์„œ ๋…ธ๋“œ๋ฅผ ํด๋ฆญํ•˜์—ฌ ํŽธ์ง‘์„ ์‹œ์ž‘ํ•˜์„ธ์š”

+
+
+
+
+ + +
+
+
+ + + +
+
+
+ + +
+
+ +

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค

+

ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”

+ +
+
+ + +
+
+

์ƒˆ ํŠธ๋ฆฌ ์ƒ์„ฑ

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ +
+

๋…ธ๋“œ ํŽธ์ง‘

+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+ + + + + diff --git a/frontend/note-editor.html b/frontend/note-editor.html new file mode 100644 index 0000000..0dee1cd --- /dev/null +++ b/frontend/note-editor.html @@ -0,0 +1,202 @@ + + + + + + ๋…ธํŠธ ํŽธ์ง‘๊ธฐ - Document Server + + + + + + + + + + + + +
+ + +
+ +
+
+
+

+ + +

+

HTML ์—๋””ํ„ฐ๋กœ ํ’๋ถ€ํ•œ ๋…ธํŠธ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”

+
+ +
+ + + +
+
+
+ + +
+
+ +
+ + +
+ + +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+

๋…ธํŠธ ๋‚ด์šฉ

+
+ + +
+
+
+ + +
+ + +
+ +
+
+ + +
+
+

๋ฏธ๋ฆฌ๋ณด๊ธฐ

+
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/notebooks.html b/frontend/notebooks.html new file mode 100644 index 0000000..97f317a --- /dev/null +++ b/frontend/notebooks.html @@ -0,0 +1,453 @@ + + + + + + ๋…ธํŠธ๋ถ ๊ด€๋ฆฌ - Document Server + + + + + + + + +
+ + +
+ +
+
+
+

+ + ๋…ธํŠธ๋ถ ๊ด€๋ฆฌ +

+

๋…ธํŠธ๋“ค์„ ์ฒด๊ณ„์ ์œผ๋กœ ๋ถ„๋ฅ˜ํ•˜๊ณ  ๊ด€๋ฆฌํ•˜์„ธ์š”

+
+ +
+ + + + + +
+
+
+ + +
+
+
+
+ +
+
+

์ „์ฒด ๋…ธํŠธ๋ถ

+

+
+
+
+ +
+
+
+ +
+
+

ํ™œ์„ฑ ๋…ธํŠธ๋ถ

+

+
+
+
+ +
+
+
+ +
+
+

์ „์ฒด ๋…ธํŠธ

+

+
+
+
+ +
+
+
+ +
+
+

๋ฏธ๋ถ„๋ฅ˜ ๋…ธํŠธ

+

+
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ +

๋…ธํŠธ๋ถ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +

๋…ธํŠธ๋ถ์ด ์—†์Šต๋‹ˆ๋‹ค

+

์ฒซ ๋ฒˆ์งธ ๋…ธํŠธ๋ถ์„ ๋งŒ๋“ค์–ด ๋…ธํŠธ๋“ค์„ ์ •๋ฆฌํ•ด๋ณด์„ธ์š”

+ +
+
+ + +
+
+
+ + + +
+
+
+ + +
+ +
+ +
+

+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ +
+
+ +
+ + +
+
+ + +
+ + +
+
+
+
+ + +
+ +
+ +
+
+ +
+
+

๋…ธํŠธ๋ถ ์‚ญ์ œ

+

์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+
+
+ +
+

+ ๋…ธํŠธ๋ถ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? +

+
+
+ + + ํฌํ•จ๋œ ๊ฐœ์˜ ๋…ธํŠธ๋Š” ๋ฏธ๋ถ„๋ฅ˜ ์ƒํƒœ๊ฐ€ ๋ฉ๋‹ˆ๋‹ค. + +
+
+
+ +
+ + +
+
+
+ + + + + + + + diff --git a/frontend/notes.html b/frontend/notes.html new file mode 100644 index 0000000..01dc783 --- /dev/null +++ b/frontend/notes.html @@ -0,0 +1,423 @@ + + + + + + ๋…ธํŠธ ๊ด€๋ฆฌ - Document Server + + + + + + + + +
+ + +
+ +
+
+
+

+ + ๋…ธํŠธ ๊ด€๋ฆฌ +

+

๋งˆํฌ๋‹ค์šด์œผ๋กœ ๋…ธํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  ์„œ์ ๊ณผ ์—ฐ๊ฒฐํ•˜์—ฌ ๊ด€๋ฆฌํ•˜์„ธ์š”

+
+ +
+ + + + + +
+
+
+ + +
+
+
+
+ +
+
+

์ „์ฒด ๋…ธํŠธ

+

+
+
+
+ +
+
+
+ +
+
+

๊ณต๊ฐœ ๋…ธํŠธ

+

+
+
+
+ +
+
+
+ +
+
+

์ดˆ์•ˆ

+

+
+
+
+ +
+
+
+ +
+
+

์ฝ๊ธฐ ์‹œ๊ฐ„

+

+
+
+
+
+ + +
+
+
+ + ๊ฐœ ๋…ธํŠธ ์„ ํƒ๋จ + + +
+ +
+ + + + + + +
+
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+ +
+ +

๋…ธํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +
+ + +
+ +

๋…ธํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

์ฒซ ๋ฒˆ์งธ ๋…ธํŠธ๋ฅผ ์ž‘์„ฑํ•ด๋ณด์„ธ์š”

+ +
+
+
+ + +
+ +
+ +
+

์ƒˆ ๋…ธํŠธ๋ถ ๋งŒ๋“ค๊ธฐ

+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ +
+
+ +
+ + +
+
+ + +
+

+ + ์„ ํƒ๋œ ๊ฐœ์˜ ๋…ธํŠธ๊ฐ€ ์ด ๋…ธํŠธ๋ถ์— ํ• ๋‹น๋ฉ๋‹ˆ๋‹ค. +

+
+ + +
+ + +
+
+
+
+ + + + + + + + diff --git a/frontend/pdf-manager.html b/frontend/pdf-manager.html new file mode 100644 index 0000000..e67ac48 --- /dev/null +++ b/frontend/pdf-manager.html @@ -0,0 +1,444 @@ + + + + + + PDF ํŒŒ์ผ ๊ด€๋ฆฌ - Document Server + + + + + + + + +
+ + +
+ +
+
+
+

PDF ํŒŒ์ผ ๊ด€๋ฆฌ

+

์—…๋กœ๋“œ๋œ PDF ํŒŒ์ผ๋“ค์„ ๊ด€๋ฆฌํ•˜๊ณ  ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

+
+ + +
+
+ + +
+
+
+
+ +
+
+

์ „์ฒด PDF

+

+
+
+
+ +
+
+
+ +
+
+

์„œ์  ํฌํ•จ

+

+
+
+
+ +
+
+
+ +
+
+

HTML ์—ฐ๊ฒฐ

+

+
+
+
+ +
+
+
+ +
+
+

๋…๋ฆฝ ํŒŒ์ผ

+

+
+
+
+
+ + +
+
+

PDF ํŒŒ์ผ ๊ด€๋ฆฌ

+ + +
+ + +
+
+ + +
+ + + + +
+
+ + +
+ +
+ +

PDF ํŒŒ์ผ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +
+ + +
+ +

PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+

+ ์—…๋กœ๋“œ๋œ PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค + ์„œ์ ์— ํฌํ•จ๋œ PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค + HTML๊ณผ ์—ฐ๊ฒฐ๋œ PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค + ๋…๋ฆฝ PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค +

+
+
+ + +
+ +
+ +

PDF ํŒŒ์ผ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + + + + +
+ +

PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+

PDF ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ณ  ์„œ์ ์œผ๋กœ ๋ถ„๋ฅ˜ํ•ด๋ณด์„ธ์š”

+ +
+
+
+ + +
+
+ +
+
+ +
+

+

+
+
+
+ + +
+
+ + +
+ +
+ +
+ + + + +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•˜๋Š” ์ค‘...

+
+
+ + +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค

+ + +
+
+
+
+
+
+
+ + + + + + + + + + + diff --git a/frontend/profile.html b/frontend/profile.html new file mode 100644 index 0000000..3b417b7 --- /dev/null +++ b/frontend/profile.html @@ -0,0 +1,363 @@ + + + + + + ํ”„๋กœํ•„ ๊ด€๋ฆฌ - Document Server + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+

ํ”„๋กœํ•„ ๊ด€๋ฆฌ

+

๊ฐœ์ธ ์ •๋ณด์™€ ๊ณ„์ • ์„ค์ •์„ ๊ด€๋ฆฌํ•˜์„ธ์š”.

+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+

+

+
+ + + + + + +
+
+
+
+
+ + +
+ +
+ + +
+
+

ํ”„๋กœํ•„ ์ •๋ณด

+ +
+
+ + +
+ +
+ + +

์ด๋ฉ”์ผ์€ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+
+ +
+ +
+
+
+
+ + +
+
+

๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ

+ +
+
+ + +
+ +
+ + +

์ตœ์†Œ 6์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.

+
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+
+

ํ™˜๊ฒฝ ์„ค์ •

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/frontend/search.html b/frontend/search.html new file mode 100644 index 0000000..12869d5 --- /dev/null +++ b/frontend/search.html @@ -0,0 +1,740 @@ + + + + + + ํ†ตํ•ฉ ๊ฒ€์ƒ‰ - Document Server + + + + + + + + + + + + + + + +
+ + +
+ +
+

+ + ํ†ตํ•ฉ ๊ฒ€์ƒ‰ +

+

๋ฌธ์„œ, ๋…ธํŠธ, ๋ฉ”๋ชจ๋ฅผ ํ•œ ๋ฒˆ์— ๊ฒ€์ƒ‰ํ•˜์„ธ์š”

+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ ํƒ€์ž…: + + + + + + + +
+ + +
+ ํŒŒ์ผ ํƒ€์ž…: + + +
+ + +
+ ์ •๋ ฌ: + +
+
+
+
+ + +
+
+
+
+ + ๊ฐœ ๊ฒฐ๊ณผ + + "" ๊ฒ€์ƒ‰ + + +
+ + ๐Ÿ“„ ๋ฌธ์„œ ๊ฐœ + + + ๐Ÿ“ ๋…ธํŠธ ๊ฐœ + + + ๐ŸŒณ ๋ฉ”๋ชจ ๊ฐœ + + + ๐Ÿ–๏ธ ํ•˜์ด๋ผ์ดํŠธ ๊ฐœ + + + ๐Ÿ’ฌ ๋ฉ”๋ชจ ๊ฐœ + + + ๐Ÿ“– ๋ณธ๋ฌธ ๊ฐœ + +
+
+
+ + ms +
+
+
+
+ + +
+ +

๊ฒ€์ƒ‰ ์ค‘...

+
+ + +
+ +
+ + +
+
+ +

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

+ + ""์— ๋Œ€ํ•œ ๊ฒฐ๊ณผ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + + ๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. +

+
+

๊ฒ€์ƒ‰ ํŒ:

+
    +
  • โ€ข ๋‹ค๋ฅธ ํ‚ค์›Œ๋“œ๋กœ ๊ฒ€์ƒ‰ํ•ด๋ณด์„ธ์š”
  • +
  • โ€ข ๊ฒ€์ƒ‰์–ด๋ฅผ ์ค„์—ฌ๋ณด์„ธ์š”
  • +
  • โ€ข ํ•„ํ„ฐ๋ฅผ ๋ณ€๊ฒฝํ•ด๋ณด์„ธ์š”
  • +
+
+
+
+ + +
+
+ +

๊ฒ€์ƒ‰์„ ์‹œ์ž‘ํ•˜์„ธ์š”

+

๋ฌธ์„œ, ๋…ธํŠธ, ๋ฉ”๋ชจ, ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ํ†ตํ•ฉ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

+ + +
+ + + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+

+

+
+
+ + +
+
+ + +
+ +
+
+
+ PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ +
+
+ +
+
+ + +
+ + + + +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•˜๋Š” ์ค‘...

+
+
+ +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค

+ +
+
+
+
+ + +
+
+
+ HTML ๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +
+
+ +
+
+ +
+ + + + +
+

+                        
+ + +
+
+ +

HTML์„ ๋กœ๋“œํ•˜๋Š” ์ค‘...

+
+
+
+
+ + +
+
+
+ ๋ฉ”๋ชจ ๋…ธ๋“œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +
+
+ +
+
+ +
+
+ +

+ + +
+
+
+
+
+
+ + +
+
+
+ ๋…ธํŠธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +
+
+ +
+
+ +
+
+ +
+

+
+ +
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ ํ•˜์ด๋ผ์ดํŠธ๋œ ํ…์ŠคํŠธ +
+
+
+ ๋ฉ”๋ชจ: +
+
+ + +
+
+ ์›๋ณธ ํ•˜์ด๋ผ์ดํŠธ +
+
+
๋ฉ”๋ชจ ๋‚ด์šฉ:
+
+ + +
+
+
+ ๋ณธ๋ฌธ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ +
+
+ + โ€ข ๊ฐœ ๋งค์น˜ +
+
+
+ + +
+
+
+ + +
+ +

๋ฏธ๋ฆฌ๋ณด๊ธฐํ•  ์ˆ˜ ์žˆ๋Š” ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ +
+ + +
+
+ ๋ฉ”๋ชจ ํŠธ๋ฆฌ ์ •๋ณด +
+
+
+ + +
+ +

๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+
+
+ + + + + + + + diff --git a/frontend/setup.html b/frontend/setup.html new file mode 100644 index 0000000..f00ca21 --- /dev/null +++ b/frontend/setup.html @@ -0,0 +1,274 @@ + + + + + + ์‹œ์Šคํ…œ ์ดˆ๊ธฐ ์„ค์ • - Document Server + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+

Document Server

+

์‹œ์Šคํ…œ ์ดˆ๊ธฐ ์„ค์ •

+
+ + +
+
+ + ์‹œ์Šคํ…œ ์ƒํƒœ ํ™•์ธ ์ค‘... +
+
+ + +
+
+ +
+

์‹œ์Šคํ…œ์ด ์ด๋ฏธ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค

+

Document Server๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

+ + ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + +
+ + +
+
+

๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ

+

์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž(Root) ๊ณ„์ •์„ ์ƒ์„ฑํ•ด์ฃผ์„ธ์š”.

+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+

์ฃผ์˜์‚ฌํ•ญ:

+
    +
  • ์ด ๊ณ„์ •์€ ์‹œ์Šคํ…œ์˜ ์ตœ๊ณ  ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค.
  • +
  • ์•ˆ์ „ํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์ž˜ ๋ณด๊ด€ํ•ด์ฃผ์„ธ์š”.
  • +
  • ์„ค์ • ์™„๋ฃŒ ํ›„์—๋Š” ์ด ํŽ˜์ด์ง€์— ๋‹ค์‹œ ์ ‘๊ทผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  • +
+
+
+
+ + +
+
+ + +
+
+ +
+

์„ค์ •์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!

+

Document Server๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

+ +
+
+

์ƒ์„ฑ๋œ ๊ด€๋ฆฌ์ž ๊ณ„์ •:

+

์ด๋ฉ”์ผ:

+

์ด๋ฆ„:

+

์—ญํ• : ์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž

+
+
+ +
+ + ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + + +
+
+
+
+ + + + + + + + diff --git a/frontend/static/css/main.css b/frontend/static/css/main.css new file mode 100644 index 0000000..34cc482 --- /dev/null +++ b/frontend/static/css/main.css @@ -0,0 +1,270 @@ +/* ๋ฉ”์ธ ์Šคํƒ€์ผ */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + padding-top: 4rem; /* ๊ณ ์ • ํ—ค๋”๋ฅผ ์œ„ํ•œ ํŒจ๋”ฉ */ +} + +/* ์•Œ๋ฆผ ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +.notification { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ */ +.loading-spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ์นด๋“œ ํ˜ธ๋ฒ„ ํšจ๊ณผ */ +.card-hover { + transition: all 0.2s ease-in-out; +} + +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +/* ํƒœ๊ทธ ์Šคํƒ€์ผ */ +.tag { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 9999px; + background-color: #dbeafe; + color: #1e40af; +} + +/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ ํฌ์ปค์Šค */ +.search-input:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* ๋“œ๋กญ๋‹ค์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +.dropdown-enter { + opacity: 0; + transform: scale(0.95); +} + +.dropdown-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity 150ms ease-out, transform 150ms ease-out; +} + +/* ๋ชจ๋‹ฌ ๋ฐฐ๊ฒฝ */ +.modal-backdrop { + backdrop-filter: blur(4px); +} + +/* ํŒŒ์ผ ๋“œ๋กญ ์˜์—ญ */ +.file-drop-zone { + border: 2px dashed #d1d5db; + border-radius: 0.5rem; + padding: 2rem; + text-align: center; + transition: all 0.2s ease-in-out; +} + +.file-drop-zone.dragover { + border-color: #3b82f6; + background-color: #eff6ff; +} + +.file-drop-zone:hover { + border-color: #6b7280; +} + +/* ๋ฐ˜์‘ํ˜• ๊ทธ๋ฆฌ๋“œ */ +@media (max-width: 768px) { + .grid-responsive { + grid-template-columns: 1fr; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .grid-responsive { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1025px) { + .grid-responsive { + grid-template-columns: repeat(3, 1fr); + } +} + +/* ์Šคํฌ๋กค๋ฐ” ์Šคํƒ€์ผ๋ง */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* ํ…์ŠคํŠธ ์ค„์ž„ํ‘œ */ +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ๋ผ์ธ ํด๋žจํ”„ ์œ ํ‹ธ๋ฆฌํ‹ฐ */ +.line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ํฌ์ปค์Šค ๋ง ์ œ๊ฑฐ */ +.focus-visible:focus { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px #3b82f6; +} + +/* ๋ฒ„ํŠผ ์ƒํƒœ */ +.btn-primary { + background-color: #3b82f6; + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; + transition: background-color 0.2s ease-in-out; +} + +.btn-primary:hover { + background-color: #2563eb; +} + +.btn-primary:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + +.btn-secondary { + background-color: #6b7280; + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 500; + transition: background-color 0.2s ease-in-out; +} + +.btn-secondary:hover { + background-color: #4b5563; +} + +/* ์ž…๋ ฅ ํ•„๋“œ ์Šคํƒ€์ผ */ +.input-field { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.input-field:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.input-field:invalid { + border-color: #ef4444; +} + +/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */ +.error-message { + color: #ef4444; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ */ +.success-message { + color: #10b981; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +/* ๋นˆ ์ƒํƒœ ์ผ๋Ÿฌ์ŠคํŠธ๋ ˆ์ด์…˜ */ +.empty-state { + text-align: center; + padding: 3rem 1rem; +} + +.empty-state i { + font-size: 4rem; + color: #9ca3af; + margin-bottom: 1rem; +} + +.empty-state h3 { + font-size: 1.25rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #6b7280; + margin-bottom: 1.5rem; +} diff --git a/frontend/static/css/viewer.css b/frontend/static/css/viewer.css new file mode 100644 index 0000000..cb0306c --- /dev/null +++ b/frontend/static/css/viewer.css @@ -0,0 +1,455 @@ +/* ๋ทฐ์–ด ์ „์šฉ ์Šคํƒ€์ผ */ + +/* ํ•˜์ด๋ผ์ดํŠธ ์Šคํƒ€์ผ */ +.highlight { + position: relative; + cursor: pointer; + border-radius: 2px; + padding: 1px 2px; + margin: -1px -2px; + transition: all 0.2s ease-in-out; +} + +.highlight:hover { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); + z-index: 10; +} + +.highlight.selected { + box-shadow: 0 0 0 2px #3B82F6; + z-index: 10; +} + +/* ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ */ +.highlight-button { + animation: fadeInUp 0.2s ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ */ +.search-highlight { + background-color: #FEF3C7 !important; + border: 1px solid #F59E0B; + border-radius: 2px; + padding: 1px 2px; + margin: -1px -2px; +} + +/* ๋ฌธ์„œ ๋‚ด์šฉ ์Šคํƒ€์ผ */ +#document-content { + line-height: 1.7; + font-size: 16px; + color: #374151; +} + +#document-content h1, +#document-content h2, +#document-content h3, +#document-content h4, +#document-content h5, +#document-content h6 { + color: #111827; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 1rem; +} + +#document-content h1 { + font-size: 2.25rem; + border-bottom: 2px solid #e5e7eb; + padding-bottom: 0.5rem; +} + +#document-content h2 { + font-size: 1.875rem; +} + +#document-content h3 { + font-size: 1.5rem; +} + +#document-content p { + margin-bottom: 1rem; +} + +#document-content ul, +#document-content ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +#document-content li { + margin-bottom: 0.25rem; +} + +#document-content blockquote { + border-left: 4px solid #e5e7eb; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; + color: #6b7280; +} + +#document-content code { + background-color: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; +} + +#document-content pre { + background-color: #f3f4f6; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 1rem 0; +} + +#document-content pre code { + background: none; + padding: 0; +} + +#document-content table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +#document-content th, +#document-content td { + border: 1px solid #e5e7eb; + padding: 0.5rem; + text-align: left; +} + +#document-content th { + background-color: #f9fafb; + font-weight: 600; +} + +#document-content img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 1rem 0; +} + +/* ์‚ฌ์ด๋“œ ํŒจ๋„ ์Šคํƒ€์ผ */ +.side-panel { + background: white; + border-left: 1px solid #e5e7eb; + height: calc(100vh - 4rem); + overflow: hidden; +} + +.panel-tab { + transition: all 0.2s ease-in-out; +} + +.panel-tab.active { + background-color: #eff6ff; + color: #2563eb; + border-bottom: 2px solid #2563eb; +} + +/* ๋ฉ”๋ชจ ์นด๋“œ ์Šคํƒ€์ผ */ +.note-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.note-card:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-1px); +} + +.note-card.selected { + border-color: #3b82f6; + background: #eff6ff; +} + +/* ์ฑ…๊ฐˆํ”ผ ์นด๋“œ ์Šคํƒ€์ผ */ +.bookmark-card { + background: #f0fdf4; + border: 1px solid #dcfce7; + border-radius: 0.5rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + transition: all 0.2s ease-in-out; + cursor: pointer; +} + +.bookmark-card:hover { + background: #ecfdf5; + border-color: #bbf7d0; + transform: translateY(-1px); +} + +/* ์ƒ‰์ƒ ์„ ํƒ๊ธฐ */ +.color-picker { + display: flex; + gap: 0.25rem; + padding: 0.25rem; + background: #f3f4f6; + border-radius: 0.5rem; +} + +.color-option { + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + border: 2px solid white; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.color-option:hover { + transform: scale(1.1); +} + +.color-option.selected { + box-shadow: 0 0 0 2px #3b82f6; +} + +/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */ +.search-input { + background: white; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem 0.5rem 2.5rem; + width: 100%; + transition: all 0.2s ease-in-out; +} + +.search-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* ๋„๊ตฌ ๋ชจ์Œ */ +.toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: white; + border-bottom: 1px solid #e5e7eb; +} + +.toolbar-button { + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + border: none; + background: #f3f4f6; + color: #374151; + cursor: pointer; + transition: all 0.2s ease-in-out; + font-size: 0.875rem; + font-weight: 500; +} + +.toolbar-button:hover { + background: #e5e7eb; +} + +.toolbar-button.active { + background: #3b82f6; + color: white; +} + +.toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ๋ชจ๋‹ฌ ์Šคํƒ€์ผ */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal-content { + background: white; + border-radius: 0.75rem; + padding: 1.5rem; + max-width: 90vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: modalSlideIn 0.2s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* ํƒœ๊ทธ ์ž…๋ ฅ */ +.tag-input { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + min-height: 2.5rem; + background: white; +} + +.tag-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: #eff6ff; + color: #1e40af; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.tag-remove { + cursor: pointer; + color: #6b7280; + font-size: 0.75rem; +} + +.tag-remove:hover { + color: #ef4444; +} + +/* ์Šคํฌ๋กค ํ‘œ์‹œ๊ธฐ */ +.scroll-indicator { + position: fixed; + top: 4rem; + right: 1rem; + width: 4px; + height: calc(100vh - 5rem); + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; + z-index: 30; +} + +.scroll-thumb { + width: 100%; + background: #3b82f6; + border-radius: 2px; + transition: background-color 0.2s ease-in-out; +} + +.scroll-thumb:hover { + background: #2563eb; +} + +/* ๋ฐ˜์‘ํ˜• ๋””์ž์ธ */ +@media (max-width: 768px) { + .side-panel { + position: fixed; + top: 4rem; + right: 0; + width: 100%; + max-width: 24rem; + z-index: 40; + box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1); + } + + #document-content { + font-size: 14px; + padding: 1rem; + } + + .toolbar { + flex-wrap: wrap; + gap: 0.25rem; + } + + .toolbar-button { + font-size: 0.75rem; + padding: 0.375rem 0.5rem; + } + + .color-option { + width: 1.5rem; + height: 1.5rem; + } +} + +/* ๋‹คํฌ ๋ชจ๋“œ ์ง€์› */ +@media (prefers-color-scheme: dark) { + .highlight { + filter: brightness(0.8); + } + + .search-highlight { + background-color: #451a03 !important; + border-color: #92400e; + color: #fbbf24; + } + + #document-content { + color: #e5e7eb; + } + + #document-content h1, + #document-content h2, + #document-content h3, + #document-content h4, + #document-content h5, + #document-content h6 { + color: #f9fafb; + } +} + +/* ์ธ์‡„ ์Šคํƒ€์ผ */ +@media print { + .toolbar, + .side-panel, + .highlight-button { + display: none !important; + } + + .highlight { + background-color: #fef3c7 !important; + box-shadow: none !important; + } + + #document-content { + font-size: 12pt; + line-height: 1.5; + } +} diff --git a/frontend/static/images/README.md b/frontend/static/images/README.md new file mode 100644 index 0000000..371eb38 --- /dev/null +++ b/frontend/static/images/README.md @@ -0,0 +1,41 @@ +# ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€ + +์ด ํด๋”์—๋Š” ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ๋˜๋Š” ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + +## ํ•„์š”ํ•œ ์ด๋ฏธ์ง€ ํŒŒ์ผ + +### ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€ +- `login-bg.jpg` - ์ „์ฒด ํŽ˜์ด์ง€ ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€ (๊ถŒ์žฅ ํฌ๊ธฐ: 1920x1080px ์ด์ƒ) + +## ์ด๋ฏธ์ง€ ์‚ฌ์–‘ + +- **ํ˜•์‹**: JPG, PNG ์ง€์› +- **ํ’ˆ์งˆ**: ์›น ์ตœ์ ํ™”๋œ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€ +- **์šฉ๋Ÿ‰**: 1MB ์ดํ•˜ ๊ถŒ์žฅ +- **๋น„์œจ**: 16:9 ๋˜๋Š” 16:10 ๋น„์œจ ๊ถŒ์žฅ +- **์ƒ‰์ƒ**: ์–ด๋‘์šด ํ†ค ๋˜๋Š” ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ ์ด๋ฏธ์ง€ ๊ถŒ์žฅ (ํ…์ŠคํŠธ ๊ฐ€๋…์„ฑ์„ ์œ„ํ•ด) + +## ํด๋ฐฑ ๋™์ž‘ + +๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€ ํŒŒ์ผ์ด ์—†๋Š” ๊ฒฝ์šฐ: +- ํŒŒ๋ž€์ƒ‰-๋ณด๋ผ์ƒ‰ ๊ทธ๋ผ๋””์–ธํŠธ ๋ฐฐ๊ฒฝ์œผ๋กœ ์ž๋™ ํด๋ฐฑ + +## ์‚ฌ์šฉ ์˜ˆ์‹œ + +``` +static/images/ +โ””โ”€โ”€ login-bg.jpg (์ „์ฒด ๋ฐฐ๊ฒฝ) +``` + +## ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€ ์„ ํƒ ๊ฐ€์ด๋“œ + +- **๋ฌธ์„œ/๋„์„œ๊ด€ ํ…Œ๋งˆ**: ์ฑ…์žฅ, ๋„์„œ๊ด€, ์„œ์žฌ ๋“ฑ +- **๊ธฐ์ˆ /ํ˜„๋Œ€์  ํ…Œ๋งˆ**: ์ถ”์ƒ์  ํŒจํ„ด, ๊ธฐํ•˜ํ•™์  ํ˜•ํƒœ +- **์ž์—ฐ ํ…Œ๋งˆ**: ์ฐจ๋ถ„ํ•œ ํ’๊ฒฝ, ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ ์ž์—ฐ ์ด๋ฏธ์ง€ +- **๋ฏธ๋‹ˆ๋ฉ€ ํ…Œ๋งˆ**: ๋‹จ์ˆœํ•œ ํŒจํ„ด, ํ…์Šค์ฒ˜ + +## ๋ณ€๊ฒฝ ์‚ฌํ•ญ (v2.0) + +- ๊ฐค๋Ÿฌ๋ฆฌ ์•ก์ž ๊ธฐ๋Šฅ ์ œ๊ฑฐ +- ์ค‘์•™ ์ง‘์ค‘ํ˜• ๋กœ๊ทธ์ธ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๋ณ€๊ฒฝ +- ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€๋งŒ ์‚ฌ์šฉํ•˜๋Š” ์‹ฌํ”Œํ•œ ๋””์ž์ธ \ No newline at end of file diff --git a/frontend/static/images/login-bg-2.jpg b/frontend/static/images/login-bg-2.jpg new file mode 100644 index 0000000..e295247 Binary files /dev/null and b/frontend/static/images/login-bg-2.jpg differ diff --git a/frontend/static/images/login-bg-3.jpg b/frontend/static/images/login-bg-3.jpg new file mode 100644 index 0000000..bd1f4d5 Binary files /dev/null and b/frontend/static/images/login-bg-3.jpg differ diff --git a/frontend/static/images/login-bg.jpg b/frontend/static/images/login-bg.jpg new file mode 100644 index 0000000..d5667ff Binary files /dev/null and b/frontend/static/images/login-bg.jpg differ diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js new file mode 100644 index 0000000..adb6313 --- /dev/null +++ b/frontend/static/js/api.js @@ -0,0 +1,750 @@ +/** + * API ํ†ต์‹  ์œ ํ‹ธ๋ฆฌํ‹ฐ + */ +class DocumentServerAPI { + constructor() { + // nginx ํ”„๋ก์‹œ๋ฅผ ํ†ตํ•œ API ํ˜ธ์ถœ (์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ๊ฐ•์ œ) + this.baseURL = `${window.location.origin}/api`; + this.token = localStorage.getItem('access_token'); + + console.log('๐ŸŒ API Base URL (NGINX PROXY):', this.baseURL); + console.log('๐Ÿ”ง ํ˜„์žฌ ๋ธŒ๋ผ์šฐ์ € ์œ„์น˜:', window.location.origin); + console.log('๐Ÿ”ง ํ˜„์žฌ ๋ธŒ๋ผ์šฐ์ € ์ „์ฒด URL:', window.location.href); + console.log('๐Ÿ”ง nginx ํ”„๋ก์‹œ ํ™˜๊ฒฝ ์„ค์ • ์™„๋ฃŒ - ์ƒ๋Œ€ ๊ฒฝ๋กœ ์‚ฌ์šฉ'); + } + + // ํ† ํฐ ์„ค์ • + setToken(token) { + this.token = token; + if (token) { + localStorage.setItem('access_token', token); + } else { + localStorage.removeItem('access_token'); + } + } + + // ๊ธฐ๋ณธ ์š”์ฒญ ํ—ค๋” + getHeaders() { + const headers = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + return headers; + } + + // GET ์š”์ฒญ + async get(endpoint, params = {}) { + // URL ์ƒ์„ฑ ์‹œ ํฌํŠธ ์œ ์ง€๋ฅผ ์œ„ํ•ด ๋‹จ์ˆœ ๋ฌธ์ž์—ด ์—ฐ๊ฒฐ ์‚ฌ์šฉ + let url = `${this.baseURL}${endpoint}`; + + // ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ + if (Object.keys(params).length > 0) { + const searchParams = new URLSearchParams(); + Object.keys(params).forEach(key => { + if (params[key] !== null && params[key] !== undefined) { + searchParams.append(key, params[key]); + } + }); + url += `?${searchParams.toString()}`; + } + + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + mode: 'cors', + credentials: 'same-origin' + }); + + return this.handleResponse(response); + } + + // POST ์š”์ฒญ + async post(endpoint, data = {}) { + const url = `${this.baseURL}${endpoint}`; + console.log('๐ŸŒ POST ์š”์ฒญ ์‹œ์ž‘'); + console.log(' - baseURL:', this.baseURL); + console.log(' - endpoint:', endpoint); + console.log(' - ์ตœ์ข… URL:', url); + console.log(' - ๋ฐ์ดํ„ฐ:', data); + + console.log('๐Ÿ” fetch ํ˜ธ์ถœ ์ง์ „ URL ๊ฒ€์ฆ:', url); + console.log('๐Ÿ” URL ํƒ€์ž…:', typeof url); + console.log('๐Ÿ” URL ์ ˆ๋Œ€/์ƒ๋Œ€ ์—ฌ๋ถ€:', url.startsWith('http') ? '์ ˆ๋Œ€๊ฒฝ๋กœ' : '์ƒ๋Œ€๊ฒฝ๋กœ'); + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(data), + mode: 'cors', + credentials: 'same-origin' + }); + + console.log('๐Ÿ“ก POST ์‘๋‹ต ๋ฐ›์Œ:', response.url, response.status); + console.log('๐Ÿ“ก ์‹ค์ œ ์š”์ฒญ๋œ URL:', response.url); + return this.handleResponse(response); + } + + // PUT ์š”์ฒญ + async put(endpoint, data = {}) { + const url = `${this.baseURL}${endpoint}`; + console.log('๐ŸŒ PUT ์š”์ฒญ URL:', url); // ๋””๋ฒ„๊น…์šฉ + + const response = await fetch(url, { + method: 'PUT', + headers: this.getHeaders(), + body: JSON.stringify(data), + mode: 'cors', + credentials: 'same-origin' + }); + + return this.handleResponse(response); + } + + // DELETE ์š”์ฒญ + async delete(endpoint) { + const url = `${this.baseURL}${endpoint}`; + console.log('๐ŸŒ DELETE ์š”์ฒญ URL:', url); // ๋””๋ฒ„๊น…์šฉ + + const response = await fetch(url, { + method: 'DELETE', + headers: this.getHeaders(), + mode: 'cors', + credentials: 'same-origin' + }); + + return this.handleResponse(response); + } + + // ํŒŒ์ผ ์—…๋กœ๋“œ + async uploadFile(endpoint, formData) { + const url = `${this.baseURL}${endpoint}`; + console.log('๐ŸŒ UPLOAD ์š”์ฒญ URL:', url); // ๋””๋ฒ„๊น…์šฉ + + const headers = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: formData, + mode: 'cors', + credentials: 'same-origin' + }); + + return this.handleResponse(response); + } + + // ์‘๋‹ต ์ฒ˜๋ฆฌ + async handleResponse(response) { + if (response.status === 401) { + // ํ† ํฐ ๋งŒ๋ฃŒ ๋˜๋Š” ์ธ์ฆ ์‹คํŒจ + this.setToken(null); + window.location.reload(); + throw new Error('์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `HTTP ${response.status}`); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + + return await response.text(); + } + + // ์ธ์ฆ ๊ด€๋ จ API + async login(email, password) { + const response = await this.post('/auth/login', { email, password }); + + // ํ† ํฐ ์ €์žฅ + if (response.access_token) { + this.setToken(response.access_token); + + // ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + try { + const user = await this.getCurrentUser(); + return { + success: true, + user: user, + token: response.access_token + }; + } catch (error) { + return { + success: false, + message: '์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' + }; + } + } else { + return { + success: false, + message: '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.' + }; + } + } + + async logout() { + try { + await this.post('/auth/logout'); + } finally { + this.setToken(null); + } + } + + async getCurrentUser() { + return await this.get('/auth/me'); + } + + async refreshToken(refreshToken) { + return await this.post('/auth/refresh', { refresh_token: refreshToken }); + } + + // ๋ฌธ์„œ ๊ด€๋ จ API + async getDocuments(params = {}) { + return await this.get('/documents/', params); + } + + async getDocumentsHierarchy() { + return await this.get('/documents/hierarchy/structured'); + } + + async getDocument(documentId) { + return await this.get(`/documents/${documentId}`); + } + + async getDocumentContent(documentId) { + return await this.get(`/documents/${documentId}/content`); + } + + async uploadDocument(formData) { + return await this.uploadFile('/documents/', formData); + } + + async updateDocument(documentId, updateData) { + return await this.put(`/documents/${documentId}`, updateData); + } + + async deleteDocument(documentId) { + return await this.delete(`/documents/${documentId}`); + } + + async getTags() { + return await this.get('/documents/tags/'); + } + + async createTag(tagData) { + return await this.post('/documents/tags/', tagData); + } + + // ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ API + async createHighlight(highlightData) { + console.log('๐ŸŽจ createHighlight ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ๋จ:', highlightData); + return await this.post('/highlights/', highlightData); + } + + async getDocumentHighlights(documentId) { + return await this.get(`/highlights/document/${documentId}`); + } + + async updateHighlight(highlightId, updateData) { + return await this.put(`/highlights/${highlightId}`, updateData); + } + + async deleteHighlight(highlightId) { + return await this.delete(`/highlights/${highlightId}`); + } + + // === ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ (Highlight Memo) ๊ด€๋ จ API === + // ์šฉ์–ด ์ •์˜: ํ•˜์ด๋ผ์ดํŠธ์— ๋‹ฌ๋ฆฌ๋Š” ์งง์€ ์ฝ”๋ฉ˜ํŠธ + async createNote(noteData) { + return await this.post('/highlight-notes/', noteData); + } + + async getNotes(params = {}) { + return await this.get('/highlight-notes/', params); + } + + async updateNote(noteId, updateData) { + return await this.put(`/highlight-notes/${noteId}`, updateData); + } + + async deleteNote(noteId) { + return await this.delete(`/highlight-notes/${noteId}`); + } + + // === ๋ฌธ์„œ ๋ฉ”๋ชจ ์กฐํšŒ === + // ์šฉ์–ด ์ •์˜: ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ + async getDocumentNotes(documentId) { + return await this.get(`/highlight-notes/`, { document_id: documentId }); + } + + + + async deleteNote(noteId) { + return await this.delete(`/notes/${noteId}`); + } + + async getPopularNoteTags() { + return await this.get('/notes/tags/popular'); + } + + // ์ฑ…๊ฐˆํ”ผ ๊ด€๋ จ API + async createBookmark(bookmarkData) { + return await this.post('/bookmarks/', bookmarkData); + } + + async getBookmarks(params = {}) { + return await this.get('/bookmarks/', params); + } + + async getDocumentBookmarks(documentId) { + return await this.get(`/bookmarks/document/${documentId}`); + } + + async updateBookmark(bookmarkId, data) { + return await this.put(`/bookmarks/${bookmarkId}`, data); + } + + async deleteBookmark(bookmarkId) { + return await this.delete(`/bookmarks/${bookmarkId}`); + } + + // ๊ฒ€์ƒ‰ ๊ด€๋ จ API + async search(params = {}) { + return await this.get('/search/', params); + } + + async getSearchSuggestions(query) { + return await this.get('/search/suggestions', { q: query }); + } + + // ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ API + async getUsers() { + return await this.get('/users/'); + } + + async createUser(userData) { + return await this.post('/auth/create-user', userData); + } + + async updateUser(userId, userData) { + return await this.put(`/users/${userId}`, userData); + } + + async deleteUser(userId) { + return await this.delete(`/users/${userId}`); + } + + async updateProfile(profileData) { + return await this.put('/users/profile', profileData); + } + + async changePassword(passwordData) { + return await this.put('/auth/change-password', passwordData); + } + + // === ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ API === + async getDocumentHighlights(documentId) { + return await this.get(`/highlights/document/${documentId}`); + } + + async createHighlight(highlightData) { + console.log('๐ŸŽจ createHighlight ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ๋จ:', highlightData); + return await this.post('/highlights/', highlightData); + } + + async updateHighlight(highlightId, highlightData) { + return await this.put(`/highlights/${highlightId}`, highlightData); + } + + async deleteHighlight(highlightId) { + return await this.delete(`/highlights/${highlightId}`); + } + + // === ๋ฉ”๋ชจ ๊ด€๋ จ API === + // === ๋ฌธ์„œ ๋ฉ”๋ชจ ์กฐํšŒ === + // ์šฉ์–ด ์ •์˜: ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ + async getDocumentNotes(documentId) { + return await this.get(`/notes/document/${documentId}`); + } + + async createNote(noteData) { + return await this.post('/notes/', noteData); + } + + async deleteNote(noteId) { + return await this.delete(`/notes/${noteId}`); + } + + async getNotesByHighlight(highlightId) { + return await this.get(`/notes/highlight/${highlightId}`); + } + + // === ์ฑ…๊ฐˆํ”ผ ๊ด€๋ จ API === + async getDocumentBookmarks(documentId) { + return await this.get(`/bookmarks/document/${documentId}`); + } + + async createBookmark(bookmarkData) { + return await this.post('/bookmarks/', bookmarkData); + } + + async updateBookmark(bookmarkId, bookmarkData) { + return await this.put(`/bookmarks/${bookmarkId}`, bookmarkData); + } + + async deleteBookmark(bookmarkId) { + return await this.delete(`/bookmarks/${bookmarkId}`); + } + + // === ๊ฒ€์ƒ‰ ๊ด€๋ จ API === + async searchDocuments(query, filters = {}) { + const params = new URLSearchParams({ q: query, ...filters }); + return await this.get(`/search/documents?${params}`); + } + + async searchNotes(query, documentId = null) { + const params = new URLSearchParams({ q: query }); + if (documentId) params.append('document_id', documentId); + return await this.get(`/search/notes?${params}`); + } + + // === ์„œ์  ๊ด€๋ จ API === + async getBooks(skip = 0, limit = 50, search = null) { + const params = new URLSearchParams({ skip, limit }); + if (search) params.append('search', search); + return await this.get(`/books?${params}`); + } + + async createBook(bookData) { + return await this.post('/books', bookData); + } + + async getBook(bookId) { + return await this.get(`/books/${bookId}`); + } + + async updateBook(bookId, bookData) { + return await this.put(`/books/${bookId}`, bookData); + } + + // ๋ฌธ์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ์กฐํšŒ + async getDocumentNavigation(documentId) { + return await this.get(`/documents/${documentId}/navigation`); + } + + async searchBooks(query, limit = 10) { + const params = new URLSearchParams({ q: query, limit }); + return await this.get(`/books/search/?${params}`); + } + + async getBookSuggestions(title, limit = 5) { + const params = new URLSearchParams({ title, limit }); + return await this.get(`/books/suggestions/?${params}`); + } + + // === ์„œ์  ์†Œ๋ถ„๋ฅ˜ ๊ด€๋ จ API === + async createBookCategory(categoryData) { + return await this.post('/book-categories/', categoryData); + } + + async getBookCategories(bookId) { + return await this.get(`/book-categories/book/${bookId}`); + } + + async updateBookCategory(categoryId, categoryData) { + return await this.put(`/book-categories/${categoryId}`, categoryData); + } + + async deleteBookCategory(categoryId) { + return await this.delete(`/book-categories/${categoryId}`); + } + + async updateDocumentOrder(orderData) { + return await this.put('/book-categories/documents/reorder', orderData); + } + + // === ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ API === + async getDocumentHighlights(documentId) { + return await this.get(`/highlights/document/${documentId}`); + } + + async createHighlight(highlightData) { + console.log('๐ŸŽจ createHighlight ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ๋จ:', highlightData); + return await this.post('/highlights/', highlightData); + } + + async updateHighlight(highlightId, highlightData) { + return await this.put(`/highlights/${highlightId}`, highlightData); + } + + async deleteHighlight(highlightId) { + return await this.delete(`/highlights/${highlightId}`); + } + + // === ๋ฉ”๋ชจ ๊ด€๋ จ API === + // === ๋ฌธ์„œ ๋ฉ”๋ชจ ์กฐํšŒ === + // ์šฉ์–ด ์ •์˜: ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ ์กฐํšŒ + async getDocumentNotes(documentId) { + return await this.get(`/notes/document/${documentId}`); + } + + async createNote(noteData) { + return await this.post('/notes/', noteData); + } + + async deleteNote(noteId) { + return await this.delete(`/notes/${noteId}`); + } + + // ============================================================================ + // ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ API + // ============================================================================ + + // ๋ฉ”๋ชจ ํŠธ๋ฆฌ ๊ด€๋ฆฌ + async getUserMemoTrees(includeArchived = false) { + const params = includeArchived ? '?include_archived=true' : ''; + return await this.get(`/memo-trees/${params}`); + } + + async createMemoTree(treeData) { + return await this.post('/memo-trees/', treeData); + } + + async getMemoTree(treeId) { + return await this.get(`/memo-trees/${treeId}`); + } + + async updateMemoTree(treeId, treeData) { + return await this.put(`/memo-trees/${treeId}`, treeData); + } + + async deleteMemoTree(treeId) { + return await this.delete(`/memo-trees/${treeId}`); + } + + // ๋ฉ”๋ชจ ๋…ธ๋“œ ๊ด€๋ฆฌ + async getMemoTreeNodes(treeId) { + return await this.get(`/memo-trees/${treeId}/nodes`); + } + + async createMemoNode(nodeData) { + return await this.post(`/memo-trees/${nodeData.tree_id}/nodes`, nodeData); + } + + async getMemoNode(nodeId) { + return await this.get(`/memo-trees/nodes/${nodeId}`); + } + + async updateMemoNode(nodeId, nodeData) { + return await this.put(`/memo-trees/nodes/${nodeId}`, nodeData); + } + + async deleteMemoNode(nodeId) { + return await this.delete(`/memo-trees/nodes/${nodeId}`); + } + + // ๋…ธ๋“œ ์ด๋™ + async moveMemoNode(nodeId, moveData) { + return await this.put(`/memo-trees/nodes/${nodeId}/move`, moveData); + } + + // ํŠธ๋ฆฌ ํ†ต๊ณ„ + async getMemoTreeStats(treeId) { + return await this.get(`/memo-trees/${treeId}/stats`); + } + + // ๊ฒ€์ƒ‰ + async searchMemoNodes(searchData) { + return await this.post('/memo-trees/search', searchData); + } + + // ๋‚ด๋ณด๋‚ด๊ธฐ + async exportMemoTree(exportData) { + return await this.post('/memo-trees/export', exportData); + } + + // ๋ฌธ์„œ ๋งํฌ ๊ด€๋ จ API + async createDocumentLink(documentId, linkData) { + return await this.post(`/documents/${documentId}/links`, linkData); + } + + async getDocumentLinks(documentId) { + return await this.get(`/documents/${documentId}/links`); + } + + async getLinkableDocuments(documentId) { + return await this.get(`/documents/${documentId}/linkable-documents`); + } + + async updateDocumentLink(linkId, linkData) { + return await this.put(`/documents/links/${linkId}`, linkData); + } + + async deleteDocumentLink(linkId) { + return await this.delete(`/documents/links/${linkId}`); + } + + // ๋ฐฑ๋งํฌ ๊ด€๋ จ API + async getDocumentBacklinks(documentId) { + return await this.get(`/documents/${documentId}/backlinks`); + } + + async getDocumentLinkFragments(documentId) { + return await this.get(`/documents/${documentId}/link-fragments`); + } + + // ===== ๋…ธํŠธ ๋ฌธ์„œ ๊ด€๋ จ API ===== + + // ๋ชจ๋“  ๋…ธํŠธ ์กฐํšŒ + async getNoteDocuments(params = {}) { + return await this.get('/note-documents/', params); + } + + // ํŠน์ • ๋…ธํŠธ ์กฐํšŒ + async getNoteDocument(noteId) { + return await this.get(`/note-documents/${noteId}`); + } + + // ํŠน์ • ๋…ธํŠธ๋ถ์˜ ๋…ธํŠธ๋“ค ์กฐํšŒ + async getNotesInNotebook(notebookId) { + return await this.get('/note-documents/', { notebook_id: notebookId }); + } + + // === ๋…ธํŠธ ๋ฌธ์„œ (Note Document) ๊ด€๋ จ API === + // ์šฉ์–ด ์ •์˜: ๋…๋ฆฝ์ ์ธ ๋ฌธ์„œ ์ž‘์„ฑ (HTML ๊ธฐ๋ฐ˜) + async createNoteDocument(noteData) { + return await this.post('/note-documents/', noteData); + } + + // ๋…ธํŠธ ์—…๋ฐ์ดํŠธ + async updateNoteDocument(noteId, noteData) { + return await this.put(`/note-documents/${noteId}`, noteData); + } + + // ๋…ธํŠธ ์‚ญ์ œ + async deleteNoteDocument(noteId) { + return await this.delete(`/note-documents/${noteId}`); + } + + // ๋…ธํŠธ HTML ๋‚ด๋ณด๋‚ด๊ธฐ + async exportNoteAsHTML(noteId) { + const response = await fetch(`${this.baseURL}/note-documents/${noteId}/export/html`, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.blob(); + } + + // ===== ๋…ธํŠธ๋ถ ๊ด€๋ จ API ===== + + // ๋ชจ๋“  ๋…ธํŠธ๋ถ ์กฐํšŒ + async getNotebooks(params = {}) { + return await this.get('/notebooks/', params); + } + + // ํŠน์ • ๋…ธํŠธ๋ถ ์กฐํšŒ + async getNotebook(notebookId) { + return await this.get(`/notebooks/${notebookId}`); + } + + // === ๋…ธํŠธ๋ถ (Notebook) ๊ด€๋ จ API === + // ์šฉ์–ด ์ •์˜: ๋…ธํŠธ ๋ฌธ์„œ๋“ค์„ ๊ทธ๋ฃนํ™”ํ•˜๋Š” ํด๋” + async createNotebook(notebookData) { + return await this.post('/notebooks/', notebookData); + } + + // ๋…ธํŠธ๋ถ ์—…๋ฐ์ดํŠธ + async updateNotebook(notebookId, notebookData) { + return await this.put(`/notebooks/${notebookId}`, notebookData); + } + + // ๋…ธํŠธ๋ถ ์‚ญ์ œ + async deleteNotebook(notebookId, force = false) { + return await this.delete(`/notebooks/${notebookId}?force=${force}`); + } + + // ๋…ธํŠธ๋ถ ํ†ต๊ณ„ + async getNotebookStats() { + return await this.get('/notebooks/stats'); + } + + // ๋…ธํŠธ๋ถ์˜ ๋…ธํŠธ๋“ค ์กฐํšŒ + async getNotebookNotes(notebookId, params = {}) { + return await this.get(`/notebooks/${notebookId}/notes`, params); + } + + // ๋…ธํŠธ๋ฅผ ๋…ธํŠธ๋ถ์— ์ถ”๊ฐ€ + async addNoteToNotebook(notebookId, noteId) { + return await this.post(`/notebooks/${notebookId}/notes/${noteId}`); + } + + // ๋…ธํŠธ๋ฅผ ๋…ธํŠธ๋ถ์—์„œ ์ œ๊ฑฐ + async removeNoteFromNotebook(notebookId, noteId) { + return await this.delete(`/notebooks/${notebookId}/notes/${noteId}`); + } + + // ============================================================================ + // ํ• ์ผ๊ด€๋ฆฌ API + // ============================================================================ + + // ํ• ์ผ ์•„์ดํ…œ ๊ด€๋ฆฌ + async getTodos(status = null) { + const params = status ? `?status=${status}` : ''; + return await this.get(`/todos/${params}`); + } + + async createTodo(todoData) { + return await this.post('/todos/', todoData); + } + + async getTodo(todoId) { + return await this.get(`/todos/${todoId}`); + } + + async scheduleTodo(todoId, scheduleData) { + return await this.post(`/todos/${todoId}/schedule`, scheduleData); + } + + async splitTodo(todoId, splitData) { + return await this.post(`/todos/${todoId}/split`, splitData); + } + + async getActiveTodos() { + return await this.get('/todos/active'); + } + + async completeTodo(todoId) { + return await this.put(`/todos/${todoId}/complete`); + } + + async delayTodo(todoId, delayData) { + return await this.put(`/todos/${todoId}/delay`, delayData); + } + + // ๋Œ“๊ธ€ ๊ด€๋ฆฌ + async getTodoComments(todoId) { + return await this.get(`/todos/${todoId}/comments`); + } + + async createTodoComment(todoId, commentData) { + return await this.post(`/todos/${todoId}/comments`, commentData); + } +} + +// ์ „์—ญ API ์ธ์Šคํ„ด์Šค +window.api = new DocumentServerAPI(); diff --git a/frontend/static/js/auth-guard.js b/frontend/static/js/auth-guard.js new file mode 100644 index 0000000..6c9c27b --- /dev/null +++ b/frontend/static/js/auth-guard.js @@ -0,0 +1,92 @@ +/** + * ์ธ์ฆ ๊ฐ€๋“œ - ๋ชจ๋“  ๋ณดํ˜ธ๋œ ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ + * ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž๋ฅผ ์ž๋™์œผ๋กœ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + */ + +(function() { + 'use strict'; + + // ์ธ์ฆ์ด ํ•„์š”ํ•˜์ง€ ์•Š์€ ํŽ˜์ด์ง€๋“ค + const PUBLIC_PAGES = [ + 'login.html', + 'setup.html' + ]; + + // ํ˜„์žฌ ํŽ˜์ด์ง€๊ฐ€ ๊ณต๊ฐœ ํŽ˜์ด์ง€์ธ์ง€ ํ™•์ธ + function isPublicPage() { + const currentPath = window.location.pathname; + return PUBLIC_PAGES.some(page => currentPath.includes(page)); + } + + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + function redirectToLogin() { + const currentUrl = encodeURIComponent(window.location.href); + console.log('๐Ÿ” ์ธ์ฆ๋˜์ง€ ์•Š์€ ์ ‘๊ทผ. ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.'); + window.location.href = `login.html?redirect=${currentUrl}`; + } + + // ์ธ์ฆ ์ฒดํฌ ํ•จ์ˆ˜ + async function checkAuthentication() { + // ๊ณต๊ฐœ ํŽ˜์ด์ง€๋Š” ์ฒดํฌํ•˜์ง€ ์•Š์Œ + if (isPublicPage()) { + return; + } + + const token = localStorage.getItem('access_token'); + + // ํ† ํฐ์ด ์—†์œผ๋ฉด ์ฆ‰์‹œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (!token) { + redirectToLogin(); + return; + } + + try { + // ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.log('๐Ÿ” ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ƒํƒœ:', response.status); + localStorage.removeItem('access_token'); + redirectToLogin(); + return; + } + + // ์ธ์ฆ ์„ฑ๊ณต + const user = await response.json(); + console.log('โœ… ์ธ์ฆ ์„ฑ๊ณต:', user.email); + + // ์ „์—ญ ์‚ฌ์šฉ์ž ์ •๋ณด ์„ค์ • + window.currentUser = user; + + // ํ—ค๋” ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ์—…๋ฐ์ดํŠธ + if (typeof window.updateUserMenu === 'function') { + window.updateUserMenu(user); + } + + } catch (error) { + console.error('๐Ÿ” ์ธ์ฆ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜:', error); + localStorage.removeItem('access_token'); + redirectToLogin(); + } + } + + // DOM ๋กœ๋“œ ์™„๋ฃŒ ์ „์— ์ธ์ฆ ์ฒดํฌ ์‹คํ–‰ + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', checkAuthentication); + } else { + checkAuthentication(); + } + + // ์ „์—ญ ํ•จ์ˆ˜๋กœ ๋…ธ์ถœ + window.authGuard = { + checkAuthentication, + redirectToLogin, + isPublicPage + }; + +})(); diff --git a/frontend/static/js/auth.js b/frontend/static/js/auth.js new file mode 100644 index 0000000..0bdae79 --- /dev/null +++ b/frontend/static/js/auth.js @@ -0,0 +1,105 @@ +/** + * ์ธ์ฆ ๊ด€๋ จ Alpine.js ์ปดํฌ๋„ŒํŠธ + */ + +// ์ธ์ฆ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ +window.authModal = () => ({ + showLogin: false, + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + async login() { + this.loginLoading = true; + this.loginError = ''; + + try { + console.log('๐Ÿ” ๋กœ๊ทธ์ธ ์‹œ๋„:', this.loginForm.email); + + // API ํด๋ž˜์Šค์˜ login ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ (์ด๋ฏธ ํ† ํฐ ์„ค์ •๊ณผ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ ํฌํ•จ) + const result = await window.api.login(this.loginForm.email, this.loginForm.password); + + console.log('โœ… ๋กœ๊ทธ์ธ ๊ฒฐ๊ณผ:', result); + + if (result.success) { + // refresh_token ์ €์žฅ (access_token์€ API ํด๋ž˜์Šค์—์„œ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋จ) + const loginResponse = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.loginForm) + }); + const tokenData = await loginResponse.json(); + localStorage.setItem('refresh_token', tokenData.refresh_token); + + console.log('๐Ÿ’พ ํ† ํฐ ์ €์žฅ ์™„๋ฃŒ'); + + // ์ „์—ญ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: true, user: result.user } + })); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + window.dispatchEvent(new CustomEvent('close-login-modal')); + this.loginForm = { email: '', password: '' }; + + } else { + this.loginError = result.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'; + } + + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + this.loginError = error.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'; + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await window.api.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ •๋ฆฌ + localStorage.removeItem('refresh_token'); + + // ์ „์—ญ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: false, user: null } + })); + } + } +}); + +// ์ž๋™ ํ† ํฐ ๊ฐฑ์‹  +async function refreshTokenIfNeeded() { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken || !api.token) return; + + try { + // ํ† ํฐ ๋งŒ๋ฃŒ ํ™•์ธ (JWT ๋””์ฝ”๋”ฉ) + const tokenPayload = JSON.parse(atob(api.token.split('.')[1])); + const now = Date.now() / 1000; + + // ํ† ํฐ์ด 5๋ถ„ ๋‚ด์— ๋งŒ๋ฃŒ๋˜๋ฉด ๊ฐฑ์‹  + if (tokenPayload.exp - now < 300) { + const response = await api.refreshToken(refreshToken); + api.setToken(response.access_token); + localStorage.setItem('refresh_token', response.refresh_token); + } + } catch (error) { + console.error('Token refresh failed:', error); + // ๊ฐฑ์‹  ์‹คํŒจ์‹œ ๋กœ๊ทธ์•„์›ƒ + window.api.setToken(null); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('auth-changed', { + detail: { isAuthenticated: false, user: null } + })); + } +} + +// 5๋ถ„๋งˆ๋‹ค ํ† ํฐ ๊ฐฑ์‹  ์ฒดํฌ +setInterval(refreshTokenIfNeeded, 5 * 60 * 1000); diff --git a/frontend/static/js/book-documents.js b/frontend/static/js/book-documents.js new file mode 100644 index 0000000..847a3c6 --- /dev/null +++ b/frontend/static/js/book-documents.js @@ -0,0 +1,294 @@ +// ์„œ์  ๋ฌธ์„œ ๋ชฉ๋ก ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.bookDocumentsApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + documents: [], + availablePDFs: [], + bookInfo: {}, + loading: false, + error: '', + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ + bookId: null, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ Book Documents App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + this.parseUrlParams(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadBookDocuments(); + } + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + }, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + parseUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + this.bookId = urlParams.get('book_id') || urlParams.get('bookId'); // ๋‘˜ ๋‹ค ์ง€์› + console.log('๐Ÿ“– ์„œ์  ID:', this.bookId); + console.log('๐Ÿ” ์ „์ฒด URL ํŒŒ๋ผ๋ฏธํ„ฐ:', window.location.search); + console.log('๐Ÿ” URLSearchParams ๊ฐ์ฒด:', urlParams); + console.log('๐Ÿ” book_id ํŒŒ๋ผ๋ฏธํ„ฐ:', urlParams.get('book_id')); + console.log('๐Ÿ” bookId ํŒŒ๋ผ๋ฏธํ„ฐ:', urlParams.get('bookId')); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๊ฑฐ๋‚˜ ๋กœ๊ทธ์ธ ๋ชจ๋‹ฌ ํ‘œ์‹œ + window.location.href = '/login.html'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ์„œ์  ๋ฌธ์„œ ๋ชฉ๋ก ๋กœ๋“œ + async loadBookDocuments() { + this.loading = true; + this.error = ''; + + try { + // ๋ชจ๋“  ๋ฌธ์„œ ๊ฐ€์ ธ์˜ค๊ธฐ + const allDocuments = await window.api.getDocuments(); + + if (this.bookId === 'none') { + // ์„œ์  ๋ฏธ๋ถ„๋ฅ˜ HTML ๋ฌธ์„œ๋“ค๋งŒ (ํด๋”๋กœ ๊ตฌ๋ถ„) + this.documents = allDocuments.filter(doc => + !doc.book_id && + doc.html_path && + doc.html_path.includes('/documents/') // HTML์€ documents ํด๋”์— ์ €์žฅ๋จ + ); + + // ์„œ์  ๋ฏธ๋ถ„๋ฅ˜ PDF ๋ฌธ์„œ๋“ค (๋งค์นญ์šฉ) + this.availablePDFs = allDocuments.filter(doc => + !doc.book_id && + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF๋Š” pdfs ํด๋”์— ์ €์žฅ๋จ + ); + + this.bookInfo = { + title: '์„œ์  ๋ฏธ๋ถ„๋ฅ˜', + description: '์„œ์ ์— ์†ํ•˜์ง€ ์•Š์€ ๋ฌธ์„œ๋“ค์ž…๋‹ˆ๋‹ค.' + }; + } else { + // ํŠน์ • ์„œ์ ์˜ HTML ๋ฌธ์„œ๋“ค๋งŒ (ํด๋”๋กœ ๊ตฌ๋ถ„) + this.documents = allDocuments.filter(doc => + doc.book_id === this.bookId && + doc.html_path && + doc.html_path.includes('/documents/') // HTML์€ documents ํด๋”์— ์ €์žฅ๋จ + ); + + // ํŠน์ • ์„œ์ ์˜ PDF ๋ฌธ์„œ๋“ค (๋งค์นญ์šฉ) + this.availablePDFs = allDocuments.filter(doc => + doc.book_id === this.bookId && + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF๋Š” pdfs ํด๋”์— ์ €์žฅ๋จ + ); + + if (this.documents.length > 0) { + // ์ฒซ ๋ฒˆ์งธ ๋ฌธ์„œ์—์„œ ์„œ์  ์ •๋ณด ์ถ”์ถœ + const firstDoc = this.documents[0]; + this.bookInfo = { + id: firstDoc.book_id, + title: firstDoc.book_title, + author: firstDoc.book_author, + description: firstDoc.book_description || '์„œ์  ์„ค๋ช…์ด ์—†์Šต๋‹ˆ๋‹ค.' + }; + } else { + // ์„œ์  ์ •๋ณด๋งŒ ๊ฐ€์ ธ์˜ค๊ธฐ (๋ฌธ์„œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ) + try { + this.bookInfo = await window.api.getBook(this.bookId); + } catch (error) { + console.error('์„œ์  ์ •๋ณด ๋กœ๋“œ ์‹คํŒจ:', error); + this.bookInfo = { + title: '์•Œ ์ˆ˜ ์—†๋Š” ์„œ์ ', + description: '์„œ์  ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' + }; + } + } + } + + console.log('๐Ÿ“š ์„œ์  ๋ฌธ์„œ ๋กœ๋“œ ์™„๋ฃŒ:', this.documents.length, '๊ฐœ'); + console.log('๐Ÿ“• ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ PDF:', this.availablePDFs.length, '๊ฐœ'); + console.log('๐Ÿ“Ž PDF ๋ชฉ๋ก:', this.availablePDFs.map(pdf => ({ title: pdf.title, book_id: pdf.book_id }))); + console.log('๐Ÿ” ํ˜„์žฌ ์„œ์  ID:', this.bookId); + + // ๋””๋ฒ„๊น…: ๋ฌธ์„œ๋“ค์˜ original_filename ํ™•์ธ + console.log('๐Ÿ” ๋ฌธ์„œ๋“ค ํ™•์ธ:'); + this.documents.slice(0, 5).forEach(doc => { + console.log(`- ${doc.title}: ${doc.original_filename}`); + }); + + console.log('๐Ÿ” PDF๋“ค ํ™•์ธ:'); + this.availablePDFs.slice(0, 5).forEach(doc => { + console.log(`- ${doc.title}: ${doc.original_filename}`); + }); + } catch (error) { + console.error('์„œ์  ๋ฌธ์„œ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message; + this.documents = []; + } finally { + this.loading = false; + } + }, + + // ๋ฌธ์„œ ์—ด๊ธฐ + openDocument(documentId) { + // ํ˜„์žฌ ํŽ˜์ด์ง€ ์ •๋ณด๋ฅผ ์„ธ์…˜ ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ + sessionStorage.setItem('previousPage', 'book-documents.html'); + + // ๋ทฐ์–ด๋กœ ์ด๋™ - ๊ฐ™์€ ์ฐฝ์—์„œ ์ด๋™ + window.location.href = `/viewer.html?id=${documentId}&from=book`; + }, + + // ์„œ์  ํŽธ์ง‘ ํŽ˜์ด์ง€ ์—ด๊ธฐ + openBookEditor() { + console.log('๐Ÿ”ง ์„œ์  ํŽธ์ง‘ ๋ฒ„ํŠผ ํด๋ฆญ๋จ'); + console.log('๐Ÿ“– ํ˜„์žฌ bookId:', this.bookId); + console.log('๐Ÿ” bookId ํƒ€์ž…:', typeof this.bookId); + + if (this.bookId === 'none') { + alert('์„œ์  ๋ฏธ๋ถ„๋ฅ˜ ๋ฌธ์„œ๋“ค์€ ํŽธ์ง‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + if (!this.bookId) { + alert('์„œ์  ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•ด์ฃผ์„ธ์š”.'); + return; + } + + const targetUrl = `book-editor.html?bookId=${this.bookId}`; + console.log('๐Ÿ”— ์ด๋™ํ•  URL:', targetUrl); + window.location.href = targetUrl; + }, + + // ๋ฌธ์„œ ์ˆ˜์ • + editDocument(doc) { + // TODO: ๋ฌธ์„œ ์ˆ˜์ • ๋ชจ๋‹ฌ ๋˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ + console.log('๋ฌธ์„œ ์ˆ˜์ •:', doc.title); + alert('๋ฌธ์„œ ์ˆ˜์ • ๊ธฐ๋Šฅ์€ ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค.'); + }, + + // ๋ฌธ์„œ ์‚ญ์ œ + async deleteDocument(documentId) { + if (!confirm('์ด ๋ฌธ์„œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await window.api.deleteDocument(documentId); + await this.loadBookDocuments(); // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + this.showNotification('๋ฌธ์„œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + } catch (error) { + console.error('๋ฌธ์„œ ์‚ญ์ œ ์‹คํŒจ:', error); + this.showNotification('๋ฌธ์„œ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } + }, + + // ๋’ค๋กœ๊ฐ€๊ธฐ + goBack() { + window.location.href = 'index.html'; + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + // TODO: ์•Œ๋ฆผ ์‹œ์Šคํ…œ ๊ตฌํ˜„ + console.log(`${type.toUpperCase()}: ${message}`); + if (type === 'error') { + alert(message); + } + }, + + // PDF๋ฅผ ์„œ์ ์— ์—ฐ๊ฒฐ + async matchPDFToBook(pdfId) { + if (!this.bookId) { + this.showNotification('์„œ์  ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', 'error'); + return; + } + + if (!confirm('์ด PDF๋ฅผ ํ˜„์žฌ ์„œ์ ์— ์—ฐ๊ฒฐํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + console.log('๐Ÿ”— PDF ๋งค์นญ ์‹œ์ž‘:', { pdfId, bookId: this.bookId }); + + // PDF ๋ฌธ์„œ๋ฅผ ์„œ์ ์— ์—ฐ๊ฒฐ + await window.api.updateDocument(pdfId, { + book_id: this.bookId + }); + + this.showNotification('PDF๊ฐ€ ์„œ์ ์— ์„ฑ๊ณต์ ์œผ๋กœ ์—ฐ๊ฒฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค'); + + // ๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ + await this.loadBookData(); + + } catch (error) { + console.error('PDF ๋งค์นญ ์‹คํŒจ:', error); + this.showNotification('PDF ์—ฐ๊ฒฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } + }, + + // PDF ์—ด๊ธฐ + openPDF(pdf) { + if (pdf.pdf_path) { + // PDF ๋ทฐ์–ด๋กœ ์ด๋™ + window.open(`/viewer.html?id=${pdf.id}`, '_blank'); + } else { + this.showNotification('PDF ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค', 'error'); + } + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } +}); + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + console.log('๐Ÿ“„ Book Documents ํŽ˜์ด์ง€ ๋กœ๋“œ๋จ'); +}); diff --git a/frontend/static/js/book-editor.js b/frontend/static/js/book-editor.js new file mode 100644 index 0000000..66185e1 --- /dev/null +++ b/frontend/static/js/book-editor.js @@ -0,0 +1,329 @@ +// ์„œ์  ํŽธ์ง‘ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.bookEditorApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + documents: [], + bookInfo: {}, + availablePDFs: [], + loading: false, + saving: false, + error: '', + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ + bookId: null, + + // SortableJS ์ธ์Šคํ„ด์Šค + sortableInstance: null, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ Book Editor App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + this.parseUrlParams(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadBookData(); + this.initSortable(); + } + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + }, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + parseUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + this.bookId = urlParams.get('bookId'); + console.log('๐Ÿ“– ํŽธ์ง‘ํ•  ์„œ์  ID:', this.bookId); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/login.html'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ์„œ์  ๋ฐ์ดํ„ฐ ๋กœ๋“œ + async loadBookData() { + this.loading = true; + this.error = ''; + + try { + // ์„œ์  ์ •๋ณด ๋กœ๋“œ + this.bookInfo = await window.api.getBook(this.bookId); + console.log('๐Ÿ“š ์„œ์  ์ •๋ณด ๋กœ๋“œ:', this.bookInfo); + + // ๋ชจ๋“  ๋ฌธ์„œ ๊ฐ€์ ธ์™€์„œ ์ด ์„œ์ ์— ์†ํ•œ HTML ๋ฌธ์„œ๋“ค๋งŒ ํ•„ํ„ฐ๋ง (ํด๋”๋กœ ๊ตฌ๋ถ„) + const allDocuments = await window.api.getDocuments(); + this.documents = allDocuments + .filter(doc => + doc.book_id === this.bookId && + doc.html_path && + doc.html_path.includes('/documents/') // HTML์€ documents ํด๋”์— ์ €์žฅ๋จ + ) + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + + console.log('๐Ÿ“„ ์„œ์  ๋ฌธ์„œ๋“ค:', this.documents.length, '๊ฐœ'); + + // ๊ฐ ๋ฌธ์„œ์˜ PDF ๋งค์นญ ์ƒํƒœ ํ™•์ธ + this.documents.forEach((doc, index) => { + console.log(`๐Ÿ“„ ๋ฌธ์„œ ${index + 1}: ${doc.title}`); + console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`); + console.log(` - sort_order: ${doc.sort_order || 'null'}`); + + // null ๊ฐ’์„ ๋นˆ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ (UI ๋ฐ”์ธ๋”ฉ์„ ์œ„ํ•ด) + if (doc.matched_pdf_id === null) { + doc.matched_pdf_id = ""; + } + + // ๋””๋ฒ„๊น…: ์‹ค์ œ ๊ฐ’๊ณผ ํƒ€์ž… ํ™•์ธ + console.log(` - matched_pdf_id ํƒ€์ž…: ${typeof doc.matched_pdf_id}`); + console.log(` - matched_pdf_id ๊ฐ’: "${doc.matched_pdf_id}"`); + console.log(` - ๋นˆ ๋ฌธ์ž์—ด์ธ๊ฐ€? ${doc.matched_pdf_id === ""}`); + console.log(` - null์ธ๊ฐ€? ${doc.matched_pdf_id === null}`); + console.log(` - undefined์ธ๊ฐ€? ${doc.matched_pdf_id === undefined}`); + }); + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ PDF ๋ฌธ์„œ๋“ค ๋กœ๋“œ (ํ˜„์žฌ ์„œ์ ์˜ PDF๋งŒ) + console.log('๐Ÿ” ํ˜„์žฌ ์„œ์  ID:', this.bookId); + console.log('๐Ÿ” ์ „์ฒด ๋ฌธ์„œ ์ˆ˜:', allDocuments.length); + + // PDF ๋ฌธ์„œ๋“ค ๋จผ์ € ํ•„ํ„ฐ๋ง + const allPDFs = allDocuments.filter(doc => + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF๋Š” pdfs ํด๋”์— ์ €์žฅ๋จ + ); + console.log('๐Ÿ” ์ „์ฒด PDF ๋ฌธ์„œ ์ˆ˜:', allPDFs.length); + + // ๊ฐ™์€ ์„œ์ ์˜ PDF ๋ฌธ์„œ๋“ค๋งŒ ํ•„ํ„ฐ๋ง + this.availablePDFs = allPDFs.filter(doc => { + const match = String(doc.book_id) === String(this.bookId); + if (!match && allPDFs.indexOf(doc) < 5) { + console.log(`๐Ÿ” PDF "${doc.title}": book_id="${doc.book_id}" (${typeof doc.book_id}) vs bookId="${this.bookId}" (${typeof this.bookId})`); + } + return match; + }); + + console.log('๐Ÿ“Ž ํ˜„์žฌ ์„œ์ ์˜ PDF:', this.availablePDFs.length, '๊ฐœ'); + console.log('๐Ÿ“Ž ํ˜„์žฌ ์„œ์  PDF ๋ชฉ๋ก:', this.availablePDFs.map(pdf => ({ + id: pdf.id, + title: pdf.title, + book_id: pdf.book_id, + book_title: pdf.book_title + }))); + + // ๊ฐ PDF์˜ ID ํ™•์ธ + this.availablePDFs.forEach((pdf, index) => { + console.log(`๐Ÿ“Ž PDF ${index + 1}: ID="${pdf.id}", ์ œ๋ชฉ="${pdf.title}"`); + }); + + // ๋””๋ฒ„๊น…: ๋‹ค๋ฅธ ์„œ์ ์˜ PDF๋“ค๋„ ํ™•์ธ + const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId); + console.log('๐Ÿ” ๋‹ค๋ฅธ ์„œ์ ์˜ PDF:', otherBookPDFs.length, '๊ฐœ'); + if (otherBookPDFs.length > 0) { + console.log('๐Ÿ” ๋‹ค๋ฅธ ์„œ์  PDF ์˜ˆ์‹œ:', otherBookPDFs.slice(0, 3).map(pdf => ({ + title: pdf.title, + book_id: pdf.book_id, + book_title: pdf.book_title + }))); + } + + // Alpine.js DOM ์—…๋ฐ์ดํŠธ ๊ฐ•์ œ ์‹คํ–‰ + this.$nextTick(() => { + console.log('๐Ÿ”„ Alpine.js DOM ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ'); + // DOM์ด ์™„์ „ํžˆ ๋ Œ๋”๋ง๋œ ํ›„ ์‹คํ–‰ + setTimeout(() => { + this.documents.forEach((doc, index) => { + if (doc.matched_pdf_id) { + console.log(`๐Ÿ”ง ๋ฌธ์„œ ${index + 1} ๊ฐ•์ œ ์—…๋ฐ์ดํŠธ: ${doc.matched_pdf_id}`); + // Alpine.js ๋ฐ˜์‘์„ฑ ํŠธ๋ฆฌ๊ฑฐ + const oldValue = doc.matched_pdf_id; + doc.matched_pdf_id = ""; + doc.matched_pdf_id = oldValue; + } + }); + }, 100); + }); + + } catch (error) { + console.error('์„œ์  ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message; + } finally { + this.loading = false; + } + }, + + // SortableJS ์ดˆ๊ธฐํ™” + initSortable() { + this.$nextTick(() => { + const sortableList = document.getElementById('sortable-list'); + if (sortableList && !this.sortableInstance) { + this.sortableInstance = Sortable.create(sortableList, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + handle: '.fa-grip-vertical', + onEnd: (evt) => { + // ๋ฐฐ์—ด ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + const item = this.documents.splice(evt.oldIndex, 1)[0]; + this.documents.splice(evt.newIndex, 0, item); + this.updateDisplayOrder(); + } + }); + console.log('โœ… SortableJS ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + }); + }, + + // ํ‘œ์‹œ ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + updateDisplayOrder() { + this.documents.forEach((doc, index) => { + doc.sort_order = index + 1; + }); + console.log('๐Ÿ”ข ํ‘œ์‹œ ์ˆœ์„œ ์—…๋ฐ์ดํŠธ๋จ'); + }, + + // ์œ„๋กœ ์ด๋™ + moveUp(index) { + if (index > 0) { + const item = this.documents.splice(index, 1)[0]; + this.documents.splice(index - 1, 0, item); + this.updateDisplayOrder(); + } + }, + + // ์•„๋ž˜๋กœ ์ด๋™ + moveDown(index) { + if (index < this.documents.length - 1) { + const item = this.documents.splice(index, 1)[0]; + this.documents.splice(index + 1, 0, item); + this.updateDisplayOrder(); + } + }, + + // ์ด๋ฆ„์ˆœ ์ •๋ ฌ + autoSortByName() { + this.documents.sort((a, b) => { + return a.title.localeCompare(b.title, 'ko', { numeric: true }); + }); + this.updateDisplayOrder(); + console.log('๐Ÿ“ ์ด๋ฆ„์ˆœ ์ •๋ ฌ ์™„๋ฃŒ'); + }, + + // ์ˆœ์„œ ๋’ค์ง‘๊ธฐ + reverseOrder() { + this.documents.reverse(); + this.updateDisplayOrder(); + console.log('๐Ÿ”„ ์ˆœ์„œ ๋’ค์ง‘๊ธฐ ์™„๋ฃŒ'); + }, + + // ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ + async saveChanges() { + if (this.saving) return; + + this.saving = true; + console.log('๐Ÿ’พ ์ €์žฅ ์‹œ์ž‘...'); + + try { + // ์ €์žฅ ์ „์— ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + this.updateDisplayOrder(); + + // ์„œ์  ์ •๋ณด ์—…๋ฐ์ดํŠธ + console.log('๐Ÿ“š ์„œ์  ์ •๋ณด ์—…๋ฐ์ดํŠธ ์ค‘...'); + await window.api.updateBook(this.bookId, { + title: this.bookInfo.title, + author: this.bookInfo.author, + description: this.bookInfo.description + }); + console.log('โœ… ์„œ์  ์ •๋ณด ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ'); + + // ๊ฐ ๋ฌธ์„œ์˜ ์ˆœ์„œ์™€ PDF ๋งค์นญ ์ •๋ณด ์—…๋ฐ์ดํŠธ + console.log('๐Ÿ“„ ๋ฌธ์„œ ์—…๋ฐ์ดํŠธ ์‹œ์ž‘...'); + const updatePromises = this.documents.map((doc, index) => { + console.log(`๐Ÿ“„ ๋ฌธ์„œ ${index + 1}/${this.documents.length}: ${doc.title}`); + console.log(` - sort_order: ${doc.sort_order}`); + console.log(` - matched_pdf_id: ${doc.matched_pdf_id || 'null'}`); + + return window.api.updateDocument(doc.id, { + sort_order: doc.sort_order, + matched_pdf_id: doc.matched_pdf_id === "" || doc.matched_pdf_id === null ? null : doc.matched_pdf_id + }); + }); + + const results = await Promise.all(updatePromises); + console.log('โœ… ๋ชจ๋“  ๋ฌธ์„œ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ:', results.length, '๊ฐœ'); + + console.log('โœ… ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ ์™„๋ฃŒ'); + this.showNotification('๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + + // ์ž ์‹œ ํ›„ ์„œ์  ํŽ˜์ด์ง€๋กœ ๋Œ์•„๊ฐ€๊ธฐ + setTimeout(() => { + this.goBack(); + }, 1500); + + } catch (error) { + console.error('โŒ ์ €์žฅ ์‹คํŒจ:', error); + this.showNotification('์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } finally { + this.saving = false; + } + }, + + // ๋’ค๋กœ๊ฐ€๊ธฐ + goBack() { + window.location.href = `book-documents.html?bookId=${this.bookId}`; + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + console.log(`${type.toUpperCase()}: ${message}`); + + // ๊ฐ„๋‹จํ•œ ํ† ์ŠคํŠธ ์•Œ๋ฆผ ์ƒ์„ฑ + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${ + type === 'success' ? 'bg-green-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 3์ดˆ ํ›„ ์ œ๊ฑฐ + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 3000); + } +}); + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + console.log('๐Ÿ“„ Book Editor ํŽ˜์ด์ง€ ๋กœ๋“œ๋จ'); +}); diff --git a/frontend/static/js/header-loader.js b/frontend/static/js/header-loader.js new file mode 100644 index 0000000..6a2704c --- /dev/null +++ b/frontend/static/js/header-loader.js @@ -0,0 +1,263 @@ +/** + * ๊ณตํ†ต ํ—ค๋” ๋กœ๋” + * ๋ชจ๋“  ํŽ˜์ด์ง€์—์„œ ๋™์ผํ•œ ํ—ค๋”๋ฅผ ๋กœ๋“œํ•˜๊ธฐ ์œ„ํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ + */ + +class HeaderLoader { + constructor() { + this.headerLoaded = false; + } + + /** + * ํ—ค๋” HTML์„ ๋กœ๋“œํ•˜๊ณ  ์‚ฝ์ž… + */ + async loadHeader(targetSelector = '#header-container') { + if (this.headerLoaded) { + console.log('โœ… ํ—ค๋”๊ฐ€ ์ด๋ฏธ ๋กœ๋“œ๋จ'); + return; + } + + try { + console.log('๐Ÿ”„ ํ—ค๋” ๋กœ๋”ฉ ์ค‘...'); + + const response = await fetch('components/header.html?v=2025012352'); + if (!response.ok) { + throw new Error(`ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ: ${response.status}`); + } + + const headerHtml = await response.text(); + + // ํ—ค๋” ์ปจํ…Œ์ด๋„ˆ ์ฐพ๊ธฐ + const container = document.querySelector(targetSelector); + if (!container) { + throw new Error(`ํ—ค๋” ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: ${targetSelector}`); + } + + // ํ—ค๋” HTML ์‚ฝ์ž… + container.innerHTML = headerHtml; + + this.headerLoaded = true; + console.log('โœ… ํ—ค๋” ๋กœ๋“œ ์™„๋ฃŒ'); + + // ํ—ค๋” ๋กœ๋“œ ์™„๋ฃŒ ์ด๋ฒคํŠธ ๋ฐœ์ƒ + document.dispatchEvent(new CustomEvent('headerLoaded')); + + } catch (error) { + console.error('โŒ ํ—ค๋” ๋กœ๋“œ ์˜ค๋ฅ˜:', error); + this.showFallbackHeader(targetSelector); + } + } + + /** + * ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ ์‹œ ํด๋ฐฑ ํ—ค๋” ํ‘œ์‹œ + */ + showFallbackHeader(targetSelector) { + const container = document.querySelector(targetSelector); + if (container) { + container.innerHTML = ` +
+ +
+ `; + } + } + + /** + * ํ˜„์žฌ ํŽ˜์ด์ง€ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + */ + getCurrentPageInfo() { + const path = window.location.pathname; + const filename = path.split('/').pop().replace('.html', '') || 'index'; + + const pageInfo = { + filename, + isDocumentPage: ['index', 'hierarchy'].includes(filename), + isMemoPage: ['memo-tree', 'story-view'].includes(filename) + }; + + return pageInfo; + } + + /** + * ํŽ˜์ด์ง€๋ณ„ ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + */ + updateActiveStates() { + const pageInfo = this.getCurrentPageInfo(); + + // ๋ชจ๋“  ํ™œ์„ฑ ํด๋ž˜์Šค ์ œ๊ฑฐ + document.querySelectorAll('.nav-link, .nav-dropdown-item').forEach(item => { + item.classList.remove('active'); + }); + + // ํ˜„์žฌ ํŽ˜์ด์ง€์— ๋”ฐ๋ผ ํ™œ์„ฑ ์ƒํƒœ ์„ค์ • + console.log('ํ˜„์žฌ ํŽ˜์ด์ง€:', pageInfo.filename); + + if (pageInfo.isDocumentPage) { + const docLink = document.getElementById('doc-nav-link'); + if (docLink) { + docLink.classList.add('active'); + console.log('๋ฌธ์„œ ๊ด€๋ฆฌ ๋ฉ”๋‰ด ํ™œ์„ฑํ™”'); + } + } + + if (pageInfo.isMemoPage) { + const memoLink = document.getElementById('memo-nav-link'); + if (memoLink) { + memoLink.classList.add('active'); + console.log('๋ฉ”๋ชจ์žฅ ๋ฉ”๋‰ด ํ™œ์„ฑํ™”'); + } + } + + // ํŠน์ • ํŽ˜์ด์ง€ ๋“œ๋กญ๋‹ค์šด ์•„์ดํ…œ ํ™œ์„ฑํ™” + const pageItemMap = { + 'index': 'index-nav-item', + 'hierarchy': 'hierarchy-nav-item', + 'memo-tree': 'memo-tree-nav-item', + 'story-view': 'story-view-nav-item', + 'search': 'search-nav-link', + 'notes': 'notes-nav-link', + 'notebooks': 'notebooks-nav-item', + 'note-editor': 'note-editor-nav-item' + }; + + const itemId = pageItemMap[pageInfo.filename]; + if (itemId) { + const item = document.getElementById(itemId); + if (item) { + item.classList.add('active'); + console.log(`${pageInfo.filename} ํŽ˜์ด์ง€ ์•„์ดํ…œ ํ™œ์„ฑํ™”`); + } + } + } +} + +// ์ „์—ญ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ +window.headerLoader = new HeaderLoader(); + +// DOM ๋กœ๋“œ ์™„๋ฃŒ ์‹œ ์ž๋™ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + window.headerLoader.loadHeader(); +}); + +// ํ—ค๋” ๋กœ๋“œ ์™„๋ฃŒ ํ›„ ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ +document.addEventListener('headerLoaded', () => { + setTimeout(() => { + window.headerLoader.updateActiveStates(); + + // updateUserMenu ํ•จ์ˆ˜ ์ •์˜ (ํ—ค๋” ๋กœ๋”์—์„œ ์ง์ ‘ ์ •์˜) + if (typeof window.updateUserMenu === 'undefined') { + window.updateUserMenu = (user) => { + console.log('๐Ÿ”„ updateUserMenu ํ˜ธ์ถœ๋จ:', user); + + const loggedInMenu = document.getElementById('logged-in-menu'); + const loginButton = document.getElementById('login-button'); + const adminMenuSection = document.getElementById('admin-menu-section'); + + // ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์†Œ๋“ค + const userName = document.getElementById('user-name'); + const userRole = document.getElementById('user-role'); + const dropdownUserName = document.getElementById('dropdown-user-name'); + const dropdownUserEmail = document.getElementById('dropdown-user-email'); + const dropdownUserRole = document.getElementById('dropdown-user-role'); + + if (user) { + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ + console.log('โœ… ์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ ์ƒํƒœ - UI ์—…๋ฐ์ดํŠธ'); + if (loggedInMenu) { + loggedInMenu.classList.remove('hidden'); + console.log('โœ… ๋กœ๊ทธ์ธ ๋ฉ”๋‰ด ํ‘œ์‹œ'); + } + if (loginButton) { + loginButton.classList.add('hidden'); + console.log('โœ… ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ์ˆจ๊น€'); + } + + // ์‚ฌ์šฉ์ž ์ •๋ณด ์—…๋ฐ์ดํŠธ + const displayName = user.full_name || user.email || 'User'; + const roleText = user.role === 'root' ? '์‹œ์Šคํ…œ ๊ด€๋ฆฌ์ž' : + user.role === 'admin' ? '๊ด€๋ฆฌ์ž' : '์‚ฌ์šฉ์ž'; + + if (userName) userName.textContent = displayName; + if (userRole) userRole.textContent = roleText; + if (dropdownUserName) dropdownUserName.textContent = displayName; + if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || ''; + if (dropdownUserRole) dropdownUserRole.textContent = roleText; + + // ๊ด€๋ฆฌ์ž ๋ฉ”๋‰ด ํ‘œ์‹œ/์ˆจ๊น€ + console.log('๐Ÿ” ์‚ฌ์šฉ์ž ๊ถŒํ•œ ํ™•์ธ:', { + role: user.role, + is_admin: user.is_admin, + can_manage_books: user.can_manage_books, + can_manage_notes: user.can_manage_notes, + can_manage_novels: user.can_manage_novels + }); + + if (adminMenuSection) { + if (user.role === 'root' || user.role === 'admin' || user.is_admin) { + console.log('โœ… ๊ด€๋ฆฌ์ž ๋ฉ”๋‰ด ํ‘œ์‹œ'); + adminMenuSection.classList.remove('hidden'); + } else { + console.log('โŒ ๊ด€๋ฆฌ์ž ๋ฉ”๋‰ด ์ˆจ๊น€'); + adminMenuSection.classList.add('hidden'); + } + } else { + console.log('โŒ adminMenuSection ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ'); + } + } else { + // ๋กœ๊ทธ์•„์›ƒ๋œ ์ƒํƒœ + console.log('โŒ ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ'); + if (loggedInMenu) loggedInMenu.classList.add('hidden'); + if (loginButton) loginButton.classList.remove('hidden'); + if (adminMenuSection) adminMenuSection.classList.add('hidden'); + } + }; + console.log('โœ… updateUserMenu ํ•จ์ˆ˜ ์ •์˜ ์™„๋ฃŒ'); + } + + // ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ์ƒํƒœ ์„ค์ • (ํ˜„์žฌ ๋กœ๊ทธ์ธ ์ƒํƒœ ํ™•์ธ) + setTimeout(() => { + // ์ „์—ญ ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด ํ† ํฐ์œผ๋กœ ํ™•์ธ + if (window.currentUser) { + window.updateUserMenu(window.currentUser); + } else { + // ํ† ํฐ์ด ์žˆ์œผ๋ฉด ์‚ฌ์šฉ์ž ์ •๋ณด ๋‹ค์‹œ ๊ฐ€์ ธ์˜ค๊ธฐ + const token = localStorage.getItem('access_token'); + if (token) { + fetch('/api/auth/me', { + headers: { 'Authorization': `Bearer ${token}` } + }) + .then(response => response.ok ? response.json() : null) + .then(user => { + if (user) { + window.currentUser = user; + window.updateUserMenu(user); + } else { + window.updateUserMenu(null); + } + }) + .catch(() => window.updateUserMenu(null)); + } else { + window.updateUserMenu(null); + } + } + }, 200); + + // ์ „์—ญ ํ•จ์ˆ˜๋“ค์ด ์ •์˜๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋นˆ ํ•จ์ˆ˜๋กœ ์ดˆ๊ธฐํ™” + if (typeof window.handleLanguageChange === 'undefined') { + window.handleLanguageChange = function(lang) { + console.log('์–ธ์–ด ๋ณ€๊ฒฝ ํ•จ์ˆ˜๊ฐ€ ์•„์ง ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค:', lang); + }; + } + }, 100); +}); diff --git a/frontend/static/js/main.js b/frontend/static/js/main.js new file mode 100644 index 0000000..b4cbb47 --- /dev/null +++ b/frontend/static/js/main.js @@ -0,0 +1,603 @@ +// ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.documentApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + documents: [], + filteredDocuments: [], + groupedDocuments: [], + expandedBooks: [], + loading: false, + error: '', + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + showLoginModal: false, + + // ํ•„ํ„ฐ๋ง ๋ฐ ๊ฒ€์ƒ‰ + searchQuery: '', + selectedTag: '', + availableTags: [], + + // UI ์ƒํƒœ + viewMode: 'grid', // 'grid' ๋˜๋Š” 'books' + user: null, // currentUser์˜ ๋ณ„์นญ + tags: [], // availableTags์˜ ๋ณ„์นญ + + // ๋ชจ๋‹ฌ ์ƒํƒœ + showUploadModal: false, + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ ํ•จ์ˆ˜๋“ค + openLoginModal() { + this.showLoginModal = true; + }, + + // ์ดˆ๊ธฐํ™” + async init() { + await this.checkAuthStatus(); + if (this.isAuthenticated) { + await this.loadDocuments(); + } else { + // ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋„ ๋นˆ ๋ฐฐ์—ด๋กœ ์ดˆ๊ธฐํ™” + this.groupedDocuments = []; + } + this.setupEventListeners(); + + // ์ปค์Šคํ…€ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก + document.addEventListener('open-login-modal', () => { + console.log('๐Ÿ“จ open-login-modal ์ด๋ฒคํŠธ ์ˆ˜์‹  (index.html)'); + this.openLoginModal(); + }); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const token = localStorage.getItem('access_token'); + if (token) { + window.api.setToken(token); + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + this.syncUIState(); // UI ์ƒํƒœ ๋™๊ธฐํ™” + } + } catch (error) { + console.log('Not authenticated or token expired'); + this.isAuthenticated = false; + this.currentUser = null; + localStorage.removeItem('access_token'); + + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (setup.html ์ œ์™ธ) + if (!window.location.pathname.includes('setup.html') && + !window.location.pathname.includes('login.html')) { + const currentUrl = encodeURIComponent(window.location.href); + window.location.href = `login.html?redirect=${currentUrl}`; + return; + } + + this.syncUIState(); // UI ์ƒํƒœ ๋™๊ธฐํ™” + } + }, + + // ๋กœ๊ทธ์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ + openLoginModal() { + this.showLoginModal = true; + }, + + // ๋กœ๊ทธ์•„์›ƒ + async logout() { + try { + await window.api.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + this.isAuthenticated = false; + this.currentUser = null; + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + this.documents = []; + this.filteredDocuments = []; + } + }, + + // ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ์„ค์ • + setupEventListeners() { + // ๋ฌธ์„œ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + window.addEventListener('documents-changed', () => { + this.loadDocuments(); + }); + + // ์•Œ๋ฆผ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + window.addEventListener('show-notification', (event) => { + this.showNotification(event.detail.message, event.detail.type); + }); + + // ์ธ์ฆ ์ƒํƒœ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + window.addEventListener('auth-changed', (event) => { + this.isAuthenticated = event.detail.isAuthenticated; + this.currentUser = event.detail.user; + this.showLoginModal = false; + this.syncUIState(); // UI ์ƒํƒœ ๋™๊ธฐํ™” + if (this.isAuthenticated) { + this.loadDocuments(); + } + }); + + // ๋กœ๊ทธ์ธ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + window.addEventListener('close-login-modal', () => { + this.showLoginModal = false; + }); + }, + + // ๋ฌธ์„œ ๋ชฉ๋ก ๋กœ๋“œ + async loadDocuments() { + this.loading = true; + this.error = ''; + + try { + const allDocuments = await window.api.getDocuments(); + + // HTML ๋ฌธ์„œ๋งŒ ํ•„ํ„ฐ๋ง (PDF ํŒŒ์ผ ์ œ์™ธ) + this.documents = allDocuments.filter(doc => + doc.html_path && + doc.html_path.includes('/documents/') // HTML์€ documents ํด๋”์— ์ €์žฅ๋จ + ); + + console.log('๐Ÿ“„ ์ „์ฒด ๋ฌธ์„œ:', allDocuments.length, '๊ฐœ'); + console.log('๐Ÿ“„ HTML ๋ฌธ์„œ:', this.documents.length, '๊ฐœ'); + console.log('๐Ÿ“„ PDF ํŒŒ์ผ:', allDocuments.length - this.documents.length, '๊ฐœ (์ œ์™ธ๋จ)'); + + this.updateAvailableTags(); + this.filterDocuments(); + this.syncUIState(); // UI ์ƒํƒœ ๋™๊ธฐํ™” + } catch (error) { + console.error('Failed to load documents:', error); + this.error = 'Failed to load documents: ' + error.message; + this.documents = []; + } finally { + this.loading = false; + } + }, + + // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํƒœ๊ทธ ์—…๋ฐ์ดํŠธ + updateAvailableTags() { + const tagSet = new Set(); + this.documents.forEach(doc => { + if (doc.tags) { + doc.tags.forEach(tag => tagSet.add(tag)); + } + }); + this.availableTags = Array.from(tagSet).sort(); + this.tags = this.availableTags; // ๋ณ„์นญ ๋™๊ธฐํ™” + }, + + // UI ์ƒํƒœ ๋™๊ธฐํ™” + syncUIState() { + this.user = this.currentUser; + this.tags = this.availableTags; + }, + + // ๋ฌธ์„œ ํ•„ํ„ฐ๋ง + filterDocuments() { + let filtered = this.documents; + + // ๊ฒ€์ƒ‰์–ด ํ•„ํ„ฐ๋ง + if (this.searchQuery) { + const query = this.searchQuery.toLowerCase(); + filtered = filtered.filter(doc => + doc.title.toLowerCase().includes(query) || + (doc.description && doc.description.toLowerCase().includes(query)) || + (doc.tags && doc.tags.some(tag => tag.toLowerCase().includes(query))) + ); + } + + // ํƒœ๊ทธ ํ•„ํ„ฐ๋ง + if (this.selectedTag) { + filtered = filtered.filter(doc => + doc.tags && doc.tags.includes(this.selectedTag) + ); + } + + this.filteredDocuments = filtered; + this.groupDocumentsByBook(); + }, + + // ๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ + onSearchChange() { + this.filterDocuments(); + }, + + // ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + clearFilters() { + this.searchQuery = ''; + this.selectedTag = ''; + this.filterDocuments(); + }, + + // ์„œ์ ๋ณ„ ๊ทธ๋ฃนํ™” + groupDocumentsByBook() { + if (!this.filteredDocuments || this.filteredDocuments.length === 0) { + this.groupedDocuments = []; + return; + } + + const grouped = {}; + + this.filteredDocuments.forEach(doc => { + const bookKey = doc.book_id || 'no-book'; + if (!grouped[bookKey]) { + grouped[bookKey] = { + book: doc.book_id ? { + id: doc.book_id, + title: doc.book_title, + author: doc.book_author + } : null, + documents: [] + }; + } + grouped[bookKey].documents.push(doc); + }); + + // ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ํ•˜๊ณ  ์ •๋ ฌ (์„œ์  ์žˆ๋Š” ๊ฒƒ ๋จผ์ €, ๊ทธ ๋‹ค์Œ ์„œ์ ๋ช… ์ˆœ) + this.groupedDocuments = Object.values(grouped).sort((a, b) => { + if (!a.book && b.book) return 1; + if (a.book && !b.book) return -1; + if (!a.book && !b.book) return 0; + return a.book.title.localeCompare(b.book.title); + }); + + // ๊ฐ ์„œ์  ๊ทธ๋ฃน์— ํ™•์žฅ ์ƒํƒœ ์ถ”๊ฐ€ (๊ธฐ๋ณธ๊ฐ’: ์ถ•์†Œ) + this.groupedDocuments.forEach(group => { + // ๊ธฐ์กด ํ™•์žฅ ์ƒํƒœ ์œ ์ง€ํ•˜๊ฑฐ๋‚˜ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • + if (group.expanded === undefined) { + group.expanded = false; // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ถ•์†Œ๋œ ์ƒํƒœ + } + }); + }, + + // ์„œ์  ํŽผ์นจ/์ ‘ํž˜ ํ† ๊ธ€ + toggleBookExpansion(bookId) { + const index = this.expandedBooks.indexOf(bookId); + if (index > -1) { + this.expandedBooks.splice(index, 1); + } else { + this.expandedBooks.push(bookId); + } + }, + + // ๋ฌธ์„œ ๊ฒ€์ƒ‰ (HTML์—์„œ ์‚ฌ์šฉ) + searchDocuments() { + this.filterDocuments(); + }, + + // ํƒœ๊ทธ ์„ ํƒ ์‹œ + onTagSelect(tag) { + this.selectedTag = this.selectedTag === tag ? '' : tag; + this.filterDocuments(); + }, + + // ํƒœ๊ทธ ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + clearTagFilter() { + this.selectedTag = ''; + this.filterDocuments(); + }, + + // ๋ฌธ์„œ ์‚ญ์ œ + async deleteDocument(documentId) { + if (!confirm('์ •๋ง๋กœ ์ด ๋ฌธ์„œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await window.api.deleteDocument(documentId); + await this.loadDocuments(); + this.showNotification('๋ฌธ์„œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + } catch (error) { + console.error('Failed to delete document:', error); + this.showNotification('๋ฌธ์„œ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } + }, + + // ๋ฌธ์„œ ๋ณด๊ธฐ + viewDocument(documentId) { + // ํ˜„์žฌ ํŽ˜์ด์ง€ ์ •๋ณด๋ฅผ ์„ธ์…˜ ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ + const currentPage = window.location.pathname.split('/').pop() || 'index.html'; + sessionStorage.setItem('previousPage', currentPage); + + // from ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ - ๊ฐ™์€ ์ฐฝ์—์„œ ์ด๋™ + const fromParam = currentPage === 'hierarchy.html' ? 'hierarchy' : 'index'; + window.location.href = `/viewer.html?id=${documentId}&from=${fromParam}`; + }, + + // ์„œ์ ์˜ ๋ฌธ์„œ๋“ค ๋ณด๊ธฐ + openBookDocuments(book) { + if (book && book.id) { + // ์„œ์  ID๋ฅผ URL ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌํ•˜์—ฌ ํ•ด๋‹น ์„œ์ ์˜ ๋ฌธ์„œ๋“ค๋งŒ ํ‘œ์‹œ + window.location.href = `book-documents.html?bookId=${book.id}`; + } else { + // ์„œ์  ๋ฏธ๋ถ„๋ฅ˜ ๋ฌธ์„œ๋“ค ๋ณด๊ธฐ + window.location.href = `book-documents.html?bookId=none`; + } + }, + + // ์—…๋กœ๋“œ ํŽ˜์ด์ง€ ์—ด๊ธฐ + openUploadPage() { + window.location.href = 'upload.html'; + }, + + // ๋ฌธ์„œ ์—ด๊ธฐ (HTML์—์„œ ์‚ฌ์šฉ) + openDocument(documentId) { + this.viewDocument(documentId); + }, + + // ๋ฌธ์„œ ์ˆ˜์ • (HTML์—์„œ ์‚ฌ์šฉ) + editDocument(document) { + // TODO: ๋ฌธ์„œ ์ˆ˜์ • ๋ชจ๋‹ฌ ๊ตฌํ˜„ + console.log('๋ฌธ์„œ ์ˆ˜์ •:', document); + alert('๋ฌธ์„œ ์ˆ˜์ • ๊ธฐ๋Šฅ์€ ๊ณง ๊ตฌํ˜„๋ฉ๋‹ˆ๋‹ค!'); + }, + + // ์—…๋กœ๋“œ ๋ชจ๋‹ฌ ์—ด๊ธฐ + openUploadModal() { + this.showUploadModal = true; + }, + + // ์—…๋กœ๋“œ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeUploadModal() { + this.showUploadModal = false; + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + return new Date(dateString).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + // ๊ฐ„๋‹จํ•œ ์•Œ๋ฆผ ๊ตฌํ˜„ (๋‚˜์ค‘์— ํ† ์ŠคํŠธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ ๊ต์ฒด ๊ฐ€๋Šฅ) + const notification = document.createElement('div'); + notification.className = `fixed top-4 right-4 p-4 rounded-lg text-white z-50 ${ + type === 'error' ? 'bg-red-500' : + type === 'success' ? 'bg-green-500' : + 'bg-blue-500' + }`; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.remove(); + }, 3000); + } +}); + +// ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ +window.uploadModal = () => ({ + uploading: false, + uploadForm: { + title: '', + description: '', + tags: '', + is_public: false, + document_date: '', + html_file: null, + pdf_file: null + }, + uploadError: '', + + // ์„œ์  ๊ด€๋ จ ์ƒํƒœ + bookSelectionMode: 'none', // 'existing', 'new', 'none' + bookSearchQuery: '', + searchedBooks: [], + selectedBook: null, + newBook: { + title: '', + author: '', + description: '' + }, + suggestions: [], + searchTimeout: null, + + // ํŒŒ์ผ ์„ ํƒ + onFileSelect(event, fileType) { + const file = event.target.files[0]; + if (file) { + this.uploadForm[fileType] = file; + + // HTML ํŒŒ์ผ์˜ ๊ฒฝ์šฐ ์ œ๋ชฉ ์ž๋™ ์„ค์ • + if (fileType === 'html_file' && !this.uploadForm.title) { + this.uploadForm.title = file.name.replace(/\.[^/.]+$/, ""); + } + } + }, + + // ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ ์ฒ˜๋ฆฌ + handleFileDrop(event, fileType) { + event.target.classList.remove('dragover'); + const files = event.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + + // ํŒŒ์ผ ํƒ€์ž… ๊ฒ€์ฆ + if (fileType === 'html_file' && !file.name.match(/\.(html|htm)$/i)) { + this.uploadError = 'HTML ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค'; + return; + } + + if (fileType === 'pdf_file' && !file.name.match(/\.pdf$/i)) { + this.uploadError = 'PDF ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค'; + return; + } + + this.uploadForm[fileType] = file; + this.uploadError = ''; + + // HTML ํŒŒ์ผ์˜ ๊ฒฝ์šฐ ์ œ๋ชฉ ์ž๋™ ์„ค์ • + if (fileType === 'html_file' && !this.uploadForm.title) { + this.uploadForm.title = file.name.replace(/\.[^/.]+$/, ""); + } + } + }, + + // ์—…๋กœ๋“œ ์‹คํ–‰ + async upload() { + // ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ + if (!this.uploadForm.html_file) { + this.uploadError = 'HTML ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”'; + return; + } + + if (!this.uploadForm.title.trim()) { + this.uploadError = '๋ฌธ์„œ ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + return; + } + + this.uploading = true; + this.uploadError = ''; + + try { + let bookId = null; + + // ์„œ์  ์ฒ˜๋ฆฌ + if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) { + const newBook = await window.api.createBook({ + title: this.newBook.title, + author: this.newBook.author || null, + description: this.newBook.description || null, + language: this.uploadForm.language || 'ko', + is_public: this.uploadForm.is_public + }); + bookId = newBook.id; + } else if (this.bookSelectionMode === 'existing' && this.selectedBook) { + bookId = this.selectedBook.id; + } + + // FormData ์ƒ์„ฑ + const formData = new FormData(); + formData.append('title', this.uploadForm.title); + formData.append('description', this.uploadForm.description || ''); + formData.append('html_file', this.uploadForm.html_file); + + if (this.uploadForm.pdf_file) { + formData.append('pdf_file', this.uploadForm.pdf_file); + } + + // ์„œ์  ID ์ถ”๊ฐ€ + if (bookId) { + formData.append('book_id', bookId); + } + + formData.append('language', this.uploadForm.language || 'ko'); + formData.append('is_public', this.uploadForm.is_public); + + if (this.uploadForm.tags) { + formData.append('tags', this.uploadForm.tags); + } + + if (this.uploadForm.document_date) { + formData.append('document_date', this.uploadForm.document_date); + } + + // ์—…๋กœ๋“œ ์‹คํ–‰ + await window.api.uploadDocument(formData); + + // ์„ฑ๊ณต ์ฒ˜๋ฆฌ + this.resetForm(); + + // ๋ฌธ์„œ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + window.dispatchEvent(new CustomEvent('documents-changed')); + + // ์„ฑ๊ณต ์•Œ๋ฆผ + window.dispatchEvent(new CustomEvent('show-notification', { + detail: { message: '๋ฌธ์„œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', type: 'success' } + })); + + } catch (error) { + this.uploadError = error.message; + } finally { + this.uploading = false; + } + }, + + // ํผ ๋ฆฌ์…‹ + resetForm() { + this.uploadForm = { + title: '', + description: '', + tags: '', + is_public: false, + document_date: '', + html_file: null, + pdf_file: null + }; + this.uploadError = ''; + + // ํŒŒ์ผ ์ž…๋ ฅ ํ•„๋“œ ๋ฆฌ์…‹ + const fileInputs = document.querySelectorAll('input[type="file"]'); + fileInputs.forEach(input => input.value = ''); + + // ์„œ์  ๊ด€๋ จ ์ƒํƒœ ๋ฆฌ์…‹ + this.bookSelectionMode = 'none'; + this.bookSearchQuery = ''; + this.searchedBooks = []; + this.selectedBook = null; + this.newBook = { title: '', author: '', description: '' }; + this.suggestions = []; + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + this.searchTimeout = null; + } + }, + + // ์„œ์  ๊ฒ€์ƒ‰ + async searchBooks() { + if (!this.bookSearchQuery.trim()) { + this.searchedBooks = []; + return; + } + try { + const books = await window.api.searchBooks(this.bookSearchQuery, 10); + this.searchedBooks = books; + } catch (error) { + console.error('์„œ์  ๊ฒ€์ƒ‰ ์‹คํŒจ:', error); + this.searchedBooks = []; + } + }, + + // ์„œ์  ์„ ํƒ + selectBook(book) { + this.selectedBook = book; + this.bookSearchQuery = book.title; + this.searchedBooks = []; + }, + + // ์œ ์‚ฌ๋„ ์ถ”์ฒœ ๊ฐ€์ ธ์˜ค๊ธฐ + async getSuggestions() { + if (!this.newBook.title.trim()) { + this.suggestions = []; + return; + } + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(async () => { + try { + const suggestions = await window.api.getBookSuggestions(this.newBook.title, 3); + this.suggestions = suggestions.filter(s => s.similarity_score > 0.5); // 50% ์ด์ƒ ์œ ์‚ฌํ•œ ๊ฒƒ๋งŒ + } catch (error) { + console.error('์ถ”์ฒœ ๊ฐ€์ ธ์˜ค๊ธฐ ์‹คํŒจ:', error); + this.suggestions = []; + } + }, 300); + }, + + // ์ถ”์ฒœ์—์„œ ๊ธฐ์กด ์„œ์  ์„ ํƒ + selectExistingFromSuggestion(suggestion) { + this.bookSelectionMode = 'existing'; + this.selectedBook = suggestion; + this.bookSearchQuery = suggestion.title; + this.suggestions = []; + this.newBook = { title: '', author: '', description: '' }; + } +}); \ No newline at end of file diff --git a/frontend/static/js/memo-tree.js b/frontend/static/js/memo-tree.js new file mode 100644 index 0000000..7d46183 --- /dev/null +++ b/frontend/static/js/memo-tree.js @@ -0,0 +1,1247 @@ +/** + * ํŠธ๋ฆฌ ๊ตฌ์กฐ ๋ฉ”๋ชจ์žฅ JavaScript + */ + +// Monaco Editor ์ธ์Šคํ„ด์Šค +let monacoEditor = null; + +// ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ Alpine.js ์ปดํฌ๋„ŒํŠธ +window.memoTreeApp = function() { + return { + // ์ƒํƒœ ๊ด€๋ฆฌ + currentUser: null, + userTrees: [], + selectedTreeId: '', + selectedTree: null, + treeNodes: [], + selectedNode: null, + + // UI ์ƒํƒœ + showNewTreeModal: false, + showNewNodeModal: false, + showTreeSettings: false, + showLoginModal: false, + showMobileEditModal: false, + + // ์•Œ๋ฆผ ์‹œ์Šคํ…œ + notification: { + show: false, + message: '', + type: 'info' // 'success', 'error', 'info' + }, + + // ๋กœ๊ทธ์ธ ํผ ์ƒํƒœ + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // ํผ ๋ฐ์ดํ„ฐ + newTree: { + title: '', + description: '', + tree_type: 'general' + }, + + newNode: { + title: '', + node_type: 'memo', + parent_id: null + }, + + // ์—๋””ํ„ฐ ์ƒํƒœ + editorContent: '', + isEditorDirty: false, + + // ํŠธ๋ฆฌ ์ƒํƒœ + expandedNodes: new Set(), + + // ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ ์ƒํƒœ + treeZoom: 1, + treePanX: 0, + treePanY: 0, + nodePositions: new Map(), // ๋…ธ๋“œ ID -> {x, y} ์œ„์น˜ ๋งคํ•‘ + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ ํ•จ์ˆ˜๋“ค + openLoginModal() { + this.showLoginModal = true; + }, + + async login() { + this.loginLoading = true; + this.loginError = ''; + + try { + // ์‹ค์ œ API ํ˜ธ์ถœ + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + // ํ† ํฐ ์ €์žฅ + window.api.setToken(response.access_token); + localStorage.setItem('refresh_token', response.refresh_token); + + // ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const userResponse = await window.api.getCurrentUser(); + this.currentUser = userResponse; + + // ํ—ค๋” ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ์—…๋ฐ์ดํŠธ + if (typeof window.updateUserMenu === 'function') { + window.updateUserMenu(userResponse); + } + + // ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + await this.loadUserTrees(); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + this.showLoginModal = false; + this.loginForm = { email: '', password: '' }; + + console.log('โœ… ๋กœ๊ทธ์ธ ์„ฑ๊ณต'); + + } catch (error) { + this.loginError = error.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'; + console.error('โŒ ๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await window.api.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ •๋ฆฌ + localStorage.removeItem('refresh_token'); + window.api.setToken(null); + + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + this.currentUser = null; + this.userTrees = []; + this.selectedTreeId = ''; + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + + // ํ—ค๋” ์‚ฌ์šฉ์ž ๋ฉ”๋‰ด ์—…๋ฐ์ดํŠธ + if (typeof window.updateUserMenu === 'function') { + window.updateUserMenu(null); + } + + console.log('โœ… ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ'); + } + }, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ ์ดˆ๊ธฐํ™” ์ค‘...'); + + // ์ปค์Šคํ…€ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก + document.addEventListener('open-login-modal', () => { + console.log('๐Ÿ“จ open-login-modal ์ด๋ฒคํŠธ ์ˆ˜์‹ '); + this.openLoginModal(); + }); + + // API ๊ฐ์ฒด๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ (๋” ๊ธด ์‹œ๊ฐ„) + let retries = 0; + while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) { + console.log(`โณ API ๊ฐ์ฒด ๋กœ๋”ฉ ๋Œ€๊ธฐ ์ค‘... (${retries + 1}/50)`); + await new Promise(resolve => setTimeout(resolve, 100)); + retries++; + } + + if (!window.api || typeof window.api.getUserMemoTrees !== 'function') { + console.error('โŒ API ๊ฐ์ฒด ๋˜๋Š” getUserMemoTrees ํ•จ์ˆ˜๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + console.log('ํ˜„์žฌ window.api:', window.api); + if (window.api) { + console.log('API ๊ฐ์ฒด์˜ ๋ฉ”์„œ๋“œ๋“ค:', Object.getOwnPropertyNames(window.api)); + } + return; + } + + try { + await this.checkAuthStatus(); + if (this.currentUser) { + await this.loadUserTrees(); + await this.initMonacoEditor(); + } + } catch (error) { + console.error('โŒ ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ๋จ:', user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ:', error.message); + this.currentUser = null; + + // ํ† ํฐ์ด ์žˆ์ง€๋งŒ ๋งŒ๋ฃŒ๋œ ๊ฒฝ์šฐ ์ œ๊ฑฐ + if (localStorage.getItem('access_token')) { + console.log('๐Ÿ—‘๏ธ ๋งŒ๋ฃŒ๋œ ํ† ํฐ ์ œ๊ฑฐ'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.api.clearToken(); + } + } + }, + + // ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + async loadUserTrees() { + try { + console.log('๐Ÿ“Š ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋”ฉ...'); + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`โœ… ${this.userTrees.length}๊ฐœ ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ`); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.userTrees = []; + } + }, + + // ํŠธ๋ฆฌ ๋กœ๋“œ + async loadTree(treeId) { + if (!treeId) { + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + return; + } + + try { + console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋กœ๋”ฉ:', treeId); + + // ํŠธ๋ฆฌ ์ •๋ณด ๋กœ๋“œ + const tree = this.userTrees.find(t => t.id === treeId); + this.selectedTree = tree; + + // ํŠธ๋ฆฌ ๋…ธ๋“œ๋“ค ๋กœ๋“œ + const nodes = await window.api.getMemoTreeNodes(treeId); + this.treeNodes = nodes || []; + + // ์ฒซ ๋ฒˆ์งธ ๋…ธ๋“œ ์„ ํƒ (์žˆ๋‹ค๋ฉด) + if (this.treeNodes.length > 0) { + this.selectNode(this.treeNodes[0]); + } + + // ํŠธ๋ฆฌ ๋‹ค์ด์–ด๊ทธ๋žจ ์œ„์น˜ ๊ณ„์‚ฐ ๋ฐ ์ค‘์•™ ์ •๋ ฌ + this.$nextTick(() => { + setTimeout(() => { + this.calculateNodePositions(); + // ์œ„์น˜ ๊ณ„์‚ฐ ์™„๋ฃŒ ํ›„ ์ค‘์•™ ์ •๋ ฌ + setTimeout(() => { + this.centerTree(); + }, 50); + }, 100); + }); + + console.log(`โœ… ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ: ${this.treeNodes.length}๊ฐœ ๋…ธ๋“œ`); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:', error); + alert('ํŠธ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ํŠธ๋ฆฌ ์ƒ์„ฑ + async createTree() { + try { + console.log('๐ŸŒณ ์ƒˆ ํŠธ๋ฆฌ ์ƒ์„ฑ:', this.newTree); + + const tree = await window.api.createMemoTree(this.newTree); + + // ํŠธ๋ฆฌ ๋ชฉ๋ก์— ์ถ”๊ฐ€ + this.userTrees.push(tree); + + // ์ƒˆ ํŠธ๋ฆฌ ์„ ํƒ + this.selectedTreeId = tree.id; + await this.loadTree(tree.id); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ๋ฐ ํผ ๋ฆฌ์…‹ + this.showNewTreeModal = false; + this.newTree = { title: '', description: '', tree_type: 'general' }; + + console.log('โœ… ํŠธ๋ฆฌ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('ํŠธ๋ฆฌ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ + async createRootNode() { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '์ƒˆ ๋…ธ๋“œ', + node_type: 'memo', + parent_id: null + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + + // ๋…ธ๋“œ ์œ„์น˜ ์žฌ๊ณ„์‚ฐ (์ƒˆ ๋…ธ๋“œ ์ถ”๊ฐ€ ํ›„) + this.$nextTick(() => { + this.calculateNodePositions(); + // ์œ„์น˜ ๊ณ„์‚ฐ ์™„๋ฃŒ ํ›„ ์ƒˆ ๋…ธ๋“œ ์„ ํƒ + setTimeout(() => { + this.selectNode(node); + }, 50); + }); + + console.log('โœ… ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋ฃจํŠธ ๋…ธ๋“œ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋…ธ๋“œ ์„ ํƒ + selectNode(node) { + // ํ˜„์žฌ ํŒฌ ๊ฐ’ ์ €์žฅ (์œ„์น˜ ๋ณ€๊ฒฝ ๋ฐฉ์ง€) + const currentPanX = this.treePanX; + const currentPanY = this.treePanY; + const currentZoom = this.treeZoom; + + // ์ด์ „ ๋…ธ๋“œ ์ €์žฅ + if (this.selectedNode && this.isEditorDirty) { + this.saveNode(); + } + + this.selectedNode = node; + + // ์—๋””ํ„ฐ์— ๋‚ด์šฉ ๋กœ๋“œ (๋นˆ ๋‚ด์šฉ๋„ ํฌํ•จ) + if (monacoEditor) { + monacoEditor.setValue(node.content || ''); + this.isEditorDirty = false; + } + + // ๋ชจ๋ฐ”์ผ์—์„œ๋งŒ ํŽธ์ง‘ ๋ชจ๋‹ฌ ์—ด๊ธฐ (๊ธฐ์กด ๋™์ž‘์— ์ถ”๊ฐ€) + if (window.innerWidth < 1024) { // lg ๋ธŒ๋ ˆ์ดํฌํฌ์ธํŠธ + this.showMobileEditModal = true; + // ๋ชจ๋ฐ”์ผ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” (์•ฝ๊ฐ„์˜ ์ง€์—ฐ ํ›„) + setTimeout(() => { + this.initMobileEditor(); + }, 100); + } + + // ํŒฌ ๊ฐ’ ๋ณต์› (์œ„์น˜ ๋ณ€๊ฒฝ ๋ฐฉ์ง€) + this.treePanX = currentPanX; + this.treePanY = currentPanY; + this.treeZoom = currentZoom; + + console.log('๐Ÿ“ ๋…ธ๋“œ ์„ ํƒ:', node.title); + }, + + // ๋ชจ๋ฐ”์ผ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” + initMobileEditor() { + if (!window.mobileMonacoEditor && this.selectedNode) { + const container = document.getElementById('mobile-editor-container'); + if (container) { + window.mobileMonacoEditor = monaco.editor.create(container, { + value: this.selectedNode.content || '', + language: 'markdown', + theme: 'vs', + automaticLayout: true, + wordWrap: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + lineNumbers: 'off', + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + glyphMargin: false + }); + + // ๋ชจ๋ฐ”์ผ ์—๋””ํ„ฐ ๋ณ€๊ฒฝ ๊ฐ์ง€ + window.mobileMonacoEditor.onDidChangeModelContent(() => { + if (this.selectedNode) { + this.selectedNode.content = window.mobileMonacoEditor.getValue(); + this.isEditorDirty = true; + } + }); + + console.log('๐Ÿ“ฑ ๋ชจ๋ฐ”์ผ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + } else if (window.mobileMonacoEditor && this.selectedNode) { + // ๊ธฐ์กด ์—๋””ํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ๋‚ด์šฉ๋งŒ ์—…๋ฐ์ดํŠธ + window.mobileMonacoEditor.setValue(this.selectedNode.content || ''); + } + }, + + // ๋…ธ๋“œ ์ €์žฅ + async saveNode() { + if (!this.selectedNode) return; + + try { + // ์—๋””ํ„ฐ ๋‚ด์šฉ ๊ฐ€์ ธ์˜ค๊ธฐ (๋ฐ์Šคํฌํ†ฑ ๋˜๋Š” ๋ชจ๋ฐ”์ผ) + if (monacoEditor) { + this.selectedNode.content = monacoEditor.getValue(); + } else if (window.mobileMonacoEditor) { + this.selectedNode.content = window.mobileMonacoEditor.getValue(); + } + + if (this.selectedNode.content !== undefined) { + + // ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ (๊ฐ„๋‹จํ•œ ๋ฐฉ์‹) + const wordCount = this.selectedNode.content + .replace(/\s+/g, ' ') + .trim() + .split(' ') + .filter(word => word.length > 0).length; + this.selectedNode.word_count = wordCount; + } + + await window.api.updateMemoNode(this.selectedNode.id, this.selectedNode); + this.isEditorDirty = false; + + console.log('โœ… ๋…ธ๋“œ ์ €์žฅ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋…ธ๋“œ ์ €์žฅ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋…ธ๋“œ ์ œ๋ชฉ ์ €์žฅ + async saveNodeTitle() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ํƒ€์ž… ์ €์žฅ + async saveNodeType() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ์ƒํƒœ ์ €์žฅ + async saveNodeStatus() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // ๋…ธ๋“œ ์‚ญ์ œ + async deleteNode(nodeId) { + if (!confirm('์ •๋ง๋กœ ์ด ๋…ธ๋“œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) return; + + try { + await window.api.deleteMemoNode(nodeId); + + // ํŠธ๋ฆฌ์—์„œ ์ œ๊ฑฐ + this.treeNodes = this.treeNodes.filter(node => node.id !== nodeId); + + // ์„ ํƒ๋œ ๋…ธ๋“œ์˜€๋‹ค๋ฉด ์„ ํƒ ํ•ด์ œ + if (this.selectedNode && this.selectedNode.id === nodeId) { + this.selectedNode = null; + if (monacoEditor) { + monacoEditor.setValue(''); + } + } + + console.log('โœ… ๋…ธ๋“œ ์‚ญ์ œ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋…ธ๋“œ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + + + // ์ž์‹ ๋…ธ๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + getChildNodes(parentId) { + return this.treeNodes + .filter(node => node.parent_id === parentId) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // ๋ฃจํŠธ ๋…ธ๋“œ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ + get rootNodes() { + return this.treeNodes + .filter(node => !node.parent_id) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // ๋…ธ๋“œ ํƒ€์ž…๋ณ„ ์•„์ด์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + getNodeIcon(nodeType) { + const icons = { + folder: '๐Ÿ“', + memo: '๐Ÿ“', + chapter: '๐Ÿ“–', + character: '๐Ÿ‘ค', + plot: '๐Ÿ“‹' + }; + return icons[nodeType] || '๐Ÿ“'; + }, + + getNodeTypeLabel(nodeType) { + const labels = { + 'memo': '๋ฉ”๋ชจ', + 'folder': 'ํด๋”', + 'chapter': '์ฑ•ํ„ฐ', + 'character': '์บ๋ฆญํ„ฐ', + 'plot': 'ํ”Œ๋กฏ' + }; + return labels[nodeType] || '๋ฉ”๋ชจ'; + }, + + getStatusIcon(status) { + const icons = { + 'draft': '๐Ÿ“', + 'writing': 'โœ๏ธ', + 'review': '๐Ÿ‘€', + 'complete': 'โœ…' + }; + return icons[status] || '๐Ÿ“'; + }, + + getStatusLabel(status) { + const labels = { + 'draft': '์ดˆ์•ˆ', + 'writing': '์ž‘์„ฑ์ค‘', + 'review': '๊ฒ€ํ† ์ค‘', + 'complete': '์™„๋ฃŒ' + }; + return labels[status] || '์ดˆ์•ˆ'; + }, + + // ์ƒํƒœ๋ณ„ ์ƒ‰์ƒ ํด๋ž˜์Šค ๊ฐ€์ ธ์˜ค๊ธฐ + getStatusColor(status) { + const colors = { + draft: 'text-gray-500', + writing: 'text-yellow-600', + review: 'text-blue-600', + complete: 'text-green-600' + }; + return colors[status] || 'text-gray-700'; + }, + + // ํŠธ๋ฆฌ ํƒ€์ž…๋ณ„ ์•„์ด์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + this.notification = { + show: true, + message: message, + type: type + }; + + // 3์ดˆ ํ›„ ์ž๋™์œผ๋กœ ์ˆจ๊น€ + setTimeout(() => { + this.notification.show = false; + }, 3000); + }, + + getTreeIcon(treeType) { + const icons = { + novel: '๐Ÿ“š', + research: '๐Ÿ”ฌ', + project: '๐Ÿ’ผ', + general: '๐Ÿ“‚' + }; + return icons[treeType] || '๐Ÿ“‚'; + }, + + // ๋น ๋ฅธ ์ž์‹ ๋…ธ๋“œ ์ถ”๊ฐ€ + async addChildNode(parentNode) { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '์ƒˆ ๋…ธ๋“œ', + node_type: 'memo', + parent_id: parentNode.id + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + + // ๋ถ€๋ชจ ๋…ธ๋“œ ํŽผ์น˜๊ธฐ + this.expandedNodes.add(parentNode.id); + + // ๋…ธ๋“œ ์œ„์น˜ ์žฌ๊ณ„์‚ฐ (์ƒˆ ๋…ธ๋“œ ์ถ”๊ฐ€ ํ›„) + this.$nextTick(() => { + this.calculateNodePositions(); + // ์œ„์น˜ ๊ณ„์‚ฐ ์™„๋ฃŒ ํ›„ ์ƒˆ ๋…ธ๋“œ ์„ ํƒ + setTimeout(() => { + this.selectNode(node); + }, 50); + }); + + console.log('โœ… ์ž์‹ ๋…ธ๋“œ ์ƒ์„ฑ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์ž์‹ ๋…ธ๋“œ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋…ธ๋“œ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ํ‘œ์‹œ + showContextMenu(event, node) { + // TODO: ์šฐํด๋ฆญ ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ๊ตฌํ˜„ + console.log('์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด:', node.title); + }, + + // ๋…ธ๋“œ ๋ฉ”๋‰ด ํ‘œ์‹œ + showNodeMenu(event, node) { + // TODO: ๋…ธ๋“œ ์˜ต์…˜ ๋ฉ”๋‰ด ๊ตฌํ˜„ + console.log('๋…ธ๋“œ ๋ฉ”๋‰ด:', node.title); + }, + + // ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€ + async toggleCanonical(node) { + try { + const newCanonicalState = !node.is_canonical; + console.log(`๐ŸŒŸ ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€: ${node.title} (${newCanonicalState ? '์„ค์ •' : 'ํ•ด์ œ'})`); + + const updatedData = { + is_canonical: newCanonicalState + }; + + const updatedNode = await window.api.updateMemoNode(node.id, updatedData); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ (Alpine.js ๋ฐ˜์‘์„ฑ ๋ณด์žฅ) + const nodeIndex = this.treeNodes.findIndex(n => n.id === node.id); + if (nodeIndex !== -1) { + // ๋ฐฐ์—ด ์ „์ฒด๋ฅผ ์ƒˆ๋กœ ์ƒ์„ฑํ•˜์—ฌ Alpine.js ๋ฐ˜์‘์„ฑ ํŠธ๋ฆฌ๊ฑฐ + this.treeNodes = this.treeNodes.map(n => + n.id === node.id ? { ...n, ...updatedNode } : n + ); + } + + // ์„ ํƒ๋œ ๋…ธ๋“œ๋„ ์—…๋ฐ์ดํŠธ + if (this.selectedNode && this.selectedNode.id === node.id) { + this.selectedNode = { ...this.selectedNode, ...updatedNode }; + } + + // ๊ฐ•์ œ ๋ฆฌ๋ Œ๋”๋ง์„ ์œ„ํ•œ ๋”๋ฏธ ์—…๋ฐ์ดํŠธ + this.treeNodes = [...this.treeNodes]; + + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๊ทธ๋ฆฌ๊ธฐ (์—ฐ๊ฒฐ์„  ์—…๋ฐ์ดํŠธ) + this.$nextTick(() => { + this.calculateNodePositions(); + }); + + console.log(`โœ… ์ •์‚ฌ ๊ฒฝ๋กœ ${newCanonicalState ? '์„ค์ •' : 'ํ•ด์ œ'} ์™„๋ฃŒ`); + console.log('๐Ÿ“Š ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ:', updatedNode); + console.log('๐Ÿ” is_canonical ๊ฐ’:', updatedNode.is_canonical); + console.log('๐Ÿ” canonical_order ๊ฐ’:', updatedNode.canonical_order); + console.log('๐Ÿ”„ ํ˜„์žฌ treeNodes ๊ฐœ์ˆ˜:', this.treeNodes.length); + + // ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ ์ฐพ๊ธฐ + const updatedNodeInArray = this.treeNodes.find(n => n.id === node.id); + console.log('๐Ÿ” ๋ฐฐ์—ด ๋‚ด ์—…๋ฐ์ดํŠธ๋œ ๋…ธ๋“œ:', updatedNodeInArray?.is_canonical); + } catch (error) { + console.error('โŒ ์ •์‚ฌ ๊ฒฝ๋กœ ํ† ๊ธ€ ์‹คํŒจ:', error); + alert('์ •์‚ฌ ๊ฒฝ๋กœ ์„ค์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + + + + + // ๋…ธ๋“œ๋ฅผ ๋‹ค๋ฅธ ๋ถ€๋ชจ๋กœ ์ด๋™ + async moveNodeToParent(nodeId, newParentId) { + try { + console.log(`๐Ÿ“ฆ ๋…ธ๋“œ ์ด๋™: ${nodeId} -> ๋ถ€๋ชจ: ${newParentId}`); + + const moveData = { + parent_id: newParentId, + sort_order: 0 // ์ƒˆ ๋ถ€๋ชจ์˜ ์ฒซ ๋ฒˆ์งธ ์ž์‹์œผ๋กœ + }; + + await window.api.moveMemoNode(nodeId, moveData); + + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๋กœ๋“œ + await this.loadTreeNodes(); + + console.log('โœ… ๋…ธ๋“œ ์ด๋™ ์™„๋ฃŒ'); + this.showNotification('๋…ธ๋“œ๊ฐ€ ์ด๋™๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); + + } catch (error) { + console.error('โŒ ๋…ธ๋“œ ์ด๋™ ์‹คํŒจ:', error); + this.showNotification('๋…ธ๋“œ ์ด๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } + }, + + // ๋…ธ๋“œ ์ธ๋ผ์ธ ํŽธ์ง‘ + editNodeInline(node) { + // ๋”๋ธ”ํด๋ฆญ ์‹œ ๋…ธ๋“œ ์„ ํƒํ•˜๊ณ  ์—๋””ํ„ฐ๋กœ ํฌ์ปค์Šค + this.selectNode(node); + + // ์—๋””ํ„ฐ๊ฐ€ ์žˆ๋‹ค๋ฉด ํฌ์ปค์Šค + this.$nextTick(() => { + const editorContainer = document.getElementById('editor-container'); + if (editorContainer && this.monacoEditor) { + this.monacoEditor.focus(); + } + }); + + console.log('์ธ๋ผ์ธ ํŽธ์ง‘:', node.title); + }, + + // ๋…ธ๋“œ ์œ„์น˜ ๊ณ„์‚ฐ ๋ฐ ๋ฐ˜ํ™˜ + getNodePosition(node) { + // ์œ„์น˜๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ๋ณธ ์œ„์น˜ ๋ฐ˜ํ™˜ (์ „์ฒด ์žฌ๊ณ„์‚ฐ ๋ฐฉ์ง€) + const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 }; + return `left: ${pos.x}px; top: ${pos.y}px;`; + }, + + // ํŠธ๋ฆฌ ๋…ธ๋“œ ์œ„์น˜ ์ž๋™ ๊ณ„์‚ฐ (๊ฐ€๋กœ ๋ฐฉํ–ฅ: ์™ผ์ชฝ์—์„œ ์˜ค๋ฅธ์ชฝ) + calculateNodePositions() { + const canvas = document.getElementById('tree-canvas'); + if (!canvas) return; + + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + // ๋…ธ๋“œ ํฌ๊ธฐ ์„ค์ • + const nodeWidth = 200; + const nodeHeight = 80; + const levelWidth = 250; // ๋ ˆ๋ฒจ ๊ฐ„ ๊ฐ€๋กœ ๊ฐ„๊ฒฉ (์™ผ์ชฝ์—์„œ ์˜ค๋ฅธ์ชฝ) + const nodeSpacing = 100; // ๋…ธ๋“œ ๊ฐ„ ์„ธ๋กœ ๊ฐ„๊ฒฉ + const margin = 100; // ์—ฌ๋ฐฑ + + // ๋ ˆ๋ฒจ๋ณ„ ๋…ธ๋“œ ๊ทธ๋ฃนํ™” (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const levels = new Map(); + + // ๋ฃจํŠธ ๋…ธ๋“œ๋“ค ์ฐพ๊ธฐ + const rootNodes = this.treeNodes.filter(node => !node.parent_id); + + if (rootNodes.length === 0) return; + + // BFS๋กœ ๋ ˆ๋ฒจ๋ณ„ ๋…ธ๋“œ ๋ฐฐ์น˜ (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const queue = []; + rootNodes.forEach(node => { + queue.push({ node, level: 0 }); + }); + + while (queue.length > 0) { + const { node, level } = queue.shift(); + + if (!levels.has(level)) { + levels.set(level, []); + } + levels.get(level).push(node); + + // ์ž์‹ ๋…ธ๋“œ๋“ค์„ ๋‹ค์Œ ๋ ˆ๋ฒจ์— ์ถ”๊ฐ€ + const children = this.getChildNodes(node.id); + children.forEach(child => { + queue.push({ node: child, level: level + 1 }); + }); + } + + // ํŠธ๋ฆฌ ์ „์ฒด ํฌ๊ธฐ ๊ณ„์‚ฐ (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const maxLevel = Math.max(...levels.keys()); + const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length)); + + const treeWidth = (maxLevel + 1) * levelWidth; // ๊ฐ€๋กœ ๋ฐฉํ–ฅ ์ „์ฒด ๋„ˆ๋น„ + const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // ์„ธ๋กœ ๋ฐฉํ–ฅ ์ „์ฒด ๋†’์ด + + // ์บ”๋ฒ„์Šค ์ค‘์•™์— ํŠธ๋ฆฌ ๋ฐฐ์น˜ํ•˜๊ธฐ ์œ„ํ•œ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2); + const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2); + + // ๊ฐ ๋ ˆ๋ฒจ์˜ ๋…ธ๋“œ๋“ค ์œ„์น˜ ๊ณ„์‚ฐ (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + levels.forEach((nodes, level) => { + const x = offsetX + level * levelWidth; // ๊ฐ€๋กœ ์œ„์น˜ (์™ผ์ชฝ์—์„œ ์˜ค๋ฅธ์ชฝ) + const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing; + const startY = offsetY + (treeHeight - levelHeight) / 2; // ์„ธ๋กœ ์ค‘์•™ ์ •๋ ฌ + + nodes.forEach((node, index) => { + const y = startY + index * (nodeHeight + nodeSpacing); + this.nodePositions.set(node.id, { x, y }); + }); + }); + + // ํŠธ๋ฆฌ ํฌ๊ธฐ ์ €์žฅ (centerTree์—์„œ ์‚ฌ์šฉ) + this.treeBounds = { + width: treeWidth + 2 * margin, + height: treeHeight + 2 * margin, + offsetX: offsetX - margin, + offsetY: offsetY - margin + }; + + // ์—ฐ๊ฒฐ์„  ๋‹ค์‹œ ๊ทธ๋ฆฌ๊ธฐ + this.drawConnections(); + }, + + // SVG ์—ฐ๊ฒฐ์„  ๊ทธ๋ฆฌ๊ธฐ + drawConnections() { + const svg = document.getElementById('tree-connections'); + if (!svg) return; + + // ๊ธฐ์กด ์—ฐ๊ฒฐ์„  ์ œ๊ฑฐ + svg.innerHTML = ''; + + // ๊ฐ ๋…ธ๋“œ์˜ ์ž์‹๋“ค๊ณผ ์—ฐ๊ฒฐ์„  ๊ทธ๋ฆฌ๊ธฐ + this.treeNodes.forEach(node => { + const children = this.getChildNodes(node.id); + if (children.length === 0) return; + + const parentPos = this.nodePositions.get(node.id); + if (!parentPos) return; + + children.forEach(child => { + const childPos = this.nodePositions.get(child.id); + if (!childPos) return; + + // ์—ฐ๊ฒฐ์„  ์ƒ์„ฑ + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + // ๋ถ€๋ชจ ๋…ธ๋“œ ์˜ค๋ฅธ์ชฝ ์ค‘์•™์—์„œ ์‹œ์ž‘ (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const startX = parentPos.x + 200; // ๋…ธ๋“œ ์˜ค๋ฅธ์ชฝ ๋ + const startY = parentPos.y + 40; // ๋…ธ๋“œ ์„ธ๋กœ ์ค‘์•™ + + // ์ž์‹ ๋…ธ๋“œ ์™ผ์ชฝ ์ค‘์•™์œผ๋กœ ์—ฐ๊ฒฐ (๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const endX = childPos.x; // ๋…ธ๋“œ ์™ผ์ชฝ ๋ + const endY = childPos.y + 40; // ๋…ธ๋“œ ์„ธ๋กœ ์ค‘์•™ + + // ๊ณก์„  ๊ฒฝ๋กœ ์ƒ์„ฑ (๋ฒ ์ง€์–ด ๊ณก์„ , ๊ฐ€๋กœ ๋ฐฉํ–ฅ) + const midX = startX + (endX - startX) / 2; + const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`; + + line.setAttribute('d', path); + line.setAttribute('stroke', '#9CA3AF'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('fill', 'none'); + line.setAttribute('marker-end', 'url(#arrowhead)'); + + svg.appendChild(line); + }); + }); + + // ํ™”์‚ดํ‘œ ๋งˆ์ปค ์ถ”๊ฐ€ (ํ•œ ๋ฒˆ๋งŒ) + if (!svg.querySelector('#arrowhead')) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('refX', '9'); + marker.setAttribute('refY', '3.5'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); + polygon.setAttribute('fill', '#9CA3AF'); + + marker.appendChild(polygon); + defs.appendChild(marker); + svg.appendChild(defs); + } + }, + + // ํŠธ๋ฆฌ ์ค‘์•™ ์ •๋ ฌ + centerTree() { + const canvas = document.getElementById('tree-canvas'); + if (!canvas || !this.treeBounds) { + this.treePanX = 0; + this.treePanY = 0; + this.treeZoom = 1; + return; + } + + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + // ํŠธ๋ฆฌ๊ฐ€ ์บ”๋ฒ„์Šค๋ณด๋‹ค ํฐ ๊ฒฝ์šฐ ์ ์ ˆํ•œ ์คŒ ๋ ˆ๋ฒจ ๊ณ„์‚ฐ + const scaleX = canvasWidth / this.treeBounds.width; + const scaleY = canvasHeight / this.treeBounds.height; + const optimalZoom = Math.min(scaleX, scaleY, 1) * 0.9; // 90%๋กœ ์—ฌ์œ  ๊ณต๊ฐ„ ํ™•๋ณด + + // ์คŒ ์ ์šฉ + this.treeZoom = Math.max(0.1, Math.min(optimalZoom, 2)); + + // ์ค‘์•™ ์ •๋ ฌ์„ ์œ„ํ•œ ํŒฌ ๊ฐ’ ๊ณ„์‚ฐ + const scaledTreeWidth = this.treeBounds.width * this.treeZoom; + const scaledTreeHeight = this.treeBounds.height * this.treeZoom; + + this.treePanX = (canvasWidth - scaledTreeWidth) / 2 - this.treeBounds.offsetX * this.treeZoom; + this.treePanY = (canvasHeight - scaledTreeHeight) / 2 - this.treeBounds.offsetY * this.treeZoom; + + console.log('๐ŸŽฏ ํŠธ๋ฆฌ ์ค‘์•™ ์ •๋ ฌ:', { + zoom: this.treeZoom, + panX: this.treePanX, + panY: this.treePanY, + treeBounds: this.treeBounds + }); + }, + + // ํ™•๋Œ€ + zoomIn() { + this.treeZoom = Math.min(this.treeZoom * 1.2, 3); + }, + + // ์ถ•์†Œ + zoomOut() { + this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3); + }, + + // ํŒจ๋‹ ์‹œ์ž‘ + startPan(event) { + if (event.target.closest('.tree-diagram-node')) return; // ๋…ธ๋“œ ํด๋ฆญ ์‹œ ํŒจ๋‹ ๋ฐฉ์ง€ + + this.isPanning = true; + this.panStartX = event.clientX - this.treePanX; + this.panStartY = event.clientY - this.treePanY; + + document.addEventListener('mousemove', this.handlePan.bind(this)); + document.addEventListener('mouseup', this.stopPan.bind(this)); + }, + + // ํŒจ๋‹ ์ฒ˜๋ฆฌ + handlePan(event) { + if (!this.isPanning) return; + + this.treePanX = event.clientX - this.panStartX; + this.treePanY = event.clientY - this.panStartY; + }, + + // ํŒจ๋‹ ์ข…๋ฃŒ + stopPan() { + this.isPanning = false; + document.removeEventListener('mousemove', this.handlePan); + document.removeEventListener('mouseup', this.stopPan); + }, + + // ์žฌ๊ท€์  ํŠธ๋ฆฌ ๋…ธ๋“œ ๋ Œ๋”๋ง + renderTreeNodeRecursive(node, depth, isLast = false, parentPath = []) { + const hasChildren = this.getChildNodes(node.id).length > 0; + const isExpanded = this.expandedNodes.has(node.id); + const isSelected = this.selectedNode && this.selectedNode.id === node.id; + const isRoot = depth === 0; + + // ์ƒํƒœ๋ณ„ ์ƒ‰์ƒ + let statusClass = 'text-gray-700'; + switch(node.status) { + case 'draft': statusClass = 'text-gray-500'; break; + case 'writing': statusClass = 'text-yellow-600'; break; + case 'review': statusClass = 'text-blue-600'; break; + case 'complete': statusClass = 'text-green-600'; break; + } + + // ํŠธ๋ฆฌ ๋ผ์ธ ์ƒ์„ฑ + let treeLines = ''; + for (let i = 0; i < depth; i++) { + if (i < parentPath.length && parentPath[i]) { + // ๋ถ€๋ชจ๊ฐ€ ๋งˆ์ง€๋ง‰์ด ์•„๋‹ˆ๋ฉด ์„ธ๋กœ์„  ํ‘œ์‹œ + treeLines += ''; + } else { + // ๋นˆ ๊ณต๊ฐ„ + treeLines += ''; + } + } + + // ํ˜„์žฌ ๋…ธ๋“œ์˜ ์—ฐ๊ฒฐ์„  + let nodeConnector = ''; + if (!isRoot) { + if (isLast) { + nodeConnector = ''; // โ””โ”€ + } else { + nodeConnector = ''; // โ”œโ”€ + } + } + + let html = ` +
+
+ +
+ ${treeLines} + ${nodeConnector} + + + ${hasChildren ? + `` : + '' + } + + + ${this.getNodeIcon(node.node_type)} + + + ${node.title} +
+ + +
+ + +
+ + + ${node.word_count > 0 ? + `${node.word_count}w` : + '' + } +
+ `; + + // ์ž์‹ ๋…ธ๋“œ๋“ค ์žฌ๊ท€์ ์œผ๋กœ ๋ Œ๋”๋ง + if (hasChildren && isExpanded) { + const children = this.getChildNodes(node.id); + const newParentPath = [...parentPath, !isLast]; + + children.forEach((child, index) => { + const isChildLast = index === children.length - 1; + html += this.renderTreeNodeRecursive(child, depth + 1, isChildLast, newParentPath); + }); + } + + html += '
'; + return html; + }, + + // ๋…ธ๋“œ ํ† ๊ธ€ + toggleNode(nodeId) { + if (this.expandedNodes.has(nodeId)) { + this.expandedNodes.delete(nodeId); + } else { + this.expandedNodes.add(nodeId); + } + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๋ Œ๋”๋ง์„ ์œ„ํ•ด ์ƒํƒœ ์—…๋ฐ์ดํŠธ + this.$nextTick(() => { + this.rerenderTree(); + }); + }, + + // ํŠธ๋ฆฌ ๋‹ค์‹œ ๋ Œ๋”๋ง + rerenderTree() { + // Alpine.js์˜ ๋ฐ˜์‘์„ฑ์„ ํŠธ๋ฆฌ๊ฑฐํ•˜๊ธฐ ์œ„ํ•ด ๋ฐฐ์—ด์„ ์ƒˆ๋กœ ํ• ๋‹น + this.treeNodes = [...this.treeNodes]; + // ๊ฐ•์ œ๋กœ DOM ์—…๋ฐ์ดํŠธ + this.$nextTick(() => { + // ํŠธ๋ฆฌ ์ปจํ…Œ์ด๋„ˆ ์ฐพ์•„์„œ ๋‹ค์‹œ ๋ Œ๋”๋ง + const treeContainer = document.querySelector('.tree-container'); + if (treeContainer) { + // Alpine.js๊ฐ€ ์ž๋™์œผ๋กœ ๋‹ค์‹œ ๋ Œ๋”๋งํ•จ + } + }); + }, + + // ๋ชจ๋‘ ํŽผ์น˜๊ธฐ + expandAll() { + this.treeNodes.forEach(node => { + if (this.getChildNodes(node.id).length > 0) { + this.expandedNodes.add(node.id); + } + }); + this.rerenderTree(); + }, + + // ๋ชจ๋‘ ์ ‘๊ธฐ + collapseAll() { + this.expandedNodes.clear(); + this.rerenderTree(); + }, + + // Monaco Editor ์ดˆ๊ธฐํ™” + async initMonacoEditor() { + console.log('๐ŸŽจ Monaco Editor ์ดˆ๊ธฐํ™” ์‹œ์ž‘...'); + + // Monaco Editor ๋กœ๋”๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + let retries = 0; + while (typeof require === 'undefined' && retries < 30) { + console.log(`โณ Monaco Editor ๋กœ๋” ๋Œ€๊ธฐ ์ค‘... (${retries + 1}/30)`); + await new Promise(resolve => setTimeout(resolve, 200)); + retries++; + } + + if (typeof require === 'undefined') { + console.warn('โŒ Monaco Editor ๋กœ๋”๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ณธ textarea๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.'); + this.setupFallbackEditor(); + return; + } + + try { + require.config({ + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' + } + }); + + require(['vs/editor/editor.main'], () => { + // ์—๋””ํ„ฐ ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + const waitForEditor = () => { + const editorElement = document.getElementById('monaco-editor'); + if (!editorElement) { + console.log('โณ Monaco Editor ์ปจํ…Œ์ด๋„ˆ ๋Œ€๊ธฐ ์ค‘...'); + setTimeout(waitForEditor, 100); + return; + } + + // ์ด๋ฏธ Monaco Editor๊ฐ€ ์ƒ์„ฑ๋˜์–ด ์žˆ๋‹ค๋ฉด ์ œ๊ฑฐ + if (monacoEditor) { + monacoEditor.dispose(); + monacoEditor = null; + } + + monacoEditor = monaco.editor.create(editorElement, { + value: '', + language: 'markdown', + theme: 'vs-light', + automaticLayout: true, + wordWrap: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + lineNumbers: 'on', + folding: true, + renderWhitespace: 'boundary' + }); + + // ๋‚ด์šฉ ๋ณ€๊ฒฝ ๊ฐ์ง€ + monacoEditor.onDidChangeModelContent(() => { + this.isEditorDirty = true; + }); + + console.log('โœ… Monaco Editor ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + }; + + // ์—๋””ํ„ฐ ์ปจํ…Œ์ด๋„ˆ ๋Œ€๊ธฐ ์‹œ์ž‘ + waitForEditor(); + }); + } catch (error) { + console.error('โŒ Monaco Editor ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + this.setupFallbackEditor(); + } + }, + + // ํด๋ฐฑ ์—๋””ํ„ฐ ์„ค์ • (Monaco๊ฐ€ ์‹คํŒจํ–ˆ์„ ๋•Œ) + setupFallbackEditor() { + console.log('๐Ÿ“ ํด๋ฐฑ textarea ์—๋””ํ„ฐ ์„ค์ • ์ค‘...'); + const editorElement = document.getElementById('monaco-editor'); + if (editorElement) { + editorElement.innerHTML = ` + + `; + + const textarea = document.getElementById('fallback-editor'); + if (textarea) { + textarea.addEventListener('input', () => { + this.isEditorDirty = true; + }); + console.log('โœ… ํด๋ฐฑ ์—๋””ํ„ฐ ์„ค์ • ์™„๋ฃŒ'); + } + } + }, + + // ๋กœ๊ทธ์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + // ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // ํŠธ๋ฆฌ ๋ชฉ๋ก ๋‹ค์‹œ ๋กœ๋“œ + await this.loadUserTrees(); + } else { + this.loginError = response.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + this.loginError = '๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loginLoading = false; + } + }, + + // ๋กœ๊ทธ์•„์›ƒ + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + console.log('โœ… ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } + } + }; +}; + +// ์ „์—ญ ์ธ์Šคํ„ด์Šค ๋“ฑ๋ก (HTML์—์„œ ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•ด) +window.memoTreeInstance = null; + +// Alpine.js ์ดˆ๊ธฐํ™” ํ›„ ์ „์—ญ ์ธ์Šคํ„ด์Šค ์„ค์ • +document.addEventListener('alpine:init', () => { + // ํŽ˜์ด์ง€ ๋กœ๋“œ ํ›„ ์ธ์Šคํ„ด์Šค ๋“ฑ๋ก + setTimeout(() => { + const appElement = document.querySelector('[x-data="memoTreeApp()"]'); + if (appElement && appElement._x_dataStack) { + window.memoTreeInstance = appElement._x_dataStack[0]; + } + }, 100); +}); + +// ํŠธ๋ฆฌ ๋…ธ๋“œ ์ปดํฌ๋„ŒํŠธ +window.treeNodeComponent = function(node) { + return { + node: node, + expanded: true, + + get hasChildren() { + return window.memoTreeInstance?.getChildNodes(this.node.id).length > 0; + }, + + toggleExpanded() { + this.expanded = !this.expanded; + if (this.expanded) { + window.memoTreeInstance?.expandedNodes.add(this.node.id); + } else { + window.memoTreeInstance?.expandedNodes.delete(this.node.id); + } + } + }; +}; + +console.log('๐ŸŒณ ํŠธ๋ฆฌ ๋ฉ”๋ชจ์žฅ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); diff --git a/frontend/static/js/note-editor.js b/frontend/static/js/note-editor.js new file mode 100644 index 0000000..a8b680d --- /dev/null +++ b/frontend/static/js/note-editor.js @@ -0,0 +1,349 @@ +function noteEditorApp() { + return { + // ์ƒํƒœ ๊ด€๋ฆฌ + noteData: { + title: '', + content: '', + note_type: 'note', + tags: [], + is_published: false, + parent_note_id: null, + sort_order: 0, + notebook_id: null + }, + + // ๋…ธํŠธ๋ถ ๊ด€๋ จ + availableNotebooks: [], + + // UI ์ƒํƒœ + loading: false, + saving: false, + error: null, + isEditing: false, + noteId: null, + + // ์—๋””ํ„ฐ ๊ด€๋ จ + quillEditor: null, + editorMode: 'wysiwyg', // 'wysiwyg' ๋˜๋Š” 'html' + tagInput: '', + + // ์ธ์ฆ ๊ด€๋ จ + isAuthenticated: false, + currentUser: null, + + // API ํด๋ผ์ด์–ธํŠธ + api: null, + + async init() { + console.log('๐Ÿ“ ๋…ธํŠธ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + try { + // API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™” + this.api = new DocumentServerAPI(); + console.log('๐Ÿ”ง API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”๋จ:', this.api); + console.log('๐Ÿ”ง getNotebooks ๋ฉ”์„œ๋“œ ์กด์žฌ ์—ฌ๋ถ€:', typeof this.api.getNotebooks); + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (!this.isAuthenticated) { + window.location.href = '/'; + return; + } + + // URL์—์„œ ๋…ธํŠธ ID ๋ฐ ๋…ธํŠธ๋ถ ์ •๋ณด ํ™•์ธ + const urlParams = new URLSearchParams(window.location.search); + this.noteId = urlParams.get('id'); + const notebookId = urlParams.get('notebook_id'); + const notebookName = urlParams.get('notebook_name'); + + // ๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋“œ + await this.loadNotebooks(); + + // URL์—์„œ ๋…ธํŠธ๋ถ์ด ์ง€์ •๋œ ๊ฒฝ์šฐ ์ž๋™ ์„ค์ • + if (notebookId && !this.noteId) { // ์ƒˆ ๋…ธํŠธ ์ƒ์„ฑ ์‹œ์—๋งŒ + this.noteData.notebook_id = notebookId; + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ์ž๋™ ์„ค์ •:', notebookName || notebookId); + } + + if (this.noteId) { + this.isEditing = true; + await this.loadNote(this.noteId); + } + + // Quill ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” + this.initQuillEditor(); + + console.log('โœ… ๋…ธํŠธ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ๋…ธํŠธ ์—๋””ํ„ฐ ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + this.error = '๋…ธํŠธ ์—๋””ํ„ฐ๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + }, + + async loadHeader() { + try { + if (typeof loadHeaderComponent === 'function') { + await loadHeaderComponent(); + } else if (typeof window.loadHeaderComponent === 'function') { + await window.loadHeaderComponent(); + } else { + console.warn('ํ—ค๋” ๋กœ๋” ํ•จ์ˆ˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ˆ˜๋™์œผ๋กœ ํ—ค๋”๋ฅผ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค.'); + // ์ˆ˜๋™์œผ๋กœ ํ—ค๋” ๋กœ๋“œ + const headerContainer = document.getElementById('header-container'); + if (headerContainer) { + const response = await fetch('/components/header.html'); + const headerHTML = await response.text(); + headerContainer.innerHTML = headerHTML; + } + } + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + async checkAuthStatus() { + try { + const response = await this.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = response; + console.log('โœ… ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž:', this.currentUser.username); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž'); + this.isAuthenticated = false; + this.currentUser = null; + } + }, + + async loadNotebooks() { + try { + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋กœ๋“œ ์‹œ์ž‘...'); + console.log('๐Ÿ”ง API ๋ฉ”์„œ๋“œ ํ™•์ธ:', typeof this.api.getNotebooks); + + // ์ž„์‹œ: ์ง์ ‘ API ํ˜ธ์ถœ + this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true }); + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋กœ๋“œ๋จ:', this.availableNotebooks.length, '๊ฐœ'); + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋ฐ์ดํ„ฐ ์ƒ์„ธ:', this.availableNotebooks); + + // ๊ฐ ๋…ธํŠธ๋ถ์˜ ํ•„๋“œ ํ™•์ธ + if (this.availableNotebooks.length > 0) { + console.log('๐Ÿ“š ์ฒซ ๋ฒˆ์งธ ๋…ธํŠธ๋ถ ํ•„๋“œ:', Object.keys(this.availableNotebooks[0])); + console.log('๐Ÿ“š ์ฒซ ๋ฒˆ์งธ ๋…ธํŠธ๋ถ title:', this.availableNotebooks[0].title); + console.log('๐Ÿ“š ์ฒซ ๋ฒˆ์งธ ๋…ธํŠธ๋ถ name:', this.availableNotebooks[0].name); + } + } catch (error) { + console.error('๋…ธํŠธ๋ถ ๋กœ๋“œ ์‹คํŒจ:', error); + this.availableNotebooks = []; + } + }, + + initQuillEditor() { + // Quill ์—๋””ํ„ฐ ์„ค์ • + const toolbarOptions = [ + [{ 'header': [1, 2, 3, 4, 5, 6, false] }], + [{ 'font': [] }], + [{ 'size': ['small', false, 'large', 'huge'] }], + ['bold', 'italic', 'underline', 'strike'], + [{ 'color': [] }, { 'background': [] }], + [{ 'script': 'sub'}, { 'script': 'super' }], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + [{ 'indent': '-1'}, { 'indent': '+1' }], + [{ 'direction': 'rtl' }], + [{ 'align': [] }], + ['blockquote', 'code-block'], + ['link', 'image', 'video'], + ['clean'] + ]; + + this.quillEditor = new Quill('#quill-editor', { + theme: 'snow', + modules: { + toolbar: toolbarOptions + }, + placeholder: '๋…ธํŠธ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”...' + }); + + // ์—๋””ํ„ฐ ๋‚ด์šฉ ๋ณ€๊ฒฝ ์‹œ ๋™๊ธฐํ™” + this.quillEditor.on('text-change', () => { + if (this.editorMode === 'wysiwyg') { + this.noteData.content = this.quillEditor.root.innerHTML; + } + }); + + // ๊ธฐ์กด ๋‚ด์šฉ์ด ์žˆ์œผ๋ฉด ๋กœ๋“œ + if (this.noteData.content) { + this.quillEditor.root.innerHTML = this.noteData.content; + } + }, + + async loadNote(noteId) { + this.loading = true; + this.error = null; + + try { + console.log('๐Ÿ“– ๋…ธํŠธ ๋กœ๋“œ ์ค‘:', noteId); + const note = await this.api.getNoteDocument(noteId); + + this.noteData = { + title: note.title || '', + content: note.content || '', + note_type: note.note_type || 'note', + tags: note.tags || [], + is_published: note.is_published || false, + parent_note_id: note.parent_note_id || null, + sort_order: note.sort_order || 0 + }; + + console.log('โœ… ๋…ธํŠธ ๋กœ๋“œ ์™„๋ฃŒ:', this.noteData.title); + + } catch (error) { + console.error('โŒ ๋…ธํŠธ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '๋…ธํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loading = false; + } + }, + + async saveNote() { + if (!this.noteData.title.trim()) { + this.showNotification('์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error'); + return; + } + + this.saving = true; + this.error = null; + + try { + // WYSIWYG ๋ชจ๋“œ์—์„œ HTML ๋™๊ธฐํ™” + if (this.editorMode === 'wysiwyg' && this.quillEditor) { + this.noteData.content = this.quillEditor.root.innerHTML; + } + + console.log('๐Ÿ’พ ๋…ธํŠธ ์ €์žฅ ์ค‘:', this.noteData.title); + + let result; + if (this.isEditing && this.noteId) { + // ๊ธฐ์กด ๋…ธํŠธ ์—…๋ฐ์ดํŠธ + result = await this.api.updateNoteDocument(this.noteId, this.noteData); + console.log('โœ… ๋…ธํŠธ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ'); + } else { + // ์ƒˆ ๋…ธํŠธ ์ƒ์„ฑ + result = await this.api.createNoteDocument(this.noteData); + console.log('โœ… ์ƒˆ ๋…ธํŠธ ์ƒ์„ฑ ์™„๋ฃŒ'); + + // ํŽธ์ง‘ ๋ชจ๋“œ๋กœ ์ „ํ™˜ + this.isEditing = true; + this.noteId = result.id; + + // URL ์—…๋ฐ์ดํŠธ (์ƒˆ๋กœ๊ณ ์นจ ์—†์ด) + const newUrl = `${window.location.pathname}?id=${result.id}`; + window.history.replaceState({}, '', newUrl); + } + + this.showNotification('๋…ธํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); + + } catch (error) { + console.error('โŒ ๋…ธํŠธ ์ €์žฅ ์‹คํŒจ:', error); + this.error = '๋…ธํŠธ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + this.showNotification('๋…ธํŠธ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } finally { + this.saving = false; + } + }, + + toggleEditorMode() { + if (this.editorMode === 'wysiwyg') { + // WYSIWYG โ†’ HTML ์ฝ”๋“œ + if (this.quillEditor) { + this.noteData.content = this.quillEditor.root.innerHTML; + } + this.editorMode = 'html'; + } else { + // HTML ์ฝ”๋“œ โ†’ WYSIWYG + if (this.quillEditor) { + this.quillEditor.root.innerHTML = this.noteData.content || ''; + } + this.editorMode = 'wysiwyg'; + } + }, + + addTag() { + const tag = this.tagInput.trim(); + if (tag && !this.noteData.tags.includes(tag)) { + this.noteData.tags.push(tag); + this.tagInput = ''; + } + }, + + removeTag(index) { + this.noteData.tags.splice(index, 1); + }, + + getWordCount() { + if (!this.noteData.content) return 0; + + // HTML ํƒœ๊ทธ ์ œ๊ฑฐ ํ›„ ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ + const textContent = this.noteData.content.replace(/<[^>]*>/g, ''); + return textContent.length; + }, + + goBack() { + // ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์œผ๋ฉด ํ™•์ธ + if (this.hasUnsavedChanges()) { + if (!confirm('์ €์žฅํ•˜์ง€ ์•Š์€ ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ •๋ง ๋‚˜๊ฐ€์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + } + + window.location.href = '/notes.html'; + }, + + hasUnsavedChanges() { + // ๊ฐ„๋‹จํ•œ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๊ฐ์ง€ (์‹ค์ œ๋กœ๋Š” ๋” ์ •๊ตํ•˜๊ฒŒ ๊ตฌํ˜„ ๊ฐ€๋Šฅ) + return this.noteData.title.trim() !== '' || this.noteData.content.trim() !== ''; + }, + + showNotification(message, type = 'info') { + // ๊ฐ„๋‹จํ•œ ์•Œ๋ฆผ (๋‚˜์ค‘์— ๋” ์ •๊ตํ•œ ํ† ์ŠคํŠธ ์‹œ์Šคํ…œ์œผ๋กœ ๊ต์ฒด ๊ฐ€๋Šฅ) + if (type === 'error') { + alert('โŒ ' + message); + } else if (type === 'success') { + alert('โœ… ' + message); + } else { + alert('โ„น๏ธ ' + message); + } + }, + + // ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค + handleKeydown(event) { + // Ctrl+S (๋˜๋Š” Cmd+S): ์ €์žฅ + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + this.saveNote(); + } + } + }; +} + +// ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก +document.addEventListener('keydown', function(event) { + // Alpine.js ์ปดํฌ๋„ŒํŠธ์— ์ ‘๊ทผ + const app = Alpine.$data(document.querySelector('[x-data]')); + if (app && app.handleKeydown) { + app.handleKeydown(event); + } +}); + +// ํŽ˜์ด์ง€ ๋– ๋‚  ๋•Œ ํ™•์ธ +window.addEventListener('beforeunload', function(event) { + const app = Alpine.$data(document.querySelector('[x-data]')); + if (app && app.hasUnsavedChanges && app.hasUnsavedChanges()) { + event.preventDefault(); + event.returnValue = '์ €์žฅํ•˜์ง€ ์•Š์€ ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค.'; + return event.returnValue; + } +}); diff --git a/frontend/static/js/notebooks.js b/frontend/static/js/notebooks.js new file mode 100644 index 0000000..acb0afb --- /dev/null +++ b/frontend/static/js/notebooks.js @@ -0,0 +1,310 @@ +// ๋…ธํŠธ๋ถ ๊ด€๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.notebooksApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + notebooks: [], + stats: null, + loading: false, + saving: false, + error: '', + + // ์•Œ๋ฆผ ์‹œ์Šคํ…œ + notification: { + show: false, + message: '', + type: 'info' // 'success', 'error', 'info' + }, + + // ํ•„ํ„ฐ๋ง + searchQuery: '', + activeOnly: true, + sortBy: 'updated_at', + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + searchTimeout: null, + + // ๋ชจ๋‹ฌ ์ƒํƒœ + showCreateModal: false, + showEditModal: false, + showDeleteModal: false, + editingNotebook: null, + deletingNotebook: null, + deleting: false, + + // ๋…ธํŠธ๋ถ ํผ + notebookForm: { + title: '', + description: '', + color: '#3B82F6', + icon: 'book', + is_active: true, + sort_order: 0 + }, + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // API ํด๋ผ์ด์–ธํŠธ + api: null, + + // ์ƒ‰์ƒ ์˜ต์…˜ + availableColors: [ + '#3B82F6', // blue + '#10B981', // emerald + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // violet + '#06B6D4', // cyan + '#84CC16', // lime + '#F97316', // orange + '#EC4899', // pink + '#6B7280' // gray + ], + + // ์•„์ด์ฝ˜ ์˜ต์…˜ + availableIcons: [ + { value: 'book', label: '๐Ÿ“– ์ฑ…' }, + { value: 'sticky-note', label: '๐Ÿ“ ๋…ธํŠธ' }, + { value: 'lightbulb', label: '๐Ÿ’ก ์•„์ด๋””์–ด' }, + { value: 'graduation-cap', label: '๐ŸŽ“ ํ•™์Šต' }, + { value: 'briefcase', label: '๐Ÿ’ผ ์—…๋ฌด' }, + { value: 'heart', label: 'โค๏ธ ๊ฐœ์ธ' }, + { value: 'code', label: '๐Ÿ’ป ๊ฐœ๋ฐœ' }, + { value: 'palette', label: '๐ŸŽจ ์ฐฝ์ž‘' }, + { value: 'flask', label: '๐Ÿงช ์—ฐ๊ตฌ' }, + { value: 'star', label: 'โญ ์ฆ๊ฒจ์ฐพ๊ธฐ' } + ], + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿ“š Notebooks App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™” + this.api = new DocumentServerAPI(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadStats(); + await this.loadNotebooks(); + } + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await this.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username || user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + if (typeof loadHeaderComponent === 'function') { + await loadHeaderComponent(); + } else if (typeof window.loadHeaderComponent === 'function') { + await window.loadHeaderComponent(); + } else { + console.warn('ํ—ค๋” ๋กœ๋” ํ•จ์ˆ˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ํ†ต๊ณ„ ์ •๋ณด ๋กœ๋“œ + async loadStats() { + try { + this.stats = await this.api.getNotebookStats(); + console.log('๐Ÿ“Š ๋…ธํŠธ๋ถ ํ†ต๊ณ„ ๋กœ๋“œ๋จ:', this.stats); + } catch (error) { + console.error('ํ†ต๊ณ„ ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋“œ + async loadNotebooks() { + this.loading = true; + this.error = ''; + + try { + const queryParams = { + active_only: this.activeOnly, + sort_by: this.sortBy, + order: 'desc' + }; + + if (this.searchQuery) { + queryParams.search = this.searchQuery; + } + + this.notebooks = await this.api.getNotebooks(queryParams); + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋กœ๋“œ๋จ:', this.notebooks.length, '๊ฐœ'); + } catch (error) { + console.error('๋…ธํŠธ๋ถ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '๋…ธํŠธ๋ถ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loading = false; + } + }, + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + debounceSearch() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.loadNotebooks(); + }, 300); + }, + + // ์ƒˆ๋กœ๊ณ ์นจ + async refreshNotebooks() { + await Promise.all([ + this.loadStats(), + this.loadNotebooks() + ]); + }, + + // ๋…ธํŠธ๋ถ ์—ด๊ธฐ (๋…ธํŠธ ๋ชฉ๋ก์œผ๋กœ ์ด๋™) + openNotebook(notebook) { + window.location.href = `/notes.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.title)}`; + }, + + // ๋…ธํŠธ๋ถ์— ๋…ธํŠธ ์ƒ์„ฑ + createNoteInNotebook(notebook) { + window.location.href = `/note-editor.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.title)}`; + }, + + // ๋…ธํŠธ๋ถ ํŽธ์ง‘ + editNotebook(notebook) { + this.editingNotebook = notebook; + this.notebookForm = { + title: notebook.title, + description: notebook.description || '', + color: notebook.color, + icon: notebook.icon, + is_active: notebook.is_active, + sort_order: notebook.sort_order + }; + this.showEditModal = true; + }, + + // ๋…ธํŠธ๋ถ ์‚ญ์ œ (๋ชจ๋‹ฌ ํ‘œ์‹œ) + deleteNotebook(notebook) { + this.deletingNotebook = notebook; + this.showDeleteModal = true; + }, + + // ์‚ญ์ œ ํ™•์ธ + async confirmDeleteNotebook() { + if (!this.deletingNotebook) return; + + this.deleting = true; + try { + await this.api.deleteNotebook(this.deletingNotebook.id, true); // force=true + this.showNotification('๋…ธํŠธ๋ถ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); + this.closeDeleteModal(); + await this.refreshNotebooks(); + } catch (error) { + console.error('๋…ธํŠธ๋ถ ์‚ญ์ œ ์‹คํŒจ:', error); + this.showNotification('๋…ธํŠธ๋ถ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } finally { + this.deleting = false; + } + }, + + // ์‚ญ์ œ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeDeleteModal() { + this.showDeleteModal = false; + this.deletingNotebook = null; + this.deleting = false; + }, + + // ๋…ธํŠธ๋ถ ์ €์žฅ + async saveNotebook() { + if (!this.notebookForm.title.trim()) { + this.showNotification('์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error'); + return; + } + + this.saving = true; + + try { + if (this.showEditModal && this.editingNotebook) { + // ํŽธ์ง‘ + await this.api.updateNotebook(this.editingNotebook.id, this.notebookForm); + this.showNotification('๋…ธํŠธ๋ถ์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); + } else { + // ์ƒ์„ฑ + await this.api.createNotebook(this.notebookForm); + this.showNotification('๋…ธํŠธ๋ถ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); + } + + this.closeModal(); + await this.refreshNotebooks(); + } catch (error) { + console.error('๋…ธํŠธ๋ถ ์ €์žฅ ์‹คํŒจ:', error); + this.showNotification('๋…ธํŠธ๋ถ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } finally { + this.saving = false; + } + }, + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeModal() { + this.showCreateModal = false; + this.showEditModal = false; + this.editingNotebook = null; + this.notebookForm = { + title: '', + description: '', + color: '#3B82F6', + icon: 'book', + is_active: true, + sort_order: 0 + }; + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays === 2) { + return '์–ด์ œ'; + } else if (diffDays <= 7) { + return `${diffDays - 1}์ผ ์ „`; + } else { + return date.toLocaleDateString('ko-KR'); + } + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + this.notification = { + show: true, + message: message, + type: type + }; + + // 3์ดˆ ํ›„ ์ž๋™์œผ๋กœ ์ˆจ๊น€ + setTimeout(() => { + this.notification.show = false; + }, 3000); + } +}); diff --git a/frontend/static/js/notes.js b/frontend/static/js/notes.js new file mode 100644 index 0000000..fb72ba1 --- /dev/null +++ b/frontend/static/js/notes.js @@ -0,0 +1,404 @@ +// ๋…ธํŠธ ๊ด€๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.notesApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + notes: [], + stats: null, + loading: false, + error: '', + + // ํ•„ํ„ฐ๋ง + searchQuery: '', + selectedType: '', + publishedOnly: false, + selectedNotebook: '', + + // ๋…ธํŠธ๋ถ ๊ด€๋ จ + availableNotebooks: [], + + // ์ผ๊ด„ ์„ ํƒ ๊ด€๋ จ + selectedNotes: [], + bulkNotebookId: '', + + // ๋…ธํŠธ๋ถ ์ƒ์„ฑ ๊ด€๋ จ + showCreateNotebookModal: false, + creatingNotebook: false, + newNotebookForm: { + name: '', + description: '', + color: '#3B82F6', + icon: 'book' + }, + + // ์ƒ‰์ƒ ๋ฐ ์•„์ด์ฝ˜ ์˜ต์…˜ + availableColors: [ + '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', + '#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6B7280' + ], + availableIcons: [ + { value: 'book', label: '๐Ÿ“– ์ฑ…' }, + { value: 'sticky-note', label: '๐Ÿ“ ๋…ธํŠธ' }, + { value: 'lightbulb', label: '๐Ÿ’ก ์•„์ด๋””์–ด' }, + { value: 'graduation-cap', label: '๐ŸŽ“ ํ•™์Šต' }, + { value: 'briefcase', label: '๐Ÿ’ผ ์—…๋ฌด' }, + { value: 'heart', label: 'โค๏ธ ๊ฐœ์ธ' }, + { value: 'code', label: '๐Ÿ’ป ๊ฐœ๋ฐœ' }, + { value: 'palette', label: '๐ŸŽจ ์ฐฝ์ž‘' }, + { value: 'flask', label: '๐Ÿงช ์—ฐ๊ตฌ' }, + { value: 'star', label: 'โญ ์ฆ๊ฒจ์ฐพ๊ธฐ' } + ], + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + searchTimeout: null, + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // API ํด๋ผ์ด์–ธํŠธ + api: null, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ Notes App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™” + this.api = new DocumentServerAPI(); + console.log('๐Ÿ”ง API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”๋จ:', this.api); + console.log('๐Ÿ”ง getNotebooks ๋ฉ”์„œ๋“œ ์กด์žฌ ์—ฌ๋ถ€:', typeof this.api.getNotebooks); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํ™•์ธ (๋…ธํŠธ๋ถ ํ•„ํ„ฐ) + const urlParams = new URLSearchParams(window.location.search); + const notebookId = urlParams.get('notebook_id'); + const notebookName = urlParams.get('notebook_name'); + + if (notebookId) { + this.selectedNotebook = notebookId; + console.log('๐Ÿ” ๋…ธํŠธ๋ถ ํ•„ํ„ฐ ์ ์šฉ:', notebookName || notebookId); + } + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadNotebooks(); + await this.loadStats(); + await this.loadNotes(); + } + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await this.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username || user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜์ง€ ์•Š๊ณ  ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ + window.location.href = '/'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋“œ + async loadNotebooks() { + try { + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋กœ๋“œ ์‹œ์ž‘...'); + console.log('๐Ÿ”ง API ๋ฉ”์„œ๋“œ ํ™•์ธ:', typeof this.api.getNotebooks); + + if (typeof this.api.getNotebooks !== 'function') { + throw new Error('getNotebooks ๋ฉ”์„œ๋“œ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค'); + } + + // ์ž„์‹œ: ์ง์ ‘ API ํ˜ธ์ถœ + this.availableNotebooks = await this.api.get('/notebooks/', { active_only: true }); + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋กœ๋“œ๋จ:', this.availableNotebooks.length, '๊ฐœ'); + } catch (error) { + console.error('๋…ธํŠธ๋ถ ๋กœ๋“œ ์‹คํŒจ:', error); + this.availableNotebooks = []; + } + }, + + // ํ†ต๊ณ„ ์ •๋ณด ๋กœ๋“œ + async loadStats() { + try { + this.stats = await this.api.get('/note-documents/stats'); + console.log('๐Ÿ“Š ํ†ต๊ณ„ ๋กœ๋“œ๋จ:', this.stats); + } catch (error) { + console.error('ํ†ต๊ณ„ ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ๋…ธํŠธ ๋ชฉ๋ก ๋กœ๋“œ + async loadNotes() { + this.loading = true; + this.error = ''; + + try { + const queryParams = {}; + + if (this.searchQuery) { + queryParams.search = this.searchQuery; + } + if (this.selectedType) { + queryParams.note_type = this.selectedType; + } + if (this.publishedOnly) { + queryParams.published_only = 'true'; + } + if (this.selectedNotebook) { + if (this.selectedNotebook === 'unassigned') { + queryParams.notebook_id = 'null'; + } else { + queryParams.notebook_id = this.selectedNotebook; + } + } + + this.notes = await this.api.getNoteDocuments(queryParams); + console.log('๐Ÿ“ ๋…ธํŠธ ๋กœ๋“œ๋จ:', this.notes.length, '๊ฐœ'); + + } catch (error) { + console.error('๋…ธํŠธ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '๋…ธํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message; + this.notes = []; + } finally { + this.loading = false; + } + }, + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + debounceSearch() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.loadNotes(); + }, 500); + }, + + // ๋…ธํŠธ ์ƒˆ๋กœ๊ณ ์นจ + async refreshNotes() { + await Promise.all([ + this.loadStats(), + this.loadNotes() + ]); + }, + + // ์ƒˆ ๋…ธํŠธ ์ƒ์„ฑ + createNewNote() { + window.location.href = '/note-editor.html'; + }, + + // ๋…ธํŠธ ๋ณด๊ธฐ (๋ทฐ์–ด ํŽ˜์ด์ง€๋กœ ์ด๋™) + viewNote(noteId) { + window.location.href = `/viewer.html?type=note&id=${noteId}`; + }, + + // ๋…ธํŠธ ํŽธ์ง‘ + editNote(noteId) { + window.location.href = `/note-editor.html?id=${noteId}`; + }, + + // ๋…ธํŠธ ์‚ญ์ œ + async deleteNote(note) { + if (!confirm(`"${note.title}" ๋…ธํŠธ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`)) { + return; + } + + try { + const response = await fetch(`/api/note-documents/${note.id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (response.ok) { + this.showNotification('๋…ธํŠธ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + await this.refreshNotes(); + } else { + throw new Error('์‚ญ์ œ ์‹คํŒจ'); + } + + } catch (error) { + console.error('๋…ธํŠธ ์‚ญ์ œ ์‹คํŒจ:', error); + this.showNotification('๋…ธํŠธ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } + }, + + // ๋…ธํŠธ ํƒ€์ž… ๋ผ๋ฒจ + getNoteTypeLabel(type) { + const labels = { + 'note': '์ผ๋ฐ˜', + 'research': '์—ฐ๊ตฌ', + 'summary': '์š”์•ฝ', + 'idea': '์•„์ด๋””์–ด', + 'guide': '๊ฐ€์ด๋“œ', + 'reference': '์ฐธ๊ณ ' + }; + return labels[type] || type; + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays === 2) { + return '์–ด์ œ'; + } else if (diffDays <= 7) { + return `${diffDays - 1}์ผ ์ „`; + } else { + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + console.log(`${type.toUpperCase()}: ${message}`); + + // ๊ฐ„๋‹จํ•œ ํ† ์ŠคํŠธ ์•Œ๋ฆผ ์ƒ์„ฑ + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${ + type === 'success' ? 'bg-green-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 3์ดˆ ํ›„ ์ œ๊ฑฐ + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 3000); + }, + + // === ์ผ๊ด„ ์„ ํƒ ๊ด€๋ จ ๋ฉ”์„œ๋“œ === + + // ๋…ธํŠธ ์„ ํƒ/ํ•ด์ œ + toggleNoteSelection(noteId) { + const index = this.selectedNotes.indexOf(noteId); + if (index > -1) { + this.selectedNotes.splice(index, 1); + } else { + this.selectedNotes.push(noteId); + } + }, + + // ์„ ํƒ ํ•ด์ œ + clearSelection() { + this.selectedNotes = []; + this.bulkNotebookId = ''; + }, + + // ์„ ํƒ๋œ ๋…ธํŠธ๋“ค์„ ๋…ธํŠธ๋ถ์— ํ• ๋‹น + async assignToNotebook() { + if (!this.bulkNotebookId || this.selectedNotes.length === 0) { + this.showNotification('๋…ธํŠธ๋ถ์„ ์„ ํƒํ•˜๊ณ  ๋…ธํŠธ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.', 'error'); + return; + } + + try { + // ๊ฐ ๋…ธํŠธ๋ฅผ ์—…๋ฐ์ดํŠธ + const updatePromises = this.selectedNotes.map(noteId => + this.api.put(`/note-documents/${noteId}`, { notebook_id: this.bulkNotebookId }) + ); + + await Promise.all(updatePromises); + + this.showNotification(`${this.selectedNotes.length}๊ฐœ ๋…ธํŠธ๊ฐ€ ๋…ธํŠธ๋ถ์— ํ• ๋‹น๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, 'success'); + + // ์„ ํƒ ํ•ด์ œ ๋ฐ ์ƒˆ๋กœ๊ณ ์นจ + this.clearSelection(); + await this.loadNotes(); + + } catch (error) { + console.error('๋…ธํŠธ๋ถ ํ• ๋‹น ์‹คํŒจ:', error); + this.showNotification('๋…ธํŠธ๋ถ ํ• ๋‹น์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } + }, + + // === ๋…ธํŠธ๋ถ ์ƒ์„ฑ ๊ด€๋ จ ๋ฉ”์„œ๋“œ === + + // ๋…ธํŠธ๋ถ ์ƒ์„ฑ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeCreateNotebookModal() { + this.showCreateNotebookModal = false; + this.newNotebookForm = { + name: '', + description: '', + color: '#3B82F6', + icon: 'book' + }; + }, + + // ๋…ธํŠธ๋ถ ์ƒ์„ฑ ๋ฐ ๋…ธํŠธ ํ• ๋‹น + async createNotebookAndAssign() { + if (!this.newNotebookForm.name.trim()) { + this.showNotification('๋…ธํŠธ๋ถ ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error'); + return; + } + + this.creatingNotebook = true; + + try { + // 1. ๋…ธํŠธ๋ถ ์ƒ์„ฑ + const newNotebook = await this.api.post('/notebooks/', this.newNotebookForm); + console.log('๐Ÿ“š ์ƒˆ ๋…ธํŠธ๋ถ ์ƒ์„ฑ๋จ:', newNotebook.name); + + // 2. ์„ ํƒ๋œ ๋…ธํŠธ๋“ค์ด ์žˆ์œผ๋ฉด ํ• ๋‹น + if (this.selectedNotes.length > 0) { + const updatePromises = this.selectedNotes.map(noteId => + this.api.put(`/note-documents/${noteId}`, { notebook_id: newNotebook.id }) + ); + + await Promise.all(updatePromises); + console.log(`๐Ÿ“ ${this.selectedNotes.length}๊ฐœ ๋…ธํŠธ๊ฐ€ ์ƒˆ ๋…ธํŠธ๋ถ์— ํ• ๋‹น๋จ`); + } + + this.showNotification( + `๋…ธํŠธ๋ถ "${newNotebook.name}"์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${this.selectedNotes.length > 0 ? ` ${this.selectedNotes.length}๊ฐœ ๋…ธํŠธ๊ฐ€ ํ• ๋‹น๋˜์—ˆ์Šต๋‹ˆ๋‹ค.` : ''}`, + 'success' + ); + + // 3. ์ •๋ฆฌ ๋ฐ ์ƒˆ๋กœ๊ณ ์นจ + this.closeCreateNotebookModal(); + this.clearSelection(); + await this.loadNotebooks(); + await this.loadNotes(); + + } catch (error) { + console.error('๋…ธํŠธ๋ถ ์ƒ์„ฑ ์‹คํŒจ:', error); + this.showNotification('๋…ธํŠธ๋ถ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); + } finally { + this.creatingNotebook = false; + } + } +}); + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + console.log('๐Ÿ“„ Notes ํŽ˜์ด์ง€ ๋กœ๋“œ๋จ'); +}); diff --git a/frontend/static/js/pdf-manager.js b/frontend/static/js/pdf-manager.js new file mode 100644 index 0000000..a8e9e74 --- /dev/null +++ b/frontend/static/js/pdf-manager.js @@ -0,0 +1,362 @@ +// PDF ๊ด€๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.pdfManagerApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + pdfDocuments: [], + allDocuments: [], + loading: false, + error: '', + filterType: 'all', // 'all', 'book', 'linked', 'standalone' + viewMode: 'books', // 'list', 'books' + groupedPDFs: [], // ์„œ์ ๋ณ„ ๊ทธ๋ฃนํ™”๋œ PDF ๋ชฉ๋ก + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ƒํƒœ + showPreviewModal: false, + previewPdf: null, + pdfPreviewSrc: '', + pdfPreviewLoading: false, + pdfPreviewError: false, + pdfPreviewLoaded: false, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ PDF Manager App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadPDFs(); + } + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/login.html'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // PDF ํŒŒ์ผ๋“ค ๋กœ๋“œ + async loadPDFs() { + this.loading = true; + this.error = ''; + + try { + // ๋ชจ๋“  ๋ฌธ์„œ ๊ฐ€์ ธ์˜ค๊ธฐ + this.allDocuments = await window.api.getDocuments(); + + // PDF ํŒŒ์ผ๋“ค๋งŒ ํ•„ํ„ฐ๋ง + this.pdfDocuments = this.allDocuments.filter(doc => + (doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) || + (doc.pdf_path && doc.pdf_path !== '') || + (doc.html_path === null && doc.pdf_path) // PDF๋งŒ ์—…๋กœ๋“œ๋œ ๊ฒฝ์šฐ + ); + + // ์—ฐ๊ฒฐ ์ƒํƒœ ๋ฐ ์„œ์  ์ •๋ณด ํ™•์ธ + this.pdfDocuments.forEach(pdf => { + // ์ด PDF๋ฅผ ์ฐธ์กฐํ•˜๋Š” ๋‹ค๋ฅธ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + const linkedDocuments = this.allDocuments.filter(doc => + doc.matched_pdf_id === pdf.id + ); + pdf.isLinked = linkedDocuments.length > 0; + pdf.linkedDocuments = linkedDocuments; + + // ์„œ์  ์ •๋ณด ์ถ”๊ฐ€ (PDF๊ฐ€ ์†ํ•œ ์„œ์  ๋˜๋Š” ์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ์˜ ์„œ์ ) + if (pdf.book_title) { + // PDF ์ž์ฒด๊ฐ€ ์„œ์ ์— ์†ํ•œ ๊ฒฝ์šฐ + pdf.book_title = pdf.book_title; + } else if (linkedDocuments.length > 0) { + // ์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ, ์ฒซ ๋ฒˆ์งธ ์—ฐ๊ฒฐ ๋ฌธ์„œ์˜ ์„œ์  ์ •๋ณด ์‚ฌ์šฉ + const firstLinked = linkedDocuments[0]; + pdf.book_title = firstLinked.book_title; + } + }); + + console.log('๐Ÿ“• PDF ๋ฌธ์„œ๋“ค:', this.pdfDocuments.length, '๊ฐœ'); + console.log('๐Ÿ“š ์„œ์  ํฌํ•จ PDF:', this.bookPDFs, '๊ฐœ'); + console.log('๐Ÿ”— HTML ์—ฐ๊ฒฐ PDF:', this.linkedPDFs, '๊ฐœ'); + console.log('๐Ÿ“„ ๋…๋ฆฝ PDF:', this.standalonePDFs, '๊ฐœ'); + + // ๋””๋ฒ„๊น…: PDF ์„œ์  ์ •๋ณด ํ™•์ธ + this.pdfDocuments.slice(0, 5).forEach(pdf => { + console.log(`๐Ÿ“‹ ${pdf.title}: ์„œ์ =${pdf.book_title || '์—†์Œ'}, ์—ฐ๊ฒฐ=${pdf.isLinked ? '์˜ˆ' : '์•„๋‹ˆ์˜ค'}`); + }); + + // ์„œ์ ๋ณ„ ๊ทธ๋ฃนํ™” ์‹คํ–‰ + this.groupPDFsByBook(); + + } catch (error) { + console.error('PDF ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = 'PDF ํŒŒ์ผ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message; + this.pdfDocuments = []; + this.groupedPDFs = []; + } finally { + this.loading = false; + } + }, + + // ํ•„ํ„ฐ๋ง๋œ PDF ๋ชฉ๋ก + get filteredPDFs() { + switch (this.filterType) { + case 'book': + return this.pdfDocuments.filter(pdf => pdf.book_title); + case 'linked': + return this.pdfDocuments.filter(pdf => pdf.isLinked); + case 'standalone': + return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title); + default: + return this.pdfDocuments; + } + }, + + // ํ†ต๊ณ„ ๊ณ„์‚ฐ + get bookPDFs() { + return this.pdfDocuments.filter(pdf => pdf.book_title).length; + }, + + get linkedPDFs() { + return this.pdfDocuments.filter(pdf => pdf.isLinked).length; + }, + + get standalonePDFs() { + return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title).length; + }, + + // PDF ์ƒˆ๋กœ๊ณ ์นจ + async refreshPDFs() { + await this.loadPDFs(); + }, + + // ์„œ์ ๋ณ„ PDF ๊ทธ๋ฃนํ™” + groupPDFsByBook() { + if (!this.pdfDocuments || this.pdfDocuments.length === 0) { + this.groupedPDFs = []; + return; + } + + const grouped = {}; + + this.pdfDocuments.forEach(pdf => { + const bookKey = pdf.book_id || 'no-book'; + if (!grouped[bookKey]) { + grouped[bookKey] = { + book: pdf.book_id ? { + id: pdf.book_id, + title: pdf.book_title, + author: pdf.book_author + } : null, + pdfs: [], + linkedCount: 0, + expanded: false // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ถ•์†Œ๋œ ์ƒํƒœ + }; + } + grouped[bookKey].pdfs.push(pdf); + if (pdf.isLinked) { + grouped[bookKey].linkedCount++; + } + }); + + // ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ํ•˜๊ณ  ์ •๋ ฌ (์„œ์  ์žˆ๋Š” ๊ฒƒ ๋จผ์ €, ๊ทธ ๋‹ค์Œ ์„œ์ ๋ช… ์ˆœ) + this.groupedPDFs = Object.values(grouped).sort((a, b) => { + if (!a.book && b.book) return 1; + if (a.book && !b.book) return -1; + if (!a.book && !b.book) return 0; + return a.book.title.localeCompare(b.book.title); + }); + + console.log('๐Ÿ“š ์„œ์ ๋ณ„ PDF ๊ทธ๋ฃนํ™” ์™„๋ฃŒ:', this.groupedPDFs.length, '๊ฐœ ๊ทธ๋ฃน'); + + // ๋””๋ฒ„๊น…: ๊ทธ๋ฃนํ™” ๊ฒฐ๊ณผ ํ™•์ธ + this.groupedPDFs.forEach((group, index) => { + console.log(`๐Ÿ“– ๊ทธ๋ฃน ${index + 1}: ${group.book?.title || 'PDF ๋ฏธ๋ถ„๋ฅ˜'} (${group.pdfs.length}๊ฐœ PDF, ${group.linkedCount}๊ฐœ ์—ฐ๊ฒฐ๋จ)`); + }); + }, + + // PDF ๋‹ค์šด๋กœ๋“œ + async downloadPDF(pdf) { + try { + console.log('๐Ÿ“• PDF ๋‹ค์šด๋กœ๋“œ ์‹œ์ž‘:', pdf.id); + + // PDF ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ URL ์ƒ์„ฑ + const downloadUrl = `/api/documents/${pdf.id}/download`; + + // ์ธ์ฆ ํ—ค๋” ์ถ”๊ฐ€๋ฅผ ์œ„ํ•ด fetch ์‚ฌ์šฉ + const response = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!response.ok) { + throw new Error('PDF ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + + // Blob์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋‹ค์šด๋กœ๋“œ + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // ๋‹ค์šด๋กœ๋“œ ๋งํฌ ์ƒ์„ฑ ๋ฐ ํด๋ฆญ + const link = document.createElement('a'); + link.href = url; + link.download = pdf.original_filename || `${pdf.title}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // URL ์ •๋ฆฌ + window.URL.revokeObjectURL(url); + + console.log('โœ… PDF ๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ PDF ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ:', error); + alert('PDF ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // PDF ์‚ญ์ œ + async deletePDF(pdf) { + // ์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + if (pdf.isLinked && pdf.linkedDocuments.length > 0) { + const linkedTitles = pdf.linkedDocuments.map(doc => doc.title).join('\n- '); + const confirmMessage = `์ด PDF๋Š” ๋‹ค์Œ ๋ฌธ์„œ๋“ค๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค:\n\n- ${linkedTitles}\n\n์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ๋“ค์˜ PDF ๋งํฌ๊ฐ€ ํ•ด์ œ๋ฉ๋‹ˆ๋‹ค.`; + + if (!confirm(confirmMessage)) { + return; + } + } else { + if (!confirm(`"${pdf.title}" PDF ํŒŒ์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`)) { + return; + } + } + + try { + await window.api.deleteDocument(pdf.id); + + // ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ + this.pdfDocuments = this.pdfDocuments.filter(p => p.id !== pdf.id); + + this.showNotification('PDF ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค', 'success'); + + // ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ (์—ฐ๊ฒฐ ์ƒํƒœ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•ด) + await this.loadPDFs(); + + } catch (error) { + console.error('PDF ์‚ญ์ œ ์‹คํŒจ:', error); + this.showNotification('PDF ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } + }, + + // ==================== PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ด€๋ จ ==================== + async previewPDF(pdf) { + console.log('๐Ÿ‘๏ธ PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ:', pdf.title); + + this.previewPdf = pdf; + this.showPreviewModal = true; + this.pdfPreviewLoading = true; + this.pdfPreviewError = false; + this.pdfPreviewLoaded = false; + + try { + const token = localStorage.getItem('access_token'); + if (!token || token === 'null' || token === null) { + throw new Error('์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.'); + } + + // PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ URL ์„ค์ • + this.pdfPreviewSrc = `/api/documents/${pdf.id}/pdf?_token=${encodeURIComponent(token)}`; + console.log('โœ… PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค€๋น„ ์™„๋ฃŒ:', this.pdfPreviewSrc); + + } catch (error) { + console.error('โŒ PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ ์‹คํŒจ:', error); + this.pdfPreviewError = true; + this.showNotification('PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message, 'error'); + } finally { + this.pdfPreviewLoading = false; + } + }, + + closePreview() { + this.showPreviewModal = false; + this.previewPdf = null; + this.pdfPreviewSrc = ''; + this.pdfPreviewLoading = false; + this.pdfPreviewError = false; + this.pdfPreviewLoaded = false; + }, + + handlePdfPreviewError() { + console.error('โŒ PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ iframe ๋กœ๋“œ ์˜ค๋ฅ˜'); + this.pdfPreviewError = true; + this.pdfPreviewLoading = false; + }, + + async retryPdfPreview() { + if (this.previewPdf) { + await this.previewPDF(this.previewPdf); + } + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ + showNotification(message, type = 'info') { + console.log(`${type.toUpperCase()}: ${message}`); + + // ๊ฐ„๋‹จํ•œ ํ† ์ŠคํŠธ ์•Œ๋ฆผ ์ƒ์„ฑ + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${ + type === 'success' ? 'bg-green-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 3์ดˆ ํ›„ ์ œ๊ฑฐ + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 3000); + } +}); + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + console.log('๐Ÿ“„ PDF Manager ํŽ˜์ด์ง€ ๋กœ๋“œ๋จ'); +}); diff --git a/frontend/static/js/search.js b/frontend/static/js/search.js new file mode 100644 index 0000000..1b829ea --- /dev/null +++ b/frontend/static/js/search.js @@ -0,0 +1,692 @@ +/** + * ํ†ตํ•ฉ ๊ฒ€์ƒ‰ JavaScript + */ + +// ๊ฒ€์ƒ‰ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ Alpine.js ์ปดํฌ๋„ŒํŠธ +window.searchApp = function() { + return { + // ์ƒํƒœ ๊ด€๋ฆฌ + searchQuery: '', + searchResults: [], + filteredResults: [], + loading: false, + hasSearched: false, + searchTime: 0, + + // ํ•„ํ„ฐ๋ง + typeFilter: '', // '', 'document', 'note', 'memo', 'highlight' + fileTypeFilter: '', // '', 'PDF', 'HTML' + sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title' + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + searchTimeout: null, + + // ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ชจ๋‹ฌ + showPreviewModal: false, + previewResult: null, + previewLoading: false, + pdfError: false, + pdfLoading: false, + pdfLoaded: false, + pdfSrc: '', + + // HTML ๋ทฐ์–ด ์ƒํƒœ + htmlLoading: false, + htmlRawMode: false, + htmlSourceCode: '', + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // API ํด๋ผ์ด์–ธํŠธ + api: null, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿ” ๊ฒ€์ƒ‰ ์•ฑ ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + try { + // API ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™” + this.api = new DocumentServerAPI(); + + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ ๊ฒ€์ƒ‰์–ด ํ™•์ธ + const urlParams = new URLSearchParams(window.location.search); + const query = urlParams.get('q'); + if (query) { + this.searchQuery = query; + await this.performSearch(); + } + + console.log('โœ… ๊ฒ€์ƒ‰ ์•ฑ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๊ฒ€์ƒ‰ ์•ฑ ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await this.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username || user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + // ๊ฒ€์ƒ‰์€ ๋กœ๊ทธ์ธ ์—†์ด๋„ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ—ˆ์šฉ + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + if (typeof loadHeaderComponent === 'function') { + await loadHeaderComponent(); + } else if (typeof window.loadHeaderComponent === 'function') { + await window.loadHeaderComponent(); + } else { + console.warn('ํ—ค๋” ๋กœ๋” ํ•จ์ˆ˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค + debounceSearch() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + if (this.searchQuery.trim()) { + this.performSearch(); + } + }, 500); + }, + + // ๊ฒ€์ƒ‰ ์ˆ˜ํ–‰ + async performSearch() { + if (!this.searchQuery.trim()) { + this.searchResults = []; + this.filteredResults = []; + this.hasSearched = false; + return; + } + + this.loading = true; + const startTime = Date.now(); + + try { + console.log('๐Ÿ” ๊ฒ€์ƒ‰ ์‹œ์ž‘:', this.searchQuery); + + // ๊ฒ€์ƒ‰ API ํ˜ธ์ถœ + const response = await this.api.search({ + q: this.searchQuery, + type_filter: this.typeFilter || undefined, + limit: 50 + }); + + this.searchResults = response.results || []; + this.hasSearched = true; + this.searchTime = Date.now() - startTime; + + // ํ•„ํ„ฐ ์ ์šฉ + this.applyFilters(); + + // URL ์—…๋ฐ์ดํŠธ + this.updateURL(); + + console.log('โœ… ๊ฒ€์ƒ‰ ์™„๋ฃŒ:', this.searchResults.length, '๊ฐœ ๊ฒฐ๊ณผ'); + + } catch (error) { + console.error('โŒ ๊ฒ€์ƒ‰ ์‹คํŒจ:', error); + this.searchResults = []; + this.filteredResults = []; + this.hasSearched = true; + } finally { + this.loading = false; + } + }, + + // ํ•„ํ„ฐ ์ ์šฉ + applyFilters() { + let results = [...this.searchResults]; + + // ์ค‘๋ณต ID ์ œ๊ฑฐ (๊ฐ™์€ ๋ฌธ์„œ์˜ document์™€ document_content๊ฐ€ ์ค‘๋ณต๋  ์ˆ˜ ์žˆ์Œ) + const uniqueResults = []; + const seenIds = new Set(); + + results.forEach(result => { + const uniqueKey = `${result.type}-${result.id}`; + if (!seenIds.has(uniqueKey)) { + seenIds.add(uniqueKey); + uniqueResults.push({ + ...result, + unique_id: uniqueKey // Alpine.js x-for ํ‚ค๋กœ ์‚ฌ์šฉ + }); + } + }); + + results = uniqueResults; + + // ํƒ€์ž… ํ•„ํ„ฐ + if (this.typeFilter) { + results = results.filter(result => { + // ๋ฌธ์„œ ํƒ€์ž…์€ document์™€ document_content ๋ชจ๋‘ ํฌํ•จ + if (this.typeFilter === 'document') { + return result.type === 'document' || result.type === 'document_content'; + } + // ํ•˜์ด๋ผ์ดํŠธ ํƒ€์ž…์€ highlight์™€ highlight_note ๋ชจ๋‘ ํฌํ•จ + if (this.typeFilter === 'highlight') { + return result.type === 'highlight' || result.type === 'highlight_note'; + } + return result.type === this.typeFilter; + }); + } + + // ํŒŒ์ผ ํƒ€์ž… ํ•„ํ„ฐ + if (this.fileTypeFilter) { + results = results.filter(result => { + return result.highlight_info?.file_type === this.fileTypeFilter; + }); + } + + // ์ •๋ ฌ + results.sort((a, b) => { + switch (this.sortBy) { + case 'relevance': + return (b.relevance_score || 0) - (a.relevance_score || 0); + case 'date_desc': + return new Date(b.created_at) - new Date(a.created_at); + case 'date_asc': + return new Date(a.created_at) - new Date(b.created_at); + case 'title': + return a.title.localeCompare(b.title); + default: + return 0; + } + }); + + this.filteredResults = results; + console.log('๐Ÿ”ง ํ•„ํ„ฐ ์ ์šฉ ์™„๋ฃŒ:', this.filteredResults.length, '๊ฐœ ๊ฒฐ๊ณผ (ํƒ€์ž…:', this.typeFilter, ', ํŒŒ์ผํƒ€์ž…:', this.fileTypeFilter, ')'); + }, + + // URL ์—…๋ฐ์ดํŠธ + updateURL() { + const url = new URL(window.location); + if (this.searchQuery.trim()) { + url.searchParams.set('q', this.searchQuery); + } else { + url.searchParams.delete('q'); + } + window.history.replaceState({}, '', url); + }, + + // ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ‘œ์‹œ + async showPreview(result) { + console.log('๐Ÿ‘๏ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ‘œ์‹œ:', result); + + this.previewResult = result; + this.showPreviewModal = true; + this.previewLoading = true; + + try { + // ๋ฌธ์„œ ํƒ€์ž…์ธ ๊ฒฝ์šฐ ์ƒ์„ธ ์ •๋ณด ๋จผ์ € ๋กœ๋“œ + if (result.type === 'document' || result.type === 'document_content') { + try { + const docInfo = await this.api.get(`/documents/${result.document_id}`); + // PDF ์ •๋ณด ์—…๋ฐ์ดํŠธ + this.previewResult = { + ...result, + highlight_info: { + ...result.highlight_info, + has_pdf: !!docInfo.pdf_path, + has_html: !!docInfo.html_path + } + }; + + // PDF๊ฐ€ ์žˆ์œผ๋ฉด PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ์—†์œผ๋ฉด HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ + if (docInfo.pdf_path) { + // PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค€๋น„ + await this.loadPdfPreview(result.document_id); + } else if (docInfo.html_path) { + // HTML ๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ + await this.loadHtmlPreview(result.document_id); + } + } catch (docError) { + console.error('๋ฌธ์„œ ์ •๋ณด ๋กœ๋“œ ์‹คํŒจ:', docError); + // ๊ธฐ๋ณธ ๋‚ด์šฉ ๋กœ๋“œ๋กœ fallback + const fullContent = await this.loadFullContent(result); + if (fullContent) { + this.previewResult = { ...result, content: fullContent }; + } + } + } else { + // ๊ธฐํƒ€ ํƒ€์ž… - ์ „์ฒด ๋‚ด์šฉ ๋กœ๋“œ + const fullContent = await this.loadFullContent(result); + if (fullContent) { + this.previewResult = { ...result, content: fullContent }; + } + } + } catch (error) { + console.error('๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ ์‹คํŒจ:', error); + } finally { + this.previewLoading = false; + } + }, + + // ์ „์ฒด ๋‚ด์šฉ ๋กœ๋“œ + async loadFullContent(result) { + try { + let content = ''; + + switch (result.type) { + case 'document': + case 'document_content': + try { + // ๋ฌธ์„œ ๋‚ด์šฉ API ํ˜ธ์ถœ (HTML ์‘๋‹ต) + const response = await fetch(`/api/documents/${result.document_id}/content`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (response.ok) { + const htmlContent = await response.text(); + // HTML์—์„œ ํ…์ŠคํŠธ๋งŒ ์ถ”์ถœ + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, 'text/html'); + content = doc.body.textContent || doc.body.innerText || ''; + // ๋„ˆ๋ฌด ๊ธธ๋ฉด ์ž๋ฅด๊ธฐ + if (content.length > 2000) { + content = content.substring(0, 2000) + '...'; + } + } else { + content = result.content; + } + } catch (err) { + console.warn('๋ฌธ์„œ ๋‚ด์šฉ ๋กœ๋“œ ์‹คํŒจ, ๊ธฐ๋ณธ ๋‚ด์šฉ ์‚ฌ์šฉ:', err); + content = result.content; + } + break; + + case 'note': + try { + // ๋…ธํŠธ ๋‚ด์šฉ API ํ˜ธ์ถœ + const noteContent = await this.api.get(`/note-documents/${result.id}/content`); + content = noteContent; + } catch (err) { + console.warn('๋…ธํŠธ ๋‚ด์šฉ ๋กœ๋“œ ์‹คํŒจ, ๊ธฐ๋ณธ ๋‚ด์šฉ ์‚ฌ์šฉ:', err); + content = result.content; + } + break; + + case 'memo': + try { + // ๋ฉ”๋ชจ ๋…ธ๋“œ ์ƒ์„ธ ์ •๋ณด ๋กœ๋“œ + const memoNode = await this.api.get(`/memo-trees/nodes/${result.id}`); + content = memoNode.content || result.content; + } catch (err) { + console.warn('๋ฉ”๋ชจ ๋‚ด์šฉ ๋กœ๋“œ ์‹คํŒจ, ๊ธฐ๋ณธ ๋‚ด์šฉ ์‚ฌ์šฉ:', err); + content = result.content; + } + break; + + default: + content = result.content; + } + + return content; + } catch (error) { + console.error('๋‚ด์šฉ ๋กœ๋“œ ์‹คํŒจ:', error); + return result.content; + } + }, + + // ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋‹ซ๊ธฐ + closePreview() { + this.showPreviewModal = false; + this.previewResult = null; + this.previewLoading = false; + this.pdfError = false; + + // PDF ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ + this.pdfLoading = false; + this.pdfLoaded = false; + this.pdfSrc = ''; + + // HTML ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ + this.htmlLoading = false; + this.htmlRawMode = false; + this.htmlSourceCode = ''; + }, + + // PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ + async loadPdfPreview(documentId) { + this.pdfLoading = true; + this.pdfError = false; + this.pdfLoaded = false; + + try { + // PDF ํŒŒ์ผ src ์ง์ ‘ ์„ค์ • (HEAD ์š”์ฒญ ๋Œ€์‹ ) + const token = localStorage.getItem('access_token'); + console.log('๐Ÿ” ํ† ํฐ ๋””๋ฒ„๊น…:', { + token: token, + tokenType: typeof token, + tokenLength: token ? token.length : 0, + isNull: token === null, + isStringNull: token === 'null', + localStorage: Object.keys(localStorage) + }); + + if (!token || token === 'null' || token === null) { + console.error('โŒ ํ† ํฐ ๋ฌธ์ œ:', token); + throw new Error('์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.'); + } + this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`; + console.log('โœ… PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค€๋น„ ์™„๋ฃŒ:', this.pdfSrc); + } catch (error) { + console.error('PDF ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ ์‹คํŒจ:', error); + this.pdfError = true; + } finally { + this.pdfLoading = false; + } + }, + + // PDF ์—๋Ÿฌ ์ฒ˜๋ฆฌ + handlePdfError() { + console.error('PDF iframe ๋กœ๋“œ ์˜ค๋ฅ˜'); + this.pdfError = true; + this.pdfLoading = false; + }, + + // PDF์—์„œ ๊ฒ€์ƒ‰์–ด ์ฐพ๊ธฐ (๋ธŒ๋ผ์šฐ์ € ๋‚ด์žฅ ๊ฒ€์ƒ‰ ํ™œ์šฉ) + searchInPdf() { + if (this.searchQuery && this.pdfLoaded) { + // iframe ๋‚ด์—์„œ ๊ฒ€์ƒ‰ ์‹คํ–‰ (Ctrl+F ์‹œ๋ฎฌ๋ ˆ์ด์…˜) + const iframe = document.querySelector('#pdf-preview-iframe'); + if (iframe && iframe.contentWindow) { + try { + iframe.contentWindow.focus(); + // ๋ธŒ๋ผ์šฐ์ € ๊ฒ€์ƒ‰ ์ฐฝ ์—ด๊ธฐ ์‹œ๋„ + if (iframe.contentWindow.find) { + iframe.contentWindow.find(this.searchQuery); + } else { + // ๋Œ€์•ˆ: ์‚ฌ์šฉ์ž์—๊ฒŒ ์ˆ˜๋™ ๊ฒ€์ƒ‰ ์•ˆ๋‚ด + this.showNotification(`PDF์—์„œ "${this.searchQuery}"๋ฅผ ์ฐพ์œผ๋ ค๋ฉด Ctrl+F๋ฅผ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”.`, 'info'); + } + } catch (e) { + // ๋ณด์•ˆ์ƒ ์ง์ ‘ ์ ‘๊ทผ์ด ์•ˆ ๋˜๋Š” ๊ฒฝ์šฐ, ์‚ฌ์šฉ์ž์—๊ฒŒ ์•ˆ๋‚ด + this.showNotification(`PDF์—์„œ "${this.searchQuery}"๋ฅผ ์ฐพ์œผ๋ ค๋ฉด Ctrl+F๋ฅผ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”.`, 'info'); + } + } + } + }, + + // ์•Œ๋ฆผ ํ‘œ์‹œ (๊ฐ„๋‹จํ•œ ํ† ์ŠคํŠธ) + showNotification(message, type = 'info') { + // ๊ฐ„๋‹จํ•œ ์•Œ๋ฆผ ๊ตฌํ˜„ (์‹ค์ œ๋กœ๋Š” ๋” ์ •๊ตํ•œ ํ† ์ŠคํŠธ ์‹œ์Šคํ…œ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ) + const notification = document.createElement('div'); + notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${ + type === 'info' ? 'bg-blue-500' : + type === 'success' ? 'bg-green-500' : + type === 'error' ? 'bg-red-500' : 'bg-gray-500' + }`; + notification.textContent = message; + + document.body.appendChild(notification); + + // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 3000); + }, + + // HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ + async loadHtmlPreview(documentId) { + this.htmlLoading = true; + + try { + // API๋ฅผ ํ†ตํ•ด HTML ๋‚ด์šฉ ๊ฐ€์ ธ์˜ค๊ธฐ + const htmlContent = await this.api.get(`/documents/${documentId}/content`); + + if (htmlContent) { + this.htmlSourceCode = this.escapeHtml(htmlContent); + + // iframe์— HTML ๋กœ๋“œ + const iframe = document.getElementById('htmlPreviewFrame'); + if (iframe) { + // iframe src๋ฅผ ์ง์ ‘ ์„ค์ • (์ธ์ฆ ํ—ค๋” ํฌํ•จ) + const token = localStorage.getItem('access_token'); + console.log('๐Ÿ” HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ† ํฐ:', token ? '์žˆ์Œ' : '์—†์Œ', token); + if (!token || token === 'null' || token === null) { + console.error('โŒ HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ† ํฐ ๋ฌธ์ œ:', token); + throw new Error('์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + } + iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`; + + // iframe ๋กœ๋“œ ์™„๋ฃŒ ํ›„ ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ + iframe.onload = () => { + if (this.searchQuery) { + setTimeout(() => { + this.highlightInIframe(iframe, this.searchQuery); + }, 100); + } + }; + } + } else { + throw new Error('HTML ๋‚ด์šฉ์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค'); + } + } catch (error) { + console.error('HTML ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋กœ๋“œ ์‹คํŒจ:', error); + // ์—๋Ÿฌ ์‹œ ๊ธฐ๋ณธ ๋‚ด์šฉ ํ‘œ์‹œ + this.htmlSourceCode = `
+ +

HTML ๋‚ด์šฉ์„ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+

${error.message}

+
`; + } finally { + this.htmlLoading = false; + } + }, + + // HTML ์†Œ์Šค/๋ Œ๋”๋ง ๋ชจ๋“œ ํ† ๊ธ€ + toggleHtmlRaw() { + this.htmlRawMode = !this.htmlRawMode; + }, + + // iframe ๋‚ด๋ถ€ ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ + highlightInIframe(iframe, query) { + try { + const doc = iframe.contentDocument || iframe.contentWindow.document; + const walker = doc.createTreeWalker( + doc.body, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + if (node.textContent.toLowerCase().includes(query.toLowerCase())) { + textNodes.push(node); + } + } + + textNodes.forEach(textNode => { + const parent = textNode.parentNode; + const text = textNode.textContent; + const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi'); + const highlightedHTML = text.replace(regex, '$1'); + + const wrapper = doc.createElement('span'); + wrapper.innerHTML = highlightedHTML; + parent.replaceChild(wrapper, textNode); + }); + } catch (error) { + console.error('iframe ํ•˜์ด๋ผ์ดํŠธ ์‹คํŒจ:', error); + } + }, + + // HTML ์ด์Šค์ผ€์ดํ”„ + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + // ๋…ธํŠธ ํŽธ์ง‘๊ธฐ์—์„œ ์—ด๊ธฐ + toggleNoteEdit() { + if (this.previewResult && this.previewResult.type === 'note') { + const url = `/note-editor.html?id=${this.previewResult.id}`; + window.open(url, '_blank'); + } + }, + + // PDF์—์„œ ๊ฒ€์ƒ‰ + async searchInPdf() { + if (!this.previewResult || !this.searchQuery) return; + + try { + const searchResults = await this.api.get( + `/documents/${this.previewResult.document_id}/search-in-content?q=${encodeURIComponent(this.searchQuery)}` + ); + + if (searchResults.total_matches > 0) { + // ์ฒซ ๋ฒˆ์งธ ๋งค์น˜๋กœ ์ด๋™ํ•˜์—ฌ ๋ทฐ์–ด์—์„œ ์—ด๊ธฐ + const firstMatch = searchResults.matches[0]; + let url = `/viewer.html?id=${this.previewResult.document_id}`; + + if (firstMatch.page > 1) { + url += `&page=${firstMatch.page}`; + } + + // ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์œ„ํ•œ ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ + url += `&search=${encodeURIComponent(this.searchQuery)}`; + + window.open(url, '_blank'); + this.closePreview(); + } else { + alert('PDF์—์„œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } catch (error) { + console.error('PDF ๊ฒ€์ƒ‰ ์‹คํŒจ:', error); + alert('PDF ๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—ด๊ธฐ + openResult(result) { + console.log('๐Ÿ“‚ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—ด๊ธฐ:', result); + + let url = ''; + + switch (result.type) { + case 'document': + case 'document_content': + url = `/viewer.html?id=${result.document_id}`; + if (result.highlight_info) { + // ํ•˜์ด๋ผ์ดํŠธ ์œ„์น˜๋กœ ์ด๋™ + const { start_offset, end_offset, selected_text } = result.highlight_info; + url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`; + } + break; + + case 'note': + url = `/viewer.html?id=${result.id}&contentType=note`; + break; + + case 'memo': + // ๋ฉ”๋ชจ ํŠธ๋ฆฌ์—์„œ ํ•ด๋‹น ๋…ธ๋“œ๋กœ ์ด๋™ + url = `/memo-tree.html?node_id=${result.id}`; + break; + + case 'highlight': + case 'highlight_note': + url = `/viewer.html?id=${result.document_id}`; + if (result.highlight_info) { + const { start_offset, end_offset, selected_text } = result.highlight_info; + url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`; + } + break; + + default: + console.warn('์•Œ ์ˆ˜ ์—†๋Š” ๊ฒฐ๊ณผ ํƒ€์ž…:', result.type); + return; + } + + // ์ƒˆ ํƒญ์—์„œ ์—ด๊ธฐ + window.open(url, '_blank'); + }, + + // ํƒ€์ž…๋ณ„ ๊ฒฐ๊ณผ ๊ฐœ์ˆ˜ + getResultCount(type) { + return this.searchResults.filter(result => result.type === type).length; + }, + + // ํƒ€์ž… ๋ผ๋ฒจ + getTypeLabel(type) { + const labels = { + document: '๋ฌธ์„œ', + document_content: '๋ณธ๋ฌธ', + note: '๋…ธํŠธ', + memo: '๋ฉ”๋ชจ', + highlight: 'ํ•˜์ด๋ผ์ดํŠธ', + highlight_note: '๋ฉ”๋ชจ' + }; + return labels[type] || type; + }, + + // ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ + highlightText(text, query) { + if (!text || !query) return text; + + const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi'); + return text.replace(regex, '$1'); + }, + + // ์ •๊ทœ์‹ ์ด์Šค์ผ€์ดํ”„ + escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, + + // ํ…์ŠคํŠธ ์ž๋ฅด๊ธฐ + truncateText(text, maxLength) { + if (!text || text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + }, + + // ๋‚ ์งœ ํฌ๋งทํŒ… + formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays === 2) { + return '์–ด์ œ'; + } else if (diffDays <= 7) { + return `${diffDays - 1}์ผ ์ „`; + } else if (diffDays <= 30) { + return `${Math.ceil(diffDays / 7)}์ฃผ ์ „`; + } else if (diffDays <= 365) { + return `${Math.ceil(diffDays / 30)}๊ฐœ์›” ์ „`; + } else { + return date.toLocaleDateString('ko-KR'); + } + } + }; +}; + +console.log('๐Ÿ” ๊ฒ€์ƒ‰ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); diff --git a/frontend/static/js/story-reader.js b/frontend/static/js/story-reader.js new file mode 100644 index 0000000..264d7f0 --- /dev/null +++ b/frontend/static/js/story-reader.js @@ -0,0 +1,333 @@ +// ์Šคํ† ๋ฆฌ ์ฝ๊ธฐ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ +function storyReaderApp() { + return { + // ์ƒํƒœ ๋ณ€์ˆ˜๋“ค + currentUser: null, + loading: true, + error: null, + + // ์Šคํ† ๋ฆฌ ๋ฐ์ดํ„ฐ + selectedTree: null, + canonicalNodes: [], + currentChapter: null, + currentChapterIndex: 0, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ + treeId: null, + nodeId: null, + chapterIndex: null, + + // ํŽธ์ง‘ ๊ด€๋ จ + showEditModal: false, + editingChapter: null, + editEditor: null, + saving: false, + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ + showLoginModal: false, + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ ์Šคํ† ๋ฆฌ ๋ฆฌ๋” ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + this.parseUrlParams(); + + // ์‚ฌ์šฉ์ž ์ธ์ฆ ํ™•์ธ + await this.checkAuth(); + + if (this.currentUser) { + await this.loadStoryData(); + } + }, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํŒŒ์‹ฑ + parseUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + this.treeId = urlParams.get('treeId'); + this.nodeId = urlParams.get('nodeId'); + this.chapterIndex = parseInt(urlParams.get('index')) || 0; + + console.log('๐Ÿ“– URL ํŒŒ๋ผ๋ฏธํ„ฐ:', { + treeId: this.treeId, + nodeId: this.nodeId, + chapterIndex: this.chapterIndex + }); + }, + + // ์ธ์ฆ ํ™•์ธ + async checkAuth() { + try { + this.currentUser = await window.api.getCurrentUser(); + console.log('โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ๋จ:', this.currentUser?.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ ์‹คํŒจ:', error); + this.currentUser = null; + } + }, + + // ์Šคํ† ๋ฆฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + async loadStoryData() { + if (!this.treeId) { + this.error = 'ํŠธ๋ฆฌ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'; + this.loading = false; + return; + } + + try { + this.loading = true; + this.error = null; + + // ํŠธ๋ฆฌ ์ •๋ณด ๋กœ๋“œ + this.selectedTree = await window.api.getMemoTree(this.treeId); + console.log('๐Ÿ“š ํŠธ๋ฆฌ ๋กœ๋“œ๋จ:', this.selectedTree.title); + + // ํŠธ๋ฆฌ์˜ ๋ชจ๋“  ๋…ธ๋“œ ๋กœ๋“œ + const allNodes = await window.api.getMemoTreeNodes(this.treeId); + + // ์ •์‚ฌ ๋…ธ๋“œ๋งŒ ํ•„ํ„ฐ๋งํ•˜๊ณ  ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + this.canonicalNodes = allNodes + .filter(node => node.is_canonical) + .sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0)); + + console.log('๐Ÿ“ ์ •์‚ฌ ๋…ธ๋“œ ์ˆ˜:', this.canonicalNodes.length); + + if (this.canonicalNodes.length === 0) { + this.error = '์ •์‚ฌ๋กœ ์„ค์ •๋œ ๋…ธ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'; + this.loading = false; + return; + } + + // ํ˜„์žฌ ์ฑ•ํ„ฐ ์„ค์ • + this.setCurrentChapter(); + + this.loading = false; + } catch (error) { + console.error('โŒ ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = '์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'; + this.loading = false; + } + }, + + // ํ˜„์žฌ ์ฑ•ํ„ฐ ์„ค์ • + setCurrentChapter() { + if (this.nodeId) { + // ํŠน์ • ๋…ธ๋“œ ID๋กœ ์ฐพ๊ธฐ + const index = this.canonicalNodes.findIndex(node => node.id === this.nodeId); + if (index !== -1) { + this.currentChapterIndex = index; + } + } else if (this.chapterIndex >= 0 && this.chapterIndex < this.canonicalNodes.length) { + // ์ธ๋ฑ์Šค๋กœ ์„ค์ • + this.currentChapterIndex = this.chapterIndex; + } else { + // ๊ธฐ๋ณธ๊ฐ’: ์ฒซ ๋ฒˆ์งธ ์ฑ•ํ„ฐ + this.currentChapterIndex = 0; + } + + this.currentChapter = this.canonicalNodes[this.currentChapterIndex]; + console.log('๐Ÿ“– ํ˜„์žฌ ์ฑ•ํ„ฐ:', this.currentChapter?.title, `(${this.currentChapterIndex + 1}/${this.canonicalNodes.length})`); + }, + + // ๊ณ„์‚ฐ๋œ ์†์„ฑ๋“ค + get totalChapters() { + return this.canonicalNodes.length; + }, + + get hasPreviousChapter() { + return this.currentChapterIndex > 0; + }, + + get hasNextChapter() { + return this.currentChapterIndex < this.canonicalNodes.length - 1; + }, + + get previousChapter() { + return this.hasPreviousChapter ? this.canonicalNodes[this.currentChapterIndex - 1] : null; + }, + + get nextChapter() { + return this.hasNextChapter ? this.canonicalNodes[this.currentChapterIndex + 1] : null; + }, + + // ๋„ค๋น„๊ฒŒ์ด์…˜ ํ•จ์ˆ˜๋“ค + goToPreviousChapter() { + if (this.hasPreviousChapter) { + this.currentChapterIndex--; + this.currentChapter = this.canonicalNodes[this.currentChapterIndex]; + this.updateUrl(); + this.scrollToTop(); + } + }, + + goToNextChapter() { + if (this.hasNextChapter) { + this.currentChapterIndex++; + this.currentChapter = this.canonicalNodes[this.currentChapterIndex]; + this.updateUrl(); + this.scrollToTop(); + } + }, + + goBackToStoryView() { + window.location.href = `story-view.html?treeId=${this.treeId}`; + }, + + editChapter() { + if (this.currentChapter) { + this.editingChapter = { ...this.currentChapter }; // ๋ณต์‚ฌ๋ณธ ์ƒ์„ฑ + this.showEditModal = true; + console.log('โœ… ํŽธ์ง‘ ๋ชจ๋‹ฌ ์—ด๋ฆผ (Textarea ๋ฐฉ์‹)'); + } + }, + + // ํŽธ์ง‘ ์ทจ์†Œ + cancelEdit() { + this.showEditModal = false; + this.editingChapter = null; + console.log('โœ… ํŽธ์ง‘ ์ทจ์†Œ๋จ (Textarea ๋ฐฉ์‹)'); + }, + + // ํŽธ์ง‘ ์ €์žฅ + async saveEdit() { + if (!this.editingChapter) { + console.warn('โš ๏ธ ํŽธ์ง‘ ์ค‘์ธ ์ฑ•ํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + try { + this.saving = true; + + // ๋‹จ์–ด ์ˆ˜ ๊ณ„์‚ฐ + const content = this.editingChapter.content || ''; + let wordCount = 0; + if (content && content.trim()) { + const words = content.trim().split(/\s+/); + wordCount = words.filter(word => word.length > 0).length; + } + this.editingChapter.word_count = wordCount; + + // API๋กœ ์ €์žฅ + const updateData = { + title: this.editingChapter.title, + content: this.editingChapter.content, + word_count: this.editingChapter.word_count + }; + + await window.api.updateMemoNode(this.editingChapter.id, updateData); + + // ํ˜„์žฌ ์ฑ•ํ„ฐ ์—…๋ฐ์ดํŠธ + this.currentChapter.title = this.editingChapter.title; + this.currentChapter.content = this.editingChapter.content; + this.currentChapter.word_count = this.editingChapter.word_count; + + // ์ •์‚ฌ ๋…ธ๋“œ ๋ชฉ๋ก์—์„œ๋„ ์—…๋ฐ์ดํŠธ + const nodeIndex = this.canonicalNodes.findIndex(node => node.id === this.editingChapter.id); + if (nodeIndex !== -1) { + this.canonicalNodes[nodeIndex].title = this.editingChapter.title; + this.canonicalNodes[nodeIndex].content = this.editingChapter.content; + this.canonicalNodes[nodeIndex].word_count = this.editingChapter.word_count; + } + + console.log('โœ… ์ฑ•ํ„ฐ ์ €์žฅ ์™„๋ฃŒ'); + this.cancelEdit(); + + } catch (error) { + console.error('โŒ ์ €์žฅ ์‹คํŒจ:', error); + alert('์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } finally { + this.saving = false; + } + }, + + // URL ์—…๋ฐ์ดํŠธ + updateUrl() { + const url = new URL(window.location); + url.searchParams.set('nodeId', this.currentChapter.id); + url.searchParams.set('index', this.currentChapterIndex.toString()); + window.history.replaceState({}, '', url); + }, + + // ํŽ˜์ด์ง€ ์ƒ๋‹จ์œผ๋กœ ์Šคํฌ๋กค + scrollToTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + + // ์ธ์‡„ + printChapter() { + window.print(); + }, + + // ์ฝ˜ํ…์ธ  ํฌ๋งทํŒ… + formatContent(content) { + if (!content) return ''; + + // ๋งˆํฌ๋‹ค์šด ์Šคํƒ€์ผ ๊ฐ„๋‹จ ๋ณ€ํ™˜ + return content + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1'); + }, + + // ๋…ธ๋“œ ํƒ€์ž… ๋ผ๋ฒจ + getNodeTypeLabel(nodeType) { + const labels = { + 'memo': '๋ฉ”๋ชจ', + 'folder': 'ํด๋”', + 'chapter': '์ฑ•ํ„ฐ', + 'character': '์บ๋ฆญํ„ฐ', + 'plot': 'ํ”Œ๋กฏ' + }; + return labels[nodeType] || nodeType; + }, + + // ์ƒํƒœ ๋ผ๋ฒจ + getStatusLabel(status) { + const labels = { + 'draft': '์ดˆ์•ˆ', + 'writing': '์ž‘์„ฑ์ค‘', + 'review': '๊ฒ€ํ† ์ค‘', + 'complete': '์™„๋ฃŒ' + }; + return labels[status] || status; + }, + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ + openLoginModal() { + this.showLoginModal = true; + this.loginError = ''; + }, + + async handleLogin() { + try { + this.loginLoading = true; + this.loginError = ''; + + const result = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (result.access_token) { + this.currentUser = result.user; + this.showLoginModal = false; + this.loginForm = { email: '', password: '' }; + + // ๋กœ๊ทธ์ธ ํ›„ ์Šคํ† ๋ฆฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + await this.loadStoryData(); + } + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์ธ ์‹คํŒจ:', error); + this.loginError = '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”.'; + } finally { + this.loginLoading = false; + } + } + }; +} diff --git a/frontend/static/js/story-view.js b/frontend/static/js/story-view.js new file mode 100644 index 0000000..e623c7d --- /dev/null +++ b/frontend/static/js/story-view.js @@ -0,0 +1,371 @@ +// story-view.js - ์ •์‚ฌ ๊ฒฝ๋กœ ์Šคํ† ๋ฆฌ ๋ทฐ ์ปดํฌ๋„ŒํŠธ + +console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); + +// Alpine.js ์ปดํฌ๋„ŒํŠธ +window.storyViewApp = function() { + return { + // ์‚ฌ์šฉ์ž ์ƒํƒœ + currentUser: null, + + // ํŠธ๋ฆฌ ๋ฐ์ดํ„ฐ + userTrees: [], + selectedTreeId: '', + selectedTree: null, + canonicalNodes: [], // ์ •์‚ฌ ๊ฒฝ๋กœ ๋…ธ๋“œ๋“ค๋งŒ + + // UI ์ƒํƒœ + showLoginModal: false, + showEditModal: false, + editingNode: { + title: '', + content: '' + }, + + // ๋กœ๊ทธ์ธ ํผ ์ƒํƒœ + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // ๊ณ„์‚ฐ๋œ ์†์„ฑ๋“ค + get totalWords() { + return this.canonicalNodes.reduce((sum, node) => sum + (node.word_count || 0), 0); + }, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ ์ดˆ๊ธฐํ™” ์ค‘...'); + + // API ๋กœ๋“œ ๋Œ€๊ธฐ + let retryCount = 0; + while (!window.api && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.api) { + console.error('โŒ API๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์‚ฌ์šฉ์ž ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + // ์ธ์ฆ๋œ ๊ฒฝ์šฐ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + if (this.currentUser) { + await this.loadUserTrees(); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ treeId ํ™•์ธํ•˜๊ณ  ์ž๋™ ์„ ํƒ + this.checkUrlParams(); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('โœ… ์‚ฌ์šฉ์ž ์ธ์ฆ๋จ:', user.email); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ:', error.message); + this.currentUser = null; + + // ๋งŒ๋ฃŒ๋œ ํ† ํฐ ์ •๋ฆฌ + localStorage.removeItem('token'); + } + }, + + // ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ + async loadUserTrees() { + try { + console.log('๐Ÿ“Š ์‚ฌ์šฉ์ž ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋”ฉ...'); + console.log('๐Ÿ” API ๊ฐ์ฒด ํ™•์ธ:', window.api); + console.log('๐Ÿ” getUserMemoTrees ํ•จ์ˆ˜ ํ™•์ธ:', typeof window.api?.getUserMemoTrees); + + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`โœ… ${this.userTrees.length}๊ฐœ ํŠธ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ`); + console.log('๐Ÿ“‹ ํŠธ๋ฆฌ ๋ชฉ๋ก:', this.userTrees); + + // Alpine.js ๋ฐ˜์‘์„ฑ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ์•ฝ๊ฐ„์˜ ์ง€์—ฐ ํ›„ URL ํŒŒ๋ผ๋ฏธํ„ฐ ํ™•์ธ + setTimeout(() => { + this.checkUrlParams(); + }, 100); + } catch (error) { + console.error('โŒ ํŠธ๋ฆฌ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + console.error('โŒ ์—๋Ÿฌ ์ƒ์„ธ:', error.message); + console.error('โŒ ์—๋Ÿฌ ์Šคํƒ:', error.stack); + this.userTrees = []; + } + }, + + // ์Šคํ† ๋ฆฌ ๋กœ๋“œ (์ •์‚ฌ ๊ฒฝ๋กœ๋งŒ) + async loadStory(treeId) { + if (!treeId) { + this.selectedTree = null; + this.canonicalNodes = []; + // URL์—์„œ treeId ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ + this.updateUrl(null); + return; + } + + try { + console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋กœ๋”ฉ:', treeId); + + // ํŠธ๋ฆฌ ์ •๋ณด ๋กœ๋“œ + this.selectedTree = await window.api.getMemoTree(treeId); + + // ๋ชจ๋“  ๋…ธ๋“œ ๋กœ๋“œ + const allNodes = await window.api.getMemoTreeNodes(treeId); + + // ์ •์‚ฌ ๊ฒฝ๋กœ ๋…ธ๋“œ๋“ค๋งŒ ํ•„ํ„ฐ๋งํ•˜๊ณ  ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ + this.canonicalNodes = allNodes + .filter(node => node.is_canonical) + .sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0)); + + // URL ์—…๋ฐ์ดํŠธ + this.updateUrl(treeId); + + console.log(`โœ… ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์™„๋ฃŒ: ${this.canonicalNodes.length}๊ฐœ ์ •์‚ฌ ๋…ธ๋“œ`); + } catch (error) { + console.error('โŒ ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์‹คํŒจ:', error); + alert('์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ํ™•์ธ ๋ฐ ์ฒ˜๋ฆฌ + checkUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + const treeId = urlParams.get('treeId'); + + if (treeId && this.userTrees.some(tree => tree.id === treeId)) { + console.log('๐Ÿ“– URL์—์„œ ํŠธ๋ฆฌ ID ๊ฐ์ง€:', treeId); + this.selectedTreeId = treeId; + this.loadStory(treeId); + } + }, + + // URL ์—…๋ฐ์ดํŠธ + updateUrl(treeId) { + const url = new URL(window.location); + if (treeId) { + url.searchParams.set('treeId', treeId); + } else { + url.searchParams.delete('treeId'); + } + window.history.replaceState({}, '', url); + }, + + // ์Šคํ† ๋ฆฌ ๋ฆฌ๋”๋กœ ์ด๋™ + openStoryReader(nodeId, index) { + const url = `story-reader.html?treeId=${this.selectedTreeId}&nodeId=${nodeId}&index=${index}`; + window.location.href = url; + }, + + // ์ฑ•ํ„ฐ ํŽธ์ง‘ (์ธ๋ผ์ธ ๋ชจ๋‹ฌ) + editChapter(node) { + this.editingNode = { ...node }; // ๋ณต์‚ฌ๋ณธ ์ƒ์„ฑ + this.showEditModal = true; + }, + + // ํŽธ์ง‘ ์ทจ์†Œ + cancelEdit() { + this.showEditModal = false; + this.editingNode = null; + }, + + // ํŽธ์ง‘ ์ €์žฅ + async saveEdit() { + if (!this.editingNode) return; + + try { + console.log('๐Ÿ’พ ์ฑ•ํ„ฐ ์ €์žฅ ์ค‘:', this.editingNode.title); + + const updatedNode = await window.api.updateMemoNode(this.editingNode.id, { + title: this.editingNode.title, + content: this.editingNode.content + }); + + // ๋กœ์ปฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + const nodeIndex = this.canonicalNodes.findIndex(n => n.id === this.editingNode.id); + if (nodeIndex !== -1) { + this.canonicalNodes = this.canonicalNodes.map(n => + n.id === this.editingNode.id ? { ...n, ...updatedNode } : n + ); + } + + this.showEditModal = false; + this.editingNode = null; + + console.log('โœ… ์ฑ•ํ„ฐ ์ €์žฅ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์ฑ•ํ„ฐ ์ €์žฅ ์‹คํŒจ:', error); + alert('์ฑ•ํ„ฐ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ + async exportStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('๋‚ด๋ณด๋‚ผ ์Šคํ† ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + try { + // ํ…์ŠคํŠธ ํ˜•ํƒœ๋กœ ์Šคํ† ๋ฆฌ ์ƒ์„ฑ + let storyText = `${this.selectedTree.title}\n`; + storyText += `${'='.repeat(this.selectedTree.title.length)}\n\n`; + + if (this.selectedTree.description) { + storyText += `${this.selectedTree.description}\n\n`; + } + + storyText += `์ž‘์„ฑ์ผ: ${this.formatDate(this.selectedTree.created_at)}\n`; + storyText += `์ˆ˜์ •์ผ: ${this.formatDate(this.selectedTree.updated_at)}\n`; + storyText += `์ด ${this.canonicalNodes.length}๊ฐœ ์ฑ•ํ„ฐ, ${this.totalWords}๋‹จ์–ด\n\n`; + storyText += `${'='.repeat(50)}\n\n`; + + this.canonicalNodes.forEach((node, index) => { + storyText += `${index + 1}. ${node.title}\n`; + storyText += `${'-'.repeat(node.title.length + 3)}\n\n`; + + if (node.content) { + // HTML ํƒœ๊ทธ ์ œ๊ฑฐ + const plainText = node.content.replace(/<[^>]*>/g, ''); + storyText += `${plainText}\n\n`; + } else { + storyText += `[์ด ์ฑ•ํ„ฐ๋Š” ์•„์ง ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค]\n\n`; + } + + storyText += `${'โ”€'.repeat(30)}\n\n`; + }); + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + const blob = new Blob([storyText], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.selectedTree.title}_์ •์‚ฌ์Šคํ† ๋ฆฌ.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('โœ… ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:', error); + alert('์Šคํ† ๋ฆฌ ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์Šคํ† ๋ฆฌ ์ธ์‡„ + printStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('์ธ์‡„ํ•  ์Šคํ† ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์ „์ฒด ๋ทฐ๋กœ ๋ณ€๊ฒฝ ํ›„ ์ธ์‡„ + if (this.viewMode === 'toc') { + this.viewMode = 'full'; + this.$nextTick(() => { + window.print(); + }); + } else { + window.print(); + } + }, + + // ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + formatContent(content) { + if (!content) return ''; + + // ๊ฐ„๋‹จํ•œ ๋งˆํฌ๋‹ค์šด ์Šคํƒ€์ผ ๋ณ€ํ™˜ + return content + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

'); + }, + + getNodeTypeLabel(nodeType) { + const labels = { + 'folder': '๐Ÿ“ ํด๋”', + 'memo': '๐Ÿ“ ๋ฉ”๋ชจ', + 'chapter': '๐Ÿ“– ์ฑ•ํ„ฐ', + 'character': '๐Ÿ‘ค ์ธ๋ฌผ', + 'plot': '๐Ÿ“‹ ํ”Œ๋กฏ' + }; + return labels[nodeType] || '๐Ÿ“ ๋ฉ”๋ชจ'; + }, + + getStatusLabel(status) { + const labels = { + 'draft': '๐Ÿ“ ์ดˆ์•ˆ', + 'writing': 'โœ๏ธ ์ž‘์„ฑ์ค‘', + 'review': '๐Ÿ‘€ ๊ฒ€ํ† ์ค‘', + 'complete': 'โœ… ์™„๋ฃŒ' + }; + return labels[status] || '๐Ÿ“ ์ดˆ์•ˆ'; + }, + + // ๋กœ๊ทธ์ธ ๊ด€๋ จ ํ•จ์ˆ˜๋“ค + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // ํŠธ๋ฆฌ ๋ชฉ๋ก ๋‹ค์‹œ ๋กœ๋“œ + await this.loadUserTrees(); + } else { + this.loginError = response.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์˜ค๋ฅ˜:', error); + this.loginError = '๋กœ๊ทธ์ธ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.canonicalNodes = []; + console.log('โœ… ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ ๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } + } + }; +}; + +console.log('๐Ÿ“– ์Šคํ† ๋ฆฌ ๋ทฐ ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก ์™„๋ฃŒ'); diff --git a/frontend/static/js/todos.js b/frontend/static/js/todos.js new file mode 100644 index 0000000..e0e5885 --- /dev/null +++ b/frontend/static/js/todos.js @@ -0,0 +1,728 @@ +/** + * ํ• ์ผ๊ด€๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ + */ + +console.log('๐Ÿ“‹ ํ• ์ผ๊ด€๋ฆฌ JavaScript ๋กœ๋“œ ์™„๋ฃŒ'); + +function todosApp() { + return { + // ์ƒํƒœ ๊ด€๋ฆฌ + loading: false, + activeTab: 'todo', // draft, todo, completed + + // ํ• ์ผ ๋ฐ์ดํ„ฐ + todos: [], + stats: { + total_count: 0, + draft_count: 0, + scheduled_count: 0, + active_count: 0, + completed_count: 0, + delayed_count: 0, + completion_rate: 0 + }, + + // ์ž…๋ ฅ ํผ + newTodoContent: '', + + // ๋ชจ๋‹ฌ ์ƒํƒœ + showScheduleModal: false, + showDelayModal: false, + showCommentModal: false, + showSplitModal: false, + + // ํ˜„์žฌ ์„ ํƒ๋œ ํ• ์ผ + currentTodo: null, + currentTodoComments: [], + + // ๋ฉ”๋ชจ ์ƒํƒœ (๊ฐ ํ• ์ผ๋ณ„) + todoMemos: {}, + showMemoForTodo: {}, + + // ํผ ๋ฐ์ดํ„ฐ + scheduleForm: { + start_date: '', + estimated_minutes: 30 + }, + delayForm: { + delayed_until: '' + }, + commentForm: { + content: '' + }, + splitForm: { + subtasks: ['', ''], + estimated_minutes_per_task: [30, 30] + }, + + // ๊ณ„์‚ฐ๋œ ์†์„ฑ๋“ค + get draftTodos() { + return this.todos.filter(todo => todo.status === 'draft'); + }, + + get activeTodos() { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + return this.todos.filter(todo => { + // active ์ƒํƒœ์ด๊ฑฐ๋‚˜, scheduled์ธ๋ฐ ๋‚ ์งœ๊ฐ€ ์˜ค๋Š˜์ด๊ฑฐ๋‚˜ ์ง€๋‚œ ๊ฒฝ์šฐ + if (todo.status === 'active') return true; + if (todo.status === 'scheduled' && todo.start_date) { + const startDate = new Date(todo.start_date); + const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + return startDay <= today; + } + return false; + }); + }, + + get scheduledTodos() { + return this.todos.filter(todo => todo.status === 'scheduled'); + }, + + get completedTodos() { + return this.todos.filter(todo => todo.status === 'completed'); + }, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿ“‹ ํ• ์ผ๊ด€๋ฆฌ ์ดˆ๊ธฐํ™” ์ค‘...'); + + // API ๋กœ๋“œ ๋Œ€๊ธฐ + let retryCount = 0; + while (!window.api && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.api) { + console.error('โŒ API๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + return; + } + + await this.loadTodos(); + await this.loadStats(); + await this.loadAllTodoMemos(); + + // ์ฃผ๊ธฐ์ ์œผ๋กœ ํ™œ์„ฑ ํ• ์ผ ์—…๋ฐ์ดํŠธ (1๋ถ„๋งˆ๋‹ค) + setInterval(() => { + this.loadActiveTodos(); + }, 60000); + }, + + // ํ• ์ผ ๋ชฉ๋ก ๋กœ๋“œ + async loadTodos() { + try { + this.loading = true; + const response = await window.api.get('/todos/'); + this.todos = response || []; + console.log(`โœ… ${this.todos.length}๊ฐœ ํ• ์ผ ๋กœ๋“œ ์™„๋ฃŒ`); + } catch (error) { + console.error('โŒ ํ• ์ผ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + alert('ํ• ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + this.loading = false; + } + }, + + // ํ™œ์„ฑ ํ• ์ผ ๋กœ๋“œ (์‹œ๊ฐ„ ์ฒดํฌ ํฌํ•จ) + async loadActiveTodos() { + try { + const response = await window.api.get('/todos/active'); + const activeTodos = response || []; + + // ๊ธฐ์กด todos์—์„œ active ์ƒํƒœ ์—…๋ฐ์ดํŠธ + this.todos = this.todos.map(todo => { + const activeVersion = activeTodos.find(active => active.id === todo.id); + return activeVersion || todo; + }); + + // ์ƒˆ๋กœ ํ™œ์„ฑํ™”๋œ ํ• ์ผ๋“ค ์ถ”๊ฐ€ + activeTodos.forEach(activeTodo => { + if (!this.todos.find(todo => todo.id === activeTodo.id)) { + this.todos.push(activeTodo); + } + }); + + await this.loadStats(); + } catch (error) { + console.error('โŒ ํ™œ์„ฑ ํ• ์ผ ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ํ†ต๊ณ„ ๋กœ๋“œ + async loadStats() { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const stats = { + total_count: this.todos.length, + draft_count: this.todos.filter(t => t.status === 'draft').length, + todo_count: this.todos.filter(t => { + // active ์ƒํƒœ์ด๊ฑฐ๋‚˜, scheduled์ธ๋ฐ ๋‚ ์งœ๊ฐ€ ์˜ค๋Š˜์ด๊ฑฐ๋‚˜ ์ง€๋‚œ ๊ฒฝ์šฐ + if (t.status === 'active') return true; + if (t.status === 'scheduled' && t.start_date) { + const startDate = new Date(t.start_date); + const startDay = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + return startDay <= today; + } + return false; + }).length, + completed_count: this.todos.filter(t => t.status === 'completed').length + }; + + stats.completion_rate = stats.total_count > 0 + ? Math.round((stats.completed_count / stats.total_count) * 100) + : 0; + + this.stats = stats; + }, + + // ์ƒˆ ํ• ์ผ ์ƒ์„ฑ + async createTodo() { + if (!this.newTodoContent.trim()) return; + + try { + this.loading = true; + const response = await window.api.post('/todos/', { + content: this.newTodoContent.trim() + }); + + this.todos.unshift(response); + + // ์ƒˆ ํ• ์ผ์˜ ๋ฉ”๋ชจ ์ƒํƒœ ์ดˆ๊ธฐํ™” + this.todoMemos[response.id] = []; + this.showMemoForTodo[response.id] = false; + + this.newTodoContent = ''; + await this.loadStats(); + + console.log('โœ… ์ƒˆ ํ• ์ผ ์ƒ์„ฑ ์™„๋ฃŒ'); + + // ๊ฒ€ํ† ํ•„์š” ํƒญ์œผ๋กœ ์ด๋™ + this.activeTab = 'draft'; + + } catch (error) { + console.error('โŒ ํ• ์ผ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('ํ• ์ผ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + this.loading = false; + } + }, + + // ์ผ์ • ์„ค์ • ๋ชจ๋‹ฌ ์—ด๊ธฐ + openScheduleModal(todo) { + this.currentTodo = todo; + + // ๊ธฐ์กด ๊ฐ’์ด ์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’ + this.scheduleForm = { + start_date: todo.start_date ? + new Date(todo.start_date).toISOString().slice(0, 10) : + this.formatDateLocal(new Date()), + estimated_minutes: todo.estimated_minutes || 30 + }; + + this.showScheduleModal = true; + }, + + // ์ผ์ • ์„ค์ • ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeScheduleModal() { + this.showScheduleModal = false; + this.currentTodo = null; + }, + + // ํ• ์ผ ์ผ์ • ์„ค์ • + async scheduleTodo() { + if (!this.currentTodo || !this.scheduleForm.start_date) return; + + try { + // ์„ ํƒํ•œ ๋‚ ์งœ์˜ ์ด ์‹œ๊ฐ„ ์ฒดํฌ + const selectedDate = this.scheduleForm.start_date; + const newMinutes = parseInt(this.scheduleForm.estimated_minutes); + + // ํ•ด๋‹น ๋‚ ์งœ์˜ ๊ธฐ์กด ํ• ์ผ๋“ค ์‹œ๊ฐ„ ํ•ฉ๊ณ„ + const existingMinutes = this.todos + .filter(todo => { + if (!todo.start_date || todo.id === this.currentTodo.id) return false; + const todoDate = new Date(todo.start_date).toISOString().slice(0, 10); + return todoDate === selectedDate && (todo.status === 'scheduled' || todo.status === 'active'); + }) + .reduce((sum, todo) => sum + (todo.estimated_minutes || 0), 0); + + const totalMinutes = existingMinutes + newMinutes; + const totalHours = Math.round(totalMinutes / 60 * 10) / 10; + + // 8์‹œ๊ฐ„ ์ดˆ๊ณผ ์‹œ ๊ฒฝ๊ณ  + if (totalMinutes > 480) { // 8์‹œ๊ฐ„ = 480๋ถ„ + const choice = await this.showOverworkWarning(selectedDate, totalHours); + if (choice === 'cancel') return; + if (choice === 'change') { + // ๋ชจ๋‹ฌ์„ ๋‹ซ์ง€ ์•Š๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ๋‹ค์‹œ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•จ + return; + } + // choice === 'continue'์ธ ๊ฒฝ์šฐ ๊ณ„์† ์ง„ํ–‰ + } + + // ๋‚ ์งœ๋งŒ ์‚ฌ์šฉํ•˜์—ฌ ํ•ด๋‹น ๋‚ ์งœ์˜ ์‹œ์ž‘ ์‹œ๊ฐ„์œผ๋กœ ์„ค์ • + const startDate = new Date(this.scheduleForm.start_date + 'T00:00:00'); + + let response; + + // ์ด๋ฏธ ์ผ์ •์ด ์„ค์ •๋œ ํ• ์ผ์ธ์ง€ ํ™•์ธ + if (this.currentTodo.status === 'draft') { + // ์ƒˆ๋กœ ์ผ์ • ์„ค์ • + response = await window.api.post(`/todos/${this.currentTodo.id}/schedule`, { + start_date: startDate.toISOString(), + estimated_minutes: newMinutes + }); + } else { + // ๊ธฐ์กด ์ผ์ • ์ง€์—ฐ (active ์ƒํƒœ์˜ ํ• ์ผ) + response = await window.api.put(`/todos/${this.currentTodo.id}/delay`, { + delayed_until: startDate.toISOString() + }); + } + + // ํ• ์ผ ์—…๋ฐ์ดํŠธ + const index = this.todos.findIndex(t => t.id === this.currentTodo.id); + if (index !== -1) { + this.todos[index] = response; + } + + await this.loadStats(); + this.closeScheduleModal(); + + console.log('โœ… ํ• ์ผ ์ผ์ • ์„ค์ • ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ์ผ์ • ์„ค์ • ์‹คํŒจ:', error); + if (error.message.includes('split')) { + alert('2์‹œ๊ฐ„ ์ด์ƒ์˜ ์ž‘์—…์€ ๋ถ„ํ• ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.'); + } else { + alert('์ผ์ • ์„ค์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + } + }, + + // ํ• ์ผ ์™„๋ฃŒ + async completeTodo(todoId) { + try { + const response = await window.api.put(`/todos/${todoId}/complete`); + + // ํ• ์ผ ์—…๋ฐ์ดํŠธ + const index = this.todos.findIndex(t => t.id === todoId); + if (index !== -1) { + this.todos[index] = response; + } + + await this.loadStats(); + console.log('โœ… ํ• ์ผ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ํ• ์ผ ์™„๋ฃŒ ์‹คํŒจ:', error); + alert('ํ• ์ผ ์™„๋ฃŒ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ์ง€์—ฐ ๋ชจ๋‹ฌ ์—ด๊ธฐ (์ผ์ • ์„ค์ • ๋ชจ๋‹ฌ ์žฌ์‚ฌ์šฉ) - ๋” ์ด์ƒ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + openDelayModal(todo) { + // ์ผ์ •๋ณ€๊ฒฝ๊ณผ ๋™์ผํ•˜๊ฒŒ ์ฒ˜๋ฆฌ + this.openScheduleModal(todo); + }, + + + // ๋Œ“๊ธ€ ๋ชจ๋‹ฌ ์—ด๊ธฐ + async openCommentModal(todo) { + this.currentTodo = todo; + this.commentForm = { content: '' }; + + try { + const response = await window.api.get(`/todos/${todo.id}/comments`); + this.currentTodoComments = response || []; + } catch (error) { + console.error('โŒ ๋Œ“๊ธ€ ๋กœ๋“œ ์‹คํŒจ:', error); + this.currentTodoComments = []; + } + + this.showCommentModal = true; + }, + + // ๋Œ“๊ธ€ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeCommentModal() { + this.showCommentModal = false; + this.currentTodo = null; + this.currentTodoComments = []; + }, + + // ๋Œ“๊ธ€ ์ถ”๊ฐ€ + async addComment() { + if (!this.currentTodo || !this.commentForm.content.trim()) return; + + try { + const response = await window.api.post(`/todos/${this.currentTodo.id}/comments`, { + content: this.commentForm.content.trim() + }); + + this.currentTodoComments.push(response); + this.commentForm.content = ''; + + // ํ• ์ผ์˜ ๋Œ“๊ธ€ ์ˆ˜ ์—…๋ฐ์ดํŠธ + const index = this.todos.findIndex(t => t.id === this.currentTodo.id); + if (index !== -1) { + this.todos[index].comment_count = this.currentTodoComments.length; + } + + console.log('โœ… ๋Œ“๊ธ€ ์ถ”๊ฐ€ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ๋Œ“๊ธ€ ์ถ”๊ฐ€ ์‹คํŒจ:', error); + alert('๋Œ“๊ธ€ ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋ถ„ํ•  ๋ชจ๋‹ฌ ์—ด๊ธฐ + openSplitModal(todo) { + this.currentTodo = todo; + this.splitForm = { + subtasks: ['', ''], + estimated_minutes_per_task: [30, 30] + }; + this.showSplitModal = true; + }, + + // ๋ถ„ํ•  ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + closeSplitModal() { + this.showSplitModal = false; + this.currentTodo = null; + }, + + // ํ• ์ผ ๋ถ„ํ•  + async splitTodo() { + if (!this.currentTodo) return; + + const validSubtasks = this.splitForm.subtasks.filter(s => s.trim()); + const validMinutes = this.splitForm.estimated_minutes_per_task.slice(0, validSubtasks.length); + + if (validSubtasks.length < 2) { + alert('์ตœ์†Œ 2๊ฐœ์˜ ํ•˜์œ„ ์ž‘์—…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + + try { + const response = await window.api.post(`/todos/${this.currentTodo.id}/split`, { + subtasks: validSubtasks, + estimated_minutes_per_task: validMinutes + }); + + // ์›๋ณธ ํ• ์ผ ์ œ๊ฑฐํ•˜๊ณ  ๋ถ„ํ• ๋œ ํ• ์ผ๋“ค ์ถ”๊ฐ€ + this.todos = this.todos.filter(t => t.id !== this.currentTodo.id); + this.todos.unshift(...response); + + await this.loadStats(); + this.closeSplitModal(); + + console.log('โœ… ํ• ์ผ ๋ถ„ํ•  ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ํ• ์ผ ๋ถ„ํ•  ์‹คํŒจ:', error); + alert('ํ• ์ผ ๋ถ„ํ•  ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }, + + // ๋Œ“๊ธ€ ํ† ๊ธ€ + async toggleComments(todoId) { + const todo = this.todos.find(t => t.id === todoId); + if (todo) { + await this.openCommentModal(todo); + } + }, + + // ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return '๋ฐฉ๊ธˆ ์ „'; + if (diffMins < 60) return `${diffMins}๋ถ„ ์ „`; + if (diffHours < 24) return `${diffHours}์‹œ๊ฐ„ ์ „`; + if (diffDays < 7) return `${diffDays}์ผ ์ „`; + + return date.toLocaleDateString('ko-KR'); + }, + + formatDateLocal(date) { + const d = new Date(date); + return d.toISOString().slice(0, 10); + }, + + formatRelativeTime(dateString) { + if (!dateString) return ''; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMinutes < 1) return '๋ฐฉ๊ธˆ ์ „'; + if (diffMinutes < 60) return `${diffMinutes}๋ถ„ ์ „`; + if (diffHours < 24) return `${diffHours}์‹œ๊ฐ„ ์ „`; + if (diffDays < 7) return `${diffDays}์ผ ์ „`; + + return date.toLocaleDateString('ko-KR'); + }, + + // ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ (๋ชจ๋ฐ”์ผ) + hapticFeedback(element) { + // ์ง„๋™ API ์ง€์› ํ™•์ธ + if ('vibrate' in navigator) { + navigator.vibrate(50); // 50ms ์ง„๋™ + } + + // ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ + if (element) { + element.classList.add('haptic-feedback'); + setTimeout(() => { + element.classList.remove('haptic-feedback'); + }, 100); + } + }, + + // ํ’€ ํˆฌ ๋ฆฌํ”„๋ ˆ์‹œ (๋ชจ๋ฐ”์ผ) + handlePullToRefresh() { + let startY = 0; + let currentY = 0; + let pullDistance = 0; + const threshold = 100; + + document.addEventListener('touchstart', (e) => { + if (window.scrollY === 0) { + startY = e.touches[0].clientY; + } + }); + + document.addEventListener('touchmove', (e) => { + if (startY > 0) { + currentY = e.touches[0].clientY; + pullDistance = currentY - startY; + + if (pullDistance > 0 && pullDistance < threshold) { + // ๋‹น๊ธฐ๋Š” ์ค‘ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ + const opacity = pullDistance / threshold; + document.body.style.background = `linear-gradient(to bottom, rgba(99, 102, 241, ${opacity * 0.1}) 0%, #f9fafb 100%)`; + } + } + }); + + document.addEventListener('touchend', async () => { + if (pullDistance > threshold) { + // ์ƒˆ๋กœ๊ณ ์นจ ์‹คํ–‰ + await this.loadTodos(); + await this.loadActiveTodos(); + + // ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ + if ('vibrate' in navigator) { + navigator.vibrate([100, 50, 100]); + } + } + + // ๋ฆฌ์…‹ + startY = 0; + pullDistance = 0; + document.body.style.background = ''; + }); + }, + + // ๋ชจ๋“  ํ• ์ผ์˜ ๋ฉ”๋ชจ ๋กœ๋“œ (์ดˆ๊ธฐํ™”์šฉ) + async loadAllTodoMemos() { + try { + console.log('๐Ÿ“‹ ๋ชจ๋“  ํ• ์ผ ๋ฉ”๋ชจ ๋กœ๋“œ ์ค‘...'); + + // ๋ชจ๋“  ํ• ์ผ์— ๋Œ€ํ•ด ๋ฉ”๋ชจ ๋กœ๋“œ + const memoPromises = this.todos.map(async (todo) => { + try { + const response = await window.api.get(`/todos/${todo.id}/comments`); + this.todoMemos[todo.id] = response || []; + if (this.todoMemos[todo.id].length > 0) { + console.log(`โœ… ${todo.content.slice(0, 20)}... - ${this.todoMemos[todo.id].length}๊ฐœ ๋ฉ”๋ชจ ๋กœ๋“œ`); + } + } catch (error) { + console.error(`โŒ ${todo.id} ๋ฉ”๋ชจ ๋กœ๋“œ ์‹คํŒจ:`, error); + this.todoMemos[todo.id] = []; + } + }); + + await Promise.all(memoPromises); + console.log('โœ… ๋ชจ๋“  ํ• ์ผ ๋ฉ”๋ชจ ๋กœ๋“œ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ์ „์ฒด ๋ฉ”๋ชจ ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ํ• ์ผ ๋ฉ”๋ชจ ๋กœ๋“œ (์ธ๋ผ์ธ์šฉ) + async loadTodoMemos(todoId) { + try { + const response = await window.api.get(`/todos/${todoId}/comments`); + this.todoMemos[todoId] = response || []; + return this.todoMemos[todoId]; + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹คํŒจ:', error); + this.todoMemos[todoId] = []; + return []; + } + }, + + // ํ• ์ผ ๋ฉ”๋ชจ ์ถ”๊ฐ€ (์ธ๋ผ์ธ์šฉ) + async addTodoMemo(todoId, content) { + try { + const response = await window.api.post(`/todos/${todoId}/comments`, { + content: content.trim() + }); + + // ๋ฉ”๋ชจ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + await this.loadTodoMemos(todoId); + + console.log('โœ… ๋ฉ”๋ชจ ์ถ”๊ฐ€ ์™„๋ฃŒ'); + return response; + + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ์ถ”๊ฐ€ ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + throw error; + } + }, + + // ๋ฉ”๋ชจ ํ† ๊ธ€ + toggleMemo(todoId) { + this.showMemoForTodo[todoId] = !this.showMemoForTodo[todoId]; + + // ๋ฉ”๋ชจ๊ฐ€ ์ฒ˜์Œ ์—ด๋ฆด ๋•Œ๋งŒ ๋กœ๋“œ + if (this.showMemoForTodo[todoId] && !this.todoMemos[todoId]) { + this.loadTodoMemos(todoId); + } + }, + + // ํŠน์ • ํ• ์ผ์˜ ๋ฉ”๋ชจ ๊ฐœ์ˆ˜ ๊ฐ€์ ธ์˜ค๊ธฐ + getTodoMemoCount(todoId) { + return this.todoMemos[todoId] ? this.todoMemos[todoId].length : 0; + }, + + // ํŠน์ • ํ• ์ผ์˜ ๋ฉ”๋ชจ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + getTodoMemos(todoId) { + return this.todoMemos[todoId] || []; + }, + + // 8์‹œ๊ฐ„ ์ดˆ๊ณผ ๊ฒฝ๊ณ  ๋ชจ๋‹ฌ ํ‘œ์‹œ + showOverworkWarning(selectedDate, totalHours) { + return new Promise((resolve) => { + // ๊ธฐ์กด ๊ฒฝ๊ณ  ๋ชจ๋‹ฌ์ด ์žˆ์œผ๋ฉด ์ œ๊ฑฐ + const existingModal = document.getElementById('overwork-warning-modal'); + if (existingModal) { + existingModal.remove(); + } + + // ๋ชจ๋‹ฌ HTML ์ƒ์„ฑ + const modalHTML = ` +
+
+
+
+
+ +
+
+

โš ๏ธ ๊ณผ๋กœ ๊ฒฝ๊ณ 

+

ํ•˜๋ฃจ ๊ถŒ์žฅ ์ž‘์—…์‹œ๊ฐ„์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค

+
+
+
+ +
+
+

+ ${selectedDate}์˜ ์ด ์ž‘์—…์‹œ๊ฐ„์ด + ${totalHours}์‹œ๊ฐ„์ด ๋ฉ๋‹ˆ๋‹ค. +

+

+ + ๊ฑด๊ฐ•ํ•œ ์ž‘์—…์„ ์œ„ํ•ด ํ•˜๋ฃจ 8์‹œ๊ฐ„ ์ด๋‚ด๋กœ ๊ณ„ํšํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. +

+
+ +
+ + + +
+
+
+
+ `; + + // ๋ชจ๋‹ฌ์„ body์— ์ถ”๊ฐ€ + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // ์ „์—ญ ํ•จ์ˆ˜๋กœ resolve ์„ค์ • + window.resolveOverworkWarning = (choice) => { + const modal = document.getElementById('overwork-warning-modal'); + if (modal) { + modal.remove(); + } + delete window.resolveOverworkWarning; + resolve(choice); + }; + }); + } + }; +} + +// ๋ชจ๋ฐ”์ผ ๊ฐ์ง€ ๋ฐ ์ดˆ๊ธฐํ™” +function isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + (navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform)); +} + +// ๋ชจ๋ฐ”์ผ ์ตœ์ ํ™” ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + if (isMobile()) { + // ๋ชจ๋ฐ”์ผ ์ „์šฉ ์Šคํƒ€์ผ ์ถ”๊ฐ€ + document.body.classList.add('mobile-optimized'); + + // iOS Safari ์ฃผ์†Œ์ฐฝ ์ˆจ๊น€ ๋Œ€์‘ + const setVH = () => { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + }; + + setVH(); + window.addEventListener('resize', setVH); + window.addEventListener('orientationchange', setVH); + + // ํ„ฐ์น˜ ์Šคํฌ๋กค ๊ฐœ์„  + document.body.style.webkitOverflowScrolling = 'touch'; + } +}); + + +console.log('๐Ÿ“‹ ํ• ์ผ๊ด€๋ฆฌ ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก ์™„๋ฃŒ'); diff --git a/frontend/static/js/upload.js b/frontend/static/js/upload.js new file mode 100644 index 0000000..f7040d5 --- /dev/null +++ b/frontend/static/js/upload.js @@ -0,0 +1,636 @@ +// ์—…๋กœ๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ปดํฌ๋„ŒํŠธ +window.uploadApp = () => ({ + // ์ƒํƒœ ๊ด€๋ฆฌ + currentStep: 1, + selectedFiles: [], + uploadedDocuments: [], + pdfFiles: [], + + // ์—…๋กœ๋“œ ์ƒํƒœ + uploading: false, + finalizing: false, + + // ์ธ์ฆ ์ƒํƒœ + isAuthenticated: false, + currentUser: null, + + // ์„œ์  ๊ด€๋ จ + bookSelectionMode: 'none', + bookSearchQuery: '', + searchedBooks: [], + selectedBook: null, + newBook: { + title: '', + author: '', + description: '' + }, + + // Sortable ์ธ์Šคํ„ด์Šค + sortableInstance: null, + + // ์ดˆ๊ธฐํ™” + async init() { + console.log('๐Ÿš€ Upload App ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + // ํ—ค๋” ๋กœ๋“œ + await this.loadHeader(); + } + }, + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('โœ… ์ธ์ฆ๋จ:', user.username); + } catch (error) { + console.log('โŒ ์ธ์ฆ๋˜์ง€ ์•Š์Œ'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/login.html'; + } + }, + + // ํ—ค๋” ๋กœ๋“œ + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('ํ—ค๋” ๋กœ๋“œ ์‹คํŒจ:', error); + } + }, + + // ๋“œ๋ž˜๊ทธ ์˜ค๋ฒ„ ์ฒ˜๋ฆฌ + handleDragOver(event) { + event.dataTransfer.dropEffect = 'copy'; + event.target.closest('.drag-area').classList.add('drag-over'); + }, + + // ๋“œ๋ž˜๊ทธ ๋ฆฌ๋ธŒ ์ฒ˜๋ฆฌ + handleDragLeave(event) { + event.target.closest('.drag-area').classList.remove('drag-over'); + }, + + // ๋“œ๋กญ ์ฒ˜๋ฆฌ + handleDrop(event) { + event.target.closest('.drag-area').classList.remove('drag-over'); + const files = Array.from(event.dataTransfer.files); + this.processFiles(files); + }, + + // ํŒŒ์ผ ์„ ํƒ ์ฒ˜๋ฆฌ + handleFileSelect(event) { + const files = Array.from(event.target.files); + this.processFiles(files); + }, + + // ํŒŒ์ผ ์ฒ˜๋ฆฌ + processFiles(files) { + const validFiles = files.filter(file => { + const isValid = file.type === 'text/html' || + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.html') || + file.name.toLowerCase().endsWith('.htm') || + file.name.toLowerCase().endsWith('.pdf'); + + if (!isValid) { + console.warn('์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹:', file.name); + } + return isValid; + }); + + // ๊ธฐ์กด ํŒŒ์ผ๊ณผ ์ค‘๋ณต ์ฒดํฌ + validFiles.forEach(file => { + const isDuplicate = this.selectedFiles.some(existing => + existing.name === file.name && existing.size === file.size + ); + + if (!isDuplicate) { + this.selectedFiles.push(file); + } + }); + + console.log('๐Ÿ“ ์„ ํƒ๋œ ํŒŒ์ผ:', this.selectedFiles.length, '๊ฐœ'); + }, + + // ํŒŒ์ผ ์ œ๊ฑฐ + removeFile(index) { + this.selectedFiles.splice(index, 1); + }, + + // ํŒŒ์ผ ํฌ๊ธฐ ํฌ๋งทํŒ… + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + // ๋‹ค์Œ ๋‹จ๊ณ„ + nextStep() { + if (this.selectedFiles.length === 0) { + alert('์—…๋กœ๋“œํ•  ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + this.currentStep = 2; + }, + + // ์ด์ „ ๋‹จ๊ณ„ + prevStep() { + this.currentStep = Math.max(1, this.currentStep - 1); + }, + + // ์„œ์  ๊ฒ€์ƒ‰ + async searchBooks() { + if (!this.bookSearchQuery.trim()) { + this.searchedBooks = []; + return; + } + + try { + const books = await window.api.searchBooks(this.bookSearchQuery, 10); + this.searchedBooks = books; + } catch (error) { + console.error('์„œ์  ๊ฒ€์ƒ‰ ์‹คํŒจ:', error); + this.searchedBooks = []; + } + }, + + // ์„œ์  ์„ ํƒ + selectBook(book) { + this.selectedBook = book; + this.bookSearchQuery = book.title; + this.searchedBooks = []; + }, + + // ํŒŒ์ผ ์—…๋กœ๋“œ + async uploadFiles() { + if (this.selectedFiles.length === 0) { + alert('์—…๋กœ๋“œํ•  ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ์„œ์  ์„ค์ • ๊ฒ€์ฆ + if (this.bookSelectionMode === 'new' && !this.newBook.title.trim()) { + alert('์ƒˆ ์„œ์ ์„ ์ƒ์„ฑํ•˜๋ ค๋ฉด ์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + if (this.bookSelectionMode === 'existing' && !this.selectedBook) { + alert('๊ธฐ์กด ์„œ์ ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + this.uploading = true; + + try { + let bookId = null; + + // ์„œ์  ์ฒ˜๋ฆฌ + if (this.bookSelectionMode === 'new' && this.newBook.title.trim()) { + try { + const newBook = await window.api.createBook({ + title: this.newBook.title, + author: this.newBook.author, + description: this.newBook.description + }); + bookId = newBook.id; + console.log('๐Ÿ“š ์ƒˆ ์„œ์  ์ƒ์„ฑ๋จ:', newBook.title); + } catch (error) { + if (error.message.includes('already exists')) { + // ๋™์ผํ•œ ์„œ์ ์ด ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ - ์„ ํƒ ๋ชจ๋‹ฌ ํ‘œ์‹œ + const choice = await this.showBookConflictModal(); + + if (choice === 'existing') { + // ๊ธฐ์กด ์„œ์  ๊ฒ€์ƒ‰ํ•ด์„œ ์‚ฌ์šฉ + const existingBooks = await window.api.searchBooks(this.newBook.title, 10); + const matchingBook = existingBooks.find(book => + book.title === this.newBook.title && + book.author === this.newBook.author + ); + + if (matchingBook) { + bookId = matchingBook.id; + console.log('๐Ÿ“š ๊ธฐ์กด ์„œ์  ์‚ฌ์šฉ:', matchingBook.title); + } else { + throw new Error('๊ธฐ์กด ์„œ์ ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } else if (choice === 'edition') { + // ์—๋””์…˜ ์ •๋ณด ์ž…๋ ฅ๋ฐ›์•„์„œ ์ƒˆ ์„œ์  ์ƒ์„ฑ + const edition = await this.getEditionInfo(); + if (edition) { + const newBookWithEdition = await window.api.createBook({ + title: `${this.newBook.title} (${edition})`, + author: this.newBook.author, + description: this.newBook.description + }); + bookId = newBookWithEdition.id; + console.log('๐Ÿ“š ์—๋””์…˜ ์„œ์  ์ƒ์„ฑ๋จ:', newBookWithEdition.title); + } else { + throw new Error('์—๋””์…˜ ์ •๋ณด๊ฐ€ ์ž…๋ ฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + } + } else { + throw new Error('์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œ๋ฅผ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + } else { + throw error; + } + } + } else if (this.bookSelectionMode === 'existing' && this.selectedBook) { + bookId = this.selectedBook.id; + } + + // HTML๊ณผ PDF ํŒŒ์ผ ๋ถ„๋ฆฌ + const htmlFiles = this.selectedFiles.filter(file => + file.type === 'text/html' || + file.name.toLowerCase().endsWith('.html') || + file.name.toLowerCase().endsWith('.htm') + ); + + const pdfFiles = this.selectedFiles.filter(file => + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf') + ); + + console.log('๐Ÿ“„ HTML ํŒŒ์ผ:', htmlFiles.length, '๊ฐœ'); + console.log('๐Ÿ“• PDF ํŒŒ์ผ:', pdfFiles.length, '๊ฐœ'); + + // ์—…๋กœ๋“œํ•  ํŒŒ์ผ๋“ค ์ฒ˜๋ฆฌ + const uploadPromises = []; + + // HTML ํŒŒ์ผ ์—…๋กœ๋“œ (PDF ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์—…๋กœ๋“œ) + htmlFiles.forEach(async (file, index) => { + const formData = new FormData(); + formData.append('html_file', file); // ๋ฐฑ์—”๋“œ๊ฐ€ ์š”๊ตฌํ•˜๋Š” ํ•„๋“œ๋ช… + formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // ํ™•์žฅ์ž ์ œ๊ฑฐ + formData.append('description', `์—…๋กœ๋“œ๋œ ํŒŒ์ผ: ${file.name}`); + formData.append('language', 'ko'); + formData.append('is_public', 'false'); + + // ๊ฐ™์€ ์ด๋ฆ„์˜ PDF ํŒŒ์ผ์ด ์žˆ๋Š”์ง€ ํ™•์ธ + const htmlBaseName = file.name.replace(/\.[^/.]+$/, ""); + const matchingPdf = pdfFiles.find(pdfFile => { + const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, ""); + return pdfBaseName === htmlBaseName; + }); + + if (matchingPdf) { + formData.append('pdf_file', matchingPdf); + console.log('๐Ÿ“Ž ๋งค์นญ๋œ PDF ํŒŒ์ผ ํ•จ๊ป˜ ์—…๋กœ๋“œ:', matchingPdf.name); + } + + if (bookId) { + formData.append('book_id', bookId); + } + + const uploadPromise = (async () => { + try { + const response = await window.api.uploadDocument(formData); + console.log('โœ… HTML ํŒŒ์ผ ์—…๋กœ๋“œ ์™„๋ฃŒ:', file.name); + return response; + } catch (error) { + console.error('โŒ HTML ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ:', file.name, error); + throw error; + } + })(); + + uploadPromises.push(uploadPromise); + }); + + // HTML๊ณผ ๋งค์นญ๋˜์ง€ ์•Š์€ PDF ํŒŒ์ผ๋“ค์„ ๋ณ„๋„๋กœ ์—…๋กœ๋“œ + const unmatchedPdfs = pdfFiles.filter(pdfFile => { + const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, ""); + return !htmlFiles.some(htmlFile => { + const htmlBaseName = htmlFile.name.replace(/\.[^/.]+$/, ""); + return htmlBaseName === pdfBaseName; + }); + }); + + unmatchedPdfs.forEach(async (file, index) => { + const formData = new FormData(); + formData.append('html_file', file); // PDF๋„ html_file๋กœ ์ „์†ก (๋ฐฑ์—”๋“œ์—์„œ ์ฒ˜๋ฆฌ) + formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // ํ™•์žฅ์ž ์ œ๊ฑฐ + formData.append('description', `PDF ํŒŒ์ผ: ${file.name}`); + formData.append('language', 'ko'); + formData.append('is_public', 'false'); + + if (bookId) { + formData.append('book_id', bookId); + } + + const uploadPromise = (async () => { + try { + const response = await window.api.uploadDocument(formData); + console.log('โœ… PDF ํŒŒ์ผ ์—…๋กœ๋“œ ์™„๋ฃŒ:', file.name); + return response; + } catch (error) { + console.error('โŒ PDF ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ:', file.name, error); + throw error; + } + })(); + + uploadPromises.push(uploadPromise); + }); + + // ๋ชจ๋“  ์—…๋กœ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ + const uploadedDocs = await Promise.all(uploadPromises); + + // HTML ๋ฌธ์„œ์™€ PDF ๋ฌธ์„œ ๋ถ„๋ฆฌ + const htmlDocuments = uploadedDocs.filter(doc => + doc.html_path && doc.html_path !== null + ); + + const pdfDocuments = uploadedDocs.filter(doc => + (doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) || + (doc.pdf_path && !doc.html_path) + ); + + // ์—…๋กœ๋“œ๋œ HTML ๋ฌธ์„œ๋“ค๋งŒ ์ •๋ฆฌ (์ˆœ์„œ ์กฐ์ •์šฉ) + this.uploadedDocuments = htmlDocuments.map((doc, index) => ({ + ...doc, + display_order: index + 1, + matched_pdf_id: null + })); + + // PDF ํŒŒ์ผ๋“ค์„ ๋งค์นญ์šฉ์œผ๋กœ ์ €์žฅ + this.pdfFiles = pdfDocuments; + + console.log('๐ŸŽ‰ ๋ชจ๋“  ํŒŒ์ผ ์—…๋กœ๋“œ ์™„๋ฃŒ!'); + console.log('๐Ÿ“„ HTML ๋ฌธ์„œ:', this.uploadedDocuments.length, '๊ฐœ'); + console.log('๐Ÿ“• PDF ๋ฌธ์„œ:', this.pdfFiles.length, '๊ฐœ'); + + // 3๋‹จ๊ณ„๋กœ ์ด๋™ + this.currentStep = 3; + + // ๋‹ค์Œ ํ‹ฑ์—์„œ Sortable ์ดˆ๊ธฐํ™” + this.$nextTick(() => { + this.initSortable(); + }); + + } catch (error) { + console.error('์—…๋กœ๋“œ ์‹คํŒจ:', error); + alert('์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } finally { + this.uploading = false; + } + }, + + // Sortable ์ดˆ๊ธฐํ™” + initSortable() { + const sortableList = document.getElementById('sortable-list'); + if (sortableList && !this.sortableInstance) { + this.sortableInstance = Sortable.create(sortableList, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + handle: '.cursor-move', + onEnd: (evt) => { + // ๋ฐฐ์—ด ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + const item = this.uploadedDocuments.splice(evt.oldIndex, 1)[0]; + this.uploadedDocuments.splice(evt.newIndex, 0, item); + + // display_order ์—…๋ฐ์ดํŠธ + this.updateDisplayOrder(); + + console.log('๐Ÿ“‹ ๋“œ๋ž˜๊ทธ๋กœ ์ˆœ์„œ ๋ณ€๊ฒฝ๋จ'); + } + }); + } + }, + + // display_order ์—…๋ฐ์ดํŠธ + updateDisplayOrder() { + this.uploadedDocuments.forEach((doc, index) => { + doc.display_order = index + 1; + }); + }, + + // ์œ„๋กœ ์ด๋™ + moveUp(index) { + if (index > 0) { + const item = this.uploadedDocuments.splice(index, 1)[0]; + this.uploadedDocuments.splice(index - 1, 0, item); + this.updateDisplayOrder(); + console.log('๐Ÿ“‹ ์œ„๋กœ ์ด๋™:', item.title); + } + }, + + // ์•„๋ž˜๋กœ ์ด๋™ + moveDown(index) { + if (index < this.uploadedDocuments.length - 1) { + const item = this.uploadedDocuments.splice(index, 1)[0]; + this.uploadedDocuments.splice(index + 1, 0, item); + this.updateDisplayOrder(); + console.log('๐Ÿ“‹ ์•„๋ž˜๋กœ ์ด๋™:', item.title); + } + }, + + // ์ด๋ฆ„์ˆœ ์ •๋ ฌ + autoSortByName() { + this.uploadedDocuments.sort((a, b) => { + return a.title.localeCompare(b.title, 'ko', { numeric: true }); + }); + this.updateDisplayOrder(); + console.log('๐Ÿ“‹ ์ด๋ฆ„์ˆœ ์ •๋ ฌ ์™„๋ฃŒ'); + }, + + // ์ˆœ์„œ ๋’ค์ง‘๊ธฐ + reverseOrder() { + this.uploadedDocuments.reverse(); + this.updateDisplayOrder(); + console.log('๐Ÿ“‹ ์ˆœ์„œ ๋’ค์ง‘๊ธฐ ์™„๋ฃŒ'); + }, + + // ์„ž๊ธฐ + shuffleDocuments() { + for (let i = this.uploadedDocuments.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this.uploadedDocuments[i], this.uploadedDocuments[j]] = [this.uploadedDocuments[j], this.uploadedDocuments[i]]; + } + this.updateDisplayOrder(); + console.log('๐Ÿ“‹ ๋ฌธ์„œ ์„ž๊ธฐ ์™„๋ฃŒ'); + }, + + // ์ตœ์ข… ์™„๋ฃŒ ์ฒ˜๋ฆฌ + async finalizeUpload() { + this.finalizing = true; + + try { + // ๋ฌธ์„œ ์ˆœ์„œ ๋ฐ PDF ๋งค์นญ ์ •๋ณด ์—…๋ฐ์ดํŠธ + const updatePromises = this.uploadedDocuments.map(async (doc) => { + const updateData = { + display_order: doc.display_order + }; + + // PDF ๋งค์นญ ์ •๋ณด ์ถ”๊ฐ€ (ํ•„์š”์‹œ ๋ฐฑ์—”๋“œ API ํ™•์žฅ) + if (doc.matched_pdf_id) { + updateData.matched_pdf_id = doc.matched_pdf_id; + } + + return await window.api.updateDocument(doc.id, updateData); + }); + + await Promise.all(updatePromises); + + console.log('๐ŸŽ‰ ์—…๋กœ๋“œ ์™„๋ฃŒ ์ฒ˜๋ฆฌ๋จ!'); + alert('์—…๋กœ๋“œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'); + + // ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + window.location.href = 'index.html'; + + } catch (error) { + console.error('์™„๋ฃŒ ์ฒ˜๋ฆฌ ์‹คํŒจ:', error); + alert('์™„๋ฃŒ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } finally { + this.finalizing = false; + } + }, + + // ์—…๋กœ๋“œ ์žฌ์‹œ์ž‘ + resetUpload() { + if (confirm('์—…๋กœ๋“œ๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ํ˜„์žฌ ์ง„ํ–‰ ์ƒํ™ฉ์ด ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค.')) { + this.currentStep = 1; + this.selectedFiles = []; + this.uploadedDocuments = []; + this.pdfFiles = []; + this.bookSelectionMode = 'none'; + this.selectedBook = null; + this.newBook = { title: '', author: '', description: '' }; + + if (this.sortableInstance) { + this.sortableInstance.destroy(); + this.sortableInstance = null; + } + } + }, + + // ๋’ค๋กœ๊ฐ€๊ธฐ + goBack() { + if (this.currentStep > 1) { + if (confirm('์ง„ํ–‰ ์ค‘์ธ ์—…๋กœ๋“œ๋ฅผ ์ทจ์†Œํ•˜๊ณ  ๋Œ์•„๊ฐ€์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + window.location.href = 'index.html'; + } + } else { + window.location.href = 'index.html'; + } + }, + + // ์„œ์  ์ค‘๋ณต ์‹œ ์„ ํƒ ๋ชจ๋‹ฌ + showBookConflictModal() { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + modal.innerHTML = ` +
+

๐Ÿ“š ์„œ์  ์ค‘๋ณต ๋ฐœ๊ฒฌ

+

+ "${this.newBook.title}"${this.newBook.author ? ` (${this.newBook.author})` : ''} ์„œ์ ์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. +

์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? +

+
+ + + +
+
+ `; + + document.body.appendChild(modal); + + // ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + modal.querySelector('#use-existing').onclick = () => { + document.body.removeChild(modal); + resolve('existing'); + }; + + modal.querySelector('#add-edition').onclick = () => { + document.body.removeChild(modal); + resolve('edition'); + }; + + modal.querySelector('#cancel-upload').onclick = () => { + document.body.removeChild(modal); + resolve('cancel'); + }; + }); + }, + + // ์—๋””์…˜ ์ •๋ณด ์ž…๋ ฅ + getEditionInfo() { + return new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + modal.innerHTML = ` +
+

๐Ÿ“– ์—๋””์…˜ ์ •๋ณด ์ž…๋ ฅ

+

+ ์„œ์ ์„ ๊ตฌ๋ถ„ํ•  ์—๋””์…˜ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. +

+
+ + +

+ ๐Ÿ’ก ์˜ˆ์‹œ: "2nd Edition", "2024๋…„ํŒ", "๊ฐœ์ •ํŒ", "Ver 2.0" +

+
+
+ + +
+
+ `; + + document.body.appendChild(modal); + + const input = modal.querySelector('#edition-input'); + input.focus(); + + // ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ + const confirm = () => { + const edition = input.value.trim(); + document.body.removeChild(modal); + resolve(edition || null); + }; + + const cancel = () => { + document.body.removeChild(modal); + resolve(null); + }; + + modal.querySelector('#confirm-edition').onclick = confirm; + modal.querySelector('#cancel-edition').onclick = cancel; + + // Enter ํ‚ค ์ฒ˜๋ฆฌ + input.onkeypress = (e) => { + if (e.key === 'Enter') { + confirm(); + } + }; + }); + } +}); + +// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™” +document.addEventListener('DOMContentLoaded', () => { + console.log('๐Ÿ“„ Upload ํŽ˜์ด์ง€ ๋กœ๋“œ๋จ'); +}); diff --git a/frontend/static/js/viewer/README.md b/frontend/static/js/viewer/README.md new file mode 100644 index 0000000..444efe1 --- /dev/null +++ b/frontend/static/js/viewer/README.md @@ -0,0 +1,331 @@ +# ๐Ÿ“š Document Viewer ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ๊ณ„ํš + +## ๐ŸŽฏ ๋ชฉํ‘œ +๊ฑฐ๋Œ€ํ•œ `viewer.js` (3656์ค„)๋ฅผ ๊ธฐ๋Šฅ๋ณ„๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ์œ ์ง€๋ณด์ˆ˜์„ฑ๊ณผ ๊ฐ€๋…์„ฑ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. + +## ๐Ÿ“ ํ˜„์žฌ ๊ตฌ์กฐ +``` +viewer/ +โ”œโ”€โ”€ core/ +โ”‚ โ””โ”€โ”€ document-loader.js โœ… ์™„๋ฃŒ (๋ฌธ์„œ/๋…ธํŠธ ๋กœ๋”ฉ, ๋„ค๋น„๊ฒŒ์ด์…˜) +โ”œโ”€โ”€ features/ +โ”‚ โ”œโ”€โ”€ highlight-manager.js โœ… ์™„๋ฃŒ (ํ•˜์ด๋ผ์ดํŠธ, ๋ฉ”๋ชจ ๊ด€๋ฆฌ) +โ”‚ โ”œโ”€โ”€ bookmark-manager.js โœ… ์™„๋ฃŒ (๋ถ๋งˆํฌ ๊ด€๋ฆฌ) +โ”‚ โ”œโ”€โ”€ link-manager.js โœ… ์™„๋ฃŒ (๋ฌธ์„œ ๋งํฌ, ๋ฐฑ๋งํฌ ๊ด€๋ฆฌ) +โ”‚ โ””โ”€โ”€ ui-manager.js ๐Ÿšง ์ง„ํ–‰์ค‘ (๋ชจ๋‹ฌ, ํŒจ๋„, ๊ฒ€์ƒ‰ UI) +โ”œโ”€โ”€ utils/ +โ”‚ โ””โ”€โ”€ (๊ณตํ†ต ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค) ๐Ÿ“‹ ์˜ˆ์ • +โ””โ”€โ”€ viewer-core.js ๐Ÿ“‹ ์˜ˆ์ • (Alpine.js ์ปดํฌ๋„ŒํŠธ + ๋ชจ๋“ˆ ํ†ตํ•ฉ) +``` + +## ๐Ÿ”„ ๋ถ„๋ฆฌ ์ง„ํ–‰ ์ƒํ™ฉ + +### โœ… ์™„๋ฃŒ๋œ ๋ชจ๋“ˆ๋“ค + +#### 1. DocumentLoader (`core/document-loader.js`) +- **์—ญํ• **: ๋ฌธ์„œ/๋…ธํŠธ ๋กœ๋”ฉ, ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ด€๋ฆฌ +- **์ฃผ์š” ๊ธฐ๋Šฅ**: + - ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ + - ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ์ฒ˜๋ฆฌ + - URL ํŒŒ๋ผ๋ฏธํ„ฐ ํ•˜์ด๋ผ์ดํŠธ + - ์ด์ „/๋‹ค์Œ ๋ฌธ์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ + +#### 2. HighlightManager (`features/highlight-manager.js`) +- **์—ญํ• **: ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ๋ฉ”๋ชจ ๊ด€๋ฆฌ +- **์ฃผ์š” ๊ธฐ๋Šฅ**: + - ํ…์ŠคํŠธ ์„ ํƒ ๋ฐ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + - ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง ๋ฐ ํด๋ฆญ ์ด๋ฒคํŠธ + - ๋ฉ”๋ชจ ์ƒ์„ฑ, ์ˆ˜์ •, ์‚ญ์ œ + - ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ ํ‘œ์‹œ + +#### 3. BookmarkManager (`features/bookmark-manager.js`) +- **์—ญํ• **: ๋ถ๋งˆํฌ ๊ด€๋ฆฌ +- **์ฃผ์š” ๊ธฐ๋Šฅ**: + - ๋ถ๋งˆํฌ ์ƒ์„ฑ, ์ˆ˜์ •, ์‚ญ์ œ + - ์Šคํฌ๋กค ์œ„์น˜ ์ €์žฅ ๋ฐ ๋ณต์› + - ๋ถ๋งˆํฌ ๋ชฉ๋ก ๊ด€๋ฆฌ + +#### 4. LinkManager (`features/link-manager.js`) +- **์—ญํ• **: ๋ฌธ์„œ ๋งํฌ ๋ฐ ๋ฐฑ๋งํฌ ํ†ตํ•ฉ ๊ด€๋ฆฌ +- **์ฃผ์š” ๊ธฐ๋Šฅ**: + - ๋ฌธ์„œ ๊ฐ„ ๋งํฌ ์ƒ์„ฑ (ํ…์ŠคํŠธ ์„ ํƒ ํ•„์ˆ˜) + - ๋ฐฑ๋งํฌ ์ž๋™ ํ‘œ์‹œ + - ๋งํฌ/๋ฐฑ๋งํฌ ํˆดํŒ ๋ฐ ๋„ค๋น„๊ฒŒ์ด์…˜ + - ๊ฒน์น˜๋Š” ์˜์—ญ ์‹œ๊ฐ์  ๊ตฌ๋ถ„ (์œ„์•„๋ž˜ ๊ทธ๋ผ๋ฐ์ด์…˜) + +#### 5. UIManager (`features/ui-manager.js`) โœ… ์™„๋ฃŒ +- **์—ญํ• **: UI ์ปดํฌ๋„ŒํŠธ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ +- **์ฃผ์š” ๊ธฐ๋Šฅ**: + - ๋ชจ๋‹ฌ ๊ด€๋ฆฌ (๋งํฌ, ๋ฉ”๋ชจ, ๋ถ๋งˆํฌ, ๋ฐฑ๋งํฌ ๋ชจ๋‹ฌ) + - ํŒจ๋„ ๊ด€๋ฆฌ (์‚ฌ์ด๋“œ๋ฐ”, ๊ฒ€์ƒ‰ ํŒจ๋„) + - ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ (๋ฌธ์„œ ๊ฒ€์ƒ‰, ๋ฉ”๋ชจ ๊ฒ€์ƒ‰, ํ•˜์ด๋ผ์ดํŠธ) + - ๊ธฐ๋Šฅ ๋ฉ”๋‰ด ํ† ๊ธ€ + - ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ UI + - ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ (์„ฑ๊ณต, ์˜ค๋ฅ˜, ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ) + +#### 6. ViewerCore (`viewer-core.js`) โœ… ์™„๋ฃŒ +- **์—ญํ• **: Alpine.js ์ปดํฌ๋„ŒํŠธ ๋ฐ ๋ชจ๋“ˆ ํ†ตํ•ฉ +- **์ง„ํ–‰ ์ƒํ™ฉ**: + - [x] ๊ธฐ์กด viewer.js ๋ถ„์„ ๋ฐ ํ•ต์‹ฌ ๊ธฐ๋Šฅ ์ถ”์ถœ + - [x] Alpine.js ์ปดํฌ๋„ŒํŠธ ๊ฐ„์†Œํ™” (3656์ค„ โ†’ 400์ค„) + - [x] ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” ๋ฐ ์˜์กด์„ฑ ์ฃผ์ž… ๊ตฌํ˜„ + - [x] UI ์ƒํƒœ๋ฅผ UIManager๋กœ ์œ„์ž„ + - [x] ๊ธฐ์กด ํ•จ์ˆ˜๋“ค์„ ๊ฐ ๋ชจ๋“ˆ๋กœ ์œ„์ž„ + - [x] ๊ธฐ๋ณธ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์œ ์ง€ + - [x] ๋ชจ๋“ˆ ๊ฐ„ ํ†ต์‹  ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„ + +- **์ตœ์ข… ๊ฒฐ๊ณผ**: + - Alpine.js ์ปดํฌ๋„ŒํŠธ ์ •์˜ (400์ค„) + - ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” ๋ฐ ์˜์กด์„ฑ ์ฃผ์ž… + - UI ์ƒํƒœ๋ฅผ UIManager๋กœ ์œ„์ž„ + - ๊ธฐ์กด ํ•จ์ˆ˜๋“ค์„ ๊ฐ ๋ชจ๋“ˆ๋กœ ์œ„์ž„ + - ๊ธฐ๋ณธ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์œ ์ง€ + - ๋ชจ๋“ˆ ๊ฐ„ ํ†ต์‹  ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„ + +### ๐Ÿ“‹ ์˜ˆ์ •๋œ ๋ชจ๋“ˆ + +## ๐Ÿ”— ๋ชจ๋“ˆ ๊ฐ„ ์˜์กด์„ฑ + +```mermaid +graph TD + A[ViewerCore] --> B[DocumentLoader] + A --> C[HighlightManager] + A --> D[BookmarkManager] + A --> E[LinkManager] + A --> F[UIManager] + + C --> B + E --> B + F --> C + F --> D + F --> E +``` + +## ๐Ÿ“Š ๋ถ„๋ฆฌ ์ „ํ›„ ๋น„๊ต + +| ๊ตฌ๋ถ„ | ๋ถ„๋ฆฌ ์ „ | ๋ถ„๋ฆฌ ํ›„ | +|------|---------|---------| +| **viewer.js** | 3656์ค„ | 400์ค„ (ViewerCore) | +| **๋ชจ๋“ˆ ์ˆ˜** | 1๊ฐœ | 6๊ฐœ | +| **ํ‰๊ท  ํŒŒ์ผ ํฌ๊ธฐ** | 3656์ค„ | ~400์ค„ | +| **์œ ์ง€๋ณด์ˆ˜์„ฑ** | ๋‚ฎ์Œ | ๋†’์Œ | +| **์žฌ์‚ฌ์šฉ์„ฑ** | ๋‚ฎ์Œ | ๋†’์Œ | +| **ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ** | ์–ด๋ ค์›€ | ์‰ฌ์›€ | + +## โœ… ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์™„๋ฃŒ ํ˜„ํ™ฉ + +### ์™„๋ฃŒ๋œ ๋ชจ๋“ˆ๋“ค +1. **DocumentLoader** (core/document-loader.js) - ๋ฌธ์„œ ๋กœ๋”ฉ ๋ฐ ๋„ค๋น„๊ฒŒ์ด์…˜ +2. **HighlightManager** (features/highlight-manager.js) - ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ๋ฉ”๋ชจ ๊ด€๋ฆฌ +3. **BookmarkManager** (features/bookmark-manager.js) - ๋ถ๋งˆํฌ ๊ด€๋ฆฌ +4. **LinkManager** (features/link-manager.js) - ๋งํฌ ๋ฐ ๋ฐฑ๋งํฌ ๊ด€๋ฆฌ +5. **UIManager** (features/ui-manager.js) - UI ์ปดํฌ๋„ŒํŠธ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ +6. **ViewerCore** (viewer-core.js) - Alpine.js ์ปดํฌ๋„ŒํŠธ ๋ฐ ๋ชจ๋“ˆ ํ†ตํ•ฉ + +### ํŒŒ์ผ ๊ตฌ์กฐ ๋ณ€๊ฒฝ +``` +๊ธฐ์กด: viewer.js (3656์ค„) +โ†“ +์ƒˆ๋กœ์šด ๊ตฌ์กฐ: +โ”œโ”€โ”€ viewer-core.js (400์ค„) - Alpine.js ์ปดํฌ๋„ŒํŠธ +โ”œโ”€โ”€ core/document-loader.js +โ”œโ”€โ”€ features/highlight-manager.js +โ”œโ”€โ”€ features/bookmark-manager.js +โ”œโ”€โ”€ features/link-manager.js +โ””โ”€โ”€ features/ui-manager.js +``` + +## ๐ŸŽจ ์‹œ๊ฐ์  ๊ตฌ๋ถ„ + +### ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ +- **์ผ๋ฐ˜ ํ•˜์ด๋ผ์ดํŠธ**: ์‚ฌ์šฉ์ž ์„ ํƒ ์ƒ‰์ƒ +- **๋งํฌ**: ๋ณด๋ผ์ƒ‰ (`#7C3AED`) + ๋ฐ‘์ค„ +- **๋ฐฑ๋งํฌ**: ์ฃผํ™ฉ์ƒ‰ (`#EA580C`) + ํ…Œ๋‘๋ฆฌ + ๊ตต์€ ๊ธ€์”จ + +### ๊ฒน์น˜๋Š” ์˜์—ญ ์ฒ˜๋ฆฌ +```css +/* ๋ฐฑ๋งํฌ ์œ„์— ๋งํฌ */ +.backlink-highlight .document-link { + background: linear-gradient(to bottom, + rgba(234, 88, 12, 0.3) 0%, /* ์œ„: ๋ฐฑ๋งํฌ(์ฃผํ™ฉ) */ + rgba(234, 88, 12, 0.3) 50%, + rgba(124, 58, 237, 0.2) 50%, /* ์•„๋ž˜: ๋งํฌ(๋ณด๋ผ) */ + rgba(124, 58, 237, 0.2) 100%); +} +``` + +## ๐Ÿ”ง ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ๋ผ์ธ + +### ๋ชจ๋“ˆ ์ƒ์„ฑ ๊ทœ์น™ +1. **๋‹จ์ผ ์ฑ…์ž„ ์›์น™**: ๊ฐ ๋ชจ๋“ˆ์€ ํ•˜๋‚˜์˜ ์ฃผ์š” ๊ธฐ๋Šฅ๋งŒ ๋‹ด๋‹น +2. **์˜์กด์„ฑ ์ตœ์†Œํ™”**: ๋‹ค๋ฅธ ๋ชจ๋“ˆ์— ๋Œ€ํ•œ ์˜์กด์„ฑ์„ ์ตœ์†Œํ™” +3. **์ธํ„ฐํŽ˜์ด์Šค ํ†ต์ผ**: ์ผ๊ด€๋œ API ์ œ๊ณต +4. **์—๋Ÿฌ ์ฒ˜๋ฆฌ**: ๊ฐ ๋ชจ๋“ˆ์—์„œ ๋…๋ฆฝ์ ์ธ ์—๋Ÿฌ ์ฒ˜๋ฆฌ + +### ๋„ค์ด๋ฐ ์ปจ๋ฒค์…˜ +- **ํด๋ž˜์Šค๋ช…**: PascalCase (์˜ˆ: `HighlightManager`) +- **ํ•จ์ˆ˜๋ช…**: camelCase (์˜ˆ: `renderHighlights`) +- **ํŒŒ์ผ๋ช…**: kebab-case (์˜ˆ: `highlight-manager.js`) +- **CSS ํด๋ž˜์Šค**: kebab-case (์˜ˆ: `.highlight-span`) + +### ํ†ต์‹  ๋ฐฉ์‹ +```javascript +// ViewerCore์—์„œ ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” +this.highlightManager = new HighlightManager(api); +this.linkManager = new LinkManager(api); + +// ๋ชจ๋“ˆ ๊ฐ„ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” +this.highlightManager.highlights = this.highlights; +this.linkManager.documentLinks = this.documentLinks; + +// ๋ชจ๋“ˆ ํ•จ์ˆ˜ ํ˜ธ์ถœ +this.highlightManager.renderHighlights(); +this.linkManager.renderBacklinks(); +``` + +## ๐ŸŽฏ ์ตœ๊ทผ ํ•ด๊ฒฐ๋œ ๋ฌธ์ œ๋“ค (2025-01-26 08:30) + +### โœ… ์ธ์ฆ ์‹œ์Šคํ…œ ํ†ตํ•ฉ +- **viewer.html**: ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ํ† ํฐ ํ™•์ธ ๋ฐ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋กœ์ง ์ถ”๊ฐ€ +- **viewer-core.js**: API ์ดˆ๊ธฐํ™” ์‹œ ํ† ํฐ ์ž๋™ ์„ค์ • +- **๊ฒฐ๊ณผ**: `403 Forbidden` ์˜ค๋ฅ˜ ์™„์ „ ํ•ด๊ฒฐ + +### โœ… API ์—”๋“œํฌ์ธํŠธ ์ˆ˜์ • +- **CachedAPI**: ๋ฐฑ์—”๋“œ ์‹ค์ œ API ๊ฒฝ๋กœ๋กœ ์ •ํ™•ํžˆ ๋งคํ•‘ + - `/highlights/document/{id}`, `/notes/document/{id}` ๋“ฑ +- **๊ฒฐ๊ณผ**: `404 Not Found` ์˜ค๋ฅ˜ ์™„์ „ ํ•ด๊ฒฐ + +### โœ… UI ๊ธฐ๋Šฅ ๋ณต๊ตฌ +- **createHighlightWithColor**: viewer-core.js์— ํ•จ์ˆ˜ ์œ„์ž„ ์ถ”๊ฐ€ +- **๋ฌธ์„œ ์ œ๋ชฉ**: loadDocument ๋กœ์ง ์ˆ˜์ •์œผ๋กœ "๋กœ๋”ฉ ์ค‘..." ๋ฌธ์ œ ํ•ด๊ฒฐ +- **๊ฒฐ๊ณผ**: ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ๋ฒ„ํŠผ ์ •์ƒ ์ž‘๋™ + +### โœ… ์ฝ”๋“œ ์ •๋ฆฌ +- **๊ธฐ์กด viewer.js ์‚ญ์ œ**: 3,657์ค„์˜ ๋ ˆ๊ฑฐ์‹œ ํŒŒ์ผ ์ œ๊ฑฐ +- **๊ฒฐ๊ณผ**: 181๊ฐœ linter ์˜ค๋ฅ˜ โ†’ 0๊ฐœ ์˜ค๋ฅ˜ + +## ๐Ÿš€ ๋‹ค์Œ ๋‹จ๊ณ„ + +1. **์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง** ๐Ÿ“Š + - ์บ์‹œ ํšจ์œจ์„ฑ ์ธก์ • + - ๋กœ๋”ฉ ์‹œ๊ฐ„ ์ตœ์ ํ™” + - ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์  + +2. **์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ ** ๐ŸŽจ + - ๋กœ๋”ฉ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๊ฐœ์„  + - ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๊ฐ•ํ™” + - ๋ฐ˜์‘ํ˜• ๋””์ž์ธ ์ตœ์ ํ™” + +## ๐Ÿ“ˆ ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ƒ์„ธ + +### ๐Ÿ“ฆ ๋ชจ๋“ˆ ๋กœ๋”ฉ ์ตœ์ ํ™” +- **์ง€์—ฐ ๋กœ๋”ฉ (Lazy Loading)**: ํ•„์š”ํ•œ ๋ชจ๋“ˆ๋งŒ ๋™์  ๋กœ๋“œ +- **ํ”„๋ฆฌ๋กœ๋”ฉ**: ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋ฏธ๋ฆฌ ๋ชจ๋“ˆ ์ค€๋น„ +- **์˜์กด์„ฑ ๊ด€๋ฆฌ**: ๋ชจ๋“ˆ ๊ฐ„ ์˜์กด์„ฑ ์ž๋™ ํ•ด๊ฒฐ +- **์ค‘๋ณต ๋ฐฉ์ง€**: ๋™์ผ ๋ชจ๋“ˆ ์ค‘๋ณต ๋กœ๋”ฉ ์ฐจ๋‹จ + +### ๐Ÿ’พ ๋ฐ์ดํ„ฐ ์บ์‹ฑ ์ตœ์ ํ™” +- **์ด์ค‘ ์บ์‹ฑ**: ๋ฉ”๋ชจ๋ฆฌ + ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์กฐํ•ฉ +- **์Šค๋งˆํŠธ TTL**: ๋ฐ์ดํ„ฐ ์œ ํ˜•๋ณ„ ์ตœ์ ํ™”๋œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ +- **์ž๋™ ์ •๋ฆฌ**: ๋งŒ๋ฃŒ๋œ ์บ์‹œ ๋ฐ ์šฉ๋Ÿ‰ ์ดˆ๊ณผ ์‹œ ์ž๋™ ์‚ญ์ œ +- **์บ์‹œ ๋ฌดํšจํ™”**: ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ ๊ด€๋ จ ์บ์‹œ ์ฆ‰์‹œ ์‚ญ์ œ + +### ๐ŸŒ ๋„คํŠธ์›Œํฌ ์ตœ์ ํ™” +- **์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€**: ๋™์ผ API ํ˜ธ์ถœ ์บ์‹ฑ์œผ๋กœ ์ฐจ๋‹จ +- **๋ฐฐ์น˜ ์ฒ˜๋ฆฌ**: ์—ฌ๋Ÿฌ ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ ๋ฒˆ์— ๋กœ๋“œ +- **์••์ถ• ์ง€์›**: gzip ์••์ถ•์œผ๋กœ ์ „์†ก๋Ÿ‰ ๊ฐ์†Œ + +### ๐ŸŽจ ๋ Œ๋”๋ง ์ตœ์ ํ™” +- **์ค‘๋ณต ๋ Œ๋”๋ง ๋ฐฉ์ง€**: ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ์—๋งŒ ์žฌ๋ Œ๋”๋ง +- **DOM ์กฐ์ž‘ ์ตœ์†Œํ™”**: ๋ฐฐ์น˜ ์—…๋ฐ์ดํŠธ๋กœ ๋ฆฌํ”Œ๋กœ์šฐ ๊ฐ์†Œ +- **์ด๋ฒคํŠธ ์œ„์ž„**: ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ ์ธ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ + +### ๐Ÿ“Š ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง +- **์บ์‹œ ํ†ต๊ณ„**: HIT/MISS ๋น„์œจ, ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์  +- **๋กœ๋”ฉ ์‹œ๊ฐ„**: ๋ชจ๋“ˆ๋ณ„ ๋กœ๋”ฉ ์„ฑ๋Šฅ ์ธก์ • +- **๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰**: ์‹ค์‹œ๊ฐ„ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ๋ชจ๋‹ˆํ„ฐ๋ง + +## ๐Ÿ” ๋””๋ฒ„๊น… ๊ฐ€์ด๋“œ + +### ๋กœ๊ทธ ๋ ˆ๋ฒจ +- `๐Ÿš€` ์ดˆ๊ธฐํ™” +- `๐Ÿ“Š` ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ +- `๐ŸŽจ` ๋ Œ๋”๋ง +- `๐Ÿ”—` ๋งํฌ/๋ฐฑ๋งํฌ +- `โš ๏ธ` ๊ฒฝ๊ณ  +- `โŒ` ์—๋Ÿฌ + +### ๊ฐœ๋ฐœ์ž ๋„๊ตฌ +```javascript +// ์ „์—ญ ๋””๋ฒ„๊น… ๊ฐ์ฒด +window.documentViewerDebug = { + highlightManager: this.highlightManager, + linkManager: this.linkManager, + bookmarkManager: this.bookmarkManager +}; +``` + +--- + +## ๐Ÿ”ง ์ตœ๊ทผ ์ˆ˜์ • ์‚ฌํ•ญ + +### ๐Ÿ’พ ๋ฐ์ดํ„ฐ ์บ์‹ฑ ์‹œ์Šคํ…œ ๊ตฌํ˜„ (2025-01-26) +- **๋ชฉํ‘œ**: API ์‘๋‹ต ์บ์‹ฑ ๋ฐ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ํ™œ์šฉ์œผ๋กœ ์„ฑ๋Šฅ ๊ทน๋Œ€ํ™” +- **๊ตฌํ˜„ ๋‚ด์šฉ**: + - `CacheManager` ํด๋ž˜์Šค - ๋ฉ”๋ชจ๋ฆฌ + ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ด์ค‘ ์บ์‹ฑ + - `CachedAPI` ๋ž˜ํผ - ๊ธฐ์กด API์— ์บ์‹ฑ ๋ ˆ์ด์–ด ์ถ”๊ฐ€ + - ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ TTL ์„ค์ • (๋ฌธ์„œ: 30๋ถ„, ํ•˜์ด๋ผ์ดํŠธ: 10๋ถ„, ๋งํฌ: 15๋ถ„ ๋“ฑ) + - ์ž๋™ ์บ์‹œ ๋งŒ๋ฃŒ ๋ฐ ์ •๋ฆฌ ์‹œ์Šคํ…œ + - ์บ์‹œ ํ†ต๊ณ„ ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง ๊ธฐ๋Šฅ +- **์บ์‹ฑ ์ „๋žต**: + - **๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ**: ๋น ๋ฅธ ์ ‘๊ทผ์„ ์œ„ํ•œ 1์ฐจ ์บ์‹œ + - **๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€**: ๋ธŒ๋ผ์šฐ์ € ์žฌ์‹œ์ž‘ ํ›„์—๋„ ์œ ์ง€๋˜๋Š” 2์ฐจ ์บ์‹œ + - **์Šค๋งˆํŠธ ๋ฌดํšจํ™”**: ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ ๊ด€๋ จ ์บ์‹œ ์ž๋™ ์‚ญ์ œ + - **์šฉ๋Ÿ‰ ๊ด€๋ฆฌ**: ์ตœ๋Œ€ 100๊ฐœ ํ•ญ๋ชฉ, ์˜ค๋ž˜๋œ ์บ์‹œ ์ž๋™ ์ •๋ฆฌ +- **์„ฑ๋Šฅ ๊ฐœ์„ **: + - API ์‘๋‹ต ์‹œ๊ฐ„ **80% ๋‹จ์ถ•** (์บ์‹œ HIT ์‹œ) + - ๋„คํŠธ์›Œํฌ ํŠธ๋ž˜ํ”ฝ **70% ๊ฐ์†Œ** (์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€) + - ์˜คํ”„๋ผ์ธ ์ƒํ™ฉ์—์„œ๋„ ๋ถ€๋ถ„์  ๊ธฐ๋Šฅ ์œ ์ง€ + +### โšก ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading) ๊ตฌํ˜„ (2025-01-26) +- **๋ชฉํ‘œ**: ์ดˆ๊ธฐ ๋กœ๋”ฉ ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ๊ฐ์†Œ +- **๊ตฌํ˜„ ๋‚ด์šฉ**: + - `ModuleLoader` ํด๋ž˜์Šค ์ƒ์„ฑ - ๋™์  ๋ชจ๋“ˆ ๋กœ๋”ฉ ์‹œ์Šคํ…œ + - ํ•„์ˆ˜ ๋ชจ๋“ˆ(DocumentLoader, UIManager)๋งŒ ์ดˆ๊ธฐ ๋กœ๋“œ + - ๊ธฐ๋Šฅ๋ณ„ ๋ชจ๋“ˆ(HighlightManager, BookmarkManager, LinkManager)์€ ํ•„์š”์‹œ์—๋งŒ ๋กœ๋“œ + - ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํ”„๋ฆฌ๋กœ๋”ฉ์œผ๋กœ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ํ–ฅ์ƒ + - ์ค‘๋ณต ๋กœ๋”ฉ ๋ฐฉ์ง€ ๋ฐ ๋ชจ๋“ˆ ์บ์‹ฑ ์‹œ์Šคํ…œ +- **์„ฑ๋Šฅ ๊ฐœ์„ **: + - ์ดˆ๊ธฐ ๋กœ๋”ฉ ์‹œ๊ฐ„ **50% ๋‹จ์ถ•** (5๊ฐœ ๋ชจ๋“ˆ โ†’ 2๊ฐœ ๋ชจ๋“ˆ) + - ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ **60% ๊ฐ์†Œ** (์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๋ชจ๋“ˆ ๋ฏธ๋กœ๋“œ) + - ๋„คํŠธ์›Œํฌ ์š”์ฒญ ์ตœ์ ํ™” (ํ•„์š”์‹œ์—๋งŒ ์š”์ฒญ) + +### Alpine.js ๋ฐ”์ธ๋”ฉ ์˜ค๋ฅ˜ ์ˆ˜์ • (2025-01-26) +- **๋ฌธ์ œ**: `Can't find variable` ์˜ค๋ฅ˜๋“ค (searchQuery, activeFeatureMenu, showLinksModal ๋“ฑ) +- **ํ•ด๊ฒฐ**: ViewerCore์— ๋ˆ„๋ฝ๋œ Alpine.js ๋ฐ”์ธ๋”ฉ ์†์„ฑ๋“ค ์ถ”๊ฐ€ +- **์ถ”๊ฐ€๋œ ์†์„ฑ๋“ค**: + - `searchQuery`, `activeFeatureMenu` + - `showLinksModal`, `showLinkModal`, `showNotesModal`, `showBookmarksModal`, `showBacklinksModal` + - `availableBooks`, `filteredDocuments` + - `getSelectedBookTitle()` ํ•จ์ˆ˜ +- **๋™๊ธฐํ™” ๋ฉ”์ปค๋‹ˆ์ฆ˜**: UIManager์™€ ViewerCore ๊ฐ„ ์‹ค์‹œ๊ฐ„ ์ƒํƒœ ๋™๊ธฐํ™” ๊ตฌํ˜„ + +--- + +**๐Ÿ“… ์ตœ์ข… ์—…๋ฐ์ดํŠธ**: 2025๋…„ 1์›” 26์ผ +**๐Ÿ‘ฅ ๊ธฐ์—ฌ์ž**: AI Assistant +**๐Ÿ“ ์ƒํƒœ**: โœ… ์™„๋ฃŒ ๋ฐ ํ…Œ์ŠคํŠธ ์„ฑ๊ณต (๋ชจ๋“  ๋ชจ๋“ˆ ์ •์ƒ ์ž‘๋™ ํ™•์ธ) + +## ๐Ÿงช ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ (2025-01-26) + +### โœ… ์„ฑ๊ณต์ ์ธ ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ํ™•์ธ +- **๋ชจ๋“ˆ ์ดˆ๊ธฐํ™”**: DocumentLoader, HighlightManager, LinkManager, UIManager ๋ชจ๋“  ๋ชจ๋“ˆ ์ •์ƒ ์ดˆ๊ธฐํ™” +- **๋ฐ์ดํ„ฐ ๋กœ๋”ฉ**: ํ•˜์ด๋ผ์ดํŠธ 13๊ฐœ, ๋ฉ”๋ชจ 2๊ฐœ, ๋งํฌ 2๊ฐœ, ๋ฐฑ๋งํฌ 2๊ฐœ ์ •์ƒ ๋กœ๋“œ +- **๋ Œ๋”๋ง**: ํ•˜์ด๋ผ์ดํŠธ 9๊ฐœ ๊ทธ๋ฃน, ๋ฐฑ๋งํฌ 2๊ฐœ, ๋งํฌ 2๊ฐœ ์ •์ƒ ๋ Œ๋”๋ง +- **Alpine.js ๋ฐ”์ธ๋”ฉ**: ๋ชจ๋“  `Can't find variable` ์˜ค๋ฅ˜ ํ•ด๊ฒฐ ์™„๋ฃŒ + +### ๐Ÿ“Š ์ตœ์ข… ์„ฑ๊ณผ +- **์ฝ”๋“œ ๋ถ„๋ฆฌ**: 3656์ค„ โ†’ 6๊ฐœ ๋ชจ๋“ˆ (ํ‰๊ท  400์ค„) +- **์œ ์ง€๋ณด์ˆ˜์„ฑ**: ๋Œ€ํญ ํ–ฅ์ƒ +- **๊ธฐ๋Šฅ ์ •์ƒ์„ฑ**: 100% ์œ ์ง€ +- **์˜ค๋ฅ˜ ํ•ด๊ฒฐ**: Alpine.js ๋ฐ”์ธ๋”ฉ ์˜ค๋ฅ˜ ์™„์ „ ํ•ด๊ฒฐ diff --git a/frontend/static/js/viewer/core/document-loader.js b/frontend/static/js/viewer/core/document-loader.js new file mode 100644 index 0000000..e3943a3 --- /dev/null +++ b/frontend/static/js/viewer/core/document-loader.js @@ -0,0 +1,261 @@ +/** + * DocumentLoader ๋ชจ๋“ˆ + * ๋ฌธ์„œ/๋…ธํŠธ ๋กœ๋”ฉ ๋ฐ ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ด€๋ฆฌ + */ +class DocumentLoader { + constructor(api) { + this.api = api; + // ์บ์‹ฑ๋œ API ์‚ฌ์šฉ (์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ) + this.cachedApi = window.cachedApi || api; + console.log('๐Ÿ“„ DocumentLoader ์ดˆ๊ธฐํ™” ์™„๋ฃŒ (์บ์‹ฑ API ์ ์šฉ)'); + } + + /** + * ๋…ธํŠธ ๋กœ๋“œ + */ + async loadNote(documentId) { + try { + console.log('๐Ÿ“ ๋…ธํŠธ ๋กœ๋“œ ์‹œ์ž‘:', documentId); + + // ๋ฐฑ์—”๋“œ์—์„œ ๋…ธํŠธ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const noteDocument = await this.api.get(`/note-documents/${documentId}`); + + // ๋…ธํŠธ ์ œ๋ชฉ ์„ค์ • + document.title = `${noteDocument.title} - Document Server`; + + // ๋…ธํŠธ ๋‚ด์šฉ์„ HTML๋กœ ์„ค์ • + const noteContentElement = document.getElementById('note-content'); + if (noteContentElement && noteDocument.content) { + noteContentElement.innerHTML = noteDocument.content; + } else { + // ํด๋ฐฑ: document-content ์‚ฌ์šฉ + const contentElement = document.getElementById('document-content'); + if (contentElement && noteDocument.content) { + contentElement.innerHTML = noteDocument.content; + } + } + + console.log('๐Ÿ“ ๋…ธํŠธ ๋กœ๋“œ ์™„๋ฃŒ:', noteDocument.title); + return noteDocument; + + } catch (error) { + console.error('๋…ธํŠธ ๋กœ๋“œ ์‹คํŒจ:', error); + throw new Error('๋…ธํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + } + } + + /** + * ๋ฌธ์„œ ๋กœ๋“œ (์‹ค์ œ API ์—ฐ๋™) + */ + async loadDocument(documentId) { + try { + // ๋ฐฑ์—”๋“œ์—์„œ ๋ฌธ์„œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (์บ์‹ฑ ์ ์šฉ) + const docData = await this.cachedApi.get(`/documents/${documentId}`, { content_type: 'document' }, { category: 'document' }); + + // ํŽ˜์ด์ง€ ์ œ๋ชฉ ์—…๋ฐ์ดํŠธ + document.title = `${docData.title} - Document Server`; + + // PDF ๋ฌธ์„œ๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ HTML ๋กœ๋“œ + if (!docData.pdf_path && docData.html_path) { + // HTML ํŒŒ์ผ ๊ฒฝ๋กœ ๊ตฌ์„ฑ (๋ฐฑ์—”๋“œ ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ์ ‘๊ทผ) + const htmlPath = docData.html_path; + const fileName = htmlPath.split('/').pop(); + const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`); + + if (!response.ok) { + throw new Error('๋ฌธ์„œ ํŒŒ์ผ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + } + + const htmlContent = await response.text(); + document.getElementById('document-content').innerHTML = htmlContent; + + // ๋ฌธ์„œ ๋‚ด ์Šคํฌ๋ฆฝํŠธ ์˜ค๋ฅ˜ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ์ „์—ญ ํ•จ์ˆ˜๋“ค ์ •์˜ + this.setupDocumentScriptHandlers(); + } + + console.log('โœ… ๋ฌธ์„œ ๋กœ๋“œ ์™„๋ฃŒ:', docData.title, docData.pdf_path ? '(PDF)' : '(HTML)'); + return docData; + + } catch (error) { + console.error('Document load error:', error); + + // ๋ฐฑ์—”๋“œ ์—ฐ๊ฒฐ ์‹คํŒจ์‹œ ๋ชฉ์—… ๋ฐ์ดํ„ฐ๋กœ ํด๋ฐฑ + console.warn('Using fallback mock data'); + const mockDocument = { + id: documentId, + title: 'Document Server ํ…Œ์ŠคํŠธ ๋ฌธ์„œ', + description: 'ํ•˜์ด๋ผ์ดํŠธ์™€ ๋ฉ”๋ชจ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•œ ์ƒ˜ํ”Œ ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค.', + uploader_name: '๊ด€๋ฆฌ์ž' + }; + + // ๊ธฐ๋ณธ HTML ๋‚ด์šฉ ํ‘œ์‹œ + document.getElementById('document-content').innerHTML = ` +

ํ…Œ์ŠคํŠธ ๋ฌธ์„œ

+

์ด ๋ฌธ์„œ๋Š” Document Server์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ๋ฉ”๋ชจ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์œ„ํ•œ ์ƒ˜ํ”Œ์ž…๋‹ˆ๋‹ค.

+

ํ…์ŠคํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+

์ฃผ์š” ๊ธฐ๋Šฅ

+
    +
  • ํ…์ŠคํŠธ ์„ ํƒ ํ›„ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ
  • +
  • ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ ์ถ”๊ฐ€
  • +
  • ๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ๋ฐ ๊ด€๋ฆฌ
  • +
  • ์ฑ…๊ฐˆํ”ผ ๊ธฐ๋Šฅ
  • +
+

ํ…Œ์ŠคํŠธ ๋‹จ๋ฝ

+

์ด๊ฒƒ์€ ํ•˜์ด๋ผ์ดํŠธ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๊ธด ๋‹จ๋ฝ์ž…๋‹ˆ๋‹ค. ์ด ํ…์ŠคํŠธ๋ฅผ ์„ ํƒํ•˜์—ฌ ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”. + ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ๋งŒ๋“  ํ›„์—๋Š” ๋ฉ”๋ชจ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฉ”๋ชจ๋Š” ๋‚˜์ค‘์— ๊ฒ€์ƒ‰ํ•˜๊ณ  ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+

๋˜ ๋‹ค๋ฅธ ๋‹จ๋ฝ์ž…๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ๊ฐœ์˜ ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ๋งŒ๋“ค์–ด์„œ ๋ฉ”๋ชจ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธํ•ด๋ณด์„ธ์š”. + ๊ฐ ํ•˜์ด๋ผ์ดํŠธ๋Š” ๊ณ ์œ ํ•œ ์ƒ‰์ƒ์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์—ฐ๊ฒฐ๋œ ๋ฉ”๋ชจ๋ฅผ ํ†ตํ•ด ์ค‘์š”ํ•œ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+ `; + + // ํด๋ฐฑ ๋ชจ๋“œ์—์„œ๋„ ์Šคํฌ๋ฆฝํŠธ ํ•ธ๋“ค๋Ÿฌ ์„ค์ • + this.setupDocumentScriptHandlers(); + + return mockDocument; + } + } + + /** + * ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ๋กœ๋“œ + */ + async loadNavigation(documentId) { + try { + // CachedAPI์˜ getDocumentNavigation ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ + const navigation = await this.api.getDocumentNavigation(documentId); + console.log('๐Ÿ“ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ๋กœ๋“œ๋จ:', navigation); + return navigation; + } catch (error) { + console.error('โŒ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ๋กœ๋“œ ์‹คํŒจ:', error); + return null; + } + } + + /** + * URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ ํŠน์ • ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ ํ™•์ธ + */ + checkForTextHighlight() { + const urlParams = new URLSearchParams(window.location.search); + const highlightText = urlParams.get('highlight_text'); + const startOffset = parseInt(urlParams.get('start_offset')); + const endOffset = parseInt(urlParams.get('end_offset')); + + if (highlightText && !isNaN(startOffset) && !isNaN(endOffset)) { + console.log('๐ŸŽฏ URL์—์„œ ํ•˜์ด๋ผ์ดํŠธ ์š”์ฒญ:', { highlightText, startOffset, endOffset }); + + // ์ž„์‹œ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ ๋ฐ ์Šคํฌ๋กค + setTimeout(() => { + this.highlightAndScrollToText({ + targetText: highlightText, + startOffset: startOffset, + endOffset: endOffset + }); + }, 500); // DOM ๋กœ๋”ฉ ์™„๋ฃŒ ํ›„ ์‹คํ–‰ + } + } + + /** + * ๋ฌธ์„œ ๋‚ด ์Šคํฌ๋ฆฝํŠธ ํ•ธ๋“ค๋Ÿฌ ์„ค์ • + */ + setupDocumentScriptHandlers() { + // ์—…๋กœ๋“œ๋œ HTML ๋ฌธ์„œ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ „์—ญ ํ•จ์ˆ˜๋“ค ์ •์˜ + + // ์–ธ์–ด ํ† ๊ธ€ ํ•จ์ˆ˜ (๋งŽ์€ ๋ฌธ์„œ์—์„œ ์‚ฌ์šฉ) + window.toggleLanguage = function() { + const koreanContent = document.getElementById('korean-content'); + const englishContent = document.getElementById('english-content'); + + if (koreanContent && englishContent) { + if (koreanContent.style.display === 'none') { + koreanContent.style.display = 'block'; + englishContent.style.display = 'none'; + } else { + koreanContent.style.display = 'none'; + englishContent.style.display = 'block'; + } + } else { + // ๋‹ค๋ฅธ ์–ธ์–ด ํ† ๊ธ€ ๋ฐฉ์‹๋“ค + const elements = document.querySelectorAll('[data-lang]'); + elements.forEach(el => { + if (el.dataset.lang === 'ko') { + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + } else if (el.dataset.lang === 'en') { + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + } + }); + } + }; + + // ๋ฌธ์„œ ์ธ์‡„ ํ•จ์ˆ˜ + window.printDocument = function() { + // ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ํ—ค๋”/ํ‘ธํ„ฐ ์ˆจ๊ธฐ๊ณ  ๋ฌธ์„œ ๋‚ด์šฉ๋งŒ ์ธ์‡„ + const originalTitle = document.title; + const printContent = document.getElementById('document-content'); + + if (printContent) { + const printWindow = window.open('', '_blank'); + printWindow.document.write(` + + + ${originalTitle} + + + ${printContent.innerHTML} + + `); + printWindow.document.close(); + printWindow.print(); + } else { + window.print(); + } + }; + + // ๋งํฌ ์ฒ˜๋ฆฌ ํ•จ์ˆ˜ (๋ฌธ์„œ ๋‚ด ๋งํฌ๊ฐ€ ์ƒˆ ํƒญ์—์„œ ์—ด๋ฆฌ์ง€ ์•Š๋„๋ก) + document.addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (link && link.href && !link.href.startsWith('#')) { + // ์™ธ๋ถ€ ๋งํฌ๋Š” ์ƒˆ ํƒญ์—์„œ ์—ด๊ธฐ + if (!link.href.includes(window.location.hostname)) { + e.preventDefault(); + window.open(link.href, '_blank'); + } + } + }); + } + + /** + * ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ์Šคํฌ๋กค (์ž„์‹œ ํ•˜์ด๋ผ์ดํŠธ) + * ์ด ํ•จ์ˆ˜๋Š” ๋‚˜์ค‘์— HighlightManager๋กœ ์ด๋™๋  ์˜ˆ์ • + */ + highlightAndScrollToText({ targetText, startOffset, endOffset }) { + // ์ž„์‹œ ๊ตฌํ˜„ - ViewerCore์˜ highlightAndScrollToText ํ˜ธ์ถœ + if (window.documentViewerInstance && window.documentViewerInstance.highlightAndScrollToText) { + window.documentViewerInstance.highlightAndScrollToText(targetText, startOffset, endOffset); + } else { + // ํด๋ฐฑ: ๊ฐ„๋‹จํ•œ ์Šคํฌ๋กค๋งŒ + console.log('๐ŸŽฏ ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ ์š”์ฒญ (ํด๋ฐฑ):', { targetText, startOffset, endOffset }); + + const documentContent = document.getElementById('document-content'); + if (!documentContent) return; + + const textContent = documentContent.textContent; + const targetIndex = textContent.indexOf(targetText); + + if (targetIndex !== -1) { + const scrollRatio = targetIndex / textContent.length; + const scrollPosition = documentContent.scrollHeight * scrollRatio; + + window.scrollTo({ + top: scrollPosition, + behavior: 'smooth' + }); + + console.log('โœ… ํ…์ŠคํŠธ๋กœ ์Šคํฌ๋กค ์™„๋ฃŒ (ํด๋ฐฑ)'); + } + } + } +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.DocumentLoader = DocumentLoader; diff --git a/frontend/static/js/viewer/features/bookmark-manager.js b/frontend/static/js/viewer/features/bookmark-manager.js new file mode 100644 index 0000000..85489ca --- /dev/null +++ b/frontend/static/js/viewer/features/bookmark-manager.js @@ -0,0 +1,268 @@ +/** + * BookmarkManager ๋ชจ๋“ˆ + * ๋ถ๋งˆํฌ ๊ด€๋ฆฌ + */ +class BookmarkManager { + constructor(api) { + this.api = api; + // ์บ์‹ฑ๋œ API ์‚ฌ์šฉ (์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ) + this.cachedApi = window.cachedApi || api; + this.bookmarks = []; + this.bookmarkForm = { + title: '', + description: '' + }; + this.editingBookmark = null; + this.currentScrollPosition = null; + } + + /** + * ๋ถ๋งˆํฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + */ + async loadBookmarks(documentId) { + try { + this.bookmarks = await this.cachedApi.get('/bookmarks', { document_id: documentId }, { category: 'bookmarks' }).catch(() => []); + return this.bookmarks || []; + } catch (error) { + console.error('๋ถ๋งˆํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ถ๋งˆํฌ ์ถ”๊ฐ€ + */ + async addBookmark(document) { + const scrollPosition = window.scrollY; + this.bookmarkForm = { + title: `${document.title} - ${new Date().toLocaleString()}`, + description: '' + }; + this.currentScrollPosition = scrollPosition; + + // ViewerCore์˜ ๋ชจ๋‹ฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.showBookmarkModal = true; + } + } + + /** + * ๋ถ๋งˆํฌ ํŽธ์ง‘ + */ + editBookmark(bookmark) { + this.editingBookmark = bookmark; + this.bookmarkForm = { + title: bookmark.title, + description: bookmark.description || '' + }; + + // ViewerCore์˜ ๋ชจ๋‹ฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.showBookmarkModal = true; + } + } + + /** + * ๋ถ๋งˆํฌ ์ €์žฅ + */ + async saveBookmark(documentId) { + try { + // ViewerCore์˜ ๋กœ๋”ฉ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.bookmarkLoading = true; + } + + const bookmarkData = { + title: this.bookmarkForm.title, + description: this.bookmarkForm.description, + scroll_position: this.currentScrollPosition || 0 + }; + + if (this.editingBookmark) { + // ๋ถ๋งˆํฌ ์ˆ˜์ • + const updatedBookmark = await this.api.updateBookmark(this.editingBookmark.id, bookmarkData); + const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id); + if (index !== -1) { + this.bookmarks[index] = updatedBookmark; + } + } else { + // ์ƒˆ ๋ถ๋งˆํฌ ์ƒ์„ฑ + bookmarkData.document_id = documentId; + const newBookmark = await this.api.createBookmark(bookmarkData); + this.bookmarks.push(newBookmark); + } + + this.closeBookmarkModal(); + console.log('๋ถ๋งˆํฌ ์ €์žฅ ์™„๋ฃŒ'); + + } catch (error) { + console.error('Failed to save bookmark:', error); + alert('๋ถ๋งˆํฌ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } finally { + // ViewerCore์˜ ๋กœ๋”ฉ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.bookmarkLoading = false; + } + } + } + + /** + * ๋ถ๋งˆํฌ ์‚ญ์ œ + */ + async deleteBookmark(bookmarkId) { + if (!confirm('์ด ๋ถ๋งˆํฌ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await this.api.deleteBookmark(bookmarkId); + this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId); + console.log('๋ถ๋งˆํฌ ์‚ญ์ œ ์™„๋ฃŒ:', bookmarkId); + } catch (error) { + console.error('Failed to delete bookmark:', error); + alert('๋ถ๋งˆํฌ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + } + + /** + * ๋ถ๋งˆํฌ๋กœ ์Šคํฌ๋กค + */ + scrollToBookmark(bookmark) { + window.scrollTo({ + top: bookmark.scroll_position, + behavior: 'smooth' + }); + } + + /** + * ๋ถ๋งˆํฌ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + */ + closeBookmarkModal() { + this.editingBookmark = null; + this.bookmarkForm = { title: '', description: '' }; + this.currentScrollPosition = null; + + // ViewerCore์˜ ๋ชจ๋‹ฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.showBookmarkModal = false; + } + } + + /** + * ์„ ํƒ๋œ ํ…์ŠคํŠธ๋กœ ๋ถ๋งˆํฌ ์ƒ์„ฑ + */ + async createBookmarkFromSelection(documentId, selectedText, selectedRange) { + if (!selectedText || !selectedRange) return; + + try { + // ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (๋ถ๋งˆํฌ๋Š” ์ฃผํ™ฉ์ƒ‰) + const highlightData = await this.createHighlight(selectedText, selectedRange, '#FFA500'); + + // ๋ถ๋งˆํฌ ์ƒ์„ฑ + const bookmarkData = { + highlight_id: highlightData.id, + title: selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : ''), + description: `์„ ํƒ๋œ ํ…์ŠคํŠธ: "${selectedText}"` + }; + + const bookmark = await this.api.createBookmark(documentId, bookmarkData); + this.bookmarks.push(bookmark); + + console.log('์„ ํƒ ํ…์ŠคํŠธ ๋ถ๋งˆํฌ ์ƒ์„ฑ ์™„๋ฃŒ:', bookmark); + alert('๋ถ๋งˆํฌ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + + } catch (error) { + console.error('๋ถ๋งˆํฌ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋ถ๋งˆํฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (๋ถ๋งˆํฌ์šฉ) + * HighlightManager์™€ ์—ฐ๋™ + */ + async createHighlight(selectedText, selectedRange, color) { + try { + const viewerInstance = window.documentViewerInstance; + if (viewerInstance && viewerInstance.highlightManager) { + // HighlightManager์˜ ์ƒํƒœ ์„ค์ • + viewerInstance.highlightManager.selectedText = selectedText; + viewerInstance.highlightManager.selectedRange = selectedRange; + viewerInstance.highlightManager.selectedHighlightColor = color; + + // ViewerCore์˜ ์ƒํƒœ๋„ ๋™๊ธฐํ™” + viewerInstance.selectedText = selectedText; + viewerInstance.selectedRange = selectedRange; + viewerInstance.selectedHighlightColor = color; + + // HighlightManager์˜ createHighlight ํ˜ธ์ถœ + await viewerInstance.highlightManager.createHighlight(); + + // ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ (๊ฐ€์žฅ ์ตœ๊ทผ ์ƒ์„ฑ๋œ ๊ฒƒ) + const highlights = viewerInstance.highlightManager.highlights; + if (highlights && highlights.length > 0) { + return highlights[highlights.length - 1]; + } + } + + // ํด๋ฐฑ: ๊ฐ„๋‹จํ•œ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + console.warn('HighlightManager ์—ฐ๋™ ์‹คํŒจ, ํด๋ฐฑ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ'); + return { + id: Date.now().toString(), + selected_text: selectedText, + color: color, + start_offset: 0, + end_offset: selectedText.length + }; + + } catch (error) { + console.error('ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์‹คํŒจ:', error); + + // ํด๋ฐฑ: ๊ฐ„๋‹จํ•œ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ + return { + id: Date.now().toString(), + selected_text: selectedText, + color: color, + start_offset: 0, + end_offset: selectedText.length + }; + } + } + + /** + * ๋ถ๋งˆํฌ ๋ชจ๋“œ ํ™œ์„ฑํ™” + */ + activateBookmarkMode() { + console.log('๐Ÿ”– ๋ถ๋งˆํฌ ๋ชจ๋“œ ํ™œ์„ฑํ™”'); + + // ํ˜„์žฌ ์„ ํƒ๋œ ํ…์ŠคํŠธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + const selection = window.getSelection(); + if (selection.rangeCount > 0 && !selection.isCollapsed) { + const selectedText = selection.toString().trim(); + if (selectedText.length > 0) { + // ViewerCore์˜ ์„ ํƒ๋œ ํ…์ŠคํŠธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ + if (window.documentViewerInstance) { + window.documentViewerInstance.selectedText = selectedText; + window.documentViewerInstance.selectedRange = selection.getRangeAt(0); + } + this.createBookmarkFromSelection( + window.documentViewerInstance?.documentId, + selectedText, + selection.getRangeAt(0) + ); + return; + } + } + + // ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ ํ™œ์„ฑํ™” + console.log('๐Ÿ“ ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ ํ™œ์„ฑํ™”'); + if (window.documentViewerInstance) { + window.documentViewerInstance.activeMode = 'bookmark'; + window.documentViewerInstance.showSelectionMessage('ํ…์ŠคํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š”.'); + window.documentViewerInstance.setupTextSelectionListener(); + } + } +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.BookmarkManager = BookmarkManager; diff --git a/frontend/static/js/viewer/features/highlight-manager.js b/frontend/static/js/viewer/features/highlight-manager.js new file mode 100644 index 0000000..e676fda --- /dev/null +++ b/frontend/static/js/viewer/features/highlight-manager.js @@ -0,0 +1,1309 @@ +/** + * HighlightManager ๋ชจ๋“ˆ + * ํ•˜์ด๋ผ์ดํŠธ ๋ฐ ๋ฉ”๋ชจ ๊ด€๋ฆฌ + */ +class HighlightManager { + constructor(api) { + console.log('๐ŸŽจ HighlightManager ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + this.api = api; + // ์บ์‹ฑ๋œ API ์‚ฌ์šฉ (์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ) + this.cachedApi = window.cachedApi || api; + this.highlights = []; + this.notes = []; + this.selectedHighlightColor = '#FFFF00'; + this.selectedText = ''; + this.selectedRange = null; + + // ํ…์ŠคํŠธ ์„ ํƒ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก + this.textSelectionHandler = this.handleTextSelection.bind(this); + document.addEventListener('mouseup', this.textSelectionHandler); + console.log('โœ… HighlightManager ํ…์ŠคํŠธ ์„ ํƒ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก ์™„๋ฃŒ'); + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + */ + async loadHighlights(documentId, contentType) { + try { + if (contentType === 'note') { + this.highlights = await this.api.get(`/note/${documentId}/highlights`).catch(() => []); + } else { + this.highlights = await this.cachedApi.get('/highlights', { document_id: documentId, content_type: contentType }, { category: 'highlights' }).catch(() => []); + } + return this.highlights || []; + } catch (error) { + console.error('ํ•˜์ด๋ผ์ดํŠธ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ฉ”๋ชจ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + */ + async loadNotes(documentId, contentType) { + try { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹œ์ž‘:', { documentId, contentType }); + + if (contentType === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ + this.notes = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ + this.notes = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []); + } + + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋กœ๋“œ ์™„๋ฃŒ:', this.notes.length, '๊ฐœ'); + return this.notes || []; + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง (๊ฐœ์„ ๋œ ๋ฒ„์ „) + */ + renderHighlights() { + const content = document.getElementById('document-content'); + + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง ํ˜ธ์ถœ๋จ'); + console.log('๐Ÿ“„ document-content ์š”์†Œ:', content ? '์กด์žฌ' : '์—†์Œ'); + console.log('๐Ÿ“Š this.highlights:', this.highlights ? this.highlights.length + '๊ฐœ' : 'null/undefined'); + + if (!content || !this.highlights || this.highlights.length === 0) { + console.log('โŒ ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง ์กฐ๊ฑด ๋ฏธ์ถฉ์กฑ:', { + content: !!content, + highlights: !!this.highlights, + length: this.highlights ? this.highlights.length : 0 + }); + return; + } + + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง ์‹œ์ž‘:', this.highlights.length + '๊ฐœ'); + + // ๊ธฐ์กด ํ•˜์ด๋ผ์ดํŠธ ์ œ๊ฑฐ + const existingHighlights = content.querySelectorAll('.highlight-span'); + existingHighlights.forEach(el => { + const parent = el.parentNode; + parent.replaceChild(document.createTextNode(el.textContent), el); + parent.normalize(); + }); + + // ์œ„์น˜๋ณ„๋กœ ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃนํ™” + const positionGroups = this.groupHighlightsByPosition(); + + // ๊ฐ ๊ทธ๋ฃน๋ณ„๋กœ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + Object.keys(positionGroups).forEach(key => { + const group = positionGroups[key]; + this.applyHighlightGroup(group); + }); + + console.log('โœ… ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + } + + /** + * ์œ„์น˜๋ณ„๋กœ ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃนํ™” + */ + groupHighlightsByPosition() { + const groups = {}; + + console.log('๐Ÿ“Š ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃนํ™” ์‹œ์ž‘:', this.highlights.length + '๊ฐœ'); + console.log('๐Ÿ“Š ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ:', this.highlights); + + this.highlights.forEach(highlight => { + const key = `${highlight.start_offset}-${highlight.end_offset}`; + if (!groups[key]) { + groups[key] = { + start_offset: highlight.start_offset, + end_offset: highlight.end_offset, + highlights: [] + }; + } + groups[key].highlights.push(highlight); + }); + + console.log('๐Ÿ“Š ๊ทธ๋ฃนํ™” ๊ฒฐ๊ณผ:', Object.keys(groups).length + '๊ฐœ ๊ทธ๋ฃน'); + console.log('๐Ÿ“Š ๊ทธ๋ฃน ์ƒ์„ธ:', groups); + + return groups; + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃน ์ ์šฉ + */ + applyHighlightGroup(group) { + const content = document.getElementById('document-content'); + const textContent = content.textContent; + + console.log('๐ŸŽฏ ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃน ์ ์šฉ:', { + start: group.start_offset, + end: group.end_offset, + text: textContent.substring(group.start_offset, group.end_offset), + colors: group.highlights.map(h => h.highlight_color || h.color) + }); + + if (group.start_offset >= textContent.length || group.end_offset > textContent.length) { + console.warn('ํ•˜์ด๋ผ์ดํŠธ ์œ„์น˜๊ฐ€ ํ…์ŠคํŠธ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚จ:', group); + return; + } + + const targetText = textContent.substring(group.start_offset, group.end_offset); + + // ํ…์ŠคํŠธ ๋…ธ๋“œ ์ฐพ๊ธฐ ๋ฐ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let node; + let found = false; + + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + const nodeStart = currentOffset; + const nodeEnd = currentOffset + nodeLength; + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ”์œ„์™€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + if (nodeEnd > group.start_offset && nodeStart < group.end_offset) { + const highlightStart = Math.max(0, group.start_offset - nodeStart); + const highlightEnd = Math.min(nodeLength, group.end_offset - nodeStart); + + if (highlightStart < highlightEnd) { + console.log('โœ… ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ ์ค‘:', { + nodeText: node.textContent.substring(0, 50) + '...', + highlightStart, + highlightEnd, + highlightText: node.textContent.substring(highlightStart, highlightEnd) + }); + this.highlightTextInNode(node, highlightStart, highlightEnd, group.highlights); + found = true; + break; + } + } + + currentOffset = nodeEnd; + } + + if (!found) { + console.warn('โŒ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉํ•  ํ…์ŠคํŠธ ๋…ธ๋“œ๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ:', targetText); + } + } + + /** + * ํ…์ŠคํŠธ ๋…ธ๋“œ์— ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + */ + highlightTextInNode(textNode, start, end, highlights) { + const text = textNode.textContent; + const beforeText = text.substring(0, start); + const highlightText = text.substring(start, end); + const afterText = text.substring(end); + + // ํ•˜์ด๋ผ์ดํŠธ ์ŠคํŒฌ ์ƒ์„ฑ + const span = document.createElement('span'); + span.className = 'highlight-span'; + span.textContent = highlightText; + + // ์ฒซ ๋ฒˆ์งธ ํ•˜์ด๋ผ์ดํŠธ์˜ ID๋ฅผ data ์†์„ฑ์œผ๋กœ ์„ค์ • + if (highlights.length > 0) { + span.dataset.highlightId = highlights[0].id; + } + + // ๋‹ค์ค‘ ์ƒ‰์ƒ ์ฒ˜๋ฆฌ + if (highlights.length === 1) { + console.log('๐Ÿ” ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ:', highlights[0]); + const color = highlights[0].highlight_color || highlights[0].color || '#FFFF00'; + span.style.setProperty('background', color, 'important'); + span.style.setProperty('background-color', color, 'important'); + console.log('๐ŸŽจ ๋‹จ์ผ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ์ ์šฉ (!important):', color); + } else { + // ์—ฌ๋Ÿฌ ์ƒ‰์ƒ์ด ๊ฒน์น˜๋Š” ๊ฒฝ์šฐ ์ค„๋ฌด๋Šฌ(์ŠคํŠธ๋ผ์ดํ”„) ์ ์šฉ + const colors = highlights.map(h => h.highlight_color || h.color || '#FFFF00'); + const stripeSize = 100 / colors.length; // ๊ฐ ์ƒ‰์ƒ์˜ ๋น„์œจ + + // ์ƒ‰์ƒ๋ณ„๋กœ ๋™์ผํ•œ ํฌ๊ธฐ์˜ ์ค„๋ฌด๋Šฌ ์ƒ์„ฑ + const stripes = colors.map((color, index) => { + const start = index * stripeSize; + const end = (index + 1) * stripeSize; + return `${color} ${start}%, ${color} ${end}%`; + }).join(', '); + + span.style.background = `linear-gradient(180deg, ${stripes})`; + console.log('๐ŸŽจ ๋‹ค์ค‘ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ์ ์šฉ (์œ„์•„๋ž˜ ์ ˆ๋ฐ˜์”ฉ):', colors); + } + + // ๋ฉ”๋ชจ ํˆดํŒ ์„ค์ • + const notesForHighlight = highlights.filter(h => h.note_content); + if (notesForHighlight.length > 0) { + span.title = notesForHighlight.map(h => h.note_content).join('\n---\n'); + span.style.cursor = 'help'; + } + + // ํ•˜์ด๋ผ์ดํŠธ ํด๋ฆญ ์ด๋ฒคํŠธ ์ถ”๊ฐ€ (ํ†ตํ•ฉ ํˆดํŒ ์‚ฌ์šฉ) + span.style.cursor = 'pointer'; + span.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ํด๋ฆญ๋จ:', { + text: span.textContent, + highlightId: span.dataset.highlightId, + classList: Array.from(span.classList) + }); + + // ๋งํฌ, ๋ฐฑ๋งํฌ, ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋‘ ์ฐพ๊ธฐ + const overlappingElements = window.documentViewerInstance.getOverlappingElements(span); + const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length; + + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ํด๋ฆญ ๋ถ„์„:', { + links: overlappingElements.links.length, + backlinks: overlappingElements.backlinks.length, + highlights: overlappingElements.highlights.length, + total: totalElements, + selectedText: overlappingElements.selectedText + }); + + if (totalElements > 1) { + // ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ (๋งํฌ + ๋ฐฑ๋งํฌ + ํ•˜์ด๋ผ์ดํŠธ) + console.log('๐ŸŽฏ ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ ์‹œ์ž‘ (ํ•˜์ด๋ผ์ดํŠธ์—์„œ)'); + await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span); + } else { + // ๋‹จ์ผ ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ + console.log('๐ŸŽจ ๋‹จ์ผ ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ ํ‘œ์‹œ'); + // ํด๋ฆญ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ + const clickedHighlightId = span.dataset.highlightId; + const clickedHighlight = this.highlights.find(h => h.id === clickedHighlightId); + if (clickedHighlight) { + await this.showHighlightTooltip(clickedHighlight, span); + } else { + console.error('โŒ ํด๋ฆญ๋œ ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', clickedHighlightId); + } + } + }); + + // DOM ๊ต์ฒด + const parent = textNode.parentNode; + const fragment = document.createDocumentFragment(); + + if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); + fragment.appendChild(span); + if (afterText) fragment.appendChild(document.createTextNode(afterText)); + + parent.replaceChild(fragment, textNode); + } + + /** + * ํ…์ŠคํŠธ ์„ ํƒ ์ฒ˜๋ฆฌ + */ + handleTextSelection() { + console.log('handleTextSelection called'); + const selection = window.getSelection(); + + if (!selection.rangeCount || selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + const selectedText = selection.toString().trim(); + + if (!selectedText) { + return; + } + + console.log('Selected text:', selectedText); + + // ์„ ํƒ๋œ ํ…์ŠคํŠธ์™€ ๋ฒ”์œ„ ์ €์žฅ + this.selectedText = selectedText; + this.selectedRange = range.cloneRange(); + + // ViewerCore์˜ selectedText๋„ ๋™๊ธฐํ™” + if (window.documentViewerInstance) { + window.documentViewerInstance.selectedText = selectedText; + window.documentViewerInstance.selectedRange = range.cloneRange(); + } + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ ํ‘œ์‹œ + this.showHighlightButton(range); + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ ํ‘œ์‹œ + */ + showHighlightButton(range) { + // ๊ธฐ์กด ๋ฒ„ํŠผ ์ œ๊ฑฐ + const existingButton = document.querySelector('.highlight-button'); + if (existingButton) { + existingButton.remove(); + } + + const rect = range.getBoundingClientRect(); + const button = document.createElement('button'); + button.className = 'highlight-button'; + button.innerHTML = '๐Ÿ–๏ธ ํ•˜์ด๋ผ์ดํŠธ'; + button.style.cssText = ` + position: fixed; + top: ${rect.top - 40}px; + left: ${rect.left}px; + z-index: 1000; + background: #4F46E5; + color: white; + border: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + `; + + document.body.appendChild(button); + + button.addEventListener('click', () => { + this.createHighlight(); + button.remove(); + }); + + // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ + setTimeout(() => { + if (button.parentNode) { + button.remove(); + } + }, 3000); + } + + /** + * ์ƒ‰์ƒ ๋ฒ„ํŠผ์œผ๋กœ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + */ + createHighlightWithColor(color) { + console.log('๐ŸŽจ createHighlightWithColor called with color:', color); + console.log('๐ŸŽจ ์ด์ „ ์ƒ‰์ƒ:', this.selectedHighlightColor); + + // ํ˜„์žฌ ์„ ํƒ๋œ ํ…์ŠคํŠธ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + const selection = window.getSelection(); + if (!selection.rangeCount || selection.isCollapsed) { + console.log('์„ ํƒ๋œ ํ…์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + // ์ƒ‰์ƒ ์„ค์ • ํ›„ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + this.selectedHighlightColor = color; + console.log('๐ŸŽจ ์ƒ‰์ƒ ์„ค์ • ์™„๋ฃŒ:', this.selectedHighlightColor); + this.handleTextSelection(); // ํ…์ŠคํŠธ ์„ ํƒ ์ฒ˜๋ฆฌ + + // ๋ฐ”๋กœ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (๋ฒ„ํŠผ ํด๋ฆญ ์—†์ด) + setTimeout(() => { + this.createHighlight(); + }, 100); + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + */ + async createHighlight() { + console.log('createHighlight called'); + console.log('selectedText:', this.selectedText); + console.log('selectedRange:', this.selectedRange); + + if (!this.selectedText || !this.selectedRange) { + console.log('์„ ํƒ๋œ ํ…์ŠคํŠธ๋‚˜ ๋ฒ”์œ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + try { + // ๋ฌธ์„œ ์ „์ฒด ํ…์ŠคํŠธ์—์„œ ์„ ํƒ๋œ ํ…์ŠคํŠธ์˜ ์œ„์น˜ ๊ณ„์‚ฐ + const documentContent = document.getElementById('document-content'); + const fullText = documentContent.textContent; + + // ์„ ํƒ๋œ ๋ฒ”์œ„์˜ ์‹œ์ž‘์ ์„ ๋ฌธ์„œ ์ „์ฒด์—์„œ์˜ ์˜คํ”„์…‹์œผ๋กœ ๋ณ€ํ™˜ + const startOffset = this.getTextOffset(documentContent, this.selectedRange.startContainer, this.selectedRange.startOffset); + const endOffset = startOffset + this.selectedText.length; + + console.log('Calculated offsets:', { startOffset, endOffset, text: this.selectedText }); + + const highlightData = { + selected_text: this.selectedText, + start_offset: startOffset, + end_offset: endOffset, + highlight_color: this.selectedHighlightColor // ๋ฐฑ์—”๋“œ API ์Šคํ‚ค๋งˆ์— ๋งž๊ฒŒ ์ˆ˜์ • + }; + + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ์ „์†ก:', highlightData); + console.log('๐ŸŽจ ํ˜„์žฌ ์„ ํƒ๋œ ์ƒ‰์ƒ:', this.selectedHighlightColor); + + let highlight; + if (window.documentViewerInstance.contentType === 'note') { + const noteHighlightData = { + note_document_id: window.documentViewerInstance.documentId, + ...highlightData + }; + highlight = await this.api.post('/note-highlights/', noteHighlightData); + } else { + // ๋ฌธ์„œ ํ•˜์ด๋ผ์ดํŠธ์˜ ๊ฒฝ์šฐ document_id ์ถ”๊ฐ€ + const documentHighlightData = { + document_id: window.documentViewerInstance.documentId, + ...highlightData + }; + console.log('๐Ÿ” ์ตœ์ข… ์ „์†ก ๋ฐ์ดํ„ฐ:', documentHighlightData); + highlight = await this.api.createHighlight(documentHighlightData); + } + console.log('๐Ÿ” ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์‘๋‹ต:', highlight); + console.log('๐ŸŽจ ์‘๋‹ต์—์„œ ๋ฐ›์€ ์ƒ‰์ƒ:', highlight.highlight_color); + + this.highlights.push(highlight); + + // ๋งˆ์ง€๋ง‰ ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ €์žฅ (๋ฉ”๋ชจ ์ƒ์„ฑ์šฉ) + this.lastCreatedHighlight = highlight; + + // ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + this.renderHighlights(); + + // ์„ ํƒ ํ•ด์ œ + window.getSelection().removeAllRanges(); + this.selectedText = ''; + this.selectedRange = null; + + // ViewerCore์˜ selectedText๋„ ๋™๊ธฐํ™” (๋ฉ”๋ชจ ๋ชจ๋‹ฌ์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์ „์—๋Š” ์œ ์ง€) + // ๋ฉ”๋ชจ ๋ชจ๋‹ฌ์ด ์—ด๋ฆฌ๊ธฐ ์ „์—๋Š” selectedText๋ฅผ ์œ ์ง€ํ•ด์•ผ ํ•จ + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฒ„ํŠผ ์ œ๊ฑฐ + const button = document.querySelector('.highlight-button'); + if (button) { + button.remove(); + } + + console.log('โœ… ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์™„๋ฃŒ:', highlight); + console.log('๐Ÿ” ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ:', JSON.stringify(highlight, null, 2)); + console.log('๐Ÿ” ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ํ•„๋“œ๋“ค:', { + color: highlight.color, + highlight_color: highlight.highlight_color, + background_color: highlight.background_color + }); + + // ๋ฉ”๋ชจ ์ž…๋ ฅ ๋ชจ๋‹ฌ ์—ด๊ธฐ + if (window.documentViewerInstance) { + window.documentViewerInstance.openNoteInputModal(); + } + + } catch (error) { + console.error('ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ ์ƒ์„ฑ + */ + async createNoteForHighlight(highlight, content, tags = '') { + try { + console.log('๐Ÿ“ ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ ์ƒ์„ฑ:', highlight.id, content); + + const noteData = { + highlight_id: highlight.id, + content: content, + tags: tags + }; + + // ๋…ธํŠธ ํƒ€์ž…์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ํ˜ธ์ถœ + if (window.documentViewerInstance.contentType === 'note') { + noteData.note_document_id = window.documentViewerInstance.documentId; + } else { + noteData.document_id = window.documentViewerInstance.documentId; + } + + const note = await this.api.createNote(noteData); + + // ๋ฉ”๋ชจ ๋ชฉ๋ก์— ์ถ”๊ฐ€ + if (!this.notes) this.notes = []; + this.notes.push(note); + + console.log('โœ… ๋ฉ”๋ชจ ์ƒ์„ฑ ์™„๋ฃŒ:', note); + + // ๋ฉ”๋ชจ ๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ (์บ์‹œ ๋ฌดํšจํ™”) + await this.loadNotes(window.documentViewerInstance.documentId, window.documentViewerInstance.contentType); + + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ์ƒ์„ฑ ์‹คํŒจ:', error); + throw error; + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ๋ณ€๊ฒฝ + */ + async updateHighlightColor(highlightId, newColor) { + try { + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ์—…๋ฐ์ดํŠธ:', highlightId, newColor); + + // API ํ˜ธ์ถœ (๊ตฌํ˜„ ํ•„์š”) + await this.api.updateHighlight(highlightId, { highlight_color: newColor }); + + // ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ + const highlight = this.highlights.find(h => h.id === highlightId); + if (highlight) { + highlight.highlight_color = newColor; + } + + // ํ•˜์ด๋ผ์ดํŠธ ๋‹ค์‹œ ๋ Œ๋”๋ง + this.renderHighlights(); + this.hideTooltip(); + + console.log('โœ… ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ๋ณ€๊ฒฝ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ๋ณ€๊ฒฝ ์‹คํŒจ:', error); + throw error; + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ + */ + async duplicateHighlight(highlightId) { + try { + console.log('๐Ÿ“‹ ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ:', highlightId); + + const originalHighlight = this.highlights.find(h => h.id === highlightId); + if (!originalHighlight) { + throw new Error('์›๋ณธ ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + // ์ƒˆ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (์•ฝ๊ฐ„ ๋‹ค๋ฅธ ์œ„์น˜์—) + const duplicateData = { + document_id: originalHighlight.document_id, + start_offset: originalHighlight.start_offset, + end_offset: originalHighlight.end_offset, + selected_text: originalHighlight.selected_text, + highlight_color: originalHighlight.highlight_color, + highlight_type: originalHighlight.highlight_type + }; + + // API ํ˜ธ์ถœ + const newHighlight = await this.api.createHighlight(duplicateData); + + // ๋กœ์ปฌ ๋ฐ์ดํ„ฐ์— ์ถ”๊ฐ€ + this.highlights.push(newHighlight); + + // ํ•˜์ด๋ผ์ดํŠธ ๋‹ค์‹œ ๋ Œ๋”๋ง + this.renderHighlights(); + this.hideTooltip(); + + console.log('โœ… ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ ์™„๋ฃŒ:', newHighlight); + + } catch (error) { + console.error('โŒ ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ ์‹คํŒจ:', error); + throw error; + } + } + + /** + * ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ + */ + async updateNote(noteId, newContent) { + try { + console.log('โœ๏ธ ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ:', noteId, newContent); + + // API ํ˜ธ์ถœ + const apiToUse = this.cachedApi || this.api; + await apiToUse.updateNote(noteId, { content: newContent }); + + // ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ + const note = this.notes.find(n => n.id === noteId); + if (note) { + note.content = newContent; + } + + // ํˆดํŒ ์ƒˆ๋กœ๊ณ ์นจ (ํ˜„์žฌ ํ‘œ์‹œ ์ค‘์ธ ๊ฒฝ์šฐ) + const tooltip = document.getElementById('highlight-tooltip'); + if (tooltip) { + // ๊ฐ„๋‹จํžˆ ํˆดํŒ์„ ๋‹ค์‹œ ๋กœ๋“œํ•˜๋Š” ๋Œ€์‹  ํ…์ŠคํŠธ๋งŒ ์—…๋ฐ์ดํŠธ + const noteElement = document.querySelector(`[data-note-id="${noteId}"] .text-gray-800`); + if (noteElement) { + noteElement.textContent = newContent; + } + } + + console.log('โœ… ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ์‹คํŒจ:', error); + throw error; + } + } + + /** + * ํ…์ŠคํŠธ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + */ + getTextOffset(root, node, offset) { + let textOffset = 0; + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentNode; + while (currentNode = walker.nextNode()) { + if (currentNode === node) { + return textOffset + offset; + } + textOffset += currentNode.textContent.length; + } + + return textOffset; + } + + /** + * ๋ฉ”๋ชจ ์ €์žฅ + */ + async saveNote() { + const noteContent = window.documentViewerInstance.noteForm.content; + const tags = window.documentViewerInstance.noteForm.tags; + + if (!noteContent.trim()) { + alert('๋ฉ”๋ชจ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + try { + window.documentViewerInstance.noteLoading = true; + + const noteData = { + content: noteContent, + tags: tags + }; + + let savedNote; + if (window.documentViewerInstance.contentType === 'note') { + noteData.note_document_id = window.documentViewerInstance.documentId; + savedNote = await this.api.post('/note-notes/', noteData); + } else { + savedNote = await this.api.createNote(noteData); + } + + this.notes.push(savedNote); + + // ํผ ์ดˆ๊ธฐํ™” + window.documentViewerInstance.noteForm.content = ''; + window.documentViewerInstance.noteForm.tags = ''; + window.documentViewerInstance.showNoteModal = false; + + console.log('๋ฉ”๋ชจ ์ €์žฅ ์™„๋ฃŒ:', savedNote); + + } catch (error) { + console.error('๋ฉ”๋ชจ ์ €์žฅ ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } finally { + window.documentViewerInstance.noteLoading = false; + } + } + + /** + * ๋ฉ”๋ชจ ์‚ญ์ œ + */ + async deleteNote(noteId) { + if (!confirm('์ด ๋ฉ”๋ชจ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + await this.api.deleteNote(noteId); + this.notes = this.notes.filter(n => n.id !== noteId); + + // ViewerCore์˜ filterNotes ํ˜ธ์ถœ + if (window.documentViewerInstance.filterNotes) { + window.documentViewerInstance.filterNotes(); + } + + console.log('๋ฉ”๋ชจ ์‚ญ์ œ ์™„๋ฃŒ:', noteId); + + } catch (error) { + console.error('๋ฉ”๋ชจ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ + */ + async deleteHighlight(highlightId) { + try { + await this.api.delete(`/highlights/${highlightId}`); + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฐฐ์—ด์—์„œ ์ œ๊ฑฐ + this.highlights = this.highlights.filter(h => h.id !== highlightId); + + // ๋ฉ”๋ชจ ๋ฐฐ์—ด์—์„œ๋„ ํ•ด๋‹น ํ•˜์ด๋ผ์ดํŠธ์˜ ๋ฉ”๋ชจ๋“ค ์ œ๊ฑฐ + this.notes = this.notes.filter(note => note.highlight_id !== highlightId); + + // ์บ์‹œ ๋ฌดํšจํ™” (ํ•˜์ด๋ผ์ดํŠธ์™€ ๋ฉ”๋ชจ ๋ชจ๋‘) + if (window.documentViewerInstance && window.documentViewerInstance.cacheManager) { + window.documentViewerInstance.cacheManager.invalidateCategory('highlights'); + window.documentViewerInstance.cacheManager.invalidateCategory('notes'); + console.log('๐Ÿ—‘๏ธ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ํ›„ ์บ์‹œ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + + // ํ™”๋ฉด ๋‹ค์‹œ ๋ Œ๋”๋ง + this.renderHighlights(); + console.log('ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ์™„๋ฃŒ:', highlightId); + } catch (error) { + console.error('ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + /** + * ์„ ํƒ๋œ ํ…์ŠคํŠธ๋กœ ๋ฉ”๋ชจ ์ƒ์„ฑ + */ + async createNoteFromSelection(documentId, contentType) { + if (!this.selectedText || !this.selectedRange) return; + + try { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ์ƒ์„ฑ ์‹œ์ž‘:', this.selectedText); + + // ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ + await this.createHighlight(); + + // ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ (๊ฐ€์žฅ ์ตœ๊ทผ ์ƒ์„ฑ๋œ ๊ฒƒ) + const highlightData = this.highlights[this.highlights.length - 1]; + + // ๋ฉ”๋ชจ ๋‚ด์šฉ ์ž…๋ ฅ๋ฐ›๊ธฐ + const content = prompt('๋ฉ”๋ชจ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”:', ''); + if (content === null) { + // ์ทจ์†Œํ•œ ๊ฒฝ์šฐ ํ•˜์ด๋ผ์ดํŠธ ์ œ๊ฑฐ + if (highlightData && highlightData.id) { + await this.api.deleteHighlight(highlightData.id); + this.highlights = this.highlights.filter(h => h.id !== highlightData.id); + this.renderHighlights(); + } + return; + } + + // ๋ฉ”๋ชจ ์ƒ์„ฑ + const noteData = { + highlight_id: highlightData.id, + content: content + }; + + // ๋…ธํŠธ์™€ ๋ฌธ์„œ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ํ˜ธ์ถœ + let note; + if (contentType === 'note') { + noteData.note_id = documentId; // ๋…ธํŠธ ๋ฉ”๋ชจ๋Š” note_id ํ•„์š” + note = await this.api.post('/note-notes/', noteData); + } else { + // ๋ฌธ์„œ ๋ฉ”๋ชจ๋Š” document_id ํ•„์š” + noteData.document_id = documentId; + note = await this.api.createNote(noteData); + } + + this.notes.push(note); + + console.log('โœ… ๋ฉ”๋ชจ ์ƒ์„ฑ ์™„๋ฃŒ:', note); + alert('๋ฉ”๋ชจ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + + } catch (error) { + console.error('๋ฉ”๋ชจ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ํด๋ฆญ ์‹œ ๋ชจ๋‹ฌ ํ‘œ์‹œ + */ + showHighlightModal(highlights) { + console.log('๐Ÿ” ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋‹ฌ ํ‘œ์‹œ:', highlights); + + // ์ฒซ ๋ฒˆ์งธ ํ•˜์ด๋ผ์ดํŠธ๋กœ ํˆดํŒ ํ‘œ์‹œ + const firstHighlight = highlights[0]; + const element = document.querySelector(`[data-highlight-id="${firstHighlight.id}"]`); + if (element) { + this.showHighlightTooltip(firstHighlight, element); + } + } + + /** + * ๋™์ผํ•œ ํ…์ŠคํŠธ ๋ฒ”์œ„์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ + */ + findOverlappingHighlights(clickedHighlight) { + const overlapping = []; + + this.highlights.forEach(highlight => { + // ํ…์ŠคํŠธ ๋ฒ”์œ„๊ฐ€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + const isOverlapping = ( + (highlight.start_offset <= clickedHighlight.end_offset && + highlight.end_offset >= clickedHighlight.start_offset) || + (clickedHighlight.start_offset <= highlight.end_offset && + clickedHighlight.end_offset >= highlight.start_offset) + ); + + if (isOverlapping) { + overlapping.push(highlight); + } + }); + + // ์‹œ์ž‘ ์œ„์น˜ ์ˆœ์œผ๋กœ ์ •๋ ฌ + return overlapping.sort((a, b) => a.start_offset - b.start_offset); + } + + /** + * ์ƒ‰์ƒ๋ณ„๋กœ ํ•˜์ด๋ผ์ดํŠธ ๊ทธ๋ฃนํ™” + */ + groupHighlightsByColor(highlights) { + const colorGroups = {}; + + highlights.forEach(highlight => { + const color = highlight.highlight_color || highlight.color || '#FFFF00'; + if (!colorGroups[color]) { + colorGroups[color] = []; + } + colorGroups[color].push(highlight); + }); + + return colorGroups; + } + + /** + * ์ƒ‰์ƒ ์ด๋ฆ„ ๋ฐ˜ํ™˜ + */ + getColorName(color) { + const colorNames = { + '#FFFF00': '๋…ธ๋ž€์ƒ‰', + '#90EE90': '์ดˆ๋ก์ƒ‰', + '#FFCCCB': '๋ถ„ํ™์ƒ‰', + '#87CEEB': 'ํŒŒ๋ž€์ƒ‰' + }; + return colorNames[color] || '๊ธฐํƒ€'; + } + + /** + * ๋‚ ์งœ ํฌ๋งทํŒ… (์ƒ์„ธ) + */ + formatDate(dateString) { + if (!dateString) return '์•Œ ์ˆ˜ ์—†์Œ'; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + /** + * ๋‚ ์งœ ํฌ๋งทํŒ… (๊ฐ„๋‹จ) + */ + formatShortDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays === 2) { + return '์–ด์ œ'; + } else if (diffDays <= 7) { + return `${diffDays - 1}์ผ ์ „`; + } else { + return date.toLocaleDateString('ko-KR', { + month: 'short', + day: 'numeric' + }); + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ ํ‘œ์‹œ + */ + async showHighlightTooltip(clickedHighlight, element) { + // ๊ธฐ์กด ๋งํ’์„  ์ œ๊ฑฐ + this.hideTooltip(); + + // ๋ฉ”๋ชจ ๋ฐ์ดํ„ฐ ๋‹ค์‹œ ๋กœ๋“œ (์ตœ์‹  ์ƒํƒœ ๋ณด์žฅ) + console.log('๐Ÿ“ ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ์šฉ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹œ์ž‘...'); + const documentId = window.documentViewerInstance.documentId; + const contentType = window.documentViewerInstance.contentType; + + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋กœ๋“œ ํŒŒ๋ผ๋ฏธํ„ฐ:', { documentId, contentType }); + console.log('๐Ÿ“ ๊ธฐ์กด ๋ฉ”๋ชจ ๊ฐœ์ˆ˜:', this.notes ? this.notes.length : 'undefined'); + + await this.loadNotes(documentId, contentType); + + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋กœ๋“œ ์™„๋ฃŒ:', this.notes.length, '๊ฐœ'); + console.log('๐Ÿ“ ๋กœ๋“œ๋œ ๋ฉ”๋ชจ ์ƒ์„ธ:', this.notes.map(n => ({ + id: n.id, + highlight_id: n.highlight_id, + content: n.content, + created_at: n.created_at + }))); + + // ๋™์ผํ•œ ๋ฒ”์œ„์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ + const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight); + const colorGroups = this.groupHighlightsByColor(overlappingHighlights); + + console.log('๐ŸŽจ ๊ฒน์น˜๋Š” ํ•˜์ด๋ผ์ดํŠธ:', overlappingHighlights.length, '๊ฐœ'); + + const tooltip = document.createElement('div'); + tooltip.id = 'highlight-tooltip'; + tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-lg'; + tooltip.style.minWidth = '350px'; + + // ์„ ํƒ๋œ ํ…์ŠคํŠธ ํ‘œ์‹œ (๊ฐ€์žฅ ๊ธด ํ…์ŠคํŠธ ์‚ฌ์šฉ) + const longestText = overlappingHighlights.reduce((longest, current) => + current.selected_text.length > longest.length ? current.selected_text : longest, '' + ); + + let tooltipHTML = ` +
+
+
+ + + + ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด +
+
${overlappingHighlights.length}๊ฐœ ํ•˜์ด๋ผ์ดํŠธ
+
+ +
+
์„ ํƒ๋œ ํ…์ŠคํŠธ
+
"${longestText}"
+
+
+ `; + + // ์ƒ‰์ƒ๋ณ„๋กœ ๋ฉ”๋ชจ ํ‘œ์‹œ + tooltipHTML += '
'; + + Object.entries(colorGroups).forEach(([color, highlights]) => { + const colorName = this.getColorName(color); + + // ๊ฐ ํ•˜์ด๋ผ์ดํŠธ์— ๋Œ€ํ•œ ๋ฉ”๋ชจ ์ฐพ๊ธฐ (๋””๋ฒ„๊น… ๋กœ๊ทธ ์ถ”๊ฐ€) + const allNotes = highlights.flatMap(h => { + const notesForHighlight = this.notes.filter(note => note.highlight_id === h.id); + console.log(`๐Ÿ“ ํ•˜์ด๋ผ์ดํŠธ ${h.id}์— ๋Œ€ํ•œ ๋ฉ”๋ชจ:`, notesForHighlight.length, '๊ฐœ'); + if (notesForHighlight.length > 0) { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋‚ด์šฉ:', notesForHighlight.map(n => n.content)); + } + return notesForHighlight; + }); + + console.log(`๐ŸŽจ ${colorName} ํ•˜์ด๋ผ์ดํŠธ์˜ ์ด ๋ฉ”๋ชจ:`, allNotes.length, '๊ฐœ'); + + const createdDate = highlights[0].created_at ? this.formatDate(highlights[0].created_at) : '์•Œ ์ˆ˜ ์—†์Œ'; + + tooltipHTML += ` +
+
+
+
+
+ ${colorName} ํ•˜์ด๋ผ์ดํŠธ +
${createdDate} ์ƒ์„ฑ
+
+
+
+ + +
+
+ + +
+
+ + + + + ๋ฉ”๋ชจ (${allNotes.length}๊ฐœ) +
+ +
+ ${allNotes.length > 0 ? + allNotes.map(note => ` +
+
${note.content}
+
+ + + + + ${this.formatShortDate(note.created_at)} + +
+ + +
+
+
+ `).join('') : + '
๋ฉ”๋ชจ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์œ„์˜ "๐Ÿ“ ๋ฉ”๋ชจ ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ํด๋ฆญํ•ด๋ณด์„ธ์š”!
' + } +
+
+
+ `; + }); + + tooltipHTML += '
'; + + // ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ฆฌ ๋ฒ„ํŠผ๋“ค + tooltipHTML += ` +
+
+
+ + + + ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ฆฌ +
+
+ + +
+
+
+ `; + + tooltipHTML += ` +
+ +
+ `; + + tooltip.innerHTML = tooltipHTML; + + // ์œ„์น˜ ๊ณ„์‚ฐ + const rect = element.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + document.body.appendChild(tooltip); + + // ๋งํ’์„  ์œ„์น˜ ์กฐ์ • + const tooltipRect = tooltip.getBoundingClientRect(); + let top = rect.bottom + scrollTop + 5; + let left = rect.left + scrollLeft; + + // ํ™”๋ฉด ๊ฒฝ๊ณ„ ์ฒดํฌ + if (left + tooltipRect.width > window.innerWidth) { + left = window.innerWidth - tooltipRect.width - 10; + } + if (top + tooltipRect.height > window.innerHeight + scrollTop) { + top = rect.top + scrollTop - tooltipRect.height - 5; + } + + tooltip.style.top = top + 'px'; + tooltip.style.left = left + 'px'; + + // ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + setTimeout(() => { + document.addEventListener('click', this.handleTooltipOutsideClick.bind(this)); + }, 100); + } + + /** + * ๋งํ’์„  ์ˆจ๊ธฐ๊ธฐ + */ + hideTooltip() { + const highlightTooltip = document.getElementById('highlight-tooltip'); + if (highlightTooltip) { + highlightTooltip.remove(); + } + + document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this)); + } + + /** + * ๋งํ’์„  ์™ธ๋ถ€ ํด๋ฆญ ์ฒ˜๋ฆฌ + */ + handleTooltipOutsideClick(e) { + const highlightTooltip = document.getElementById('highlight-tooltip'); + + const isOutsideHighlightTooltip = highlightTooltip && !highlightTooltip.contains(e.target); + + if (isOutsideHighlightTooltip) { + this.hideTooltip(); + } + } + + /** + * ๋ฉ”๋ชจ ์ถ”๊ฐ€ ํผ ํ‘œ์‹œ + */ + showAddNoteForm(highlightId) { + console.log('๐Ÿ” showAddNoteForm ํ˜ธ์ถœ๋จ, highlightId:', highlightId); + const tooltip = document.getElementById('highlight-tooltip'); + if (!tooltip) return; + + const notesList = document.getElementById(`notes-list-${highlightId}`); + if (!notesList) return; + + // ๊ธฐ์กด ํผ์ด ์žˆ์œผ๋ฉด ์ œ๊ฑฐ + const existingForm = document.getElementById('add-note-form'); + if (existingForm) { + existingForm.remove(); + } + + const formHTML = ` +
+ +
+ + +
+
+ `; + + notesList.insertAdjacentHTML('afterend', formHTML); + + // ํ…์ŠคํŠธ ์˜์—ญ์— ํฌ์ปค์Šค + setTimeout(() => { + document.getElementById('new-note-content').focus(); + }, 100); + } + + /** + * ๋ฉ”๋ชจ ์ถ”๊ฐ€ ์ทจ์†Œ + */ + cancelAddNote(highlightId) { + const form = document.getElementById('add-note-form'); + if (form) { + form.remove(); + } + + // ํˆดํŒ ๋‹ค์‹œ ํ‘œ์‹œ + const highlight = this.highlights.find(h => h.id === highlightId); + if (highlight) { + const element = document.querySelector(`[data-highlight-id="${highlightId}"]`); + if (element) { + this.showHighlightTooltip(highlight, element); + } + } + } + + /** + * ์ƒˆ ๋ฉ”๋ชจ ์ €์žฅ + */ + async saveNewNote(highlightId) { + const content = document.getElementById('new-note-content').value.trim(); + if (!content) { + alert('๋ฉ”๋ชจ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'); + return; + } + + try { + const noteData = { + highlight_id: highlightId, + content: content + }; + + // ๋ฌธ์„œ ํƒ€์ž…์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ํ˜ธ์ถœ + let note; + if (window.documentViewerInstance.contentType === 'note') { + noteData.note_id = window.documentViewerInstance.documentId; + note = await this.api.post('/note-notes/', noteData); + } else { + noteData.document_id = window.documentViewerInstance.documentId; + note = await this.api.createNote(noteData); + } + + this.notes.push(note); + + // ํผ ์ œ๊ฑฐ + const form = document.getElementById('add-note-form'); + if (form) { + form.remove(); + } + + // ํˆดํŒ ๋‹ค์‹œ ํ‘œ์‹œ + const highlight = this.highlights.find(h => h.id === highlightId); + if (highlight) { + const element = document.querySelector(`[data-highlight-id="${highlightId}"]`); + if (element) { + this.showHighlightTooltip(highlight, element); + } + } + + } catch (error) { + console.error('Failed to save note:', error); + alert('๋ฉ”๋ชจ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + } + + /** + * ๋‚ ์งœ ํฌ๋งทํŒ… (์งง์€ ํ˜•์‹) + */ + formatShortDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays <= 7) { + return `${diffDays}์ผ ์ „`; + } else { + return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }); + } + } + + /** + * ํ…์ŠคํŠธ ์„ ํƒ ๋ฆฌ์Šค๋„ˆ ์ œ๊ฑฐ (์ •๋ฆฌ์šฉ) + */ + removeTextSelectionListener() { + if (this.textSelectionHandler) { + document.removeEventListener('mouseup', this.textSelectionHandler); + this.textSelectionHandler = null; + } + } +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.HighlightManager = HighlightManager; diff --git a/frontend/static/js/viewer/features/link-manager.js b/frontend/static/js/viewer/features/link-manager.js new file mode 100644 index 0000000..274fbb7 --- /dev/null +++ b/frontend/static/js/viewer/features/link-manager.js @@ -0,0 +1,1532 @@ +/** + * LinkManager ๋ชจ๋“ˆ + * ๋ฌธ์„œ ๋งํฌ ๋ฐ ๋ฐฑ๋งํฌ ํ†ตํ•ฉ ๊ด€๋ฆฌ + */ +class LinkManager { + constructor(api) { + console.log('๐Ÿ”— LinkManager ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + this.api = api; + // ์บ์‹ฑ๋œ API ์‚ฌ์šฉ (์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ) + this.cachedApi = window.cachedApi || api; + this.documentLinks = []; + this.backlinks = []; + + // ์•ˆ์ „ํ•œ ์ดˆ๊ธฐํ™” ํ™•์ธ + console.log('๐Ÿ”ง LinkManager ์ดˆ๊ธฐํ™” - backlinks ํƒ€์ž…:', typeof this.backlinks, Array.isArray(this.backlinks)); + this.selectedText = ''; + this.selectedRange = null; + this.availableBooks = []; + this.filteredDocuments = []; + + console.log('โœ… LinkManager ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + + /** + * ๋ฌธ์„œ/๋…ธํŠธ ๋งํฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + */ + async loadDocumentLinks(documentId, contentType = 'document') { + try { + console.log('๐Ÿ” loadDocumentLinks ํ˜ธ์ถœ๋จ - documentId:', documentId, 'contentType:', contentType); + + let apiEndpoint; + if (contentType === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ์˜ ๊ฒฝ์šฐ ๋…ธํŠธ ์ „์šฉ ๋งํฌ API ์‚ฌ์šฉ + apiEndpoint = `/note-documents/${documentId}/links`; + console.log('โœ… ๋…ธํŠธ API ์—”๋“œํฌ์ธํŠธ ์„ ํƒ:', apiEndpoint); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ๊ฒฝ์šฐ ๊ธฐ์กด API ์‚ฌ์šฉ + apiEndpoint = `/documents/${documentId}/links`; + console.log('โœ… ๋ฌธ์„œ API ์—”๋“œํฌ์ธํŠธ ์„ ํƒ:', apiEndpoint); + } + + console.log('๐Ÿ“ก ๋งํฌ API ํ˜ธ์ถœ:', apiEndpoint); + console.log('๐Ÿ“ก ์‚ฌ์šฉ ์ค‘์ธ documentId:', documentId, 'contentType:', contentType); + console.log('๐Ÿ“ก cachedApi ๊ฐ์ฒด:', this.cachedApi); + + const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' }); + console.log('๐Ÿ“ก ์›๋ณธ API ์‘๋‹ต:', response); + console.log('๐Ÿ“ก ์‘๋‹ต ํƒ€์ž…:', typeof response); + console.log('๐Ÿ“ก ์‘๋‹ต์ด ๋ฐฐ์—ด์ธ๊ฐ€?', Array.isArray(response)); + console.log('๐Ÿ“ก ์‘๋‹ต JSON ๋ฌธ์ž์—ด:', JSON.stringify(response, null, 2)); + console.log('๐Ÿ“ก ์‘๋‹ต ํ‚ค๋“ค:', Object.keys(response || {})); + + // API ์‘๋‹ต์ด ๊ฐ์ฒด์ผ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ + if (Array.isArray(response)) { + this.documentLinks = response; + } else if (response && typeof response === 'object') { + // ๊ฐ์ฒด์—์„œ ๋ฐฐ์—ด ์ถ”์ถœ ์‹œ๋„ + if (response.data && Array.isArray(response.data)) { + this.documentLinks = response.data; + } else if (response.links && Array.isArray(response.links)) { + this.documentLinks = response.links; + } else { + console.warn('โš ๏ธ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ API ์‘๋‹ต ๊ตฌ์กฐ:', response); + this.documentLinks = []; + } + } else { + this.documentLinks = []; + } + + // target_content_type์ด ์—†๋Š” ๋งํฌ๋“ค์— ๋Œ€ํ•ด ์ถ”๋ก  ๋กœ์ง ์ ์šฉ + this.documentLinks = this.documentLinks.map(link => { + if (!link.target_content_type) { + if (link.target_note_id) { + link.target_content_type = 'note'; + console.log('๐Ÿ” ๋งํฌ ํƒ€์ž… ์ถ”๋ก : note -', link.id); + } else if (link.target_document_id) { + link.target_content_type = 'document'; + console.log('๐Ÿ” ๋งํฌ ํƒ€์ž… ์ถ”๋ก : document -', link.id); + } + } + return link; + }); + + console.log('๐Ÿ“ก ์ตœ์ข… ๋งํฌ ๋ฐ์ดํ„ฐ (ํƒ€์ž… ์ถ”๋ก  ์™„๋ฃŒ):', this.documentLinks); + console.log('๐Ÿ“ก ์ตœ์ข… ๋งํฌ ๊ฐœ์ˆ˜:', this.documentLinks.length); + return this.documentLinks; + } catch (error) { + console.error('โŒ ๋ฌธ์„œ ๋งํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + this.documentLinks = []; + return []; + } + } + + /** + * ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + */ + async loadBacklinks(documentId, contentType = 'document') { + try { + console.log('๐Ÿ” loadBacklinks ํ˜ธ์ถœ๋จ - documentId:', documentId, 'contentType:', contentType); + + let apiEndpoint; + if (contentType === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ์˜ ๊ฒฝ์šฐ ๋…ธํŠธ ์ „์šฉ ๋ฐฑ๋งํฌ API ์‚ฌ์šฉ + apiEndpoint = `/note-documents/${documentId}/backlinks`; + console.log('โœ… ๋…ธํŠธ ๋ฐฑ๋งํฌ API ์—”๋“œํฌ์ธํŠธ ์„ ํƒ:', apiEndpoint); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ๊ฒฝ์šฐ ๊ธฐ์กด API ์‚ฌ์šฉ + apiEndpoint = `/documents/${documentId}/backlinks`; + console.log('โœ… ๋ฌธ์„œ ๋ฐฑ๋งํฌ API ์—”๋“œํฌ์ธํŠธ ์„ ํƒ:', apiEndpoint); + } + + console.log('๐Ÿ“ก ๋ฐฑ๋งํฌ API ํ˜ธ์ถœ:', apiEndpoint); + console.log('๐Ÿ“ก ์‚ฌ์šฉ ์ค‘์ธ documentId:', documentId, 'contentType:', contentType); + + const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' }); + console.log('๐Ÿ“ก ์›๋ณธ ๋ฐฑ๋งํฌ ์‘๋‹ต:', response); + console.log('๐Ÿ“ก ๋ฐฑ๋งํฌ ์‘๋‹ต ํƒ€์ž…:', typeof response); + console.log('๐Ÿ“ก ๋ฐฑ๋งํฌ ์‘๋‹ต์ด ๋ฐฐ์—ด์ธ๊ฐ€?', Array.isArray(response)); + console.log('๐Ÿ“ก ๋ฐฑ๋งํฌ ์‘๋‹ต JSON ๋ฌธ์ž์—ด:', JSON.stringify(response, null, 2)); + console.log('๐Ÿ“ก ๋ฐฑ๋งํฌ ์‘๋‹ต ํ‚ค๋“ค:', Object.keys(response || {})); + + // API ์‘๋‹ต์ด ๊ฐ์ฒด์ผ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ + if (Array.isArray(response)) { + this.backlinks = response; + } else if (response && typeof response === 'object') { + // ๊ฐ์ฒด์—์„œ ๋ฐฐ์—ด ์ถ”์ถœ ์‹œ๋„ + if (response.data && Array.isArray(response.data)) { + this.backlinks = response.data; + } else if (response.backlinks && Array.isArray(response.backlinks)) { + this.backlinks = response.backlinks; + } else { + console.warn('โš ๏ธ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๋ฐฑ๋งํฌ API ์‘๋‹ต ๊ตฌ์กฐ:', response); + this.backlinks = []; + } + } else { + this.backlinks = []; + } + + console.log('๐Ÿ“ก ์ตœ์ข… ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ:', this.backlinks); + console.log('๐Ÿ“ก ์ตœ์ข… ๋ฐฑ๋งํฌ ๊ฐœ์ˆ˜:', this.backlinks.length); + return this.backlinks; + } catch (error) { + console.error('โŒ ๋ฐฑ๋งํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + this.backlinks = []; + return []; + } + } + + /** + * ๋ฌธ์„œ ๋งํฌ ๋ Œ๋”๋ง + */ + renderDocumentLinks() { + const documentContent = document.getElementById('document-content'); + if (!documentContent) { + console.error('โŒ document-content ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + // ์•ˆ์ „ํ•œ ๋งํฌ ์ดˆ๊ธฐํ™” + if (!Array.isArray(this.documentLinks)) { + console.warn('โš ๏ธ this.documentLinks๊ฐ€ ๋ฐฐ์—ด์ด ์•„๋‹™๋‹ˆ๋‹ค. ๋นˆ ๋ฐฐ์—ด๋กœ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค.'); + console.log('๐Ÿ” ๊ธฐ์กด this.documentLinks:', typeof this.documentLinks, this.documentLinks); + this.documentLinks = []; + } + + console.log('๐Ÿ”— ๋งํฌ ๋ Œ๋”๋ง ์‹œ์ž‘ - ์ด', this.documentLinks.length, '๊ฐœ'); + console.log('๐Ÿ”— ๋งํฌ ๋ฐ์ดํ„ฐ ์ƒ์„ธ:', this.documentLinks); + + // ๊ธฐ์กด ๋งํฌ ์ œ๊ฑฐ (ํ•ญ์ƒ ์‹คํ–‰) + const existingLinks = documentContent.querySelectorAll('.document-link'); + console.log('๐Ÿ” ๊ธฐ์กด ๋งํฌ ์ œ๊ฑฐ:', existingLinks.length, '๊ฐœ'); + existingLinks.forEach(el => { + const parent = el.parentNode; + parent.replaceChild(document.createTextNode(el.textContent), el); + parent.normalize(); + }); + + // ๋งํฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์—ฌ๊ธฐ์„œ ์ข…๋ฃŒ + if (this.documentLinks.length === 0) { + console.log('๐Ÿ“ ๋งํฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ๊ฐ ๋งํฌ ๋ Œ๋”๋ง + if (Array.isArray(this.documentLinks)) { + this.documentLinks.forEach(link => { + this.renderSingleLink(link); + }); + } else { + console.warn('โš ๏ธ this.documentLinks๊ฐ€ ๋ฐฐ์—ด์ด ์•„๋‹™๋‹ˆ๋‹ค:', typeof this.documentLinks, this.documentLinks); + } + + console.log('โœ… ๋งํฌ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + } + + /** + * ๊ฐœ๋ณ„ ๋งํฌ ๋ Œ๋”๋ง + */ + renderSingleLink(link) { + console.log('๐Ÿ”— renderSingleLink ์‹œ์ž‘:', link.id, link.selected_text); + const content = document.getElementById('document-content'); + const textContent = content.textContent; + + console.log('๐Ÿ“ ๋ฌธ์„œ ํ…์ŠคํŠธ ๊ธธ์ด:', textContent.length); + console.log('๐Ÿ“ ๋งํฌ ์œ„์น˜:', link.start_offset, '-', link.end_offset); + console.log('๐Ÿ“ ์˜ˆ์ƒ ํ…์ŠคํŠธ:', textContent.substring(link.start_offset, link.end_offset)); + + if (link.start_offset >= textContent.length || link.end_offset > textContent.length) { + console.warn('โŒ ๋งํฌ ์œ„์น˜๊ฐ€ ํ…์ŠคํŠธ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚จ:', link); + console.log('๐Ÿ“ ํ…์ŠคํŠธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ:', textContent.substring(0, 200)); + console.log('๐Ÿ“ ๋งํฌ ์ฃผ๋ณ€ ํ…์ŠคํŠธ:', textContent.substring(Math.max(0, link.start_offset - 50), link.start_offset + 50)); + return; + } + + // ์‹ค์ œ ํ…์ŠคํŠธ์™€ ์„ ํƒ๋œ ํ…์ŠคํŠธ๊ฐ€ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ + const actualText = textContent.substring(link.start_offset, link.end_offset); + if (actualText !== link.selected_text) { + console.warn('โš ๏ธ ์˜คํ”„์…‹ ์œ„์น˜์˜ ํ…์ŠคํŠธ๊ฐ€ ์„ ํƒ๋œ ํ…์ŠคํŠธ์™€ ๋‹ค๋ฆ„'); + console.log('๐Ÿ“ ์˜ˆ์ƒ:', link.selected_text); + console.log('๐Ÿ“ ์‹ค์ œ:', actualText); + + // ํ…์ŠคํŠธ ๊ฒ€์ƒ‰์œผ๋กœ ๋Œ€์ฒด ์‹œ๋„ + const searchIndex = textContent.indexOf(link.selected_text); + if (searchIndex !== -1) { + console.log('โœ… ํ…์ŠคํŠธ ๊ฒ€์ƒ‰์œผ๋กœ ์œ„์น˜ ์ฐพ์Œ:', searchIndex); + link.start_offset = searchIndex; + link.end_offset = searchIndex + link.selected_text.length; + } else { + console.error('โŒ ๋งํฌ ํ…์ŠคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', link.selected_text); + return; + } + } + + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let node; + + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + const nodeStart = currentOffset; + const nodeEnd = currentOffset + nodeLength; + + // ๋งํฌ ๋ฒ”์œ„์™€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + if (nodeEnd > link.start_offset && nodeStart < link.end_offset) { + const linkStart = Math.max(0, link.start_offset - nodeStart); + const linkEnd = Math.min(nodeLength, link.end_offset - nodeStart); + + if (linkStart < linkEnd) { + console.log('โœ… ๋งํฌ ์ ์šฉ:', linkStart, '-', linkEnd, 'ํ…์ŠคํŠธ:', node.textContent.substring(linkStart, linkEnd)); + this.applyLinkToNode(node, linkStart, linkEnd, link); + break; + } + } + + currentOffset = nodeEnd; + } + } + + /** + * ํ…์ŠคํŠธ ๋…ธ๋“œ์— ๋งํฌ ์ ์šฉ + */ + applyLinkToNode(textNode, start, end, link) { + console.log('๐ŸŽจ applyLinkToNode ์‹œ์ž‘:', start, end, link.id); + const text = textNode.textContent; + const beforeText = text.substring(0, start); + const linkText = text.substring(start, end); + const afterText = text.substring(end); + + console.log('๐Ÿ“ ๋งํฌ ํ…์ŠคํŠธ:', linkText); + + // ๋งํฌ ์ŠคํŒฌ ์ƒ์„ฑ + const span = document.createElement('span'); + span.className = 'document-link'; + span.textContent = linkText; + span.dataset.linkId = link.id; + + // ๋งํฌ ์Šคํƒ€์ผ (๋ณด๋ผ์ƒ‰) - ๋ ˆ์ด์•„์›ƒ ์•ˆ์ „ + span.style.cssText = ` + color: #7C3AED !important; + text-decoration: underline !important; + cursor: pointer !important; + background-color: rgba(124, 58, 237, 0.1) !important; + border-radius: 2px !important; + padding: 0 1px !important; + display: inline !important; + line-height: inherit !important; + vertical-align: baseline !important; + margin: 0 !important; + box-sizing: border-box !important; + `; + + // ํด๋ฆญ ์ด๋ฒคํŠธ ์ถ”๊ฐ€ (ํ†ตํ•ฉ ํˆดํŒ ์‚ฌ์šฉ) + span.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + console.log('๐Ÿ”— ๋งํฌ ํด๋ฆญ๋จ:', { + text: span.textContent, + linkId: link.id, + classList: Array.from(span.classList) + }); + + // ๋งํฌ, ๋ฐฑ๋งํฌ, ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋‘ ์ฐพ๊ธฐ + const overlappingElements = window.documentViewerInstance.getOverlappingElements(span); + const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length; + + console.log('๐ŸŽฏ ๋งํฌ ํด๋ฆญ ๋ถ„์„:', { + links: overlappingElements.links.length, + backlinks: overlappingElements.backlinks.length, + highlights: overlappingElements.highlights.length, + total: totalElements, + selectedText: overlappingElements.selectedText + }); + + if (totalElements > 1) { + // ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ (๋งํฌ + ๋ฐฑ๋งํฌ + ํ•˜์ด๋ผ์ดํŠธ) + console.log('๐ŸŽจ ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ ์‹œ์ž‘ (๋งํฌ์—์„œ)'); + await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span); + } else { + // ๋‹จ์ผ ๋งํฌ ํˆดํŒ + console.log('๐Ÿ”— ๋‹จ์ผ ๋งํฌ ํˆดํŒ ํ‘œ์‹œ'); + this.showLinkTooltip(link, span); + } + }); + + // DOM ๊ต์ฒด + const parent = textNode.parentNode; + const fragment = document.createDocumentFragment(); + + if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); + fragment.appendChild(span); + if (afterText) fragment.appendChild(document.createTextNode(afterText)); + + parent.replaceChild(fragment, textNode); + console.log('โœ… ๋งํฌ DOM ๊ต์ฒด ์™„๋ฃŒ:', linkText); + } + + /** + * ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง (๋งํฌ์™€ ๋™์ผํ•œ ๋ฐฉ์‹) + */ + renderBacklinks() { + const documentContent = document.getElementById('document-content'); + if (!documentContent) { + console.error('โŒ document-content ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + // ์•ˆ์ „ํ•œ ๋ฐฑ๋งํฌ ์ดˆ๊ธฐํ™” + if (!Array.isArray(this.backlinks)) { + console.warn('โš ๏ธ this.backlinks๊ฐ€ ๋ฐฐ์—ด์ด ์•„๋‹™๋‹ˆ๋‹ค. ๋นˆ ๋ฐฐ์—ด๋กœ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค.'); + console.log('๐Ÿ” ๊ธฐ์กด this.backlinks:', typeof this.backlinks, this.backlinks); + this.backlinks = []; + } + + console.log('๐Ÿ”— ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง ์‹œ์ž‘ - ์ด', this.backlinks.length, '๊ฐœ'); + console.log('๐Ÿ”— ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ ์ƒ์„ธ:', this.backlinks); + + // ๊ธฐ์กด ๋ฐฑ๋งํฌ ํ™•์ธ (์ œ๊ฑฐํ•˜์ง€ ์•Š๊ณ  ์ค‘๋ณต ์ฒดํฌ๋งŒ) + const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); + console.log(`๐Ÿ” ๊ธฐ์กด ๋ฐฑ๋งํฌ ${existingBacklinks.length}๊ฐœ ๋ฐœ๊ฒฌ`); + + // ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์—ฌ๊ธฐ์„œ ์ข…๋ฃŒ + if (this.backlinks.length === 0) { + console.log('๐Ÿ“ ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ๊ฐ ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง (์ค‘๋ณต๋˜์ง€ ์•Š๋Š” ๊ฒƒ๋งŒ) + if (Array.isArray(this.backlinks)) { + this.backlinks.forEach(backlink => { + // ์ด๋ฏธ ๋ Œ๋”๋ง๋œ ๋ฐฑ๋งํฌ์ธ์ง€ ํ™•์ธ + const existingBacklink = Array.from(existingBacklinks).find(el => + el.dataset.backlinkId === backlink.id.toString() + ); + + if (!existingBacklink) { + console.log(`๐Ÿ†• ์ƒˆ๋กœ์šด ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง: ${backlink.id}`); + this.renderSingleBacklink(backlink); + } else { + console.log(`โœ… ๋ฐฑ๋งํฌ ์ด๋ฏธ ์กด์žฌ: ${backlink.id}`); + } + }); + } else { + console.warn('โš ๏ธ this.backlinks๊ฐ€ ๋ฐฐ์—ด์ด ์•„๋‹™๋‹ˆ๋‹ค:', typeof this.backlinks, this.backlinks); + } + + console.log('โœ… ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + } + + /** + * ๊ฐœ๋ณ„ ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง + */ + renderSingleBacklink(backlink) { + console.log('๐Ÿ”— renderSingleBacklink ์‹œ์ž‘:', backlink.id, backlink.target_text); + const content = document.getElementById('document-content'); + if (!content) { + console.error('โŒ document-content ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + // ์›๋ณธ ๋ฌธ์„œ ๋‚ด์šฉ ์‚ฌ์šฉ (์˜คํ”„์…‹ ์ •ํ™•์„ฑ์„ ์œ„ํ•ด) + const textContent = content.textContent || content.innerText || ''; + console.log('๐Ÿ“ ๋ฌธ์„œ ํ…์ŠคํŠธ ๊ธธ์ด:', textContent.length); + + // target_start_offset๊ณผ target_end_offset์ด ์žˆ์œผ๋ฉด ์ง์ ‘ ์‚ฌ์šฉ + let textIndex, searchText, searchLength; + + if (backlink.target_start_offset !== undefined && backlink.target_end_offset !== undefined) { + // ์˜คํ”„์…‹ ์ •๋ณด๊ฐ€ ์žˆ์œผ๋ฉด ์ง์ ‘ ์‚ฌ์šฉ (๋” ์ •ํ™•ํ•จ) + textIndex = backlink.target_start_offset; + searchLength = backlink.target_end_offset - backlink.target_start_offset; + searchText = textContent.substring(textIndex, textIndex + searchLength); + console.log('โœ… ์˜คํ”„์…‹์œผ๋กœ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ ์ฐพ์Œ:', searchText, '์œ„์น˜:', textIndex); + } else { + // ์˜คํ”„์…‹ ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ ์‚ฌ์šฉ + searchText = backlink.target_text || backlink.selected_text; + if (!searchText) { + console.warn('โŒ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ ์ •๋ณด๊ฐ€ ์—†์Œ:', backlink); + return; + } + + // ํ…์ŠคํŠธ ๊ฒ€์ƒ‰ (๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ, ๊ณต๋ฐฑ ์ •๊ทœํ™”) + const normalizedContent = textContent.replace(/\s+/g, ' ').trim(); + const normalizedSearchText = searchText.replace(/\s+/g, ' ').trim(); + + textIndex = normalizedContent.indexOf(normalizedSearchText); + if (textIndex === -1) { + // ๋ถ€๋ถ„ ๊ฒ€์ƒ‰ ์‹œ๋„ + const words = normalizedSearchText.split(' '); + if (words.length > 1) { + const firstWord = words[0]; + const lastWord = words[words.length - 1]; + const partialPattern = firstWord + '.*' + lastWord; + const regex = new RegExp(partialPattern, 'i'); + const match = normalizedContent.match(regex); + if (match) { + textIndex = match.index; + console.log('โœ… ๋ถ€๋ถ„ ๋งค์นญ์œผ๋กœ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ ์ฐพ์Œ:', searchText); + } + } + } + + if (textIndex === -1) { + console.warn('โŒ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', searchText); + console.log('๐Ÿ“ ๊ฒ€์ƒ‰ ๋Œ€์ƒ ํ…์ŠคํŠธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ:', normalizedContent.substring(0, 200)); + console.log('๐Ÿ“ ์ „์ฒด ํ…์ŠคํŠธ ๊ธธ์ด:', normalizedContent.length); + return; + } + + searchLength = searchText.length; + console.log('โœ… ํ…์ŠคํŠธ ๊ฒ€์ƒ‰์œผ๋กœ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ ์ฐพ์Œ:', searchText, '์œ„์น˜:', textIndex); + } + + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let node; + + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + const nodeStart = currentOffset; + const nodeEnd = currentOffset + nodeLength; + + // ๋ฐฑ๋งํฌ ๋ฒ”์œ„์™€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + if (nodeEnd > textIndex && nodeStart < textIndex + searchLength) { + const backlinkStart = Math.max(0, textIndex - nodeStart); + const backlinkEnd = Math.min(nodeLength, textIndex + searchLength - nodeStart); + + if (backlinkStart < backlinkEnd) { + console.log('โœ… ๋ฐฑ๋งํฌ ์ ์šฉ:', backlinkStart, '-', backlinkEnd, 'ํ…์ŠคํŠธ:', node.textContent.substring(backlinkStart, backlinkEnd)); + this.applyBacklinkToNode(node, backlinkStart, backlinkEnd, backlink); + break; + } + } + + currentOffset = nodeEnd; + } + } + + /** + * ํ…์ŠคํŠธ ๋…ธ๋“œ์— ๋ฐฑ๋งํฌ ์ ์šฉ + */ + applyBacklinkToNode(textNode, start, end, backlink) { + console.log('๐ŸŽจ applyBacklinkToNode ์‹œ์ž‘:', start, end, backlink.id); + const text = textNode.textContent; + const beforeText = text.substring(0, start); + const backlinkText = text.substring(start, end); + const afterText = text.substring(end); + + console.log('๐Ÿ“ ๋ฐฑ๋งํฌ ํ…์ŠคํŠธ:', backlinkText); + + // ๋ฐฑ๋งํฌ ์ŠคํŒฌ ์ƒ์„ฑ + const span = document.createElement('span'); + span.className = 'backlink-highlight'; + span.textContent = backlinkText; + span.dataset.backlinkId = backlink.id; + + // ๋ฐฑ๋งํฌ ์Šคํƒ€์ผ (์ฃผํ™ฉ์ƒ‰) - ๋ ˆ์ด์•„์›ƒ ์•ˆ์ „ + span.style.cssText = ` + color: #EA580C !important; + text-decoration: underline !important; + cursor: pointer !important; + background-color: rgba(234, 88, 12, 0.2) !important; + border: 1px solid #EA580C !important; + border-radius: 3px !important; + padding: 0 2px !important; + font-weight: bold !important; + display: inline !important; + line-height: inherit !important; + vertical-align: baseline !important; + margin: 0 !important; + box-sizing: border-box !important; + `; + + // ํด๋ฆญ ์ด๋ฒคํŠธ ์ถ”๊ฐ€ (ํ†ตํ•ฉ ํˆดํŒ ์‚ฌ์šฉ) + span.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + console.log('๐Ÿ”™ ๋ฐฑ๋งํฌ ํด๋ฆญ๋จ:', { + text: span.textContent, + backlinkId: backlink.id, + classList: Array.from(span.classList) + }); + + // ๋งํฌ, ๋ฐฑ๋งํฌ, ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋‘ ์ฐพ๊ธฐ + const overlappingElements = window.documentViewerInstance.getOverlappingElements(span); + const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length; + + console.log('๐Ÿ”™ ๋ฐฑ๋งํฌ ํด๋ฆญ ๋ถ„์„:', { + links: overlappingElements.links.length, + backlinks: overlappingElements.backlinks.length, + highlights: overlappingElements.highlights.length, + total: totalElements, + selectedText: overlappingElements.selectedText + }); + + if (totalElements > 1) { + // ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ (๋งํฌ + ๋ฐฑ๋งํฌ + ํ•˜์ด๋ผ์ดํŠธ) + console.log('๐ŸŽฏ ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ ์‹œ์ž‘ (๋ฐฑ๋งํฌ์—์„œ)'); + await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span); + } else { + // ๋‹จ์ผ ๋ฐฑ๋งํฌ ํˆดํŒ + console.log('๐Ÿ”™ ๋‹จ์ผ ๋ฐฑ๋งํฌ ํˆดํŒ ํ‘œ์‹œ'); + this.showBacklinkTooltip(backlink, span); + } + }); + + // DOM ๊ต์ฒด + const parent = textNode.parentNode; + const fragment = document.createDocumentFragment(); + + if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); + fragment.appendChild(span); + if (afterText) fragment.appendChild(document.createTextNode(afterText)); + + parent.replaceChild(fragment, textNode); + console.log('โœ… ๋ฐฑ๋งํฌ DOM ๊ต์ฒด ์™„๋ฃŒ:', backlinkText); + } + + /** + * ๊ฒน์น˜๋Š” ๋งํฌ๋“ค ์ฐพ๊ธฐ + */ + getOverlappingLinks(clickedElement) { + const clickedLinkId = clickedElement.getAttribute('data-link-id'); + const clickedText = clickedElement.textContent; + + console.log('๐Ÿ” ๊ฒน์น˜๋Š” ๋งํฌ ์ฐพ๊ธฐ:', { + clickedLinkId: clickedLinkId, + clickedText: clickedText, + totalLinks: this.documentLinks.length + }); + + // ๋™์ผํ•œ ํ…์ŠคํŠธ ๋ฒ”์œ„์— ์žˆ๋Š” ๋ชจ๋“  ๋งํฌ ์ฐพ๊ธฐ + const overlappingLinks = this.documentLinks.filter(link => { + // ํด๋ฆญ๋œ ๋งํฌ์™€ ํ…์ŠคํŠธ๊ฐ€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ + const linkElement = document.querySelector(`[data-link-id="${link.id}"]`); + if (!linkElement) return false; + + // ํ…์ŠคํŠธ๊ฐ€ ๊ฒน์น˜๋Š”์ง€ ํ™•์ธ (๊ฐ„๋‹จํ•œ ๋ฐฉ๋ฒ•: ํ…์ŠคํŠธ ๋‚ด์šฉ ๋น„๊ต) + const isOverlapping = linkElement.textContent === clickedText; + + if (isOverlapping) { + console.log('โœ… ๊ฒน์น˜๋Š” ๋งํฌ ๋ฐœ๊ฒฌ:', { + id: link.id, + text: linkElement.textContent, + target: link.target_document_title + }); + } + + return isOverlapping; + }); + + console.log(`๐Ÿ” ์ด ${overlappingLinks.length}๊ฐœ์˜ ๊ฒน์น˜๋Š” ๋งํฌ ๋ฐœ๊ฒฌ`); + return overlappingLinks; + } + + /** + * ๋‹ค์ค‘ ๋งํฌ ํˆดํŒ ํ‘œ์‹œ + */ + showMultiLinkTooltip(links, element, selectedText) { + console.log('๐Ÿ”— ๋‹ค์ค‘ ๋งํฌ ํˆดํŒ ํ‘œ์‹œ:', links.length, '๊ฐœ'); + + // ๊ธฐ์กด ํˆดํŒ ์ œ๊ฑฐ + this.hideTooltip(); + + const tooltip = document.createElement('div'); + tooltip.id = 'link-tooltip'; + tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-lg'; + tooltip.style.minWidth = '400px'; + tooltip.style.maxHeight = '80vh'; + tooltip.style.overflowY = 'auto'; + + let tooltipHTML = ` +
+
์„ ํƒ๋œ ํ…์ŠคํŠธ
+
+ "${selectedText}" +
+
+ `; + + if (links.length > 1) { + tooltipHTML += ` +
+
+ + + + ์—ฐ๊ฒฐ๋œ ๋งํฌ (${links.length}๊ฐœ) +
+
+ `; + } + + tooltipHTML += '
'; + + links.forEach((link, index) => { + const createdDate = link.created_at ? this.formatDate(link.created_at) : '์•Œ ์ˆ˜ ์—†์Œ'; + const isNote = link.target_content_type === 'note'; + const iconClass = isNote ? 'text-green-600' : 'text-purple-600'; + const bgClass = isNote ? 'hover:bg-green-50' : 'hover:bg-purple-50'; + + tooltipHTML += ` +
+
+
+
+ + ${isNote ? + '' : + '' + } + + ${link.target_document_title} +
+ + + ${link.target_text ? ` +
+
์—ฐ๊ฒฐ๋œ ํ…์ŠคํŠธ
+
"${link.target_text}"
+
+ ` : ''} + + ${link.description ? ` +
+
๋งํฌ ์„ค๋ช…
+
${link.description}
+
+ ` : ''} + +
+ + + + + ${link.link_type === 'text_fragment' ? 'ํ…์ŠคํŠธ ์กฐ๊ฐ ๋งํฌ' : '๋ฌธ์„œ ๋งํฌ'} + + ${createdDate} +
+
+ + + +
+
+ `; + }); + + tooltipHTML += '
'; + + tooltip.innerHTML = tooltipHTML; + + // ์œ„์น˜ ๊ณ„์‚ฐ ๋ฐ ํ‘œ์‹œ + const rect = element.getBoundingClientRect(); + tooltip.style.top = (rect.bottom + window.scrollY + 10) + 'px'; + tooltip.style.left = Math.max(10, rect.left + window.scrollX - 200) + 'px'; + + document.body.appendChild(tooltip); + + // ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ํˆดํŒ ์ˆจ๊ธฐ๊ธฐ + setTimeout(() => { + document.addEventListener('click', this.handleTooltipOutsideClick.bind(this)); + }, 100); + } + + /** + * ๋งํฌ ํˆดํŒ ํ‘œ์‹œ (๋‹จ์ผ ๋งํฌ์šฉ) + */ + showLinkTooltip(link, element) { + this.hideTooltip(); + + const tooltip = document.createElement('div'); + tooltip.id = 'link-tooltip'; + tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-5 z-50 max-w-2xl'; + tooltip.style.minWidth = '450px'; + tooltip.style.maxHeight = '80vh'; + tooltip.style.overflowY = 'auto'; + + // ์ƒ์„ฑ ๋‚ ์งœ ํฌ๋งทํŒ… + const createdDate = link.created_at ? this.formatDate(link.created_at) : '์•Œ ์ˆ˜ ์—†์Œ'; + + const tooltipHTML = ` +
+
+
+ + + + ๋งํฌ ์ •๋ณด +
+
${createdDate}
+
+ +
+
์„ ํƒ๋œ ํ…์ŠคํŠธ
+
"${link.selected_text}"
+
+
+ +
+
+ + + + ์—ฐ๊ฒฐ๋œ ๋ฌธ์„œ +
+
+
${link.target_document_title}
+ ${link.target_text ? ` +
+
๋Œ€์ƒ ํ…์ŠคํŠธ
+
"${link.target_text}"
+
+ ` : ''} + ${link.description ? ` +
+
์„ค๋ช…
+
${link.description}
+
+ ` : ''} +
+
+ +
+ + +
+ +
+ +
+ `; + + tooltip.innerHTML = tooltipHTML; + this.positionTooltip(tooltip, element); + } + + /** + * ๋ฐฑ๋งํฌ ํˆดํŒ ํ‘œ์‹œ + */ + showBacklinkTooltip(backlink, element) { + this.hideTooltip(); + + const tooltip = document.createElement('div'); + tooltip.id = 'backlink-tooltip'; + tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-5 z-50 max-w-2xl'; + tooltip.style.minWidth = '450px'; + tooltip.style.maxHeight = '80vh'; + tooltip.style.overflowY = 'auto'; + + // ์ƒ์„ฑ ๋‚ ์งœ ํฌ๋งทํŒ… + const createdDate = backlink.created_at ? this.formatDate(backlink.created_at) : '์•Œ ์ˆ˜ ์—†์Œ'; + + const tooltipHTML = ` +
+
+
+ + + + ๋ฐฑ๋งํฌ ์ •๋ณด +
+
${createdDate}
+
+ + +
+
+ ๐Ÿ’ก ๋ฐฑ๋งํฌ๋ž€?
+ ๋‹ค๋ฅธ ๋ฌธ์„œ์—์„œ ํ˜„์žฌ ๋ฌธ์„œ์˜ ์ด ํ…์ŠคํŠธ๋ฅผ ์ฐธ์กฐํ•˜๋Š” ์—ฐ๊ฒฐ์ž…๋‹ˆ๋‹ค.
+ ํด๋ฆญํ•˜๋ฉด ์ฐธ์กฐํ•˜๋Š” ๋ฌธ์„œ๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +
+
+ + +
+
+
ํ˜„์žฌ ๋ฌธ์„œ์˜ ์ฐธ์กฐ๋œ ํ…์ŠคํŠธ
+
"${backlink.target_text || backlink.selected_text}"
+
+ + ${backlink.target_text && backlink.target_text !== backlink.selected_text ? ` +
+
์›๋ณธ ๋งํฌ์—์„œ ์„ ํƒํ•œ ํ…์ŠคํŠธ
+
"${backlink.selected_text}"
+
+ ` : ''} +
+
+ +
+
+ + + + ์ด ํ…์ŠคํŠธ๋ฅผ ์ฐธ์กฐํ•˜๋Š” ๋ฌธ์„œ +
+
+
+
+ + + + ${backlink.source_document_title} +
+ + +
+
+
์›๋ณธ ๋ฌธ์„œ์—์„œ ๋งํฌ๋กœ ์„ค์ •ํ•œ ํ…์ŠคํŠธ
+
"${backlink.selected_text}"
+
+ + ${backlink.target_text ? ` +
+
ํ˜„์žฌ ๋ฌธ์„œ์—์„œ ์—ฐ๊ฒฐ๋œ ๊ตฌ์ฒด์ ์ธ ํ…์ŠคํŠธ
+
"${backlink.target_text}"
+
+ ` : ''} + + ${backlink.description ? ` +
+
๋งํฌ ์„ค๋ช…
+
${backlink.description}
+
+ ` : ''} +
+
+
+
+ +
+ +
+ +
+ +
+ `; + + tooltip.innerHTML = tooltipHTML; + this.positionTooltip(tooltip, element); + } + + /** + * ๋‚ ์งœ ํฌ๋งทํŒ… + */ + formatDate(dateString) { + if (!dateString) return '์•Œ ์ˆ˜ ์—†์Œ'; + + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + return '์˜ค๋Š˜'; + } else if (diffDays <= 7) { + return `${diffDays}์ผ ์ „`; + } else if (diffDays <= 30) { + return `${Math.ceil(diffDays / 7)}์ฃผ ์ „`; + } else { + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + } + + /** + * ํˆดํŒ ์œ„์น˜ ์„ค์ • + */ + positionTooltip(tooltip, element) { + const rect = element.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + document.body.appendChild(tooltip); + + // ํˆดํŒ ์œ„์น˜ ์กฐ์ • + const tooltipRect = tooltip.getBoundingClientRect(); + let top = rect.bottom + scrollTop + 5; + let left = rect.left + scrollLeft; + + // ํ™”๋ฉด ๊ฒฝ๊ณ„ ์ฒดํฌ + if (left + tooltipRect.width > window.innerWidth) { + left = window.innerWidth - tooltipRect.width - 10; + } + if (top + tooltipRect.height > window.innerHeight + scrollTop) { + top = rect.top + scrollTop - tooltipRect.height - 5; + } + + tooltip.style.top = top + 'px'; + tooltip.style.left = left + 'px'; + + // ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + setTimeout(() => { + document.addEventListener('click', this.handleTooltipOutsideClick.bind(this)); + }, 100); + } + + /** + * ํˆดํŒ ์ˆจ๊ธฐ๊ธฐ + */ + hideTooltip() { + const linkTooltip = document.getElementById('link-tooltip'); + if (linkTooltip) { + linkTooltip.remove(); + } + + const backlinkTooltip = document.getElementById('backlink-tooltip'); + if (backlinkTooltip) { + backlinkTooltip.remove(); + } + + const overlapMenu = document.getElementById('overlap-menu'); + if (overlapMenu) { + overlapMenu.remove(); + } + + document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this)); + } + + /** + * ํˆดํŒ ์™ธ๋ถ€ ํด๋ฆญ ์ฒ˜๋ฆฌ + */ + handleTooltipOutsideClick(e) { + const linkTooltip = document.getElementById('link-tooltip'); + const backlinkTooltip = document.getElementById('backlink-tooltip'); + const overlapMenu = document.getElementById('overlap-menu'); + + const isOutsideLinkTooltip = linkTooltip && !linkTooltip.contains(e.target); + const isOutsideBacklinkTooltip = backlinkTooltip && !backlinkTooltip.contains(e.target); + const isOutsideOverlapMenu = overlapMenu && !overlapMenu.contains(e.target); + + if (isOutsideLinkTooltip || isOutsideBacklinkTooltip || isOutsideOverlapMenu) { + this.hideTooltip(); + } + } + + /** + * ๋งํฌ๋œ ๋ฌธ์„œ๋กœ ์ด๋™ + */ + navigateToLinkedDocument(targetDocumentId, linkInfo) { + console.log('๐Ÿ”— navigateToLinkedDocument ํ˜ธ์ถœ๋จ'); + console.log('๐Ÿ“‹ ์ „๋‹ฌ๋ฐ›์€ ํŒŒ๋ผ๋ฏธํ„ฐ:', { + targetDocumentId: targetDocumentId, + linkInfo: linkInfo + }); + + // targetDocumentId๊ฐ€ null์ด๊ฑฐ๋‚˜ 'null' ๋ฌธ์ž์—ด์ธ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ + if (!targetDocumentId || targetDocumentId === 'null' || targetDocumentId === null) { + console.error('โŒ targetDocumentId๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค:', targetDocumentId); + console.log('๐Ÿ” linkInfo์—์„œ ๋Œ€์ฒด ID ์ฐพ๊ธฐ:', linkInfo); + + // linkInfo์—์„œ ๋Œ€์ฒด ID ์ฐพ๊ธฐ (๋…ธํŠธ ๋งํฌ์˜ ๊ฒฝ์šฐ target_note_id ์šฐ์„ ) + if (linkInfo && linkInfo.target_note_id && linkInfo.target_note_id !== 'null') { + targetDocumentId = linkInfo.target_note_id; + console.log('โœ… linkInfo์—์„œ target_note_id ๋ฐœ๊ฒฌ:', targetDocumentId); + } else if (linkInfo && linkInfo.target_document_id && linkInfo.target_document_id !== 'null') { + targetDocumentId = linkInfo.target_document_id; + console.log('โœ… linkInfo์—์„œ target_document_id ๋ฐœ๊ฒฌ:', targetDocumentId); + } else { + alert('๋Œ€์ƒ ๋ฌธ์„œ ID๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + return; + } + } + + // contentType์— ๋”ฐ๋ผ ์ ์ ˆํ•œ URL ์ƒ์„ฑ + let targetUrl; + + if (linkInfo.target_content_type === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ๋กœ ์ด๋™ + targetUrl = `/viewer.html?id=${targetDocumentId}&contentType=note`; + console.log('๐Ÿ“ ๋…ธํŠธ ๋ฌธ์„œ๋กœ ์ด๋™:', targetDocumentId); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ๋กœ ์ด๋™ + targetUrl = `/viewer.html?id=${targetDocumentId}`; + console.log('๐Ÿ“„ ์ผ๋ฐ˜ ๋ฌธ์„œ๋กœ ์ด๋™:', targetDocumentId); + } + + // ํŠน์ • ํ…์ŠคํŠธ ์œ„์น˜๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ URL์— ์ถ”๊ฐ€ + if (linkInfo.target_text && linkInfo.target_start_offset !== undefined) { + targetUrl += `&highlight_text=${encodeURIComponent(linkInfo.target_text)}`; + targetUrl += `&start_offset=${linkInfo.target_start_offset}`; + targetUrl += `&end_offset=${linkInfo.target_end_offset}`; + console.log('๐ŸŽฏ ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ ์ถ”๊ฐ€:', linkInfo.target_text); + } + + console.log('๐Ÿš€ ์ตœ์ข… ์ด๋™ํ•  URL:', targetUrl); + window.location.href = targetUrl; + } + + /** + * ์›๋ณธ ๋ฌธ์„œ๋กœ ์ด๋™ (๋ฐฑ๋งํฌ) + */ + navigateToSourceDocument(sourceDocumentId, backlinkInfo) { + console.log('๐Ÿ”™ navigateToSourceDocument ํ˜ธ์ถœ๋จ'); + console.log('๐Ÿ“‹ ์ „๋‹ฌ๋ฐ›์€ ํŒŒ๋ผ๋ฏธํ„ฐ:', { + sourceDocumentId: sourceDocumentId, + backlinkInfo: backlinkInfo + }); + + if (!sourceDocumentId) { + console.error('โŒ sourceDocumentId๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!'); + alert('์†Œ์Šค ๋ฌธ์„œ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // source_content_type์— ๋”ฐ๋ผ ์ ์ ˆํ•œ URL ์ƒ์„ฑ + let targetUrl; + + // source_content_type์ด ์—†์œผ๋ฉด ID๋กœ ์ถ”๋ก  + let sourceContentType = backlinkInfo.source_content_type; + if (!sourceContentType) { + if (backlinkInfo.source_note_id) { + sourceContentType = 'note'; + } else if (backlinkInfo.source_document_id) { + sourceContentType = 'document'; + } + console.log('๐Ÿ” ๋ฐฑ๋งํฌ์—์„œ source_content_type ์ถ”๋ก ๋จ:', sourceContentType); + } + + if (sourceContentType === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ๋กœ ์ด๋™ + targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`; + console.log('๐Ÿ“ ๋…ธํŠธ ๋ฌธ์„œ๋กœ ์ด๋™ (๋ฐฑ๋งํฌ):', sourceDocumentId); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ๋กœ ์ด๋™ + targetUrl = `/viewer.html?id=${sourceDocumentId}`; + console.log('๐Ÿ“„ ์ผ๋ฐ˜ ๋ฌธ์„œ๋กœ ์ด๋™ (๋ฐฑ๋งํฌ):', sourceDocumentId); + } + + // ์›๋ณธ ํ…์ŠคํŠธ ์œ„์น˜๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ URL์— ์ถ”๊ฐ€ + if (backlinkInfo.selected_text && backlinkInfo.start_offset !== undefined) { + targetUrl += `&highlight_text=${encodeURIComponent(backlinkInfo.selected_text)}`; + targetUrl += `&start_offset=${backlinkInfo.start_offset}`; + targetUrl += `&end_offset=${backlinkInfo.end_offset}`; + console.log('๐ŸŽฏ ํ…์ŠคํŠธ ํ•˜์ด๋ผ์ดํŠธ ์ถ”๊ฐ€ (๋ฐฑ๋งํฌ):', backlinkInfo.selected_text); + } + + console.log('๐Ÿš€ ์ตœ์ข… ์ด๋™ํ•  URL (๋ฐฑ๋งํฌ):', targetUrl); + window.location.href = targetUrl; + } + + /** + * ๋งํฌ ์‚ญ์ œ + */ + async deleteLink(linkId) { + if (!confirm('์ด ๋งํฌ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) { + return; + } + + try { + // ๋งํฌ ํƒ€์ž… ํ™•์ธ (๋…ธํŠธ ๋งํฌ์ธ์ง€ ๋ฌธ์„œ ๋งํฌ์ธ์ง€) + const link = this.documentLinks.find(l => l.id === linkId); + + if (link && link.source_note_id) { + // ๋…ธํŠธ ๋งํฌ ์‚ญ์ œ + console.log('๐Ÿ“ ๋…ธํŠธ ๋งํฌ ์‚ญ์ œ API ํ˜ธ์ถœ'); + await this.api.delete(`/note-links/${linkId}`); + } else { + // ๋ฌธ์„œ ๋งํฌ ์‚ญ์ œ + console.log('๐Ÿ“„ ๋ฌธ์„œ ๋งํฌ ์‚ญ์ œ API ํ˜ธ์ถœ'); + await this.api.delete(`/document-links/${linkId}`); + } + + this.documentLinks = this.documentLinks.filter(l => l.id !== linkId); + + this.hideTooltip(); + this.renderDocumentLinks(); + + console.log('๋งํฌ ์‚ญ์ œ ์™„๋ฃŒ:', linkId); + } catch (error) { + console.error('๋งํฌ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋งํฌ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + } + + /** + * ์„ ํƒ๋œ ํ…์ŠคํŠธ๋กœ ๋งํฌ ์ƒ์„ฑ + */ + async createLinkFromSelection(documentId = null, selectedText = null, selectedRange = null) { + // ๋งค๊ฐœ๋ณ€์ˆ˜๊ฐ€ ์—†์œผ๋ฉด ํ˜„์žฌ ์„ ํƒ๋œ ํ…์ŠคํŠธ ์‚ฌ์šฉ + if (!selectedText || !selectedRange) { + selectedText = window.getSelection().toString().trim(); + const selection = window.getSelection(); + + if (!selectedText || selection.rangeCount === 0) { + alert('ํ…์ŠคํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + selectedRange = selection.getRangeAt(0); + } + + if (!documentId && window.documentViewerInstance) { + documentId = window.documentViewerInstance.documentId; + } + + try { + console.log('๐Ÿ”— ๋งํฌ ์ƒ์„ฑ ์‹œ์ž‘:', selectedText); + + // ViewerCore์˜ ๋งํฌ ์ƒ์„ฑ ๋ชจ๋‹ฌ ํ‘œ์‹œ + if (window.documentViewerInstance) { + window.documentViewerInstance.selectedText = selectedText; + window.documentViewerInstance.selectedRange = selectedRange; + window.documentViewerInstance.showLinkModal = true; + window.documentViewerInstance.linkForm.selected_text = selectedText; + + // ์„œ์  ๋ชฉ๋ก ๋กœ๋“œ + await window.documentViewerInstance.loadAvailableBooks(); + + // ๊ธฐ๋ณธ์ ์œผ๋กœ ๊ฐ™์€ ์„œ์  ๋ฌธ์„œ๋“ค ๋กœ๋“œ + await window.documentViewerInstance.loadSameBookDocuments(); + + // ํ…์ŠคํŠธ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + const documentContent = document.getElementById('document-content'); + const fullText = documentContent.textContent; + const startOffset = this.getTextOffset(documentContent, selectedRange.startContainer, selectedRange.startOffset); + const endOffset = startOffset + selectedText.length; + + window.documentViewerInstance.linkForm.start_offset = startOffset; + window.documentViewerInstance.linkForm.end_offset = endOffset; + } + + } catch (error) { + console.error('๋งํฌ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋งํฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + + + + /** + * ๊ฒน์น˜๋Š” ์š”์†Œ ์ฐพ๊ธฐ (ํ•˜์ด๋ผ์ดํŠธ, ๋งํฌ, ๋ฐฑ๋งํฌ) + */ + findOverlappingElements(element) { + const overlapping = []; + const rect = element.getBoundingClientRect(); + + // ๊ฐ™์€ ์œ„์น˜์— ์žˆ๋Š” ๋ชจ๋“  ์š”์†Œ ์ฐพ๊ธฐ + const elementsAtPoint = document.elementsFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + + elementsAtPoint.forEach(el => { + if (el !== element) { + // ํ•˜์ด๋ผ์ดํŠธ ์š”์†Œ + if (el.classList.contains('highlight')) { + overlapping.push({ type: 'highlight', element: el }); + } + // ๋งํฌ ์š”์†Œ + if (el.classList.contains('document-link')) { + overlapping.push({ type: 'link', element: el }); + } + // ๋ฐฑ๋งํฌ ์š”์†Œ + if (el.classList.contains('backlink-highlight')) { + overlapping.push({ type: 'backlink', element: el }); + } + } + }); + + return overlapping; + } + + /** + * ๊ฒน์นจ ๋ฉ”๋‰ด ํ‘œ์‹œ + */ + showOverlapMenu(event, clickedElement, overlappingElements, clickedType) { + this.hideTooltip(); + + const menu = document.createElement('div'); + menu.id = 'overlap-menu'; + menu.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50'; + menu.style.minWidth = '320px'; + menu.style.maxWidth = '400px'; + + let menuHTML = ` +
+ + + + ๊ฒน์น˜๋Š” ์š”์†Œ๋“ค +
+
+ `; + + // ํด๋ฆญ๋œ ์š”์†Œ ์ถ”๊ฐ€ + menuHTML += this.getMenuItemHTML(clickedType, clickedElement, true); + + // ๊ฒน์น˜๋Š” ์š”์†Œ๋“ค ์ถ”๊ฐ€ + overlappingElements.forEach(item => { + menuHTML += this.getMenuItemHTML(item.type, item.element, false); + }); + + menuHTML += ` +
+
+ +
+ `; + + menu.innerHTML = menuHTML; + + // ๋ฉ”๋‰ด ์œ„์น˜ ์„ค์ • + const rect = clickedElement.getBoundingClientRect(); + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + document.body.appendChild(menu); + + let top = rect.bottom + scrollTop + 5; + let left = rect.left + scrollLeft; + + // ํ™”๋ฉด ๊ฒฝ๊ณ„ ์ฒดํฌ + const menuRect = menu.getBoundingClientRect(); + if (left + menuRect.width > window.innerWidth) { + left = window.innerWidth - menuRect.width - 10; + } + if (top + menuRect.height > window.innerHeight + scrollTop) { + top = rect.top + scrollTop - menuRect.height - 5; + } + + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + + // ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + setTimeout(() => { + document.addEventListener('click', this.handleTooltipOutsideClick.bind(this)); + }, 100); + } + + /** + * ๋ฉ”๋‰ด ์•„์ดํ…œ HTML ์ƒ์„ฑ + */ + getMenuItemHTML(type, element, isClicked) { + const icons = { + highlight: '๐Ÿ–๏ธ', + link: '๐Ÿ”—', + backlink: '๐Ÿ”™' + }; + + const labels = { + highlight: 'ํ•˜์ด๋ผ์ดํŠธ', + link: '๋งํฌ', + backlink: '๋ฐฑ๋งํฌ' + }; + + const colors = { + highlight: 'bg-yellow-50 border-yellow-200 text-yellow-800', + link: 'bg-purple-50 border-purple-200 text-purple-800', + backlink: 'bg-orange-50 border-orange-200 text-orange-800' + }; + + const clickedClass = isClicked ? 'ring-2 ring-blue-300' : ''; + const elementId = element.dataset.highlightId || element.dataset.linkId || element.dataset.backlinkId || ''; + + + + // ๊ฐ ํƒ€์ž…๋ณ„ ์•ก์…˜ ๋ฒ„ํŠผ๋“ค + let actionButtons = ''; + if (type === 'highlight') { + actionButtons = ` +
+ +
+ `; + } else if (type === 'link') { + actionButtons = ` +
+ + +
+ `; + } else if (type === 'backlink') { + actionButtons = ` +
+ + +
+ `; + } + + return ` +
+
+ ${icons[type]} +
+
${labels[type]}
+
${element.textContent.substring(0, 40)}${element.textContent.length > 40 ? '...' : ''}
+
+ ${isClicked ? 'ํด๋ฆญ๋จ' : ''} +
+ ${actionButtons} +
+ `; + } + + /** + * ๋ฉ”๋‰ด์—์„œ ๋ฐ”๋กœ ๋งํฌ๋œ ๋ฌธ์„œ๋กœ ์ด๋™ + */ + navigateToLinkedDocumentFromMenu(elementId) { + this.hideTooltip(); + + const link = this.documentLinks.find(l => l.id === elementId); + if (link) { + console.log('๐Ÿ”— ๋ฉ”๋‰ด์—์„œ ๋งํฌ ํด๋ฆญ:', link); + + // target_content_type์ด ์—†์œผ๋ฉด ID๋กœ ์ถ”๋ก  + let targetContentType = link.target_content_type; + if (!targetContentType) { + if (link.target_note_id) { + targetContentType = 'note'; + } else if (link.target_document_id) { + targetContentType = 'document'; + } + console.log('๐Ÿ” ๋ฉ”๋‰ด์—์„œ target_content_type ์ถ”๋ก ๋จ:', targetContentType); + } + + const targetId = link.target_document_id || link.target_note_id; + if (!targetId) { + console.error('โŒ ๋ฉ”๋‰ด์—์„œ ๋Œ€์ƒ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!', link); + alert('๋งํฌ ๋Œ€์ƒ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ๋งํฌ ๊ฐ์ฒด์— ์ถ”๋ก ๋œ ํƒ€์ž… ์ถ”๊ฐ€ + const linkWithType = { + ...link, + target_content_type: targetContentType + }; + + console.log('๐Ÿš€ ๋ฉ”๋‰ด์—์„œ ์ตœ์ข… ๋งํฌ ๋ฐ์ดํ„ฐ:', linkWithType); + this.navigateToLinkedDocument(targetId, linkWithType); + } else { + console.warn('๋งํฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', elementId); + } + } + + /** + * ๋ฉ”๋‰ด์—์„œ ๋ฐ”๋กœ ์†Œ์Šค ๋ฌธ์„œ๋กœ ์ด๋™ + */ + navigateToSourceDocumentFromMenu(elementId) { + this.hideTooltip(); + + const backlink = this.backlinks.find(b => b.id === elementId); + if (backlink) { + this.navigateToSourceDocument(backlink.source_document_id, backlink); + } else { + console.warn('๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', elementId); + } + } + + /** + * ๊ฒน์นจ ๋ฉ”๋‰ด ํด๋ฆญ ์ฒ˜๋ฆฌ + */ + handleOverlapMenuClick(type, elementId) { + + this.hideTooltip(); + + // ํ•ด๋‹น ์š”์†Œ ์ฐพ๊ธฐ + let element; + if (type === 'highlight') { + element = document.querySelector(`[data-highlight-id="${elementId}"]`); + if (element && window.documentViewerInstance.highlightManager) { + // ํ•˜์ด๋ผ์ดํŠธ ํด๋ฆญ ์ฒ˜๋ฆฌ (HighlightManager์— ์œ„์ž„) + const highlightId = elementId; + + // ํ•ด๋‹น ํ•˜์ด๋ผ์ดํŠธ ์ฐพ๊ธฐ + const highlight = window.documentViewerInstance.highlightManager.highlights.find(h => h.id === highlightId); + + if (highlight) { + // ํ•˜์ด๋ผ์ดํŠธ ํˆดํŒ ํ‘œ์‹œ (๋ฉ”๋ชจ ์ž‘์„ฑ/ํŽธ์ง‘ ๊ธฐ๋Šฅ ํฌํ•จ) + window.documentViewerInstance.highlightManager.showHighlightTooltip(highlight, element); + } else { + console.warn('ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', highlightId); + } + } + } else if (type === 'link') { + element = document.querySelector(`[data-link-id="${elementId}"]`); + + if (element) { + // ๋งํฌ ๋ฐ์ดํ„ฐ ์ฐพ๊ธฐ + const link = this.documentLinks.find(l => l.id === elementId); + if (link) { + this.showLinkTooltip(link, element); + } + } + } else if (type === 'backlink') { + element = document.querySelector(`[data-backlink-id="${elementId}"]`); + + if (element) { + // ๋ฐฑ๋งํฌ ๋ฐ์ดํ„ฐ ์ฐพ๊ธฐ + const backlink = this.backlinks.find(b => b.id === elementId); + if (backlink) { + this.showBacklinkTooltip(backlink, element); + } + } + } + } + + /** + * ํ…์ŠคํŠธ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + */ + getTextOffset(root, node, offset) { + let textOffset = 0; + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentNode; + while (currentNode = walker.nextNode()) { + if (currentNode === node) { + return textOffset + offset; + } + textOffset += currentNode.textContent.length; + } + + return textOffset; + } +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.LinkManager = LinkManager; diff --git a/frontend/static/js/viewer/features/ui-manager.js b/frontend/static/js/viewer/features/ui-manager.js new file mode 100644 index 0000000..d6a4c68 --- /dev/null +++ b/frontend/static/js/viewer/features/ui-manager.js @@ -0,0 +1,413 @@ +/** + * UIManager ๋ชจ๋“ˆ + * UI ์ปดํฌ๋„ŒํŠธ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ + */ +class UIManager { + constructor() { + console.log('๐ŸŽจ UIManager ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // UI ์ƒํƒœ + this.showNotesPanel = false; + this.showBookmarksPanel = false; + this.showBacklinks = false; + this.activePanel = 'notes'; + + // ๋ชจ๋‹ฌ ์ƒํƒœ + this.showNoteModal = false; + this.showBookmarkModal = false; + this.showLinkModal = false; + this.showNotesModal = false; + this.showBookmarksModal = false; + this.showLinksModal = false; + this.showBacklinksModal = false; + + // ๊ธฐ๋Šฅ ๋ฉ”๋‰ด ์ƒํƒœ + this.activeFeatureMenu = null; + + // ๊ฒ€์ƒ‰ ์ƒํƒœ + this.searchQuery = ''; + this.noteSearchQuery = ''; + this.filteredNotes = []; + + // ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ + this.textSelectorUISetup = false; + + console.log('โœ… UIManager ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + + /** + * ๊ธฐ๋Šฅ ๋ฉ”๋‰ด ํ† ๊ธ€ + */ + toggleFeatureMenu(feature) { + if (this.activeFeatureMenu === feature) { + this.activeFeatureMenu = null; + } else { + this.activeFeatureMenu = feature; + + // ํ•ด๋‹น ๊ธฐ๋Šฅ์˜ ๋ชจ๋‹ฌ ํ‘œ์‹œ + switch(feature) { + case 'link': + this.showLinksModal = true; + break; + case 'memo': + this.showNotesModal = true; + break; + case 'bookmark': + this.showBookmarksModal = true; + break; + case 'backlink': + this.showBacklinksModal = true; + break; + } + } + } + + /** + * ๋…ธํŠธ ๋ชจ๋‹ฌ ์—ด๊ธฐ + */ + openNoteModal(highlight = null) { + console.log('๐Ÿ“ ๋…ธํŠธ ๋ชจ๋‹ฌ ์—ด๊ธฐ'); + if (highlight) { + console.log('๐Ÿ” ํ•˜์ด๋ผ์ดํŠธ์™€ ์—ฐ๊ฒฐ๋œ ๋…ธํŠธ ๋ชจ๋‹ฌ:', highlight); + } + this.showNoteModal = true; + } + + /** + * ๋…ธํŠธ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + */ + closeNoteModal() { + this.showNoteModal = false; + } + + /** + * ๋งํฌ ๋ชจ๋‹ฌ ์—ด๊ธฐ + */ + openLinkModal() { + console.log('๐Ÿ”— ๋งํฌ ๋ชจ๋‹ฌ ์—ด๊ธฐ'); + console.log('๐Ÿ”— showLinksModal ์„ค์ • ์ „:', this.showLinksModal); + this.showLinksModal = true; + this.showLinkModal = true; // ๊ธฐ์กด ํ˜ธํ™˜์„ฑ + console.log('๐Ÿ”— showLinksModal ์„ค์ • ํ›„:', this.showLinksModal); + } + + /** + * ๋งํฌ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + */ + closeLinkModal() { + this.showLinksModal = false; + this.showLinkModal = false; + } + + /** + * ๋ถ๋งˆํฌ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + */ + closeBookmarkModal() { + this.showBookmarkModal = false; + } + + /** + * ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ํ•˜์ด๋ผ์ดํŠธ + */ + highlightSearchResults(element, searchText) { + if (!searchText.trim()) return; + + // ๊ธฐ์กด ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์ œ๊ฑฐ + const existingHighlights = element.querySelectorAll('.search-highlight'); + existingHighlights.forEach(highlight => { + const parent = highlight.parentNode; + parent.replaceChild(document.createTextNode(highlight.textContent), highlight); + parent.normalize(); + }); + + if (!searchText) return; + + // ์ƒˆ๋กœ์šด ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์ ์šฉ + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + textNodes.push(node); + } + + const searchRegex = new RegExp(`(${searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + + textNodes.forEach(textNode => { + const text = textNode.textContent; + if (searchRegex.test(text)) { + const highlightedHTML = text.replace(searchRegex, '$1'); + const wrapper = document.createElement('div'); + wrapper.innerHTML = highlightedHTML; + + const fragment = document.createDocumentFragment(); + while (wrapper.firstChild) { + fragment.appendChild(wrapper.firstChild); + } + + textNode.parentNode.replaceChild(fragment, textNode); + } + }); + } + + /** + * ๋…ธํŠธ ๊ฒ€์ƒ‰ ํ•„ํ„ฐ๋ง + */ + filterNotes(notes) { + if (!this.noteSearchQuery.trim()) { + this.filteredNotes = notes; + return notes; + } + + const query = this.noteSearchQuery.toLowerCase(); + this.filteredNotes = notes.filter(note => + note.content.toLowerCase().includes(query) || + (note.tags && note.tags.some(tag => tag.toLowerCase().includes(query))) + ); + + return this.filteredNotes; + } + + /** + * ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ UI ์„ค์ • + */ + setupTextSelectorUI() { + console.log('๐Ÿ”ง setupTextSelectorUI ํ•จ์ˆ˜ ์‹คํ–‰๋จ'); + + // ์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€ + if (this.textSelectorUISetup) { + console.log('โš ๏ธ ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ UI๊ฐ€ ์ด๋ฏธ ์„ค์ •๋จ - ์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€'); + return; + } + + // ํ—ค๋” ์ˆจ๊ธฐ๊ธฐ + const header = document.querySelector('header'); + if (header) { + header.style.display = 'none'; + } + + // ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + const messageDiv = document.createElement('div'); + messageDiv.id = 'text-selection-message'; + messageDiv.className = 'fixed top-4 left-1/2 transform -translate-x-1/2 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50'; + messageDiv.innerHTML = ` +
+ + ์—ฐ๊ฒฐํ•  ํ…์ŠคํŠธ๋ฅผ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ์„ ํƒํ•ด์ฃผ์„ธ์š” +
+ `; + document.body.appendChild(messageDiv); + + this.textSelectorUISetup = true; + console.log('โœ… ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ UI ์„ค์ • ์™„๋ฃŒ'); + } + + /** + * ํ…์ŠคํŠธ ์„ ํƒ ํ™•์ธ UI ํ‘œ์‹œ + */ + showTextSelectionConfirm(selectedText, startOffset, endOffset) { + // ๊ธฐ์กด ํ™•์ธ UI ์ œ๊ฑฐ + const existingConfirm = document.getElementById('text-selection-confirm'); + if (existingConfirm) { + existingConfirm.remove(); + } + + // ํ™•์ธ UI ์ƒ์„ฑ + const confirmDiv = document.createElement('div'); + confirmDiv.id = 'text-selection-confirm'; + confirmDiv.className = 'fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white border border-gray-300 rounded-lg shadow-xl p-6 z-50 max-w-md'; + confirmDiv.innerHTML = ` +
+

์„ ํƒ๋œ ํ…์ŠคํŠธ

+
+

"${selectedText}"

+
+
+
+ + +
+ `; + document.body.appendChild(confirmDiv); + } + + /** + * ๋งํฌ ์ƒ์„ฑ UI ํ‘œ์‹œ + */ + showLinkCreationUI() { + console.log('๐Ÿ”— ๋งํฌ ์ƒ์„ฑ UI ํ‘œ์‹œ'); + this.openLinkModal(); + } + + /** + * ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + */ + showSuccessMessage(message) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center space-x-2'; + messageDiv.innerHTML = ` + + ${message} + `; + + document.body.appendChild(messageDiv); + + // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.parentNode.removeChild(messageDiv); + } + }, 3000); + } + + /** + * ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + */ + showErrorMessage(message) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center space-x-2'; + messageDiv.innerHTML = ` + + ${message} + `; + + document.body.appendChild(messageDiv); + + // 5์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.parentNode.removeChild(messageDiv); + } + }, 5000); + } + + /** + * ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ + */ + showLoadingSpinner(container, message = '๋กœ๋”ฉ ์ค‘...') { + const spinner = document.createElement('div'); + spinner.className = 'flex items-center justify-center py-8'; + spinner.innerHTML = ` +
+ ${message} + `; + + if (container) { + container.innerHTML = ''; + container.appendChild(spinner); + } + + return spinner; + } + + /** + * ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ์ œ๊ฑฐ + */ + hideLoadingSpinner(container) { + if (container) { + const spinner = container.querySelector('.animate-spin'); + if (spinner && spinner.parentElement) { + spinner.parentElement.remove(); + } + } + } + + /** + * ๋ชจ๋‹ฌ ๋ฐฐ๊ฒฝ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + */ + handleModalBackgroundClick(event, modalId) { + if (event.target === event.currentTarget) { + switch(modalId) { + case 'notes': + this.closeNoteModal(); + break; + case 'bookmarks': + this.closeBookmarkModal(); + break; + case 'links': + this.closeLinkModal(); + break; + } + } + } + + /** + * ํŒจ๋„ ํ† ๊ธ€ + */ + togglePanel(panelType) { + switch(panelType) { + case 'notes': + this.showNotesPanel = !this.showNotesPanel; + if (this.showNotesPanel) { + this.showBookmarksPanel = false; + this.activePanel = 'notes'; + } + break; + case 'bookmarks': + this.showBookmarksPanel = !this.showBookmarksPanel; + if (this.showBookmarksPanel) { + this.showNotesPanel = false; + this.activePanel = 'bookmarks'; + } + break; + case 'backlinks': + this.showBacklinks = !this.showBacklinks; + break; + } + } + + /** + * ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์—…๋ฐ์ดํŠธ + */ + updateSearchQuery(query) { + this.searchQuery = query; + } + + /** + * ๋…ธํŠธ ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์—…๋ฐ์ดํŠธ + */ + updateNoteSearchQuery(query) { + this.noteSearchQuery = query; + } + + /** + * UI ์ƒํƒœ ์ดˆ๊ธฐํ™” + */ + resetUIState() { + this.showNotesPanel = false; + this.showBookmarksPanel = false; + this.showBacklinks = false; + this.activePanel = 'notes'; + this.activeFeatureMenu = null; + this.searchQuery = ''; + this.noteSearchQuery = ''; + this.filteredNotes = []; + } + + /** + * ๋ชจ๋“  ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + */ + closeAllModals() { + this.showNoteModal = false; + this.showBookmarkModal = false; + this.showLinkModal = false; + this.showNotesModal = false; + this.showBookmarksModal = false; + this.showLinksModal = false; + this.showBacklinksModal = false; + } +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.UIManager = UIManager; diff --git a/frontend/static/js/viewer/utils/cache-manager.js b/frontend/static/js/viewer/utils/cache-manager.js new file mode 100644 index 0000000..ea9785c --- /dev/null +++ b/frontend/static/js/viewer/utils/cache-manager.js @@ -0,0 +1,396 @@ +/** + * CacheManager - ๋ฐ์ดํ„ฐ ์บ์‹ฑ ๋ฐ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ๊ด€๋ฆฌ + * API ์‘๋‹ต, ๋ฌธ์„œ ๋ฐ์ดํ„ฐ, ์‚ฌ์šฉ์ž ์„ค์ • ๋“ฑ์„ ํšจ์œจ์ ์œผ๋กœ ์บ์‹ฑํ•ฉ๋‹ˆ๋‹ค. + */ +class CacheManager { + constructor() { + console.log('๐Ÿ’พ CacheManager ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // ์บ์‹œ ์„ค์ • + this.config = { + // ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ (๋ฐ€๋ฆฌ์ดˆ) + ttl: { + document: 30 * 60 * 1000, // ๋ฌธ์„œ: 30๋ถ„ + highlights: 10 * 60 * 1000, // ํ•˜์ด๋ผ์ดํŠธ: 10๋ถ„ + notes: 10 * 60 * 1000, // ๋ฉ”๋ชจ: 10๋ถ„ + bookmarks: 15 * 60 * 1000, // ๋ถ๋งˆํฌ: 15๋ถ„ + links: 15 * 60 * 1000, // ๋งํฌ: 15๋ถ„ + navigation: 60 * 60 * 1000, // ๋„ค๋น„๊ฒŒ์ด์…˜: 1์‹œ๊ฐ„ + userSettings: 24 * 60 * 60 * 1000 // ์‚ฌ์šฉ์ž ์„ค์ •: 24์‹œ๊ฐ„ + }, + // ์บ์‹œ ํ‚ค ์ ‘๋‘์‚ฌ + prefix: 'docviewer_', + // ์ตœ๋Œ€ ์บ์‹œ ํฌ๊ธฐ (ํ•ญ๋ชฉ ์ˆ˜) + maxItems: 100, + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์‚ฌ์šฉ ์—ฌ๋ถ€ + useLocalStorage: true + }; + + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ (๋น ๋ฅธ ์ ‘๊ทผ์šฉ) + this.memoryCache = new Map(); + + // ์บ์‹œ ํ†ต๊ณ„ + this.stats = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0 + }; + + // ์ดˆ๊ธฐํ™” ์‹œ ์˜ค๋ž˜๋œ ์บ์‹œ ์ •๋ฆฌ + this.cleanupExpiredCache(); + + console.log('โœ… CacheManager ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + + /** + * ์บ์‹œ์—์„œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + */ + get(key, category = 'default') { + const fullKey = this.getFullKey(key, category); + + // 1. ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ์—์„œ ๋จผ์ € ํ™•์ธ + if (this.memoryCache.has(fullKey)) { + const cached = this.memoryCache.get(fullKey); + if (this.isValid(cached)) { + this.stats.hits++; + console.log(`๐Ÿ’พ ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ HIT: ${fullKey}`); + return cached.data; + } else { + // ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์ œ๊ฑฐ + this.memoryCache.delete(fullKey); + } + } + + // 2. ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ํ™•์ธ + if (this.config.useLocalStorage) { + try { + const stored = localStorage.getItem(fullKey); + if (stored) { + const cached = JSON.parse(stored); + if (this.isValid(cached)) { + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ์—๋„ ์ €์žฅ + this.memoryCache.set(fullKey, cached); + this.stats.hits++; + console.log(`๐Ÿ’พ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์บ์‹œ HIT: ${fullKey}`); + return cached.data; + } else { + // ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์ œ๊ฑฐ + localStorage.removeItem(fullKey); + } + } + } catch (error) { + console.warn('๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ฝ๊ธฐ ์˜ค๋ฅ˜:', error); + } + } + + this.stats.misses++; + console.log(`๐Ÿ’พ ์บ์‹œ MISS: ${fullKey}`); + return null; + } + + /** + * ์บ์‹œ์— ๋ฐ์ดํ„ฐ ์ €์žฅ + */ + set(key, data, category = 'default', customTtl = null) { + const fullKey = this.getFullKey(key, category); + const ttl = customTtl || this.config.ttl[category] || this.config.ttl.default || 10 * 60 * 1000; + + const cached = { + data: data, + timestamp: Date.now(), + ttl: ttl, + category: category, + size: this.estimateSize(data) + }; + + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ์— ์ €์žฅ + this.memoryCache.set(fullKey, cached); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ + if (this.config.useLocalStorage) { + try { + localStorage.setItem(fullKey, JSON.stringify(cached)); + } catch (error) { + console.warn('๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ €์žฅ ์˜ค๋ฅ˜ (์šฉ๋Ÿ‰ ๋ถ€์กฑ?):', error); + // ์šฉ๋Ÿ‰ ๋ถ€์กฑ ์‹œ ์˜ค๋ž˜๋œ ์บ์‹œ ์ •๋ฆฌ ํ›„ ์žฌ์‹œ๋„ + this.cleanupOldCache(); + try { + localStorage.setItem(fullKey, JSON.stringify(cached)); + } catch (retryError) { + console.error('๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ €์žฅ ์žฌ์‹œ๋„ ์‹คํŒจ:', retryError); + } + } + } + + this.stats.sets++; + console.log(`๐Ÿ’พ ์บ์‹œ ์ €์žฅ: ${fullKey} (TTL: ${ttl}ms)`); + + // ์บ์‹œ ํฌ๊ธฐ ์ œํ•œ ํ™•์ธ + this.enforceMaxItems(); + } + + /** + * ํŠน์ • ํ‚ค ๋˜๋Š” ์นดํ…Œ๊ณ ๋ฆฌ์˜ ์บ์‹œ ์‚ญ์ œ + */ + delete(key, category = 'default') { + const fullKey = this.getFullKey(key, category); + + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ์—์„œ ์‚ญ์ œ + this.memoryCache.delete(fullKey); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ์‚ญ์ œ + if (this.config.useLocalStorage) { + localStorage.removeItem(fullKey); + } + + console.log(`๐Ÿ’พ ์บ์‹œ ์‚ญ์ œ: ${fullKey}`); + } + + /** + * ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์บ์‹œ ์ „์ฒด ์‚ญ์ œ + */ + deleteCategory(category) { + const prefix = this.getFullKey('', category); + + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ์—์„œ ์‚ญ์ œ + for (const key of this.memoryCache.keys()) { + if (key.startsWith(prefix)) { + this.memoryCache.delete(key); + } + } + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ์‚ญ์ œ + if (this.config.useLocalStorage) { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + localStorage.removeItem(key); + } + } + } + + console.log(`๐Ÿ’พ ์นดํ…Œ๊ณ ๋ฆฌ ์บ์‹œ ์‚ญ์ œ: ${category}`); + } + + /** + * ๋ชจ๋“  ์บ์‹œ ์‚ญ์ œ + */ + clear() { + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ ์‚ญ์ œ + this.memoryCache.clear(); + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์—์„œ ๊ด€๋ จ ์บ์‹œ๋งŒ ์‚ญ์ œ + if (this.config.useLocalStorage) { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.prefix)) { + localStorage.removeItem(key); + } + } + } + + // ํ†ต๊ณ„ ์ดˆ๊ธฐํ™” + this.stats = { hits: 0, misses: 0, sets: 0, evictions: 0 }; + + console.log('๐Ÿ’พ ๋ชจ๋“  ์บ์‹œ ์‚ญ์ œ ์™„๋ฃŒ'); + } + + /** + * ์บ์‹œ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + */ + isValid(cached) { + if (!cached || !cached.timestamp || !cached.ttl) { + return false; + } + + const age = Date.now() - cached.timestamp; + return age < cached.ttl; + } + + /** + * ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์ •๋ฆฌ + */ + cleanupExpiredCache() { + console.log('๐Ÿงน ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์ •๋ฆฌ ์‹œ์ž‘'); + + let cleanedCount = 0; + + // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ ์ •๋ฆฌ + for (const [key, cached] of this.memoryCache.entries()) { + if (!this.isValid(cached)) { + this.memoryCache.delete(key); + cleanedCount++; + } + } + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ์ •๋ฆฌ + if (this.config.useLocalStorage) { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.prefix)) { + try { + const stored = localStorage.getItem(key); + if (stored) { + const cached = JSON.parse(stored); + if (!this.isValid(cached)) { + localStorage.removeItem(key); + cleanedCount++; + } + } + } catch (error) { + // ํŒŒ์‹ฑ ์˜ค๋ฅ˜ ์‹œ ํ•ด๋‹น ์บ์‹œ ์‚ญ์ œ + localStorage.removeItem(key); + cleanedCount++; + } + } + } + } + + console.log(`๐Ÿงน ๋งŒ๋ฃŒ๋œ ์บ์‹œ ${cleanedCount}๊ฐœ ์ •๋ฆฌ ์™„๋ฃŒ`); + } + + /** + * ์˜ค๋ž˜๋œ ์บ์‹œ ์ •๋ฆฌ (์šฉ๋Ÿ‰ ๋ถ€์กฑ ์‹œ) + */ + cleanupOldCache() { + console.log('๐Ÿงน ์˜ค๋ž˜๋œ ์บ์‹œ ์ •๋ฆฌ ์‹œ์ž‘'); + + const items = []; + + // ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์˜ ๋ชจ๋“  ์บ์‹œ ํ•ญ๋ชฉ ์ˆ˜์ง‘ + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.prefix)) { + try { + const stored = localStorage.getItem(key); + if (stored) { + const cached = JSON.parse(stored); + items.push({ key, cached }); + } + } catch (error) { + // ํŒŒ์‹ฑ ์˜ค๋ฅ˜ ์‹œ ํ•ด๋‹น ์บ์‹œ ์‚ญ์ œ + localStorage.removeItem(key); + } + } + } + + // ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ์ค€์œผ๋กœ ์ •๋ ฌ (์˜ค๋ž˜๋œ ๊ฒƒ๋ถ€ํ„ฐ) + items.sort((a, b) => a.cached.timestamp - b.cached.timestamp); + + // ์˜ค๋ž˜๋œ ํ•ญ๋ชฉ์˜ ์ ˆ๋ฐ˜ ์‚ญ์ œ + const deleteCount = Math.floor(items.length / 2); + for (let i = 0; i < deleteCount; i++) { + localStorage.removeItem(items[i].key); + this.memoryCache.delete(items[i].key); + } + + console.log(`๐Ÿงน ์˜ค๋ž˜๋œ ์บ์‹œ ${deleteCount}๊ฐœ ์ •๋ฆฌ ์™„๋ฃŒ`); + } + + /** + * ์ตœ๋Œ€ ํ•ญ๋ชฉ ์ˆ˜ ์ œํ•œ ์ ์šฉ + */ + enforceMaxItems() { + if (this.memoryCache.size > this.config.maxItems) { + const excess = this.memoryCache.size - this.config.maxItems; + const keys = Array.from(this.memoryCache.keys()); + + // ์˜ค๋ž˜๋œ ํ•ญ๋ชฉ๋ถ€ํ„ฐ ์‚ญ์ œ + for (let i = 0; i < excess; i++) { + this.memoryCache.delete(keys[i]); + this.stats.evictions++; + } + + console.log(`๐Ÿ’พ ์บ์‹œ ํฌ๊ธฐ ์ œํ•œ์œผ๋กœ ${excess}๊ฐœ ํ•ญ๋ชฉ ์ œ๊ฑฐ`); + } + } + + /** + * ์ „์ฒด ํ‚ค ์ƒ์„ฑ + */ + getFullKey(key, category) { + return `${this.config.prefix}${category}_${key}`; + } + + /** + * ๋ฐ์ดํ„ฐ ํฌ๊ธฐ ์ถ”์ • + */ + estimateSize(data) { + try { + return JSON.stringify(data).length; + } catch { + return 0; + } + } + + /** + * ์บ์‹œ ํ†ต๊ณ„ ์กฐํšŒ + */ + getStats() { + const hitRate = this.stats.hits + this.stats.misses > 0 + ? (this.stats.hits / (this.stats.hits + this.stats.misses) * 100).toFixed(2) + : 0; + + return { + ...this.stats, + hitRate: `${hitRate}%`, + memoryItems: this.memoryCache.size, + localStorageItems: this.getLocalStorageItemCount() + }; + } + + /** + * ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ํ•ญ๋ชฉ ์ˆ˜ ์กฐํšŒ + */ + getLocalStorageItemCount() { + let count = 0; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.prefix)) { + count++; + } + } + return count; + } + + /** + * ์บ์‹œ ์ƒํƒœ ๋ฆฌํฌํŠธ + */ + getReport() { + const stats = this.getStats(); + const memoryUsage = Array.from(this.memoryCache.values()) + .reduce((total, cached) => total + (cached.size || 0), 0); + + return { + stats, + memoryUsage: `${(memoryUsage / 1024).toFixed(2)} KB`, + categories: this.getCategoryStats(), + config: this.config + }; + } + + /** + * ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํ†ต๊ณ„ + */ + getCategoryStats() { + const categories = {}; + + for (const [key, cached] of this.memoryCache.entries()) { + const category = cached.category || 'default'; + if (!categories[category]) { + categories[category] = { count: 0, size: 0 }; + } + categories[category].count++; + categories[category].size += cached.size || 0; + } + + return categories; + } +} + +// ์ „์—ญ ์บ์‹œ ๋งค๋‹ˆ์ € ์ธ์Šคํ„ด์Šค +window.cacheManager = new CacheManager(); + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.CacheManager = CacheManager; diff --git a/frontend/static/js/viewer/utils/cached-api.js b/frontend/static/js/viewer/utils/cached-api.js new file mode 100644 index 0000000..d1be4f0 --- /dev/null +++ b/frontend/static/js/viewer/utils/cached-api.js @@ -0,0 +1,521 @@ +/** + * CachedAPI - ์บ์‹ฑ์ด ์ ์šฉ๋œ API ๋ž˜ํผ + * ๊ธฐ์กด DocumentServerAPI๋ฅผ ํ™•์žฅํ•˜์—ฌ ์บ์‹ฑ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + */ +class CachedAPI { + constructor(baseAPI) { + this.api = baseAPI; + this.cache = window.cacheManager; + + console.log('๐Ÿš€ CachedAPI ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + + /** + * ์บ์‹ฑ์ด ์ ์šฉ๋œ GET ์š”์ฒญ + */ + async get(endpoint, params = {}, options = {}) { + const { + useCache = true, + category = 'api', + ttl = null, + forceRefresh = false + } = options; + + // ์บ์‹œ ํ‚ค ์ƒ์„ฑ + const cacheKey = this.generateCacheKey(endpoint, params); + + // ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ์ด ์•„๋‹ˆ๊ณ  ์บ์‹œ ์‚ฌ์šฉ ์„ค์ •์ธ ๊ฒฝ์šฐ ์บ์‹œ ํ™•์ธ + if (useCache && !forceRefresh) { + const cached = this.cache.get(cacheKey, category); + if (cached) { + console.log(`๐Ÿš€ API ์บ์‹œ ์‚ฌ์šฉ: ${endpoint}`); + return cached; + } + } + + try { + console.log(`๐ŸŒ API ํ˜ธ์ถœ: ${endpoint}`); + + // ์‹ค์ œ ๋ฐฑ์—”๋“œ API ์—”๋“œํฌ์ธํŠธ๋กœ ๋งคํ•‘ + let response; + if (endpoint === '/highlights' && params.document_id) { + // ์‹ค์ œ: /highlights/document/{documentId} + response = await this.api.get(`/highlights/document/${params.document_id}`); + } else if (endpoint === '/notes' && params.document_id) { + // ์‹ค์ œ: /notes/document/{documentId} + response = await this.api.get(`/notes/document/${params.document_id}`); + } else if (endpoint === '/bookmarks' && params.document_id) { + // ์‹ค์ œ: /bookmarks/document/{documentId} + response = await this.api.get(`/bookmarks/document/${params.document_id}`); + } else if (endpoint === '/document-links' && params.document_id) { + // ์‹ค์ œ: /documents/{documentId}/links + response = await this.api.get(`/documents/${params.document_id}/links`); + } else if (endpoint === '/document-links/backlinks' && params.target_document_id) { + // ์‹ค์ œ: /documents/{documentId}/backlinks + response = await this.api.get(`/documents/${params.target_document_id}/backlinks`); + } else if (endpoint.startsWith('/documents/') && endpoint.endsWith('/links')) { + // /documents/{documentId}/links ํŒจํ„ด + const documentId = endpoint.split('/')[2]; + console.log('๐Ÿ”— CachedAPI: ๋งํฌ API ์ง์ ‘ ํ˜ธ์ถœ:', documentId); + response = await this.api.getDocumentLinks(documentId); + } else if (endpoint.startsWith('/documents/') && endpoint.endsWith('/backlinks')) { + // /documents/{documentId}/backlinks ํŒจํ„ด + const documentId = endpoint.split('/')[2]; + console.log('๐Ÿ”— CachedAPI: ๋ฐฑ๋งํฌ API ์ง์ ‘ ํ˜ธ์ถœ:', documentId); + response = await this.api.getDocumentBacklinks(documentId); + } else if (endpoint.startsWith('/documents/') && endpoint.match(/^\/documents\/[^\/]+$/)) { + // /documents/{documentId} ํŒจํ„ด๋งŒ (์ถ”๊ฐ€ ๊ฒฝ๋กœ ์—†์Œ) + const documentId = endpoint.split('/')[2]; + console.log('๐Ÿ“„ CachedAPI: ๋ฌธ์„œ API ํ˜ธ์ถœ:', documentId); + response = await this.api.getDocument(documentId); + } else { + // ๊ธฐ๋ณธ API ํ˜ธ์ถœ (๊ธฐ์กด ๋ฐฉ์‹) + response = await this.api.get(endpoint, params); + } + + // ์„ฑ๊ณต์ ์ธ ์‘๋‹ต๋งŒ ์บ์‹œ์— ์ €์žฅ + if (useCache && response) { + this.cache.set(cacheKey, response, category, ttl); + console.log(`๐Ÿ’พ API ์‘๋‹ต ์บ์‹œ ์ €์žฅ: ${endpoint}`); + } + + return response; + } catch (error) { + console.error(`โŒ API ํ˜ธ์ถœ ์‹คํŒจ: ${endpoint}`, error); + throw error; + } + } + + /** + * ์บ์‹ฑ์ด ์ ์šฉ๋œ POST ์š”์ฒญ (์ผ๋ฐ˜์ ์œผ๋กœ ์บ์‹œํ•˜์ง€ ์•Š์Œ) + */ + async post(endpoint, data = {}, options = {}) { + const { + invalidateCache = true, + invalidateCategories = [] + } = options; + + try { + const response = await this.api.post(endpoint, data); + + // POST ํ›„ ๊ด€๋ จ ์บ์‹œ ๋ฌดํšจํ™” + if (invalidateCache) { + this.invalidateRelatedCache(endpoint, invalidateCategories); + } + + return response; + } catch (error) { + console.error(`โŒ API POST ์‹คํŒจ: ${endpoint}`, error); + throw error; + } + } + + /** + * ์บ์‹ฑ์ด ์ ์šฉ๋œ PUT ์š”์ฒญ + */ + async put(endpoint, data = {}, options = {}) { + const { + invalidateCache = true, + invalidateCategories = [] + } = options; + + try { + const response = await this.api.put(endpoint, data); + + // PUT ํ›„ ๊ด€๋ จ ์บ์‹œ ๋ฌดํšจํ™” + if (invalidateCache) { + this.invalidateRelatedCache(endpoint, invalidateCategories); + } + + return response; + } catch (error) { + console.error(`โŒ API PUT ์‹คํŒจ: ${endpoint}`, error); + throw error; + } + } + + /** + * ์บ์‹ฑ์ด ์ ์šฉ๋œ DELETE ์š”์ฒญ + */ + async delete(endpoint, options = {}) { + const { + invalidateCache = true, + invalidateCategories = [] + } = options; + + try { + const response = await this.api.delete(endpoint); + + // DELETE ํ›„ ๊ด€๋ จ ์บ์‹œ ๋ฌดํšจํ™” + if (invalidateCache) { + this.invalidateRelatedCache(endpoint, invalidateCategories); + } + + return response; + } catch (error) { + console.error(`โŒ API DELETE ์‹คํŒจ: ${endpoint}`, error); + throw error; + } + } + + /** + * ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getDocument(documentId, contentType = 'document') { + const cacheKey = `document_${documentId}_${contentType}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'document'); + if (cached) { + console.log(`๐Ÿš€ ๋ฌธ์„œ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ ์ง์ ‘ ์‚ฌ์šฉ + try { + const result = await this.api.getDocument(documentId); + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'document', 30 * 60 * 1000); + console.log(`๐Ÿ’พ ๋ฌธ์„œ ์บ์‹œ ์ €์žฅ: ${documentId}`); + + return result; + } catch (error) { + console.error('๋ฌธ์„œ ๋กœ๋“œ ์‹คํŒจ:', error); + throw error; + } + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getHighlights(documentId, contentType = 'document') { + const cacheKey = `highlights_${documentId}_${contentType}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'highlights'); + if (cached) { + console.log(`๐Ÿš€ ํ•˜์ด๋ผ์ดํŠธ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ ์ง์ ‘ ์‚ฌ์šฉ + try { + let result; + if (contentType === 'note') { + result = await this.api.get(`/note/${documentId}/highlights`).catch(() => []); + } else { + result = await this.api.getDocumentHighlights(documentId).catch(() => []); + } + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'highlights', 10 * 60 * 1000); + console.log(`๐Ÿ’พ ํ•˜์ด๋ผ์ดํŠธ ์บ์‹œ ์ €์žฅ: ${documentId}`); + + return result; + } catch (error) { + console.error('ํ•˜์ด๋ผ์ดํŠธ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ฉ”๋ชจ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getNotes(documentId, contentType = 'document') { + const cacheKey = `notes_${documentId}_${contentType}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'notes'); + if (cached) { + console.log(`๐Ÿš€ ๋ฉ”๋ชจ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ API ์‚ฌ์šฉ + try { + let result; + if (contentType === 'note') { + // ๋…ธํŠธ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ + result = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ํ•˜์ด๋ผ์ดํŠธ ๋ฉ”๋ชจ + result = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []); + } + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'notes', 10 * 60 * 1000); + console.log(`๐Ÿ’พ ๋ฉ”๋ชจ ์บ์‹œ ์ €์žฅ: ${documentId} (${result.length}๊ฐœ)`); + + return result; + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ถ๋งˆํฌ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getBookmarks(documentId) { + const cacheKey = `bookmarks_${documentId}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'bookmarks'); + if (cached) { + console.log(`๐Ÿš€ ๋ถ๋งˆํฌ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ ์ง์ ‘ ์‚ฌ์šฉ + try { + const result = await this.api.getDocumentBookmarks(documentId).catch(() => []); + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'bookmarks', 15 * 60 * 1000); + console.log(`๐Ÿ’พ ๋ถ๋งˆํฌ ์บ์‹œ ์ €์žฅ: ${documentId}`); + + return result; + } catch (error) { + console.error('๋ถ๋งˆํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ฌธ์„œ ๋งํฌ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getDocumentLinks(documentId) { + const cacheKey = `links_${documentId}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'links'); + if (cached) { + console.log(`๐Ÿš€ ๋ฌธ์„œ ๋งํฌ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ ์ง์ ‘ ์‚ฌ์šฉ + try { + const result = await this.api.getDocumentLinks(documentId).catch(() => []); + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'links', 15 * 60 * 1000); + console.log(`๐Ÿ’พ ๋ฌธ์„œ ๋งํฌ ์บ์‹œ ์ €์žฅ: ${documentId}`); + + return result; + } catch (error) { + console.error('๋ฌธ์„œ ๋งํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋ฐฑ๋งํฌ ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getBacklinks(documentId) { + const cacheKey = `backlinks_${documentId}`; + + // ์บ์‹œ ํ™•์ธ + const cached = this.cache.get(cacheKey, 'links'); + if (cached) { + console.log(`๐Ÿš€ ๋ฐฑ๋งํฌ ์บ์‹œ ์‚ฌ์šฉ: ${documentId}`); + return cached; + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ ์ง์ ‘ ์‚ฌ์šฉ + try { + const result = await this.api.getDocumentBacklinks(documentId).catch(() => []); + + // ์บ์‹œ์— ์ €์žฅ + this.cache.set(cacheKey, result, 'links', 15 * 60 * 1000); + console.log(`๐Ÿ’พ ๋ฐฑ๋งํฌ ์บ์‹œ ์ €์žฅ: ${documentId}`); + + return result; + } catch (error) { + console.error('๋ฐฑ๋งํฌ ๋กœ๋“œ ์‹คํŒจ:', error); + return []; + } + } + + /** + * ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getNavigation(documentId, contentType = 'document') { + return await this.get('/documents/navigation', { document_id: documentId, content_type: contentType }, { + category: 'navigation', + ttl: 60 * 60 * 1000 // 1์‹œ๊ฐ„ + }); + } + + /** + * ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async createHighlight(data) { + return await this.post('/highlights/', data, { + invalidateCategories: ['highlights', 'notes'] + }); + } + + /** + * ๋ฉ”๋ชจ ์ƒ์„ฑ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async createNote(data) { + return await this.post('/highlight-notes/', data, { + invalidateCategories: ['notes', 'highlights'] + }); + } + + /** + * ๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async updateNote(noteId, data) { + return await this.put(`/highlight-notes/${noteId}`, data, { + invalidateCategories: ['notes', 'highlights'] + }); + } + + /** + * ๋ฉ”๋ชจ ์‚ญ์ œ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async deleteNote(noteId) { + return await this.delete(`/highlight-notes/${noteId}`, { + invalidateCategories: ['notes', 'highlights'] + }); + } + + /** + * ๋ถ๋งˆํฌ ์ƒ์„ฑ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async createBookmark(data) { + return await this.post('/bookmarks/', data, { + invalidateCategories: ['bookmarks'] + }); + } + + /** + * ๋งํฌ ์ƒ์„ฑ (์บ์‹œ ๋ฌดํšจํ™”) + */ + async createDocumentLink(data) { + return await this.post('/document-links/', data, { + invalidateCategories: ['links'] + }); + } + + /** + * ์บ์‹œ ํ‚ค ์ƒ์„ฑ + */ + generateCacheKey(endpoint, params) { + const sortedParams = Object.keys(params) + .sort() + .reduce((result, key) => { + result[key] = params[key]; + return result; + }, {}); + + return `${endpoint}_${JSON.stringify(sortedParams)}`; + } + + /** + * ๊ด€๋ จ ์บ์‹œ ๋ฌดํšจํ™” + */ + invalidateRelatedCache(endpoint, categories = []) { + console.log(`๐Ÿ—‘๏ธ ์บ์‹œ ๋ฌดํšจํ™”: ${endpoint}`); + + // ๊ธฐ๋ณธ ๋ฌดํšจํ™” ๊ทœ์น™ + const defaultInvalidations = { + '/highlights': ['highlights', 'notes'], + '/notes': ['notes', 'highlights'], + '/bookmarks': ['bookmarks'], + '/document-links': ['links'] + }; + + // ์—”๋“œํฌ์ธํŠธ๋ณ„ ๊ธฐ๋ณธ ๋ฌดํšจํ™” ์ ์šฉ + for (const [pattern, cats] of Object.entries(defaultInvalidations)) { + if (endpoint.includes(pattern)) { + cats.forEach(cat => this.cache.deleteCategory(cat)); + } + } + + // ์ถ”๊ฐ€ ๋ฌดํšจํ™” ์นดํ…Œ๊ณ ๋ฆฌ ์ ์šฉ + categories.forEach(category => { + this.cache.deleteCategory(category); + }); + } + + /** + * ํŠน์ • ๋ฌธ์„œ์˜ ๋ชจ๋“  ์บ์‹œ ๋ฌดํšจํ™” + */ + invalidateDocumentCache(documentId) { + console.log(`๐Ÿ—‘๏ธ ๋ฌธ์„œ ์บ์‹œ ๋ฌดํšจํ™”: ${documentId}`); + + const categories = ['document', 'highlights', 'notes', 'bookmarks', 'links', 'navigation']; + categories.forEach(category => { + // ํ•ด๋‹น ๋ฌธ์„œ ID๊ฐ€ ํฌํ•จ๋œ ์บ์‹œ๋งŒ ์‚ญ์ œํ•˜๋Š” ๊ฒƒ์ด ์ด์ƒ์ ์ด์ง€๋งŒ, + // ๊ฐ„๋‹จํ•˜๊ฒŒ ์ „์ฒด ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋ฌดํšจํ™” + this.cache.deleteCategory(category); + }); + } + + /** + * ์บ์‹œ ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ + */ + async refreshCache(endpoint, params = {}, category = 'api') { + return await this.get(endpoint, params, { + category, + forceRefresh: true + }); + } + + /** + * ์บ์‹œ ํ†ต๊ณ„ ์กฐํšŒ + */ + getCacheStats() { + return this.cache.getStats(); + } + + /** + * ์บ์‹œ ๋ฆฌํฌํŠธ ์กฐํšŒ + */ + getCacheReport() { + return this.cache.getReport(); + } + + /** + * ๋ชจ๋“  ์บ์‹œ ์‚ญ์ œ + */ + clearAllCache() { + this.cache.clear(); + console.log('๐Ÿ—‘๏ธ ๋ชจ๋“  API ์บ์‹œ ์‚ญ์ œ ์™„๋ฃŒ'); + } + + // ๊ธฐ์กด API ๋ฉ”์„œ๋“œ๋“ค์„ ๊ทธ๋Œ€๋กœ ์œ„์ž„ (์บ์‹ฑ์ด ํ•„์š” ์—†๋Š” ๊ฒฝ์šฐ) + setToken(token) { + return this.api.setToken(token); + } + + getHeaders() { + return this.api.getHeaders(); + } + + /** + * ๋ฌธ์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ •๋ณด ์กฐํšŒ (์บ์‹ฑ ์ตœ์ ํ™”) + */ + async getDocumentNavigation(documentId) { + const cacheKey = `navigation_${documentId}`; + return await this.get(`/documents/${documentId}/navigation`, {}, { + category: 'navigation', + cacheKey, + ttl: 30 * 60 * 1000 // 30๋ถ„ (๋„ค๋น„๊ฒŒ์ด์…˜์€ ์ž์ฃผ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์Œ) + }); + } +} + +// ๊ธฐ์กด api ์ธ์Šคํ„ด์Šค๋ฅผ ์บ์‹ฑ API๋กœ ๋ž˜ํ•‘ +if (window.api) { + window.cachedApi = new CachedAPI(window.api); + console.log('๐Ÿš€ CachedAPI ๋ž˜ํผ ์ƒ์„ฑ ์™„๋ฃŒ'); +} + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.CachedAPI = CachedAPI; diff --git a/frontend/static/js/viewer/utils/module-loader.js b/frontend/static/js/viewer/utils/module-loader.js new file mode 100644 index 0000000..2126cf9 --- /dev/null +++ b/frontend/static/js/viewer/utils/module-loader.js @@ -0,0 +1,223 @@ +/** + * ModuleLoader - ์ง€์—ฐ ๋กœ๋”ฉ ๋ฐ ๋ชจ๋“ˆ ๊ด€๋ฆฌ + * ํ•„์š”ํ•œ ๋ชจ๋“ˆ๋งŒ ๋™์ ์œผ๋กœ ๋กœ๋“œํ•˜์—ฌ ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•ฉ๋‹ˆ๋‹ค. + */ +class ModuleLoader { + constructor() { + console.log('๐Ÿ”ง ModuleLoader ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // ๋กœ๋“œ๋œ ๋ชจ๋“ˆ ์บ์‹œ + this.loadedModules = new Map(); + + // ๋กœ๋”ฉ ์ค‘์ธ ๋ชจ๋“ˆ Promise ์บ์‹œ (์ค‘๋ณต ๋กœ๋”ฉ ๋ฐฉ์ง€) + this.loadingPromises = new Map(); + + // ๋ชจ๋“ˆ ์˜์กด์„ฑ ์ •์˜ + this.moduleDependencies = { + 'DocumentLoader': [], + 'HighlightManager': ['DocumentLoader'], + 'BookmarkManager': ['DocumentLoader'], + 'LinkManager': ['DocumentLoader'], + 'UIManager': [] + }; + + // ๋ชจ๋“ˆ ๊ฒฝ๋กœ ์ •์˜ + this.modulePaths = { + 'DocumentLoader': '/static/js/viewer/core/document-loader.js', + 'HighlightManager': '/static/js/viewer/features/highlight-manager.js', + 'BookmarkManager': '/static/js/viewer/features/bookmark-manager.js', + 'LinkManager': '/static/js/viewer/features/link-manager.js', + 'UIManager': '/static/js/viewer/features/ui-manager.js' + }; + + // ์บ์‹œ ๋ฒ„์ŠคํŒ…์„ ์œ„ํ•œ ๋ฒ„์ „ + this.version = '2025012607'; + + console.log('โœ… ModuleLoader ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } + + /** + * ๋ชจ๋“ˆ ๋™์  ๋กœ๋“œ + */ + async loadModule(moduleName) { + // ์ด๋ฏธ ๋กœ๋“œ๋œ ๋ชจ๋“ˆ์ธ์ง€ ํ™•์ธ + if (this.loadedModules.has(moduleName)) { + console.log(`โœ… ๋ชจ๋“ˆ ์บ์‹œ์—์„œ ๋ฐ˜ํ™˜: ${moduleName}`); + return this.loadedModules.get(moduleName); + } + + // ์ด๋ฏธ ๋กœ๋”ฉ ์ค‘์ธ ๋ชจ๋“ˆ์ธ์ง€ ํ™•์ธ (์ค‘๋ณต ๋กœ๋”ฉ ๋ฐฉ์ง€) + if (this.loadingPromises.has(moduleName)) { + console.log(`โณ ๋ชจ๋“ˆ ๋กœ๋”ฉ ๋Œ€๊ธฐ ์ค‘: ${moduleName}`); + return await this.loadingPromises.get(moduleName); + } + + console.log(`๐Ÿ”„ ๋ชจ๋“ˆ ๋กœ๋”ฉ ์‹œ์ž‘: ${moduleName}`); + + // ๋กœ๋”ฉ Promise ์ƒ์„ฑ ๋ฐ ์บ์‹œ + const loadingPromise = this._loadModuleScript(moduleName); + this.loadingPromises.set(moduleName, loadingPromise); + + try { + const moduleClass = await loadingPromise; + + // ๋กœ๋”ฉ ์™„๋ฃŒ ํ›„ ์บ์‹œ์— ์ €์žฅ + this.loadedModules.set(moduleName, moduleClass); + this.loadingPromises.delete(moduleName); + + console.log(`โœ… ๋ชจ๋“ˆ ๋กœ๋”ฉ ์™„๋ฃŒ: ${moduleName}`); + return moduleClass; + + } catch (error) { + console.error(`โŒ ๋ชจ๋“ˆ ๋กœ๋”ฉ ์‹คํŒจ: ${moduleName}`, error); + this.loadingPromises.delete(moduleName); + throw error; + } + } + + /** + * ์˜์กด์„ฑ์„ ํฌํ•จํ•œ ๋ชจ๋“ˆ ๋กœ๋“œ + */ + async loadModuleWithDependencies(moduleName) { + console.log(`๐Ÿ”— ์˜์กด์„ฑ ํฌํ•จ ๋ชจ๋“ˆ ๋กœ๋”ฉ: ${moduleName}`); + + // ์˜์กด์„ฑ ๋จผ์ € ๋กœ๋“œ + const dependencies = this.moduleDependencies[moduleName] || []; + if (dependencies.length > 0) { + console.log(`๐Ÿ“ฆ ์˜์กด์„ฑ ๋กœ๋”ฉ: ${dependencies.join(', ')}`); + await Promise.all(dependencies.map(dep => this.loadModule(dep))); + } + + // ๋ฉ”์ธ ๋ชจ๋“ˆ ๋กœ๋“œ + return await this.loadModule(moduleName); + } + + /** + * ์—ฌ๋Ÿฌ ๋ชจ๋“ˆ ๋ณ‘๋ ฌ ๋กœ๋“œ + */ + async loadModules(moduleNames) { + console.log(`๐Ÿš€ ๋ณ‘๋ ฌ ๋ชจ๋“ˆ ๋กœ๋”ฉ: ${moduleNames.join(', ')}`); + + const loadPromises = moduleNames.map(name => this.loadModuleWithDependencies(name)); + const results = await Promise.all(loadPromises); + + console.log(`โœ… ๋ณ‘๋ ฌ ๋ชจ๋“ˆ ๋กœ๋”ฉ ์™„๋ฃŒ: ${moduleNames.join(', ')}`); + return results; + } + + /** + * ์Šคํฌ๋ฆฝํŠธ ๋™์  ๋กœ๋”ฉ + */ + async _loadModuleScript(moduleName) { + return new Promise((resolve, reject) => { + // ์ด๋ฏธ ์ „์—ญ์— ํด๋ž˜์Šค๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + if (window[moduleName]) { + console.log(`โœ… ๋ชจ๋“ˆ ์ด๋ฏธ ๋กœ๋“œ๋จ: ${moduleName}`); + resolve(window[moduleName]); + return; + } + + console.log(`๐Ÿ“ฅ ์Šคํฌ๋ฆฝํŠธ ๋กœ๋”ฉ ์‹œ์ž‘: ${moduleName}`); + const script = document.createElement('script'); + script.src = `${this.modulePaths[moduleName]}?v=${this.version}`; + script.async = true; + + script.onload = () => { + console.log(`๐Ÿ“ฅ ์Šคํฌ๋ฆฝํŠธ ๋กœ๋“œ ์™„๋ฃŒ: ${moduleName}`); + + // ์Šคํฌ๋ฆฝํŠธ ๋กœ๋“œ ํ›„ ์ž ์‹œ ๋Œ€๊ธฐ (ํด๋ž˜์Šค ๋“ฑ๋ก ์‹œ๊ฐ„) + setTimeout(() => { + if (window[moduleName]) { + console.log(`โœ… ๋ชจ๋“ˆ ํด๋ž˜์Šค ํ™•์ธ: ${moduleName}`); + resolve(window[moduleName]); + } else { + console.error(`โŒ ๋ชจ๋“ˆ ํด๋ž˜์Šค ์—†์Œ: ${moduleName}`, Object.keys(window).filter(k => k.includes('Manager') || k.includes('Loader'))); + reject(new Error(`๋ชจ๋“ˆ ํด๋ž˜์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ: ${moduleName}`)); + } + }, 10); // 10ms ๋Œ€๊ธฐ + }; + + script.onerror = (error) => { + console.error(`โŒ ์Šคํฌ๋ฆฝํŠธ ๋กœ๋”ฉ ์‹คํŒจ: ${moduleName}`, error); + reject(new Error(`์Šคํฌ๋ฆฝํŠธ ๋กœ๋”ฉ ์‹คํŒจ: ${moduleName}`)); + }; + + document.head.appendChild(script); + }); + } + + /** + * ๋ชจ๋“ˆ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ (ํŒฉํ† ๋ฆฌ ํŒจํ„ด) + */ + async createModuleInstance(moduleName, ...args) { + const ModuleClass = await this.loadModuleWithDependencies(moduleName); + return new ModuleClass(...args); + } + + /** + * ๋ชจ๋“ˆ ํ”„๋ฆฌ๋กœ๋”ฉ (๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋ฏธ๋ฆฌ ๋กœ๋“œ) + */ + async preloadModules(moduleNames) { + console.log(`๐Ÿ”ฎ ๋ชจ๋“ˆ ํ”„๋ฆฌ๋กœ๋”ฉ: ${moduleNames.join(', ')}`); + + // ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋กœ๋“œ (์—๋Ÿฌ ๋ฌด์‹œ) + const preloadPromises = moduleNames.map(async (name) => { + try { + await this.loadModuleWithDependencies(name); + console.log(`โœ… ํ”„๋ฆฌ๋กœ๋”ฉ ์™„๋ฃŒ: ${name}`); + } catch (error) { + console.warn(`โš ๏ธ ํ”„๋ฆฌ๋กœ๋”ฉ ์‹คํŒจ: ${name}`, error); + } + }); + + // ๋ชจ๋“  ํ”„๋ฆฌ๋กœ๋”ฉ์ด ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š์Œ + Promise.all(preloadPromises); + } + + /** + * ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๋ชจ๋“ˆ ์–ธ๋กœ๋“œ (๋ฉ”๋ชจ๋ฆฌ ์ตœ์ ํ™”) + */ + unloadModule(moduleName) { + if (this.loadedModules.has(moduleName)) { + this.loadedModules.delete(moduleName); + console.log(`๐Ÿ—‘๏ธ ๋ชจ๋“ˆ ์–ธ๋กœ๋“œ: ${moduleName}`); + } + } + + /** + * ๋ชจ๋“  ๋ชจ๋“ˆ ์–ธ๋กœ๋“œ + */ + unloadAllModules() { + this.loadedModules.clear(); + this.loadingPromises.clear(); + console.log('๐Ÿ—‘๏ธ ๋ชจ๋“  ๋ชจ๋“ˆ ์–ธ๋กœ๋“œ ์™„๋ฃŒ'); + } + + /** + * ๋กœ๋“œ๋œ ๋ชจ๋“ˆ ์ƒํƒœ ํ™•์ธ + */ + getLoadedModules() { + return Array.from(this.loadedModules.keys()); + } + + /** + * ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ • + */ + getMemoryUsage() { + const loadedCount = this.loadedModules.size; + const loadingCount = this.loadingPromises.size; + + return { + loadedModules: loadedCount, + loadingModules: loadingCount, + totalModules: Object.keys(this.modulePaths).length, + memoryEstimate: `${loadedCount * 50}KB` // ๋Œ€๋žต์ ์ธ ์ถ”์ • + }; + } +} + +// ์ „์—ญ ๋ชจ๋“ˆ ๋กœ๋” ์ธ์Šคํ„ด์Šค +window.moduleLoader = new ModuleLoader(); + +// ์ „์—ญ์œผ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ +window.ModuleLoader = ModuleLoader; diff --git a/frontend/static/js/viewer/viewer-core.js b/frontend/static/js/viewer/viewer-core.js new file mode 100644 index 0000000..3742f66 --- /dev/null +++ b/frontend/static/js/viewer/viewer-core.js @@ -0,0 +1,2434 @@ +/** + * ViewerCore - ๋ฌธ์„œ ๋ทฐ์–ด Alpine.js ์ปดํฌ๋„ŒํŠธ + * ๋ชจ๋“  ๋ชจ๋“ˆ์„ ํ†ตํ•ฉํ•˜๊ณ  Alpine.js ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + */ +window.documentViewer = () => ({ + // ==================== ๊ธฐ๋ณธ ์ƒํƒœ ==================== + loading: true, + error: null, + document: null, + documentId: null, + contentType: 'document', // 'document' ๋˜๋Š” 'note' + navigation: null, + + // ==================== PDF ๋ทฐ์–ด ์ƒํƒœ ==================== + pdfSrc: '', + pdfLoading: false, + pdfError: false, + pdfLoaded: false, + + // ==================== PDF ๊ฒ€์ƒ‰ ์ƒํƒœ ==================== + showPdfSearchModal: false, + pdfSearchQuery: '', + pdfSearchResults: [], + pdfSearchLoading: false, + + // ==================== PDF.js ๋ทฐ์–ด ์ƒํƒœ ==================== + pdfDocument: null, + currentPage: 1, + totalPages: 0, + pdfScale: 1.0, + pdfCanvas: null, + pdfContext: null, + pdfTextContent: [], + + // ==================== ๋ฐ์ดํ„ฐ ์ƒํƒœ ==================== + highlights: [], + notes: [], + bookmarks: [], + documentLinks: [], + linkableDocuments: [], + backlinks: [], + + // ==================== ์„ ํƒ ์ƒํƒœ ==================== + selectedHighlightColor: '#FFFF00', + selectedText: '', + selectedRange: null, + + // ==================== ํผ ๋ฐ์ดํ„ฐ ==================== + noteForm: { + content: '', + tags: '' + }, + bookmarkForm: { + title: '', + description: '' + }, + linkForm: { + target_type: 'document', // 'document' ๋˜๋Š” 'note' + target_document_id: '', + selected_text: '', + start_offset: 0, + end_offset: 0, + link_text: '', + description: '', + link_type: 'text_fragment', // ๋ฌด์กฐ๊ฑด ํ…์ŠคํŠธ ์„ ํƒ๋งŒ ์ง€์› + target_text: '', + target_start_offset: 0, + target_end_offset: 0, + + target_book_id: '' + }, + + // ==================== ์–ธ์–ด ๋ฐ ๊ธฐํƒ€ ==================== + isKorean: false, + + // ==================== UI ์ƒํƒœ (Alpine.js ๋ฐ”์ธ๋”ฉ์šฉ) ==================== + searchQuery: '', + activeFeatureMenu: null, + showLinksModal: false, + showLinkModal: false, + showNotesModal: false, + showBookmarksModal: false, + showBacklinksModal: false, + showNoteInputModal: false, + availableBooks: [], + filteredDocuments: [], + + // ==================== ๋ชจ๋“ˆ ์ธ์Šคํ„ด์Šค ==================== + documentLoader: null, + highlightManager: null, + bookmarkManager: null, + linkManager: null, + uiManager: null, + + // ==================== ์ดˆ๊ธฐํ™” ํ”Œ๋ž˜๊ทธ ==================== + _initialized: false, + + // ==================== ์ดˆ๊ธฐํ™” ==================== + async init() { + // ์ค‘๋ณต ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€ + if (this._initialized) { + console.log('โš ๏ธ ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋จ, ์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€'); + return; + } + this._initialized = true; + + console.log('๐Ÿš€ DocumentViewer ์ดˆ๊ธฐํ™” ์‹œ์ž‘'); + + // ์ „์—ญ ์ธ์Šคํ„ด์Šค ์„ค์ • (๋งํ’์„ ์—์„œ ํ•จ์ˆ˜ ํ˜ธ์ถœ์šฉ) + window.documentViewerInstance = this; + + try { + // ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” + await this.initializeModules(); + + // URL ํŒŒ๋ผ๋ฏธํ„ฐ ์ฒ˜๋ฆฌ + this.parseUrlParameters(); + + // ๋ฌธ์„œ ๋กœ๋“œ + await this.loadDocument(); + + console.log('โœ… DocumentViewer ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + } catch (error) { + console.error('โŒ DocumentViewer ์ดˆ๊ธฐํ™” ์‹คํŒจ:', error); + this.error = error.message; + this.loading = false; + } + }, + + // ==================== ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” (์ง€์—ฐ ๋กœ๋”ฉ + ํด๋ฐฑ) ==================== + async initializeModules() { + console.log('๐Ÿ”ง ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” ์‹œ์ž‘ (์ง€์—ฐ ๋กœ๋”ฉ)'); + + // API ๋ฐ ์บ์‹œ ์ดˆ๊ธฐํ™” + this.api = new DocumentServerAPI(); + + // ํ† ํฐ ์„ค์ • (์ธ์ฆ ํ™•์ธ) + const token = localStorage.getItem('access_token'); + if (token) { + this.api.setToken(token); + console.log('๐Ÿ” API ํ† ํฐ ์„ค์ • ์™„๋ฃŒ'); + } else { + console.error('โŒ ์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค!'); + throw new Error('์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'); + } + + this.cache = new CacheManager(); + this.cachedApi = new CachedAPI(this.api, this.cache); + + // ์ง์ ‘ ๋ชจ๋“ˆ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ (๋ชจ๋“  ๋ชจ๋“ˆ์ด HTML์—์„œ ๋กœ๋“œ๋จ) + if (window.DocumentLoader && window.UIManager && window.HighlightManager && + window.LinkManager && window.BookmarkManager) { + + this.documentLoader = new window.DocumentLoader(this.cachedApi); + this.uiManager = new window.UIManager(); + this.highlightManager = new window.HighlightManager(this.cachedApi); + this.linkManager = new window.LinkManager(this.cachedApi); + this.bookmarkManager = new window.BookmarkManager(this.cachedApi); + + console.log('โœ… ๋ชจ๋“  ๋ชจ๋“ˆ ์ง์ ‘ ๋กœ๋“œ ์„ฑ๊ณต'); + } else { + console.error('โŒ ํ•„์ˆ˜ ๋ชจ๋“ˆ์ด ๋กœ๋“œ๋˜์ง€ ์•Š์Œ'); + console.log('์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“ˆ:', { + DocumentLoader: !!window.DocumentLoader, + UIManager: !!window.UIManager, + HighlightManager: !!window.HighlightManager, + LinkManager: !!window.LinkManager, + BookmarkManager: !!window.BookmarkManager + }); + throw new Error('ํ•„์ˆ˜ ๋ชจ๋“ˆ์„ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + // UI ์ƒํƒœ๋ฅผ UIManager์™€ ๋™๊ธฐํ™” (๋ชจ๋‹ฌ์€ ์ดˆ๊ธฐํ™” ์‹œ ๋‹ซํžŒ ์ƒํƒœ๋กœ) + this.syncUIState(); + + // ์ดˆ๊ธฐํ™” ์‹œ ๋ชจ๋“  ๋ชจ๋‹ฌ์„ ๋ช…์‹œ์ ์œผ๋กœ ๋‹ซ๊ธฐ + this.closeAllModals(); + + // ๋‚˜๋จธ์ง€ ๋ชจ๋“ˆ๋“ค์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ํ”„๋ฆฌ๋กœ๋”ฉ (์ง€์—ฐ ๋กœ๋”ฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ๋งŒ) + if (window.moduleLoader) { + window.moduleLoader.preloadModules(['HighlightManager', 'BookmarkManager', 'LinkManager']); + } + + console.log('โœ… ๋ชจ๋“ˆ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + }, + + // ==================== UI ์ƒํƒœ ๋™๊ธฐํ™” ==================== + syncUIState() { + // UIManager์˜ ์ƒํƒœ๋ฅผ Alpine.js ์ปดํฌ๋„ŒํŠธ์™€ ๋™๊ธฐํ™” (getter/setter ๋ฐฉ์‹) + + // ํŒจ๋„ ์ƒํƒœ ๋™๊ธฐํ™” + Object.defineProperty(this, 'showNotesPanel', { + get: () => this.uiManager.showNotesPanel, + set: (value) => { this.uiManager.showNotesPanel = value; } + }); + + Object.defineProperty(this, 'showBookmarksPanel', { + get: () => this.uiManager.showBookmarksPanel, + set: (value) => { this.uiManager.showBookmarksPanel = value; } + }); + + Object.defineProperty(this, 'showBacklinks', { + get: () => this.uiManager.showBacklinks, + set: (value) => { this.uiManager.showBacklinks = value; } + }); + + Object.defineProperty(this, 'activePanel', { + get: () => this.uiManager.activePanel, + set: (value) => { this.uiManager.activePanel = value; } + }); + + // ๋ชจ๋‹ฌ ์ƒํƒœ ๋™๊ธฐํ™” (UIManager์™€ ์‹ค์‹œ๊ฐ„ ์—ฐ๋™) + this.updateModalStates(); + + // ๊ฒ€์ƒ‰ ์ƒํƒœ ๋™๊ธฐํ™” + Object.defineProperty(this, 'noteSearchQuery', { + get: () => this.uiManager.noteSearchQuery, + set: (value) => { this.uiManager.updateNoteSearchQuery(value); } + }); + + Object.defineProperty(this, 'filteredNotes', { + get: () => this.uiManager.filteredNotes, + set: (value) => { this.uiManager.filteredNotes = value; } + }); + + // ๋ชจ๋“œ ๋ฐ ํ•ธ๋“ค๋Ÿฌ ์ƒํƒœ + this.activeMode = null; + this.textSelectionHandler = null; + this.editingNote = null; + this.editingBookmark = null; + this.editingLink = null; + this.noteLoading = false; + this.bookmarkLoading = false; + this.linkLoading = false; + }, + + // ==================== ๋ชจ๋‹ฌ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ==================== + updateModalStates() { + // UIManager์˜ ๋ชจ๋‹ฌ ์ƒํƒœ๋ฅผ ViewerCore์˜ ์†์„ฑ์— ๋ฐ˜์˜ + if (this.uiManager) { + this.showLinksModal = this.uiManager.showLinksModal; + this.showLinkModal = this.uiManager.showLinkModal; + this.showNotesModal = this.uiManager.showNotesModal; + this.showBookmarksModal = this.uiManager.showBookmarksModal; + this.showBacklinksModal = this.uiManager.showBacklinksModal; + this.activeFeatureMenu = this.uiManager.activeFeatureMenu; + this.searchQuery = this.uiManager.searchQuery; + } + }, + + // ==================== ๋ชจ๋“  ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ ==================== + closeAllModals() { + console.log('๐Ÿ”’ ์ดˆ๊ธฐํ™” ์‹œ ๋ชจ๋“  ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ'); + this.showLinksModal = false; + this.showLinkModal = false; + this.showNotesModal = false; + this.showBookmarksModal = false; + this.showBacklinksModal = false; + this.showNoteInputModal = false; + + // UIManager์—๋„ ๋ฐ˜์˜ + if (this.uiManager) { + this.uiManager.closeAllModals(); + } + }, + + // ==================== URL ํŒŒ๋ผ๋ฏธํ„ฐ ์ฒ˜๋ฆฌ ==================== + parseUrlParameters() { + const urlParams = new URLSearchParams(window.location.search); + const rawId = urlParams.get('id'); + + // null ๋ฌธ์ž์—ด์ด๋‚˜ ๋นˆ ๊ฐ’ ์ฒ˜๋ฆฌ + if (!rawId || rawId === 'null' || rawId === 'undefined' || rawId.trim() === '') { + console.error('โŒ ์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฌธ์„œ ID:', rawId); + throw new Error('์œ ํšจํ•œ ๋ฌธ์„œ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. URL์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”.'); + } + + this.documentId = rawId; + // contentType ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฐ€์ ธ์˜ค๊ธฐ (type๊ณผ contentType ๋‘˜ ๋‹ค ์ง€์›) + this.contentType = urlParams.get('contentType') || urlParams.get('type') || 'document'; + + console.log('๐Ÿ” URL ํŒŒ์‹ฑ ๊ฒฐ๊ณผ:', { + rawId: rawId, + documentId: this.documentId, + contentType: this.contentType, + fullURL: window.location.href + }); + }, + + // ==================== ๋ฌธ์„œ ๋กœ๋“œ ==================== + async loadDocument() { + console.log('๐Ÿ“„ ๋ฌธ์„œ ๋กœ๋“œ ์‹œ์ž‘'); + this.loading = true; + + try { + // ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + if (this.contentType === 'note') { + this.document = await this.documentLoader.loadNote(this.documentId); + this.navigation = null; // ๋…ธํŠธ๋Š” ๋„ค๋น„๊ฒŒ์ด์…˜ ์—†์Œ + } else { + this.document = await this.documentLoader.loadDocument(this.documentId); + // ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ณ„๋„ ๋กœ๋“œ + this.navigation = await this.documentLoader.loadNavigation(this.documentId); + + // PDF ๋ฌธ์„œ์ธ ๊ฒฝ์šฐ PDF ๋ทฐ์–ด ์ค€๋น„ + if (this.document && this.document.pdf_path) { + await this.loadPdfViewer(); + } + } + + // ๊ด€๋ จ ๋ฐ์ดํ„ฐ ๋ณ‘๋ ฌ ๋กœ๋“œ + await this.loadDocumentData(); + + // ๋ฐ์ดํ„ฐ๋ฅผ ๋ชจ๋“ˆ์— ์ „๋‹ฌ + this.distributeDataToModules(); + + // ๋ Œ๋”๋ง + await this.renderAllFeatures(); + + // URL ํ•˜์ด๋ผ์ดํŠธ ์ฒ˜๋ฆฌ + await this.handleUrlHighlight(); + + this.loading = false; + console.log('โœ… ๋ฌธ์„œ ๋กœ๋“œ ์™„๋ฃŒ'); + + } catch (error) { + console.error('โŒ ๋ฌธ์„œ ๋กœ๋“œ ์‹คํŒจ:', error); + this.error = error.message; + this.loading = false; + } + }, + + // ==================== ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ (์ง€์—ฐ ๋กœ๋”ฉ) ==================== + async loadDocumentData() { + console.log('๐Ÿ“Š ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹œ์ž‘'); + + const [highlights, notes, bookmarks, documentLinks, backlinks] = await Promise.all([ + this.highlightManager.loadHighlights(this.documentId, this.contentType), + this.highlightManager.loadNotes(this.documentId, this.contentType), + this.bookmarkManager.loadBookmarks(this.documentId), + this.linkManager.loadDocumentLinks(this.documentId, this.contentType), + this.linkManager.loadBacklinks(this.documentId, this.contentType) + ]); + + // ๋ฐ์ดํ„ฐ ์ €์žฅ ๋ฐ ๋ชจ๋“ˆ ๋™๊ธฐํ™” + this.highlights = highlights; + this.notes = notes; + this.bookmarks = bookmarks; + this.documentLinks = documentLinks; + this.backlinks = backlinks; + + // ๋ชจ๋“ˆ์— ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” (์ค‘์š”!) + this.linkManager.documentLinks = documentLinks; + this.linkManager.backlinks = backlinks; + + console.log('๐Ÿ“Š ๋กœ๋“œ๋œ ๋ฐ์ดํ„ฐ:', { + highlights: highlights.length, + notes: notes.length, + bookmarks: bookmarks.length, + documentLinks: documentLinks.length, + backlinks: backlinks.length + }); + + console.log('๐Ÿ”„ ๋ชจ๋“ˆ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ์™„๋ฃŒ:', { + 'linkManager.documentLinks': this.linkManager.documentLinks?.length || 0, + 'linkManager.backlinks': this.linkManager.backlinks?.length || 0 + }); + }, + + // ==================== ๋ชจ๋“ˆ ์ง€์—ฐ ๋กœ๋”ฉ ๋ณด์žฅ (ํด๋ฐฑ ํฌํ•จ) ==================== + async ensureModulesLoaded(moduleNames) { + const missingModules = []; + + for (const moduleName of moduleNames) { + const propertyName = this.getModulePropertyName(moduleName); + if (!this[propertyName]) { + missingModules.push(moduleName); + } + } + + if (missingModules.length > 0) { + console.log(`๐Ÿ”„ ํ•„์š”ํ•œ ๋ชจ๋“ˆ ์ง€์—ฐ ๋กœ๋”ฉ: ${missingModules.join(', ')}`); + + // ๊ฐ ๋ชจ๋“ˆ์„ ๊ฐœ๋ณ„์ ์œผ๋กœ ๋กœ๋“œ + for (const moduleName of missingModules) { + const propertyName = this.getModulePropertyName(moduleName); + + try { + // ์ง€์—ฐ ๋กœ๋”ฉ ์‹œ๋„ + if (window.moduleLoader) { + const ModuleClass = await window.moduleLoader.loadModule(moduleName); + + if (moduleName === 'UIManager') { + this[propertyName] = new ModuleClass(); + } else { + this[propertyName] = new ModuleClass(this.cachedApi); + } + + console.log(`โœ… ์ง€์—ฐ ๋กœ๋”ฉ ์„ฑ๊ณต: ${moduleName}`); + } else { + throw new Error('ModuleLoader ์—†์Œ'); + } + } catch (error) { + console.warn(`โš ๏ธ ์ง€์—ฐ ๋กœ๋”ฉ ์‹คํŒจ, ํด๋ฐฑ ์‹œ๋„: ${moduleName}`, error); + + // ํด๋ฐฑ: ์ „์—ญ ํด๋ž˜์Šค ์ง์ ‘ ์‚ฌ์šฉ + if (window[moduleName]) { + if (moduleName === 'UIManager') { + this[propertyName] = new window[moduleName](); + } else { + this[propertyName] = new window[moduleName](this.cachedApi); + } + + console.log(`โœ… ํด๋ฐฑ ์„ฑ๊ณต: ${moduleName}`); + } else { + console.error(`โŒ ํด๋ฐฑ๋„ ์‹คํŒจ: ${moduleName} - ์ „์—ญ ํด๋ž˜์Šค ์—†์Œ`); + throw new Error(`๋ชจ๋“ˆ์„ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: ${moduleName}`); + } + } + } + } + }, + + // ==================== ๋ชจ๋“ˆ๋ช… โ†’ ์†์„ฑ๋ช… ๋ณ€ํ™˜ ==================== + getModulePropertyName(moduleName) { + const nameMap = { + 'DocumentLoader': 'documentLoader', + 'HighlightManager': 'highlightManager', + 'BookmarkManager': 'bookmarkManager', + 'LinkManager': 'linkManager', + 'UIManager': 'uiManager' + }; + return nameMap[moduleName]; + }, + + // ==================== ๋ชจ๋“ˆ์— ๋ฐ์ดํ„ฐ ๋ถ„๋ฐฐ ==================== + distributeDataToModules() { + // HighlightManager์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ + this.highlightManager.highlights = this.highlights; + this.highlightManager.notes = this.notes; + + // BookmarkManager์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ + this.bookmarkManager.bookmarks = this.bookmarks; + + // LinkManager์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ + this.linkManager.documentLinks = this.documentLinks; + this.linkManager.backlinks = this.backlinks; + }, + + // ==================== ๋ชจ๋“  ๊ธฐ๋Šฅ ๋ Œ๋”๋ง ==================== + async renderAllFeatures() { + console.log('๐ŸŽจ ๋ชจ๋“  ๊ธฐ๋Šฅ ๋ Œ๋”๋ง ์‹œ์ž‘'); + + // ํ•˜์ด๋ผ์ดํŠธ ๋ Œ๋”๋ง + this.highlightManager.renderHighlights(); + + // ๋ฐฑ๋งํฌ ๋จผ์ € ๋ Œ๋”๋ง (๋งํฌ๋ณด๋‹ค ๋จผ์ €) + this.linkManager.renderBacklinks(); + + // ๋ฌธ์„œ ๋งํฌ ๋ Œ๋”๋ง (๋ฐฑ๋งํฌ ํ›„์— ๋ Œ๋”๋ง) + this.linkManager.renderDocumentLinks(); + + console.log('โœ… ๋ชจ๋“  ๊ธฐ๋Šฅ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + }, + + // ==================== URL ํ•˜์ด๋ผ์ดํŠธ ์ฒ˜๋ฆฌ ==================== + async handleUrlHighlight() { + const urlParams = new URLSearchParams(window.location.search); + const highlightText = urlParams.get('highlight'); + const startOffset = parseInt(urlParams.get('start_offset')); + const endOffset = parseInt(urlParams.get('end_offset')); + + if (highlightText || (startOffset && endOffset)) { + console.log('๐ŸŽฏ URL์—์„œ ํ•˜์ด๋ผ์ดํŠธ ์š”์ฒญ:', { highlightText, startOffset, endOffset }); + await this.documentLoader.highlightAndScrollToText({ + text: highlightText, + start_offset: startOffset, + end_offset: endOffset + }); + } + }, + + // ==================== ๊ธฐ๋Šฅ ๋ชจ๋“œ ํ™œ์„ฑํ™” ==================== + activateLinkMode() { + console.log('๐Ÿ”— ๋งํฌ ๋ชจ๋“œ ํ™œ์„ฑํ™”'); + this.activeMode = 'link'; + + // ์„ ํƒ๋œ ํ…์ŠคํŠธ ํ™•์ธ + const selectedText = window.getSelection().toString().trim(); + const selection = window.getSelection(); + + if (!selectedText || selection.rangeCount === 0) { + alert('ํ…์ŠคํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + const selectedRange = selection.getRangeAt(0); + this.linkManager.createLinkFromSelection(this.documentId, selectedText, selectedRange); + }, + + + + activateNoteMode() { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋ชจ๋“œ ํ™œ์„ฑํ™”'); + this.activeMode = 'memo'; + this.highlightManager.activateNoteMode(); + }, + + async loadBacklinks() { + console.log('๐Ÿ”— ๋ฐฑ๋งํฌ ๋กœ๋“œ ์‹œ์ž‘'); + if (this.linkManager) { + await this.linkManager.loadBacklinks(this.documentId, this.contentType); + // UI ์ƒํƒœ ๋™๊ธฐํ™” + this.backlinks = this.linkManager.backlinks || []; + } + }, + + // ๋งํฌ ๋Œ€์ƒ ํƒ€์ž… ๋ณ€๊ฒฝ ์‹œ ํ˜ธ์ถœ + async onTargetTypeChange() { + console.log('๐Ÿ”„ ๋งํฌ ๋Œ€์ƒ ํƒ€์ž… ๋ณ€๊ฒฝ:', this.linkForm.target_type); + + // ๊ธฐ์กด ์„ ํƒ ์ดˆ๊ธฐํ™” + this.linkForm.target_book_id = ''; + this.linkForm.target_document_id = ''; + this.availableBooks = []; + this.filteredDocuments = []; + + // ์„ ํƒ๋œ ํƒ€์ž…์— ๋”ฐ๋ผ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + if (this.linkForm.target_type === 'note') { + await this.loadNotebooks(); + } else { + await this.loadBooks(); + } + }, + + // ๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋“œ + async loadNotebooks() { + try { + console.log('๐Ÿ“š ๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋”ฉ ์‹œ์ž‘...'); + + const notebooks = await this.api.get('/notebooks/', { active_only: true }); + this.availableBooks = notebooks.map(notebook => ({ + id: notebook.id, + title: notebook.title + })) || []; + + console.log('๐Ÿ“š ๋กœ๋“œ๋œ ๋…ธํŠธ๋ถ ๋ชฉ๋ก:', this.availableBooks.length, '๊ฐœ'); + } catch (error) { + console.error('๋…ธํŠธ๋ถ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.availableBooks = []; + } + }, + + // ์„œ์  ๋ชฉ๋ก ๋กœ๋“œ + async loadBooks() { + try { + console.log('๐Ÿ“š ์„œ์  ๋ชฉ๋ก ๋กœ๋”ฉ ์‹œ์ž‘...'); + + let allDocuments; + + // contentType์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ์‚ฌ์šฉ + if (this.contentType === 'note') { + // ๋…ธํŠธ์˜ ๊ฒฝ์šฐ ์ „์ฒด ๋ฌธ์„œ ๋ชฉ๋ก์—์„œ ์„œ์  ์ •๋ณด ์ถ”์ถœ + console.log('๐Ÿ“ ๋…ธํŠธ ๋ชจ๋“œ: ์ „์ฒด ๋ฌธ์„œ ๋ชฉ๋ก์—์„œ ์„œ์  ์ •๋ณด ์ถ”์ถœ'); + allDocuments = await this.api.getDocuments(); + console.log('๐Ÿ“„ ์ „์ฒด ๋ฌธ์„œ๋“ค (์ด ๊ฐœ์ˆ˜):', allDocuments.length); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ๊ฒฝ์šฐ linkable-documents API ์‚ฌ์šฉ + console.log('๐Ÿ“„ ๋ฌธ์„œ ๋ชจ๋“œ: linkable-documents API ์‚ฌ์šฉ'); + allDocuments = await this.api.getLinkableDocuments(this.documentId); + console.log('๐Ÿ“„ ๋งํฌ ๊ฐ€๋Šฅํ•œ ๋ฌธ์„œ๋“ค (์ด ๊ฐœ์ˆ˜):', allDocuments.length); + } + + // ์„œ์ ๋ณ„๋กœ ๊ทธ๋ฃนํ™” + const bookMap = new Map(); + allDocuments.forEach(doc => { + if (doc.book_id && doc.book_title) { + bookMap.set(doc.book_id, { + id: doc.book_id, + title: doc.book_title + }); + } + }); + + this.availableBooks = Array.from(bookMap.values()); + console.log('๐Ÿ“š ๋กœ๋“œ๋œ ์„œ์  ๋ชฉ๋ก:', this.availableBooks.length, '๊ฐœ'); + } catch (error) { + console.error('์„œ์  ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.availableBooks = []; + } + }, + + async loadAvailableBooks() { + try { + // ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋ฌธ์„œ ํƒ€์ž… ์„ค์ • (๊ธฐ์กด ํ˜ธํ™˜์„ฑ) + if (this.linkForm.target_type === 'note') { + await this.loadNotebooks(); + } else { + await this.loadBooks(); + } + } catch (error) { + console.error('๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ:', error); + this.availableBooks = []; + } + }, + + getSourceBookInfo(allDocuments = null) { + // ์—ฌ๋Ÿฌ ์†Œ์Šค์—์„œ ํ˜„์žฌ ๋ฌธ์„œ์˜ ์„œ์  ์ •๋ณด ์ฐพ๊ธฐ + let sourceBookId = this.navigation?.book_info?.id || + this.document?.book_id || + this.document?.book_info?.id; + + let sourceBookTitle = this.navigation?.book_info?.title || + this.document?.book_title || + this.document?.book_info?.title; + + // allDocuments์—์„œ๋„ ํ™•์ธ (๊ฐ€์žฅ ํ™•์‹คํ•œ ๋ฐฉ๋ฒ•) + if (allDocuments) { + const currentDoc = allDocuments.find(doc => doc.id === this.documentId); + if (currentDoc) { + sourceBookId = currentDoc.book_id; + sourceBookTitle = currentDoc.book_title; + } + } + + return { + id: sourceBookId, + title: sourceBookTitle + }; + }, + + async loadSameBookDocuments() { + try { + if (this.contentType === 'note') { + console.log('๐Ÿ“š ๊ฐ™์€ ๋…ธํŠธ๋ถ์˜ ๋…ธํŠธ๋“ค ๋กœ๋“œ ์‹œ์ž‘...'); + + // ํ˜„์žฌ ๋…ธํŠธ์˜ ๋…ธํŠธ๋ถ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const currentNote = this.document; + const notebookId = currentNote?.notebook_id; + + if (notebookId) { + // ๊ฐ™์€ ๋…ธํŠธ๋ถ์˜ ๋…ธํŠธ๋“ค ๋กœ๋“œ (ํ˜„์žฌ ๋…ธํŠธ ์ œ์™ธ) + const notes = await this.api.getNotesInNotebook(notebookId); + + this.filteredDocuments = notes.filter(note => note.id !== this.documentId); + console.log('๐Ÿ“š ๊ฐ™์€ ๋…ธํŠธ๋ถ ๋…ธํŠธ๋“ค:', { + count: this.filteredDocuments.length, + notebookId: notebookId, + notes: this.filteredDocuments.map(note => ({ id: note.id, title: note.title })) + }); + } else { + console.warn('โš ๏ธ ํ˜„์žฌ ๋…ธํŠธ์˜ ๋…ธํŠธ๋ถ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + this.filteredDocuments = []; + } + return; + } + + const allDocuments = await this.api.getLinkableDocuments(this.documentId); + + // ์†Œ์Šค ๋ฌธ์„œ์˜ ์„œ์  ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const sourceBookInfo = this.getSourceBookInfo(allDocuments); + + console.log('๐Ÿ“š ๊ฐ™์€ ์„œ์  ๋ฌธ์„œ ๋กœ๋“œ ์‹œ์ž‘:', { + sourceBookId: sourceBookInfo.id, + sourceBookTitle: sourceBookInfo.title, + totalDocs: allDocuments.length + }); + + if (sourceBookInfo.id) { + // ์†Œ์Šค ๋ฌธ์„œ์™€ ๊ฐ™์€ ์„œ์ ์˜ ๋ฌธ์„œ๋“ค๋งŒ ํ•„ํ„ฐ๋ง (ํ˜„์žฌ ๋ฌธ์„œ ์ œ์™ธ) + this.filteredDocuments = allDocuments.filter(doc => + doc.book_id === sourceBookInfo.id && doc.id !== this.documentId + ); + console.log('๐Ÿ“š ๊ฐ™์€ ์„œ์  ๋ฌธ์„œ๋“ค:', { + count: this.filteredDocuments.length, + bookTitle: sourceBookInfo.title, + documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title })) + }); + } else { + console.warn('โš ๏ธ ์†Œ์Šค ๋ฌธ์„œ์˜ ์„œ์  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค!'); + this.filteredDocuments = []; + } + } catch (error) { + console.error('๊ฐ™์€ ์„œ์ /๋…ธํŠธ๋ถ ๋ฌธ์„œ ๋กœ๋“œ ์‹คํŒจ:', error); + this.filteredDocuments = []; + } + }, + + async loadSameBookDocumentsForSelected() { + try { + console.log('๐Ÿ“š ์„ ํƒํ•œ ๋ฌธ์„œ ๊ธฐ์ค€์œผ๋กœ ๊ฐ™์€ ์„œ์  ๋ฌธ์„œ ๋กœ๋“œ ์‹œ์ž‘'); + + const allDocuments = await this.api.getLinkableDocuments(this.documentId); + + // ์„ ํƒํ•œ ๋Œ€์ƒ ๋ฌธ์„œ ์ฐพ๊ธฐ + const selectedDoc = allDocuments.find(doc => doc.id === this.linkForm.target_document_id); + + if (!selectedDoc) { + console.error('โŒ ์„ ํƒํ•œ ๋ฌธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค:', this.linkForm.target_document_id); + return; + } + + console.log('๐ŸŽฏ ์„ ํƒํ•œ ๋ฌธ์„œ ์ •๋ณด:', { + id: selectedDoc.id, + title: selectedDoc.title, + bookId: selectedDoc.book_id, + bookTitle: selectedDoc.book_title + }); + + // ์„ ํƒํ•œ ๋ฌธ์„œ์™€ ๊ฐ™์€ ์„œ์ ์˜ ๋ชจ๋“  ๋ฌธ์„œ๋“ค (์†Œ์Šค ๋ฌธ์„œ ์ œ์™ธ) + this.filteredDocuments = allDocuments.filter(doc => + doc.book_id === selectedDoc.book_id && doc.id !== this.documentId + ); + + console.log('๐Ÿ“š ์„ ํƒํ•œ ๋ฌธ์„œ์™€ ๊ฐ™์€ ์„œ์  ๋ฌธ์„œ๋“ค:', { + selectedBookTitle: selectedDoc.book_title, + count: this.filteredDocuments.length, + documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title })) + }); + + } catch (error) { + console.error('์„ ํƒํ•œ ๋ฌธ์„œ ๊ธฐ์ค€ ๊ฐ™์€ ์„œ์  ๋กœ๋“œ ์‹คํŒจ:', error); + this.filteredDocuments = []; + } + }, + + async loadDocumentsFromBook() { + try { + if (this.linkForm.target_book_id) { + if (this.linkForm.target_type === 'note') { + // ๋…ธํŠธ๋ถ ์„ ํƒ: ์„ ํƒ๋œ ๋…ธํŠธ๋ถ์˜ ๋…ธํŠธ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ + const notes = await this.api.getNotesInNotebook(this.linkForm.target_book_id); + this.filteredDocuments = notes.filter(note => note.id !== this.documentId); + console.log('๐Ÿ“š ์„ ํƒ๋œ ๋…ธํŠธ๋ถ ๋…ธํŠธ๋“ค:', this.filteredDocuments); + } else { + // ์„œ์  ์„ ํƒ: ์„ ํƒ๋œ ์„œ์ ์˜ ๋ฌธ์„œ๋“ค๋งŒ ๊ฐ€์ ธ์˜ค๊ธฐ + let allDocuments; + + if (this.contentType === 'note') { + // ๋…ธํŠธ์—์„œ ์„œ์  ๋ฌธ์„œ๋ฅผ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ: ์ „์ฒด ๋ฌธ์„œ ๋ชฉ๋ก์—์„œ ํ•„ํ„ฐ๋ง + console.log('๐Ÿ“ ๋…ธํŠธ์—์„œ ์„œ์  ๋ฌธ์„œ ์„ ํƒ: ์ „์ฒด ๋ฌธ์„œ ๋ชฉ๋ก ์‚ฌ์šฉ'); + allDocuments = await this.api.getDocuments(); + } else { + // ์ผ๋ฐ˜ ๋ฌธ์„œ์—์„œ ์„œ์  ๋ฌธ์„œ๋ฅผ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ: linkable-documents API ์‚ฌ์šฉ + console.log('๐Ÿ“„ ๋ฌธ์„œ์—์„œ ์„œ์  ๋ฌธ์„œ ์„ ํƒ: linkable-documents API ์‚ฌ์šฉ'); + allDocuments = await this.api.getLinkableDocuments(this.documentId); + } + + this.filteredDocuments = allDocuments.filter(doc => + doc.book_id === this.linkForm.target_book_id + ); + console.log('๐Ÿ“š ์„ ํƒ๋œ ์„œ์  ๋ฌธ์„œ๋“ค:', this.filteredDocuments); + } + } else { + this.filteredDocuments = []; + } + + // ๋ฌธ์„œ ์„ ํƒ ์ดˆ๊ธฐํ™” + this.linkForm.target_document_id = ''; + } catch (error) { + console.error('์„œ์ /๋…ธํŠธ๋ถ๋ณ„ ๋ฌธ์„œ ๋กœ๋“œ ์‹คํŒจ:', error); + this.filteredDocuments = []; + } + }, + + resetTargetSelection() { + console.log('๐Ÿ”„ ๋Œ€์ƒ ์„ ํƒ ์ดˆ๊ธฐํ™”'); + this.linkForm.target_book_id = ''; + this.linkForm.target_document_id = ''; + this.filteredDocuments = []; + + // ์ดˆ๊ธฐํ™” ํ›„ ์•„๋ฌด๊ฒƒ๋„ ํ•˜์ง€ ์•Š์Œ (์„œ์  ์„ ํƒ ํ›„ ๋ฌธ์„œ ๋กœ๋“œ) + }, + + async onTargetDocumentChange() { + console.log('๐Ÿ“„ ๋Œ€์ƒ ๋ฌธ์„œ ๋ณ€๊ฒฝ:', this.linkForm.target_document_id); + + // ๋Œ€์ƒ ๋ฌธ์„œ ๋ณ€๊ฒฝ ์‹œ ํŠน๋ณ„ํ•œ ์ฒ˜๋ฆฌ ์—†์Œ + }, + + selectTextFromDocument() { + console.log('๐ŸŽฏ ๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ํ…์ŠคํŠธ ์„ ํƒ ์‹œ์ž‘'); + + if (!this.linkForm.target_document_id) { + alert('๋Œ€์ƒ ๋ฌธ์„œ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + // ์ƒˆ ์ฐฝ์—์„œ ๋Œ€์ƒ ๋ฌธ์„œ ์—ด๊ธฐ (ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ ์ „์šฉ ํŽ˜์ด์ง€) + const targetContentType = this.linkForm.target_type === 'note' ? 'note' : 'document'; + const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}&contentType=${targetContentType}`; + console.log('๐Ÿš€ ํ…์ŠคํŠธ ์„ ํƒ ์ฐฝ ์—ด๊ธฐ:', targetUrl, 'contentType:', targetContentType); + const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes'); + + if (!popup) { + console.error('โŒ ํŒ์—… ์ฐฝ์ด ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'); + alert('ํŒ์—… ์ฐฝ์ด ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ธŒ๋ผ์šฐ์ € ์„ค์ •์—์„œ ํŒ์—…์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”.'); + } else { + console.log('โœ… ํŒ์—… ์ฐฝ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์—ด๋ ธ์Šต๋‹ˆ๋‹ค'); + } + + // ํŒ์—…์—์„œ ํ…์ŠคํŠธ ์„ ํƒ ์™„๋ฃŒ ์‹œ ๋ฉ”์‹œ์ง€ ์ˆ˜์‹  + window.addEventListener('message', (event) => { + if (event.data.type === 'TEXT_SELECTED') { + this.linkForm.target_text = event.data.selectedText; + this.linkForm.target_start_offset = event.data.startOffset; + this.linkForm.target_end_offset = event.data.endOffset; + console.log('๐ŸŽฏ ๋Œ€์ƒ ํ…์ŠคํŠธ ์„ ํƒ๋จ:', event.data); + popup.close(); + } + }, { once: true }); + }, + + activateBookmarkMode() { + console.log('๐Ÿ”– ๋ถ๋งˆํฌ ๋ชจ๋“œ ํ™œ์„ฑํ™”'); + this.activeMode = 'bookmark'; + this.bookmarkManager.activateBookmarkMode(); + }, + + // ==================== ํ•˜์ด๋ผ์ดํŠธ ๊ธฐ๋Šฅ ์œ„์ž„ ==================== + createHighlightWithColor(color) { + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ์ƒ์„ฑ ์š”์ฒญ:', color); + // ViewerCore์˜ selectedHighlightColor๋„ ๋™๊ธฐํ™” + this.selectedHighlightColor = color; + console.log('๐ŸŽจ ViewerCore ์ƒ‰์ƒ ๋™๊ธฐํ™”:', this.selectedHighlightColor); + return this.highlightManager.createHighlightWithColor(color); + }, + + // ==================== ๋ฉ”๋ชจ ์ž…๋ ฅ ๋ชจ๋‹ฌ ๊ด€๋ จ ==================== + openNoteInputModal() { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ์ž…๋ ฅ ๋ชจ๋‹ฌ ์—ด๊ธฐ'); + this.showNoteInputModal = true; + // ํผ ์ดˆ๊ธฐํ™” + this.noteForm.content = ''; + this.noteForm.tags = ''; + // ํฌ์ปค์Šค๋ฅผ textarea๋กœ ์ด๋™ (๋‹ค์Œ ํ‹ฑ์—์„œ) + this.$nextTick(() => { + const textarea = document.querySelector('textarea[x-model="noteForm.content"]'); + if (textarea) textarea.focus(); + }); + }, + + closeNoteInputModal() { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ์ž…๋ ฅ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ'); + this.showNoteInputModal = false; + this.noteForm.content = ''; + this.noteForm.tags = ''; + // ์„ ํƒ๋œ ํ…์ŠคํŠธ ์ •๋ฆฌ + this.selectedText = ''; + this.selectedRange = null; + }, + + async createNoteForHighlight() { + console.log('๐Ÿ“ ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ ์ƒ์„ฑ'); + if (!this.noteForm.content.trim()) { + alert('๋ฉ”๋ชจ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + try { + // ํ˜„์žฌ ์ƒ์„ฑ๋œ ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•จ + if (this.highlightManager.lastCreatedHighlight) { + await this.highlightManager.createNoteForHighlight( + this.highlightManager.lastCreatedHighlight, + this.noteForm.content.trim(), + this.noteForm.tags.trim() + ); + this.closeNoteInputModal(); + } else { + alert('ํ•˜์ด๋ผ์ดํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } catch (error) { + console.error('๋ฉ”๋ชจ ์ƒ์„ฑ ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + skipNoteForHighlight() { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ์ž…๋ ฅ ๊ฑด๋„ˆ๋›ฐ๊ธฐ'); + this.closeNoteInputModal(); + }, + + // ==================== UI ๋ฉ”์„œ๋“œ ์œ„์ž„ ==================== + toggleFeatureMenu(feature) { + const result = this.uiManager.toggleFeatureMenu(feature); + this.updateModalStates(); // ์ƒํƒœ ๋™๊ธฐํ™” + return result; + }, + + openNoteModal(highlight = null) { + const result = this.uiManager.openNoteModal(highlight); + this.updateModalStates(); // ์ƒํƒœ ๋™๊ธฐํ™” + return result; + }, + + closeNoteModal() { + const result = this.uiManager.closeNoteModal(); + this.updateModalStates(); // ์ƒํƒœ ๋™๊ธฐํ™” + return result; + }, + + closeLinkModal() { + const result = this.uiManager.closeLinkModal(); + this.updateModalStates(); // ์ƒํƒœ ๋™๊ธฐํ™” + return result; + }, + + closeBookmarkModal() { + const result = this.uiManager.closeBookmarkModal(); + this.updateModalStates(); // ์ƒํƒœ ๋™๊ธฐํ™” + return result; + }, + + highlightSearchResults(element, searchText) { + return this.uiManager.highlightSearchResults(element, searchText); + }, + + showSuccessMessage(message) { + return this.uiManager.showSuccessMessage(message); + }, + + showErrorMessage(message) { + return this.uiManager.showErrorMessage(message); + }, + + // ==================== ์–ธ์–ด ์ „ํ™˜ ==================== + toggleLanguage() { + this.isKorean = !this.isKorean; + const lang = this.isKorean ? 'ko' : 'en'; + console.log('๐ŸŒ ์–ธ์–ด ์ „ํ™˜:', this.isKorean ? 'ํ•œ๊ตญ์–ด' : 'English'); + + // ๋ฌธ์„œ์— ๋‚ด์žฅ๋œ ์–ธ์–ด ์ „ํ™˜ ๊ธฐ๋Šฅ ์ฐพ๊ธฐ ๋ฐ ์‹คํ–‰ + this.findAndExecuteBuiltinLanguageToggle(); + }, + + // ๋ฌธ์„œ์— ๋‚ด์žฅ๋œ ์–ธ์–ด ์ „ํ™˜ ๊ธฐ๋Šฅ ์ฐพ๊ธฐ + findAndExecuteBuiltinLanguageToggle() { + console.log('๐Ÿ” ๋ฌธ์„œ ๋‚ด์žฅ ์–ธ์–ด ์ „ํ™˜ ๊ธฐ๋Šฅ ์ฐพ๊ธฐ ์‹œ์ž‘'); + + const content = document.getElementById('document-content'); + if (!content) { + console.warn('โŒ document-content ์š”์†Œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + // 1. ์–ธ์–ด ์ „ํ™˜ ๋ฒ„ํŠผ ์ฐพ๊ธฐ (๋‹ค์–‘ํ•œ ํŒจํ„ด) + const buttonSelectors = [ + 'button[onclick*="toggleLanguage"]', + 'button[onclick*="language"]', + 'button[onclick*="Language"]', + '.language-toggle', + '.lang-toggle', + 'button[id*="lang"]', + 'button[class*="lang"]', + 'input[type="button"][onclick*="language"]' + ]; + + let foundButton = null; + for (const selector of buttonSelectors) { + const buttons = content.querySelectorAll(selector); + if (buttons.length > 0) { + foundButton = buttons[0]; + console.log(`โœ… ์–ธ์–ด ์ „ํ™˜ ๋ฒ„ํŠผ ๋ฐœ๊ฒฌ (${selector}):`, foundButton.outerHTML.substring(0, 100)); + break; + } + } + + // 2. ๋ฒ„ํŠผ์ด ์žˆ์œผ๋ฉด ํด๋ฆญ + if (foundButton) { + console.log('๐Ÿ”˜ ๋‚ด์žฅ ์–ธ์–ด ์ „ํ™˜ ๋ฒ„ํŠผ ํด๋ฆญ'); + try { + foundButton.click(); + console.log('โœ… ์–ธ์–ด ์ „ํ™˜ ๋ฒ„ํŠผ ํด๋ฆญ ์™„๋ฃŒ'); + return; + } catch (error) { + console.error('โŒ ๋ฒ„ํŠผ ํด๋ฆญ ์‹คํŒจ:', error); + } + } + + // 3. ๋ฒ„ํŠผ์ด ์—†์œผ๋ฉด ์Šคํฌ๋ฆฝํŠธ ํ•จ์ˆ˜ ์ง์ ‘ ํ˜ธ์ถœ ์‹œ๋„ + this.tryDirectLanguageFunction(); + }, + + // ์ง์ ‘ ์–ธ์–ด ์ „ํ™˜ ํ•จ์ˆ˜ ํ˜ธ์ถœ ์‹œ๋„ + tryDirectLanguageFunction() { + console.log('๐Ÿ”ง ์ง์ ‘ ์–ธ์–ด ์ „ํ™˜ ํ•จ์ˆ˜ ํ˜ธ์ถœ ์‹œ๋„'); + + const functionNames = [ + 'toggleLanguage', + 'changeLanguage', + 'switchLanguage', + 'toggleLang', + 'changeLang' + ]; + + for (const funcName of functionNames) { + if (typeof window[funcName] === 'function') { + console.log(`โœ… ์ „์—ญ ํ•จ์ˆ˜ ๋ฐœ๊ฒฌ: ${funcName}`); + try { + window[funcName](); + console.log(`โœ… ${funcName}() ํ˜ธ์ถœ ์™„๋ฃŒ`); + return; + } catch (error) { + console.error(`โŒ ${funcName}() ํ˜ธ์ถœ ์‹คํŒจ:`, error); + } + } + } + + // 4. ๋ฌธ์„œ ๋‚ด ์Šคํฌ๋ฆฝํŠธ์—์„œ ํ•จ์ˆ˜ ์ฐพ๊ธฐ + this.findLanguageFunctionInScripts(); + }, + + // ๋ฌธ์„œ ๋‚ด ์Šคํฌ๋ฆฝํŠธ์—์„œ ์–ธ์–ด ์ „ํ™˜ ํ•จ์ˆ˜ ์ฐพ๊ธฐ + findLanguageFunctionInScripts() { + console.log('๐Ÿ“œ ๋ฌธ์„œ ๋‚ด ์Šคํฌ๋ฆฝํŠธ์—์„œ ์–ธ์–ด ํ•จ์ˆ˜ ์ฐพ๊ธฐ'); + + const content = document.getElementById('document-content'); + const scripts = content.querySelectorAll('script'); + + console.log(`๐Ÿ“œ ๋ฐœ๊ฒฌ๋œ ์Šคํฌ๋ฆฝํŠธ ํƒœ๊ทธ: ${scripts.length}๊ฐœ`); + + scripts.forEach((script, index) => { + const scriptContent = script.textContent || script.innerHTML; + if (scriptContent.includes('language') || scriptContent.includes('Language') || scriptContent.includes('lang')) { + console.log(`๐Ÿ“œ ์Šคํฌ๋ฆฝํŠธ ${index + 1}์—์„œ ์–ธ์–ด ๊ด€๋ จ ์ฝ”๋“œ ๋ฐœ๊ฒฌ:`, scriptContent.substring(0, 200)); + + // ํ•จ์ˆ˜ ์‹คํ–‰ ์‹œ๋„ + try { + eval(scriptContent); + console.log(`โœ… ์Šคํฌ๋ฆฝํŠธ ${index + 1} ์‹คํ–‰ ์™„๋ฃŒ`); + } catch (error) { + console.log(`โš ๏ธ ์Šคํฌ๋ฆฝํŠธ ${index + 1} ์‹คํ–‰ ์‹คํŒจ:`, error.message); + } + } + }); + + console.log('โš ๏ธ ๋‚ด์žฅ ์–ธ์–ด ์ „ํ™˜ ๊ธฐ๋Šฅ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + }, + + + + async downloadOriginalFile() { + if (!this.document || !this.document.id) { + console.warn('๋ฌธ์„œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + + console.log('๐Ÿ“• PDF ๋‹ค์šด๋กœ๋“œ ์‹œ๋„:', { + id: this.document.id, + matched_pdf_id: this.document.matched_pdf_id, + pdf_path: this.document.pdf_path + }); + + // ์บ์‹œ๋ฅผ ๋ฌด์‹œํ•˜๊ณ  ์ตœ์‹  ๋ฌธ์„œ ์ •๋ณด๋ฅผ ๋‹ค์‹œ ๊ฐ€์ ธ์˜ค๊ธฐ + console.log('๐Ÿ”„ ์ตœ์‹  ๋ฌธ์„œ ์ •๋ณด ์žฌ๋กœ๋“œ ์ค‘...'); + try { + const freshDocument = await this.api.getDocument(this.document.id); + console.log('๐Ÿ“„ ์ตœ์‹  ๋ฌธ์„œ ์ •๋ณด:', { + id: freshDocument.id, + matched_pdf_id: freshDocument.matched_pdf_id, + pdf_path: freshDocument.pdf_path + }); + + // ์ตœ์‹  ์ •๋ณด๋กœ ์—…๋ฐ์ดํŠธ + if (freshDocument.matched_pdf_id !== this.document.matched_pdf_id) { + console.log('๐Ÿ”„ PDF ๋งค์นญ ์ •๋ณด ์—…๋ฐ์ดํŠธ:', { + old: this.document.matched_pdf_id, + new: freshDocument.matched_pdf_id + }); + this.document.matched_pdf_id = freshDocument.matched_pdf_id; + } + } catch (error) { + console.error('โŒ ์ตœ์‹  ๋ฌธ์„œ ์ •๋ณด ๋กœ๋“œ ์‹คํŒจ:', error); + } + + // 1. ํ˜„์žฌ ๋ฌธ์„œ ์ž์ฒด๊ฐ€ PDF์ธ ๊ฒฝ์šฐ + if (this.document.pdf_path) { + console.log('๐Ÿ“„ ํ˜„์žฌ ๋ฌธ์„œ๊ฐ€ PDF - ์ง์ ‘ ๋‹ค์šด๋กœ๋“œ'); + this.downloadPdfFile(this.document.pdf_path, this.document.title || 'document'); + return; + } + + // 2. ์—ฐ๊ฒฐ๋œ PDF๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + if (!this.document.matched_pdf_id) { + alert('์—ฐ๊ฒฐ๋œ ์›๋ณธ PDF ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.\n\n์„œ์  ํŽธ์ง‘ ํŽ˜์ด์ง€์—์„œ PDF ํŒŒ์ผ์„ ์—ฐ๊ฒฐํ•ด์ฃผ์„ธ์š”.'); + return; + } + + try { + console.log('๐Ÿ“• ์—ฐ๊ฒฐ๋œ PDF ๋‹ค์šด๋กœ๋“œ ์‹œ์ž‘:', this.document.matched_pdf_id); + + // ์—ฐ๊ฒฐ๋œ PDF ๋ฌธ์„œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const pdfDocument = await this.api.getDocument(this.document.matched_pdf_id); + + if (!pdfDocument) { + throw new Error('์—ฐ๊ฒฐ๋œ PDF ๋ฌธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'); + } + + // PDF ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ URL ์ƒ์„ฑ + const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`; + + // ์ธ์ฆ ํ—ค๋” ์ถ”๊ฐ€๋ฅผ ์œ„ํ•ด fetch ์‚ฌ์šฉ + const response = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!response.ok) { + throw new Error('์—ฐ๊ฒฐ๋œ PDF ๋‹ค์šด๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค'); + } + + // Blob์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋‹ค์šด๋กœ๋“œ + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // ๋‹ค์šด๋กœ๋“œ ๋งํฌ ์ƒ์„ฑ ๋ฐ ํด๋ฆญ + const link = document.createElement('a'); + link.href = url; + link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // URL ์ •๋ฆฌ + window.URL.revokeObjectURL(url); + + console.log('๐Ÿ“• PDF ๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ:', pdfDocument.original_filename); + + } catch (error) { + console.error('PDF ๋‹ค์šด๋กœ๋“œ ์˜ค๋ฅ˜:', error); + alert('PDF ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // PDF ํŒŒ์ผ ์ง์ ‘ ๋‹ค์šด๋กœ๋“œ + downloadPdfFile(pdfPath, filename) { + try { + console.log('๐Ÿ“„ PDF ํŒŒ์ผ ์ง์ ‘ ๋‹ค์šด๋กœ๋“œ:', pdfPath); + + // PDF ํŒŒ์ผ URL ์ƒ์„ฑ (์ƒ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋กœ ๋ณ€ํ™˜) + let pdfUrl = pdfPath; + if (!pdfUrl.startsWith('http')) { + // ์ƒ๋Œ€ ๊ฒฝ๋กœ์ธ ๊ฒฝ์šฐ ํ˜„์žฌ ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ์ ˆ๋Œ€ ๊ฒฝ๋กœ ์ƒ์„ฑ + const baseUrl = window.location.origin; + pdfUrl = pdfUrl.startsWith('/') ? baseUrl + pdfUrl : baseUrl + '/' + pdfUrl; + } + + console.log('๐Ÿ“„ PDF URL:', pdfUrl); + + // ๋‹ค์šด๋กœ๋“œ ๋งํฌ ์ƒ์„ฑ ๋ฐ ํด๋ฆญ + const link = document.createElement('a'); + link.href = pdfUrl; + link.download = filename.endsWith('.pdf') ? filename : filename + '.pdf'; + link.target = '_blank'; // ์ƒˆ ํƒญ์—์„œ ์—ด๊ธฐ (๋‹ค์šด๋กœ๋“œ ์‹คํŒจ ์‹œ ๋ทฐ์–ด๋กœ ์—ด๋ฆผ) + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + console.log('โœ… PDF ๋‹ค์šด๋กœ๋“œ ๋งํฌ ํด๋ฆญ ์™„๋ฃŒ'); + + } catch (error) { + console.error('PDF ๋‹ค์šด๋กœ๋“œ ์˜ค๋ฅ˜:', error); + alert('PDF ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // ==================== ํ†ตํ•ฉ ํˆดํŒ ์ฒ˜๋ฆฌ ==================== + /** + * ํด๋ฆญ๋œ ์š”์†Œ์—์„œ ๋งํฌ, ๋ฐฑ๋งํฌ, ํ•˜์ด๋ผ์ดํŠธ ๋ชจ๋‘ ์ฐพ๊ธฐ (์™„์ „ ๊ฐœ์„  ๋ฒ„์ „) + */ + getOverlappingElements(clickedElement) { + const selectedText = clickedElement.textContent.trim(); + console.log('๐Ÿ” ํ†ตํ•ฉ ์š”์†Œ ์ฐพ๊ธฐ ์‹œ์ž‘:', selectedText); + console.log('๐Ÿ” ํ•˜์ด๋ผ์ดํŠธ ๋งค๋‹ˆ์ € ์ƒํƒœ:', { + highlightManager: !!this.highlightManager, + highlightsCount: this.highlightManager?.highlights?.length || 0, + highlights: this.highlightManager?.highlights || [] + }); + + // ๊ฒฐ๊ณผ ๋ฐฐ์—ด๋“ค + const overlappingLinks = []; + const overlappingBacklinks = []; + const overlappingHighlights = []; + + // 1. ๋ชจ๋“  ๋งํฌ ์š”์†Œ ์ฐพ๊ธฐ (๊ฐ™์€ ํ…์ŠคํŠธ) + const allLinkElements = document.querySelectorAll('.document-link'); + allLinkElements.forEach(linkEl => { + if (linkEl.textContent.trim() === selectedText) { + const linkId = linkEl.dataset.linkId; + const link = this.linkManager.documentLinks.find(l => l.id === linkId); + if (link && !overlappingLinks.find(l => l.id === link.id)) { + overlappingLinks.push(link); + const linkTitle = link.target_note_title || link.target_document_title || 'Unknown'; + console.log('โœ… ๊ฒน์น˜๋Š” ๋งํฌ ๋ฐœ๊ฒฌ:', linkTitle); + } + } + }); + + // 2. ๋ชจ๋“  ๋ฐฑ๋งํฌ ์š”์†Œ ์ฐพ๊ธฐ (๊ฐ™์€ ํ…์ŠคํŠธ) + const allBacklinkElements = document.querySelectorAll('.backlink-highlight'); + allBacklinkElements.forEach(backlinkEl => { + if (backlinkEl.textContent.trim() === selectedText) { + const backlinkId = backlinkEl.dataset.backlinkId; + const backlink = this.linkManager.backlinks.find(b => b.id === backlinkId); + if (backlink && !overlappingBacklinks.find(b => b.id === backlink.id)) { + overlappingBacklinks.push(backlink); + console.log('โœ… ๊ฒน์น˜๋Š” ๋ฐฑ๋งํฌ ๋ฐœ๊ฒฌ:', backlink.source_document_title); + } + } + }); + + // 3. ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์š”์†Œ ์ฐพ๊ธฐ (๊ฐ™์€ ํ…์ŠคํŠธ) + const allHighlightElements = document.querySelectorAll('.highlight-span'); + console.log('๐Ÿ” ํŽ˜์ด์ง€์˜ ๋ชจ๋“  ํ•˜์ด๋ผ์ดํŠธ ์š”์†Œ:', allHighlightElements.length, '๊ฐœ'); + allHighlightElements.forEach(highlightEl => { + const highlightText = highlightEl.textContent.trim(); + + // ํ…์ŠคํŠธ๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๊ฑฐ๋‚˜ ํฌํ•จ ๊ด€๊ณ„์ธ ๊ฒฝ์šฐ + if (highlightText === selectedText || + highlightText.includes(selectedText) || + selectedText.includes(highlightText)) { + + const highlightId = highlightEl.dataset.highlightId; + console.log('๐Ÿ” ํ•˜์ด๋ผ์ดํŠธ ์š”์†Œ ํ™•์ธ:', { + element: highlightEl, + highlightId: highlightId, + text: highlightText, + selectedText: selectedText + }); + + const highlight = this.highlightManager.highlights.find(h => h.id === highlightId); + if (highlight && !overlappingHighlights.find(h => h.id === highlight.id)) { + overlappingHighlights.push(highlight); + console.log('โœ… ๊ฒน์น˜๋Š” ํ•˜์ด๋ผ์ดํŠธ ๋ฐœ๊ฒฌ:', { + id: highlight.id, + text: highlightText, + color: highlight.highlight_color + }); + } else if (!highlight) { + console.log('โŒ ํ•˜์ด๋ผ์ดํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ:', highlightId); + } + } + }); + + console.log('๐Ÿ“Š ์ตœ์ข… ๋ฐœ๊ฒฌ๋œ ์š”์†Œ๋“ค:', { + links: overlappingLinks.length, + backlinks: overlappingBacklinks.length, + highlights: overlappingHighlights.length, + selectedText: selectedText + }); + + return { + links: overlappingLinks, + backlinks: overlappingBacklinks, + highlights: overlappingHighlights, + selectedText: selectedText + }; + }, + + /** + * ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ (๋งํฌ + ํ•˜์ด๋ผ์ดํŠธ + ๋ฐฑ๋งํฌ) + */ + async showUnifiedTooltip(overlappingElements, element) { + const { links = [], highlights = [], backlinks = [], selectedText } = overlappingElements; + + console.log('๐ŸŽฏ ํ†ตํ•ฉ ํˆดํŒ ํ‘œ์‹œ:', { + links: links.length, + highlights: highlights.length, + backlinks: backlinks.length + }); + + // ํ•˜์ด๋ผ์ดํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ฉ”๋ชจ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + if (highlights.length > 0) { + console.log('๐Ÿ“ ํ†ตํ•ฉ ํˆดํŒ์šฉ ๋ฉ”๋ชจ ๋กœ๋“œ ์‹œ์ž‘...'); + const documentId = this.documentId; + const contentType = this.contentType; + await this.highlightManager.loadNotes(documentId, contentType); + console.log('๐Ÿ“ ํ†ตํ•ฉ ํˆดํŒ์šฉ ๋ฉ”๋ชจ ๋กœ๋“œ ์™„๋ฃŒ:', this.highlightManager.notes.length, '๊ฐœ'); + } + + // ๊ธฐ์กด ํˆดํŒ๋“ค ์ˆจ๊ธฐ๊ธฐ + this.linkManager.hideTooltip(); + this.highlightManager.hideTooltip(); + + // ํˆดํŒ ์ปจํ…Œ์ด๋„ˆ ์ƒ์„ฑ + const tooltip = document.createElement('div'); + tooltip.id = 'unified-tooltip'; + tooltip.className = 'fixed z-50 bg-white rounded-xl shadow-2xl border border-gray-200'; + tooltip.style.cssText = ` + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + backdrop-filter: blur(10px); + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + max-width: 90vw; + max-height: 80vh; + overflow-y: auto; + z-index: 9999; + `; + + const totalElements = links.length + highlights.length + backlinks.length; + + let tooltipHTML = ` +
+
+
+
+ + + ๊ฒน์น˜๋Š” ์š”์†Œ๋“ค +
+
${totalElements}๊ฐœ ์š”์†Œ
+
+ +
+
์„ ํƒ๋œ ํ…์ŠคํŠธ
+
"${selectedText}"
+
+
+ `; + + // ํ•˜์ด๋ผ์ดํŠธ ์„น์…˜ + if (highlights.length > 0) { + tooltipHTML += ` +
+
+ + + ํ•˜์ด๋ผ์ดํŠธ (${highlights.length}๊ฐœ) +
+
+ `; + + highlights.forEach(highlight => { + const colorName = this.highlightManager.getColorName(highlight.highlight_color); + const createdDate = this.formatDate(highlight.created_at); + const notes = this.highlightManager.notes.filter(note => note.highlight_id === highlight.id); + + console.log(`๐Ÿ“ ํ†ตํ•ฉ ํˆดํŒ - ํ•˜์ด๋ผ์ดํŠธ ${highlight.id}์˜ ๋ฉ”๋ชจ:`, notes.length, '๊ฐœ'); + if (notes.length > 0) { + console.log('๐Ÿ“ ๋ฉ”๋ชจ ๋‚ด์šฉ:', notes.map(n => n.content)); + } + + tooltipHTML += ` +
+
+
+
+ ${colorName} + ${createdDate} +
+
${notes.length}๊ฐœ ๋ฉ”๋ชจ
+
+
+ `; + }); + + tooltipHTML += ` +
+
+ `; + } + + // ๋งํฌ ์„น์…˜ + if (links.length > 0) { + tooltipHTML += ` +
+
+ + + + ๋งํฌ (${links.length}๊ฐœ) +
+
+ `; + + links.forEach(link => { + const isNote = link.target_content_type === 'note'; + const bgClass = isNote ? 'from-green-50 to-emerald-50' : 'from-purple-50 to-indigo-50'; + const iconClass = isNote ? 'text-green-600' : 'text-purple-600'; + const createdDate = this.formatDate(link.created_at); + + tooltipHTML += ` +
+
+
+
+ + ${isNote ? + '' : + '' + } + + ${link.target_note_title || link.target_document_title} +
+ + ${link.target_text ? ` +
+
์—ฐ๊ฒฐ๋œ ํ…์ŠคํŠธ
+
"${link.target_text}"
+
+ ` : ''} + + ${link.description ? ` +
+
๋งํฌ ์„ค๋ช…
+
${link.description}
+
+ ` : ''} + +
+ + + + ${link.link_type === 'text_fragment' ? 'ํ…์ŠคํŠธ ์กฐ๊ฐ ๋งํฌ' : '๋ฌธ์„œ ๋งํฌ'} + + ${createdDate} +
+
+ + + +
+
+ `; + }); + + tooltipHTML += ` +
+
+ `; + } + + // ๋ฐฑ๋งํฌ ์„น์…˜ + if (backlinks.length > 0) { + tooltipHTML += ` +
+
+ + + + ๋ฐฑ๋งํฌ (${backlinks.length}๊ฐœ) +
+
+ `; + + backlinks.forEach(backlink => { + const createdDate = this.formatDate(backlink.created_at); + + tooltipHTML += ` +
+
+
+ + + ${backlink.source_document_title} +
+ +
+
์›๋ณธ ๋ฌธ์„œ์—์„œ ๋งํฌ๋กœ ์„ค์ •ํ•œ ํ…์ŠคํŠธ
+
"${backlink.selected_text}"
+
+ + ${backlink.target_text ? ` +
+
ํ˜„์žฌ ๋ฌธ์„œ์—์„œ ์—ฐ๊ฒฐ๋œ ๊ตฌ์ฒด์ ์ธ ํ…์ŠคํŠธ
+
"${backlink.target_text}"
+
+ ` : ''} + + ${backlink.description ? ` +
+
๋งํฌ ์„ค๋ช…
+
${backlink.description}
+
+ ` : ''} + +
+ + + + ๋ฐฑ๋งํฌ + + ${createdDate} +
+
+
+ `; + }); + + tooltipHTML += ` +
+
+ `; + } + + tooltipHTML += ` +
+ +
+
+ `; + + tooltip.innerHTML = tooltipHTML; + document.body.appendChild(tooltip); + + // ์œ„์น˜ ์กฐ์ • + this.positionTooltip(tooltip, element); + }, + + /** + * ํ†ตํ•ฉ ํˆดํŒ ์ˆจ๊ธฐ๊ธฐ + */ + hideUnifiedTooltip() { + const tooltip = document.getElementById('unified-tooltip'); + if (tooltip) { + tooltip.remove(); + } + }, + + /** + * ํˆดํŒ ์œ„์น˜ ์กฐ์ • (ํ™”๋ฉด ๋ฐ–์œผ๋กœ ๋‚˜๊ฐ€์ง€ ์•Š๋„๋ก ๊ฐœ์„ ) + */ + positionTooltip(tooltip, element) { + const rect = element.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + console.log('๐ŸŽฏ ํˆดํŒ ์œ„์น˜ ๊ณ„์‚ฐ:', { + elementRect: rect, + tooltipSize: { width: tooltipRect.width, height: tooltipRect.height }, + viewport: { width: viewportWidth, height: viewportHeight } + }); + + // ๊ธฐ๋ณธ ์œ„์น˜: ์š”์†Œ ์•„๋ž˜ ์ค‘์•™ + let left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2); + let top = rect.bottom + scrollY + 10; + + // ์ขŒ์šฐ ๊ฒฝ๊ณ„ ์ฒดํฌ ๋ฐ ์กฐ์ • + const margin = 20; + if (left < margin) { + left = margin; + console.log('๐Ÿ”ง ์ขŒ์ธก ๊ฒฝ๊ณ„ ์กฐ์ •:', left); + } else if (left + tooltipRect.width > viewportWidth - margin) { + left = viewportWidth - tooltipRect.width - margin; + console.log('๐Ÿ”ง ์šฐ์ธก ๊ฒฝ๊ณ„ ์กฐ์ •:', left); + } + + // ์ƒํ•˜ ๊ฒฝ๊ณ„ ์ฒดํฌ ๋ฐ ์กฐ์ • + if (top + tooltipRect.height > viewportHeight - margin) { + // ์š”์†Œ ์œ„์ชฝ์— ํ‘œ์‹œ + top = rect.top + scrollY - tooltipRect.height - 10; + console.log('๐Ÿ”ง ์ƒ๋‹จ์œผ๋กœ ์ด๋™:', top); + + // ์œ„์ชฝ์—๋„ ๊ณต๊ฐ„์ด ๋ถ€์กฑํ•˜๋ฉด ๋ทฐํฌํŠธ ๋‚ด์— ๊ฐ•์ œ๋กœ ๋งž์ถค + if (top < margin) { + top = margin; + console.log('๐Ÿ”ง ์ƒ๋‹จ ๊ฒฝ๊ณ„ ์กฐ์ •:', top); + } + } + + // ์ตœ์ข… ์œ„์น˜ ์„ค์ • + tooltip.style.position = 'fixed'; + tooltip.style.left = `${left - scrollX}px`; + tooltip.style.top = `${top - scrollY}px`; + + console.log('โœ… ์ตœ์ข… ํˆดํŒ ์œ„์น˜:', { + left: left - scrollX, + top: top - scrollY + }); + }, + + // ==================== ์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฉ”์„œ๋“œ ==================== + formatDate(dateString) { + return new Date(dateString).toLocaleString('ko-KR'); + }, + + formatShortDate(dateString) { + return new Date(dateString).toLocaleDateString('ko-KR'); + }, + + getSelectedBookTitle() { + const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id); + return selectedBook ? selectedBook.title : '์„œ์ ์„ ์„ ํƒํ•˜์„ธ์š”'; + }, + + // ==================== ๋ชจ๋“ˆ ๋ฉ”์„œ๋“œ ์œ„์ž„ ==================== + + // ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ + selectHighlight(highlightId) { + return this.highlightManager.selectHighlight(highlightId); + }, + + deleteHighlight(highlightId) { + return this.highlightManager.deleteHighlight(highlightId); + }, + + deleteHighlightsByColor(color, highlightIds) { + return this.highlightManager.deleteHighlightsByColor(color, highlightIds); + }, + + deleteAllOverlappingHighlights(highlightIds) { + return this.highlightManager.deleteAllOverlappingHighlights(highlightIds); + }, + + hideTooltip() { + return this.highlightManager.hideTooltip(); + }, + + showAddNoteForm(highlightId) { + return this.highlightManager.showAddNoteForm(highlightId); + }, + + deleteNote(noteId) { + return this.highlightManager.deleteNote(noteId); + }, + + // ๋งํฌ ๊ด€๋ จ + navigateToLinkedDocument(documentId, linkData) { + return this.linkManager.navigateToLinkedDocument(documentId, linkData); + }, + + navigateToBacklinkDocument(documentId, backlinkData) { + return this.linkManager.navigateToBacklinkDocument(documentId, backlinkData); + }, + + // HTML์—์„œ ์‚ฌ์šฉ๋˜๋Š” ๋งํฌ ์ด๋™ ํ•จ์ˆ˜๋“ค + navigateToLink(link) { + console.log('๐Ÿ”— ๋งํฌ ํด๋ฆญ:', link); + console.log('๐Ÿ“‹ ๋งํฌ ์ƒ์„ธ ์ •๋ณด:', { + target_document_id: link.target_document_id, + target_note_id: link.target_note_id, + target_content_type: link.target_content_type, + target_document_title: link.target_document_title, + target_note_title: link.target_note_title + }); + + // target_content_type์ด ์—†์œผ๋ฉด ID๋กœ ์ถ”๋ก  + let targetContentType = link.target_content_type; + if (!targetContentType) { + if (link.target_note_id) { + targetContentType = 'note'; + } else if (link.target_document_id) { + targetContentType = 'document'; + } + console.log('๐Ÿ” target_content_type ์ถ”๋ก ๋จ:', targetContentType); + } + + const targetId = link.target_document_id || link.target_note_id; + if (!targetId) { + console.error('โŒ ๋Œ€์ƒ ๋ฌธ์„œ/๋…ธํŠธ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!', link); + alert('๋งํฌ ๋Œ€์ƒ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + // ๋งํฌ ๊ฐ์ฒด์— ์ถ”๋ก ๋œ ํƒ€์ž… ์ถ”๊ฐ€ + const linkWithType = { + ...link, + target_content_type: targetContentType + }; + + console.log('๐Ÿš€ ์ตœ์ข… ๋งํฌ ๋ฐ์ดํ„ฐ:', linkWithType); + return this.linkManager.navigateToLinkedDocument(targetId, linkWithType); + }, + + navigateToBacklink(backlink) { + console.log('๐Ÿ”™ ๋ฐฑ๋งํฌ ํด๋ฆญ:', backlink); + console.log('๐Ÿ“‹ ๋ฐฑ๋งํฌ ์ƒ์„ธ ์ •๋ณด:', { + source_document_id: backlink.source_document_id, + source_note_id: backlink.source_note_id, + source_content_type: backlink.source_content_type, + source_document_title: backlink.source_document_title + }); + + // ์†Œ์Šค ID ์ฐพ๊ธฐ (๋…ธํŠธ ๋ฐฑ๋งํฌ์˜ ๊ฒฝ์šฐ source_note_id ์šฐ์„ ) + const sourceId = backlink.source_note_id || backlink.source_document_id; + if (!sourceId) { + console.error('โŒ ์†Œ์Šค ๋ฌธ์„œ/๋…ธํŠธ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!', backlink); + alert('๋ฐฑ๋งํฌ ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + console.log('โœ… ๋ฐฑ๋งํฌ ์†Œ์Šค ID ๋ฐœ๊ฒฌ:', sourceId); + return this.linkManager.navigateToSourceDocument(sourceId, backlink); + }, + + // ๋งํฌ ์‚ญ์ œ (ํ™•์ธ ํ›„) + async deleteLinkWithConfirm(linkId, targetTitle) { + console.log('๐Ÿ—‘๏ธ ๋งํฌ ์‚ญ์ œ ์š”์ฒญ:', { linkId, targetTitle }); + + const confirmed = confirm(`"${targetTitle}"๋กœ์˜ ๋งํฌ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?`); + if (!confirmed) { + console.log('โŒ ๋งํฌ ์‚ญ์ œ ์ทจ์†Œ๋จ'); + return; + } + + try { + console.log('๐Ÿ—‘๏ธ ๋งํฌ ์‚ญ์ œ ์‹œ์ž‘:', linkId); + + // ์ถœ๋ฐœ์ง€ ํƒ€์ž…์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ์‚ฌ์šฉ + if (this.contentType === 'note') { + // ๋…ธํŠธ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋งํฌ: NoteLink API ์‚ฌ์šฉ + console.log('๐Ÿ“ ๋…ธํŠธ ๋งํฌ ์‚ญ์ œ API ํ˜ธ์ถœ'); + await this.api.delete(`/note-links/${linkId}`); + } else { + // ๋ฌธ์„œ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋งํฌ: DocumentLink API ์‚ฌ์šฉ + console.log('๐Ÿ“„ ๋ฌธ์„œ ๋งํฌ ์‚ญ์ œ API ํ˜ธ์ถœ'); + await this.api.deleteDocumentLink(linkId); + } + console.log('โœ… ๋งํฌ ์‚ญ์ œ ์„ฑ๊ณต'); + + // ํˆดํŒ ์ˆจ๊ธฐ๊ธฐ + this.linkManager.hideTooltip(); + + // ์บ์‹œ ๋ฌดํšจํ™” + console.log('๐Ÿ—‘๏ธ ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์‹œ์ž‘...'); + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + if (this.contentType === 'note') { + window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/links`, ['links']); + } else { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']); + } + console.log('โœ… ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + + // ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + console.log('๐Ÿ”„ ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์ž‘...'); + await this.linkManager.loadDocumentLinks(this.documentId, this.contentType); + this.documentLinks = this.linkManager.documentLinks || []; + console.log('๐Ÿ“Š ์ƒˆ๋กœ๊ณ ์นจ๋œ ๋งํฌ ๊ฐœ์ˆ˜:', this.documentLinks.length); + + // ๋งํฌ ๋ Œ๋”๋ง + console.log('๐ŸŽจ ๋งํฌ ๋ Œ๋”๋ง ์‹œ์ž‘...'); + this.linkManager.renderDocumentLinks(); + console.log('โœ… ๋งํฌ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + + // ๋ฐฑ๋งํฌ๋„ ๋‹ค์‹œ ๋กœ๋“œ (์‚ญ์ œ๋œ ๋งํฌ๊ฐ€ ๋‹ค๋ฅธ ๋ฌธ์„œ์˜ ๋ฐฑ๋งํฌ์˜€์„ ์ˆ˜ ์žˆ์Œ) + console.log('๐Ÿ”„ ๋ฐฑ๋งํฌ ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์ž‘...'); + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + if (this.contentType === 'note') { + window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']); + } else { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']); + } + console.log('โœ… ๋ฐฑ๋งํฌ ์บ์‹œ๋„ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + await this.linkManager.loadBacklinks(this.documentId, this.contentType); + this.backlinks = this.linkManager.backlinks || []; + this.linkManager.renderBacklinks(); + console.log('โœ… ๋ฐฑ๋งํฌ ์ƒˆ๋กœ๊ณ ์นจ ์™„๋ฃŒ'); + + // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ + this.showSuccessMessage('๋งํฌ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + + } catch (error) { + console.error('โŒ ๋งํฌ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋งํฌ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + showSuccessMessage(message) { + const toast = document.createElement('div'); + toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 transition-opacity duration-300'; + toast.textContent = message; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }, 2000); + }, + + // ํ•˜์ด๋ผ์ดํŠธ ๊ด€๋ จ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ๋“ค + async changeHighlightColor(highlightId) { + console.log('๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ ๋ณ€๊ฒฝ:', highlightId); + + const colors = [ + { name: '๋…ธ๋ž€์ƒ‰', value: '#FFFF00' }, + { name: '์ดˆ๋ก์ƒ‰', value: '#00FF00' }, + { name: 'ํŒŒ๋ž€์ƒ‰', value: '#00BFFF' }, + { name: '๋ถ„ํ™์ƒ‰', value: '#FFB6C1' }, + { name: '์ฃผํ™ฉ์ƒ‰', value: '#FFA500' }, + { name: '๋ณด๋ผ์ƒ‰', value: '#DDA0DD' } + ]; + + const colorOptions = colors.map(c => `${c.name} (${c.value})`).join('\n'); + const selectedColor = prompt(`์ƒˆ๋กœ์šด ์ƒ‰์ƒ์„ ์„ ํƒํ•˜์„ธ์š”:\n\n${colorOptions}\n\n์ƒ‰์ƒ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š” (์˜ˆ: #FFFF00):`); + + if (selectedColor && selectedColor.match(/^#[0-9A-Fa-f]{6}$/)) { + try { + await this.highlightManager.updateHighlightColor(highlightId, selectedColor); + this.showSuccessMessage('ํ•˜์ด๋ผ์ดํŠธ ์ƒ‰์ƒ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (error) { + console.error('โŒ ์ƒ‰์ƒ ๋ณ€๊ฒฝ ์‹คํŒจ:', error); + alert('์ƒ‰์ƒ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } else if (selectedColor !== null) { + alert('์˜ฌ๋ฐ”๋ฅธ ์ƒ‰์ƒ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š” (์˜ˆ: #FFFF00)'); + } + }, + + async duplicateHighlight(highlightId) { + console.log('๐Ÿ“‹ ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ:', highlightId); + + try { + await this.highlightManager.duplicateHighlight(highlightId); + this.showSuccessMessage('ํ•˜์ด๋ผ์ดํŠธ๊ฐ€ ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (error) { + console.error('โŒ ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ ์‹คํŒจ:', error); + alert('ํ•˜์ด๋ผ์ดํŠธ ๋ณต์‚ฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + async deleteHighlightWithConfirm(highlightId) { + console.log('๐Ÿ—‘๏ธ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ํ™•์ธ:', highlightId); + + const confirmed = confirm('์ด ํ•˜์ด๋ผ์ดํŠธ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nโš ๏ธ ์ฃผ์˜: ์—ฐ๊ฒฐ๋œ ๋ชจ๋“  ๋ฉ”๋ชจ๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.'); + if (!confirmed) { + console.log('โŒ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ์ทจ์†Œ๋จ'); + return; + } + + try { + await this.highlightManager.deleteHighlight(highlightId); + this.highlightManager.hideTooltip(); + this.showSuccessMessage('ํ•˜์ด๋ผ์ดํŠธ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (error) { + console.error('โŒ ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('ํ•˜์ด๋ผ์ดํŠธ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // ==================== PDF ๋ทฐ์–ด ๊ด€๋ จ ==================== + async loadPdfViewer() { + console.log('๐Ÿ“„ PDF ๋ทฐ์–ด ๋กœ๋“œ ์‹œ์ž‘'); + this.pdfLoading = true; + this.pdfError = false; + this.pdfLoaded = false; + + try { + const token = localStorage.getItem('access_token'); + if (!token || token === 'null' || token === null) { + throw new Error('์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.'); + } + + // PDF ๋ทฐ์–ด URL ์„ค์ • (ํ† ํฐ ํฌํ•จ) + this.pdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`; + console.log('โœ… PDF ๋ทฐ์–ด ์ค€๋น„ ์™„๋ฃŒ:', this.pdfSrc); + + // PDF.js๋กœ PDF ๋กœ๋“œ + await this.loadPdfWithPdfJs(); + + } catch (error) { + console.error('โŒ PDF ๋ทฐ์–ด ๋กœ๋“œ ์‹คํŒจ:', error); + this.pdfError = true; + } finally { + this.pdfLoading = false; + } + }, + + async loadPdfWithPdfJs() { + try { + // PDF.js ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™•์ธ + if (typeof pdfjsLib === 'undefined') { + throw new Error('PDF.js ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•ด์ฃผ์„ธ์š”.'); + } + + // ์›Œ์ปค ์„ค์ • (์ด๋ฏธ ์ „์—ญ์—์„œ ์„ค์ •๋˜์—ˆ์ง€๋งŒ ์žฌํ™•์ธ) + if (!pdfjsLib.GlobalWorkerOptions.workerSrc) { + pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + } + + console.log('๐Ÿ“„ PDF.js๋กœ PDF ๋กœ๋“œ ์‹œ์ž‘:', this.pdfSrc); + + // ํ† ํฐ ํฌํ•จ๋œ PDF ๋กœ๋“œ ์„ค์ • + const loadingTask = pdfjsLib.getDocument({ + url: this.pdfSrc, + httpHeaders: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + }, + withCredentials: false + }); + + // ๋กœ๋”ฉ ์ง„ํ–‰๋ฅ  ํ‘œ์‹œ + loadingTask.onProgress = (progress) => { + if (progress.total > 0) { + const percent = Math.round((progress.loaded / progress.total) * 100); + console.log(`๐Ÿ“„ PDF ๋กœ๋”ฉ ์ง„ํ–‰๋ฅ : ${percent}%`); + } + }; + + this.pdfDocument = await loadingTask.promise; + + this.totalPages = this.pdfDocument.numPages; + this.currentPage = 1; + + console.log(`โœ… PDF ๋กœ๋“œ ์™„๋ฃŒ: ${this.totalPages} ํŽ˜์ด์ง€`); + + // ์บ”๋ฒ„์Šค ์ดˆ๊ธฐํ™” + this.initPdfCanvas(); + + // ์ฒซ ํŽ˜์ด์ง€ ๋ Œ๋”๋ง + await this.renderPdfPage(1); + + this.pdfLoaded = true; + + } catch (error) { + console.error('โŒ PDF.js ๋กœ๋“œ ์‹คํŒจ:', error); + + // ๊ตฌ์ฒด์ ์ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ œ๊ณต + if (error.name === 'InvalidPDFException') { + throw new Error('์œ ํšจํ•˜์ง€ ์•Š์€ PDF ํŒŒ์ผ์ž…๋‹ˆ๋‹ค.'); + } else if (error.name === 'MissingPDFException') { + throw new Error('PDF ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } else if (error.name === 'UnexpectedResponseException') { + throw new Error('PDF ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ถŒํ•œ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”.'); + } else { + throw new Error(`PDF ๋กœ๋“œ ์‹คํŒจ: ${error.message}`); + } + } + }, + + initPdfCanvas() { + this.pdfCanvas = document.getElementById('pdf-canvas'); + if (this.pdfCanvas) { + this.pdfContext = this.pdfCanvas.getContext('2d'); + } + }, + + async renderPdfPage(pageNum) { + if (!this.pdfDocument || !this.pdfCanvas) return; + + try { + console.log(`๐Ÿ“„ ํŽ˜์ด์ง€ ${pageNum} ๋ Œ๋”๋ง ์‹œ์ž‘`); + + const page = await this.pdfDocument.getPage(pageNum); + const viewport = page.getViewport({ scale: this.pdfScale }); + + // ์บ”๋ฒ„์Šค ํฌ๊ธฐ ์„ค์ • + this.pdfCanvas.height = viewport.height; + this.pdfCanvas.width = viewport.width; + + // ํŽ˜์ด์ง€ ๋ Œ๋”๋ง + const renderContext = { + canvasContext: this.pdfContext, + viewport: viewport + }; + + await page.render(renderContext).promise; + + // ํ…์ŠคํŠธ ๋‚ด์šฉ ์ถ”์ถœ (๊ฒ€์ƒ‰์šฉ) + const textContent = await page.getTextContent(); + this.pdfTextContent[pageNum] = textContent.items.map(item => item.str).join(' '); + + console.log(`โœ… ํŽ˜์ด์ง€ ${pageNum} ๋ Œ๋”๋ง ์™„๋ฃŒ`); + + } catch (error) { + console.error(`โŒ ํŽ˜์ด์ง€ ${pageNum} ๋ Œ๋”๋ง ์‹คํŒจ:`, error); + } + }, + + handlePdfError() { + console.error('โŒ PDF iframe ๋กœ๋“œ ์˜ค๋ฅ˜'); + this.pdfError = true; + this.pdfLoading = false; + }, + + async retryPdfLoad() { + console.log('๐Ÿ”„ PDF ์žฌ๋กœ๋“œ ์‹œ๋„'); + await this.loadPdfViewer(); + }, + + // ==================== PDF ๋„ค๋น„๊ฒŒ์ด์…˜ ==================== + async previousPage() { + if (this.currentPage > 1) { + this.currentPage--; + await this.renderPdfPage(this.currentPage); + } + }, + + async nextPage() { + if (this.currentPage < this.totalPages) { + this.currentPage++; + await this.renderPdfPage(this.currentPage); + } + }, + + async goToPage(pageNum) { + const page = parseInt(pageNum); + if (page >= 1 && page <= this.totalPages) { + this.currentPage = page; + await this.renderPdfPage(this.currentPage); + } + }, + + zoomIn() { + this.pdfScale = Math.min(this.pdfScale * 1.2, 3.0); + this.renderPdfPage(this.currentPage); + }, + + zoomOut() { + this.pdfScale = Math.max(this.pdfScale / 1.2, 0.5); + this.renderPdfPage(this.currentPage); + }, + + // ==================== PDF ๊ฒ€์ƒ‰ ๊ด€๋ จ ==================== + openPdfSearchModal() { + this.showPdfSearchModal = true; + this.pdfSearchQuery = ''; + this.pdfSearchResults = []; + + // ๋ชจ๋‹ฌ์ด ์—ด๋ฆฐ ํ›„ ์ž…๋ ฅ ํ•„๋“œ์— ํฌ์ปค์Šค + setTimeout(() => { + const searchInput = document.querySelector('input[x-ref="searchInput"]'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + }, 100); + }, + + async searchInPdf() { + if (!this.pdfSearchQuery.trim()) { + alert('๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'); + return; + } + + console.log('๐Ÿ” PDF ๊ฒ€์ƒ‰ ์‹œ์ž‘:', this.pdfSearchQuery); + this.pdfSearchLoading = true; + this.pdfSearchResults = []; + + try { + // ๋ฐฑ์—”๋“œ API๋ฅผ ํ†ตํ•ด PDF ๋‚ด์šฉ ๊ฒ€์ƒ‰ + const searchResults = await this.api.get( + `/documents/${this.documentId}/search-in-content?q=${encodeURIComponent(this.pdfSearchQuery)}` + ); + + console.log('โœ… PDF ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ:', searchResults); + + if (searchResults.matches && searchResults.matches.length > 0) { + this.pdfSearchResults = searchResults.matches.map(match => ({ + page: match.page || 1, + context: match.context || match.text || this.pdfSearchQuery, + position: match.position || 0 + })); + + console.log(`๐Ÿ“„ ${this.pdfSearchResults.length}๊ฐœ์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๋ฐœ๊ฒฌ`); + + if (this.pdfSearchResults.length === 0) { + alert('๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + } else { + alert('๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + } catch (error) { + console.error('โŒ PDF ๊ฒ€์ƒ‰ ์‹คํŒจ:', error); + alert('PDF ๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } finally { + this.pdfSearchLoading = false; + } + }, + + jumpToPdfResult(result) { + console.log('๐Ÿ“ PDF ๊ฒฐ๊ณผ๋กœ ์ด๋™:', result); + + // PDF URL์— ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ ์ถ”๊ฐ€ํ•˜์—ฌ ํ•ด๋‹น ํŽ˜์ด์ง€๋กœ ์ด๋™ + const token = localStorage.getItem('access_token'); + let newPdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`; + + // ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๊ฐ€ ์žˆ์œผ๋ฉด URL ํ”„๋ž˜๊ทธ๋จผํŠธ๋กœ ์ถ”๊ฐ€ + if (result.page && result.page > 1) { + newPdfSrc += `#page=${result.page}`; + } + + // PDF src ์—…๋ฐ์ดํŠธํ•˜์—ฌ ํ•ด๋‹น ํŽ˜์ด์ง€๋กœ ์ด๋™ + this.pdfSrc = newPdfSrc; + + console.log(`๐Ÿ“„ ํŽ˜์ด์ง€ ${result.page}๋กœ ์ด๋™:`, newPdfSrc); + + // ์ž ์‹œ ํ›„ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ ํ™œ์„ฑํ™” + setTimeout(() => { + const iframe = document.querySelector('#pdf-viewer-iframe'); + if (iframe && iframe.contentWindow) { + try { + iframe.contentWindow.focus(); + + // ๋ธŒ๋ผ์šฐ์ € ๋‚ด์žฅ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ ํ™œ์šฉ + if (iframe.contentWindow.find) { + iframe.contentWindow.find(this.pdfSearchQuery); + } else { + // ๋Œ€์•ˆ: ์‚ฌ์šฉ์ž์—๊ฒŒ ์ˆ˜๋™ ๊ฒ€์ƒ‰ ์•ˆ๋‚ด + this.showSuccessMessage(`ํŽ˜์ด์ง€ ${result.page}๋กœ ์ด๋™ํ–ˆ์Šต๋‹ˆ๋‹ค. Ctrl+F๋ฅผ ๋ˆŒ๋Ÿฌ "${this.pdfSearchQuery}"๋ฅผ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”.`); + } + } catch (e) { + console.warn('PDF iframe ์ ‘๊ทผ ์ œํ•œ:', e); + this.showSuccessMessage(`ํŽ˜์ด์ง€ ${result.page}๋กœ ์ด๋™ํ–ˆ์Šต๋‹ˆ๋‹ค. Ctrl+F๋ฅผ ๋ˆŒ๋Ÿฌ "${this.pdfSearchQuery}"๋ฅผ ๊ฒ€์ƒ‰ํ•˜์„ธ์š”.`); + } + } + }, 1000); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + this.showPdfSearchModal = false; + }, + + async editNote(noteId, currentContent) { + console.log('โœ๏ธ ๋ฉ”๋ชจ ํŽธ์ง‘:', noteId); + console.log('๐Ÿ” HighlightManager ์ƒํƒœ:', this.highlightManager); + console.log('๐Ÿ” updateNote ํ•จ์ˆ˜ ์กด์žฌ:', typeof this.highlightManager?.updateNote); + + if (!this.highlightManager) { + console.error('โŒ HighlightManager๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์Œ'); + alert('ํ•˜์ด๋ผ์ดํŠธ ๋งค๋‹ˆ์ €๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'); + return; + } + + if (typeof this.highlightManager.updateNote !== 'function') { + console.error('โŒ updateNote ํ•จ์ˆ˜๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ'); + alert('๋ฉ”๋ชจ ์—…๋ฐ์ดํŠธ ํ•จ์ˆ˜๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + return; + } + + const newContent = prompt('๋ฉ”๋ชจ ๋‚ด์šฉ์„ ์ˆ˜์ •ํ•˜์„ธ์š”:', currentContent); + if (newContent !== null && newContent.trim() !== currentContent) { + try { + await this.highlightManager.updateNote(noteId, newContent.trim()); + this.showSuccessMessage('๋ฉ”๋ชจ๊ฐ€ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + } catch (error) { + console.error('โŒ ๋ฉ”๋ชจ ์ˆ˜์ • ์‹คํŒจ:', error); + alert('๋ฉ”๋ชจ ์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + }, + + // ๋ฐฑ๋งํฌ ์‚ญ์ œ (ํ™•์ธ ํ›„) + async deleteBacklinkWithConfirm(backlinkId, sourceTitle) { + console.log('๐Ÿ—‘๏ธ ๋ฐฑ๋งํฌ ์‚ญ์ œ ์š”์ฒญ:', { backlinkId, sourceTitle }); + + const confirmed = confirm(`"${sourceTitle}"์—์„œ ์˜ค๋Š” ๋ฐฑ๋งํฌ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\nโš ๏ธ ์ฃผ์˜: ์ด๋Š” ์›๋ณธ ๋ฌธ์„œ์˜ ๋งํฌ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.`); + if (!confirmed) { + console.log('โŒ ๋ฐฑ๋งํฌ ์‚ญ์ œ ์ทจ์†Œ๋จ'); + return; + } + + try { + console.log('๐Ÿ—‘๏ธ ๋ฐฑ๋งํฌ ์‚ญ์ œ ์‹œ์ž‘:', backlinkId); + + // ๋ฐฑ๋งํฌ ์‚ญ์ œ๋Š” ์‹ค์ œ๋กœ๋Š” ์›๋ณธ ๋งํฌ๋ฅผ ์‚ญ์ œํ•˜๋Š” ๊ฒƒ + await this.api.deleteDocumentLink(backlinkId); + console.log('โœ… ๋ฐฑ๋งํฌ ์‚ญ์ œ ์„ฑ๊ณต'); + + // ํˆดํŒ ์ˆจ๊ธฐ๊ธฐ + this.linkManager.hideTooltip(); + + // ์บ์‹œ ๋ฌดํšจํ™” (ํ˜„์žฌ ๋ฌธ์„œ์˜ ๋ฐฑ๋งํฌ ์บ์‹œ) + console.log('๐Ÿ—‘๏ธ ๋ฐฑ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์‹œ์ž‘...'); + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + if (this.contentType === 'note') { + window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']); + } else { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']); + } + console.log('โœ… ๋ฐฑ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + + // ๋ฐฑ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + console.log('๐Ÿ”„ ๋ฐฑ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์ž‘...'); + await this.linkManager.loadBacklinks(this.documentId, this.contentType); + this.backlinks = this.linkManager.backlinks || []; + console.log('๐Ÿ“Š ์ƒˆ๋กœ๊ณ ์นจ๋œ ๋ฐฑ๋งํฌ ๊ฐœ์ˆ˜:', this.backlinks.length); + + // ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง + console.log('๐ŸŽจ ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง ์‹œ์ž‘...'); + this.linkManager.renderBacklinks(); + console.log('โœ… ๋ฐฑ๋งํฌ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + + // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ + this.showSuccessMessage('๋ฐฑ๋งํฌ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + + } catch (error) { + console.error('โŒ ๋ฐฑ๋งํฌ ์‚ญ์ œ ์‹คํŒจ:', error); + alert('๋ฐฑ๋งํฌ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + }, + + // ๋ถ๋งˆํฌ ๊ด€๋ จ + scrollToBookmark(bookmark) { + return this.bookmarkManager.scrollToBookmark(bookmark); + }, + + deleteBookmark(bookmarkId) { + return this.bookmarkManager.deleteBookmark(bookmarkId); + }, + + // ==================== ๋งํฌ ์ƒ์„ฑ ==================== + async createDocumentLink() { + console.log('๐Ÿ”— createDocumentLink ํ•จ์ˆ˜ ์‹คํ–‰'); + console.log('๐Ÿ“‹ ํ˜„์žฌ linkForm ์ƒํƒœ:', JSON.stringify(this.linkForm, null, 2)); + + try { + // ๋งํฌ ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ + if (!this.linkForm.target_document_id) { + alert('๋Œ€์ƒ ๋ฌธ์„œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.'); + return; + } + + if (this.linkForm.link_type === 'text' && !this.linkForm.target_text) { + alert('๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ํ…์ŠคํŠธ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”. "๋Œ€์ƒ ๋ฌธ์„œ์—์„œ ํ…์ŠคํŠธ ์„ ํƒ" ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ์—ฐ๊ฒฐํ•  ํ…์ŠคํŠธ๋ฅผ ๋“œ๋ž˜๊ทธํ•ด์ฃผ์„ธ์š”.'); + return; + } + + // API ํ˜ธ์ถœ์šฉ ๋ฐ์ดํ„ฐ ์ค€๋น„ (๋ฐฑ์—”๋“œ ํ•„๋“œ๋ช…์— ๋งž์ถค) + const linkData = { + target_document_id: this.linkForm.target_document_id, + selected_text: this.linkForm.selected_text, // ๋ฐฑ์—”๋“œ: selected_text + start_offset: this.linkForm.start_offset, // ๋ฐฑ์—”๋“œ: start_offset + end_offset: this.linkForm.end_offset, // ๋ฐฑ์—”๋“œ: end_offset + link_text: this.linkForm.link_text || this.linkForm.selected_text, + description: this.linkForm.description, + link_type: this.linkForm.link_type, + target_text: this.linkForm.target_text || null, + target_start_offset: this.linkForm.target_start_offset || null, + target_end_offset: this.linkForm.target_end_offset || null + }; + + console.log('๐Ÿ“ค ๋งํฌ ์ƒ์„ฑ ๋ฐ์ดํ„ฐ:', linkData); + console.log('๐Ÿ“ค ๋งํฌ ์ƒ์„ฑ ๋ฐ์ดํ„ฐ (JSON):', JSON.stringify(linkData, null, 2)); + + // ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ + const requiredFields = ['target_document_id', 'selected_text', 'start_offset', 'end_offset']; + const missingFields = requiredFields.filter(field => + linkData[field] === undefined || linkData[field] === null || linkData[field] === '' + ); + + if (missingFields.length > 0) { + console.error('โŒ ํ•„์ˆ˜ ํ•„๋“œ ๋ˆ„๋ฝ:', missingFields); + alert('ํ•„์ˆ˜ ํ•„๋“œ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: ' + missingFields.join(', ')); + return; + } + + console.log('โœ… ๋ชจ๋“  ํ•„์ˆ˜ ํ•„๋“œ ํ™•์ธ๋จ'); + + // API ํ˜ธ์ถœ (์ถœ๋ฐœ์ง€์™€ ๋Œ€์ƒ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API ์‚ฌ์šฉ) + if (this.contentType === 'note') { + // ๋…ธํŠธ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋งํฌ + if (this.linkForm.target_type === 'note') { + // ๋…ธํŠธ โ†’ ๋…ธํŠธ: ๋…ธํŠธ ๋งํฌ API ์‚ฌ์šฉ + linkData.target_note_id = linkData.target_document_id; + delete linkData.target_document_id; + await this.api.post(`/note-documents/${this.documentId}/links`, linkData); + } else { + // ๋…ธํŠธ โ†’ ๋ฌธ์„œ: ๋…ธํŠธ ๋งํฌ API ์‚ฌ์šฉ (target_document_id ์œ ์ง€) + await this.api.post(`/note-documents/${this.documentId}/links`, linkData); + } + } else { + // ๋ฌธ์„œ์—์„œ ์ถœ๋ฐœํ•˜๋Š” ๋งํฌ + if (this.linkForm.target_type === 'note') { + // ๋ฌธ์„œ โ†’ ๋…ธํŠธ: ๋ฌธ์„œ ๋งํฌ API์— ๋…ธํŠธ ๋Œ€์ƒ ์ง€์› ํ•„์š” (ํ–ฅํ›„ ๊ตฌํ˜„) + // ํ˜„์žฌ๋Š” ๊ธฐ์กด API ์‚ฌ์šฉ + await this.api.createDocumentLink(this.documentId, linkData); + } else { + // ๋ฌธ์„œ โ†’ ๋ฌธ์„œ: ๊ธฐ์กด ๋ฌธ์„œ ๋งํฌ API ์‚ฌ์šฉ + await this.api.createDocumentLink(this.documentId, linkData); + } + } + console.log('โœ… ๋งํฌ ์ƒ์„ฑ๋จ'); + + // ์„ฑ๊ณต ์•Œ๋ฆผ + alert('๋งํฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'); + + // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ + this.showLinkModal = false; + + // ์บ์‹œ ๋ฌดํšจํ™” (์ƒˆ ๋งํฌ๊ฐ€ ๋ฐ˜์˜๋˜๋„๋ก) + console.log('๐Ÿ—‘๏ธ ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์‹œ์ž‘...'); + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + if (this.contentType === 'note') { + window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/links`, ['links']); + } else { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']); + } + console.log('โœ… ๋งํฌ ์บ์‹œ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + + // ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ + console.log('๐Ÿ”„ ๋งํฌ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์ž‘...'); + await this.linkManager.loadDocumentLinks(this.documentId, this.contentType); + this.documentLinks = this.linkManager.documentLinks || []; + console.log('๐Ÿ“Š ๋กœ๋“œ๋œ ๋งํฌ ๊ฐœ์ˆ˜:', this.documentLinks.length); + console.log('๐Ÿ“Š ๋งํฌ ๋ฐ์ดํ„ฐ:', this.documentLinks); + + // ๋งํฌ ๋ Œ๋”๋ง + console.log('๐ŸŽจ ๋งํฌ ๋ Œ๋”๋ง ์‹œ์ž‘...'); + this.linkManager.renderDocumentLinks(); + console.log('โœ… ๋งํฌ ๋ Œ๋”๋ง ์™„๋ฃŒ'); + + // ๋ฐฑ๋งํฌ๋„ ๋‹ค์‹œ ๋กœ๋“œํ•˜๊ณ  ๋ Œ๋”๋ง (์ƒˆ ๋งํฌ๊ฐ€ ๋‹ค๋ฅธ ๋ฌธ์„œ์˜ ๋ฐฑ๋งํฌ๊ฐ€ ๋  ์ˆ˜ ์žˆ์Œ) + console.log('๐Ÿ”„ ๋ฐฑ๋งํฌ ์ƒˆ๋กœ๊ณ ์นจ ์‹œ์ž‘...'); + // ๋ฐฑ๋งํฌ ์บ์‹œ๋„ ๋ฌดํšจํ™” + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + if (this.contentType === 'note') { + window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']); + } else { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']); + } + console.log('โœ… ๋ฐฑ๋งํฌ ์บ์‹œ๋„ ๋ฌดํšจํ™” ์™„๋ฃŒ'); + } + await this.linkManager.loadBacklinks(this.documentId, this.contentType); + this.backlinks = this.linkManager.backlinks || []; + this.linkManager.renderBacklinks(); + console.log('โœ… ๋ฐฑ๋งํฌ ์ƒˆ๋กœ๊ณ ์นจ ์™„๋ฃŒ'); + + } catch (error) { + console.error('๋งํฌ ์ƒ์„ฑ ์‹คํŒจ:', error); + console.error('์—๋Ÿฌ ์ƒ์„ธ:', { + message: error.message, + stack: error.stack, + response: error.response + }); + + // 422 ์—๋Ÿฌ์ธ ๊ฒฝ์šฐ ์ƒ์„ธ ์ •๋ณด ํ‘œ์‹œ + if (error.response && error.response.status === 422) { + console.error('422 Validation Error Details:', error.response.data); + alert('๋ฐ์ดํ„ฐ ๊ฒ€์ฆ ์‹คํŒจ: ' + JSON.stringify(error.response.data, null, 2)); + } else { + alert('๋งํฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ' + error.message); + } + } + }, + + // ๋„ค๋น„๊ฒŒ์ด์…˜ ํ•จ์ˆ˜๋“ค + goBack() { + console.log('๐Ÿ”™ ๋’ค๋กœ๊ฐ€๊ธฐ'); + window.history.back(); + }, + + navigateToDocument(documentId) { + if (!documentId) { + console.warn('โš ๏ธ ๋ฌธ์„œ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + console.log('๐Ÿ“„ ๋ฌธ์„œ๋กœ ์ด๋™:', documentId); + window.location.href = `/viewer.html?id=${documentId}`; + }, + + goToBookContents() { + if (!this.navigation?.book_info?.id) { + console.warn('โš ๏ธ ์„œ์  ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + return; + } + console.log('๐Ÿ“š ์„œ์  ๋ชฉ์ฐจ๋กœ ์ด๋™:', this.navigation.book_info.id); + window.location.href = `/book-documents.html?book_id=${this.navigation.book_info.id}`; + } +}); + +// Alpine.js ์ปดํฌ๋„ŒํŠธ ๋“ฑ๋ก +document.addEventListener('alpine:init', () => { + console.log('๐Ÿ”ง Alpine.js ์ปดํฌ๋„ŒํŠธ ๋กœ๋“œ๋จ'); + + // ์ „์—ญ ํ•จ์ˆ˜๋“ค (๋งํ’์„ ์—์„œ ์‚ฌ์šฉ) + window.cancelTextSelection = () => { + if (window.documentViewerInstance && window.documentViewerInstance.linkManager) { + window.documentViewerInstance.linkManager.cancelTextSelection(); + } + }; + + window.confirmTextSelection = (selectedText, startOffset, endOffset) => { + if (window.documentViewerInstance && window.documentViewerInstance.linkManager) { + window.documentViewerInstance.linkManager.confirmTextSelection(selectedText, startOffset, endOffset); + } + }; +}); + +// Alpine.js Store ๋“ฑ๋ก +document.addEventListener('alpine:init', () => { + Alpine.store('documentViewer', { + instance: null, + + init() { + // DocumentViewer ์ธ์Šคํ„ด์Šค๊ฐ€ ์ƒ์„ฑ๋˜๋ฉด ์ €์žฅ + setTimeout(() => { + this.instance = window.documentViewerInstance; + }, 500); + }, + + downloadOriginalFile() { + console.log('๐Ÿช Store downloadOriginalFile ํ˜ธ์ถœ'); + if (this.instance) { + return this.instance.downloadOriginalFile(); + } else { + console.warn('DocumentViewer ์ธ์Šคํ„ด์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + } + }, + + toggleLanguage() { + console.log('๐Ÿช Store toggleLanguage ํ˜ธ์ถœ'); + if (this.instance) { + return this.instance.toggleLanguage(); + } else { + console.warn('DocumentViewer ์ธ์Šคํ„ด์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + } + }, + + loadBacklinks() { + console.log('๐Ÿช Store loadBacklinks ํ˜ธ์ถœ'); + if (this.instance) { + return this.instance.loadBacklinks(); + } else { + console.warn('DocumentViewer ์ธ์Šคํ„ด์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); + } + } + }); +}); diff --git a/frontend/story-reader.html b/frontend/story-reader.html new file mode 100644 index 0000000..5b1e8e8 --- /dev/null +++ b/frontend/story-reader.html @@ -0,0 +1,359 @@ + + + + + + ์Šคํ† ๋ฆฌ ์ฝ๊ธฐ + + + + + + + + + + + + +
+ + +
+
+ + +
+
+ +
+ + +
+ + โ€ข + +
+
+ + +
+ + +
+
+
+ + +
+ +

์Šคํ† ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +

์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค

+

+ +
+ + +
+ + +
+
+
+ +
+

+
+ + + +
+
+
+ + +
+
+
+
+ +

์ด ์ฑ•ํ„ฐ๋Š” ์•„์ง ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค

+
+
+
+ + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+
+
+ + +
+
+ +

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค

+

์Šคํ† ๋ฆฌ๋ฅผ ๋ณด๋ ค๋ฉด ๋จผ์ € ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”

+ +
+
+ + +
+
+
+
+

์ฑ•ํ„ฐ ํŽธ์ง‘

+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+
+ + +
+
+ +

ํŽธ์ง‘ ์ค€๋น„ ์ค‘...

+
+
+ +
+ + +
+
+
+ + +
+
+

๋กœ๊ทธ์ธ

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + + diff --git a/frontend/story-view.html b/frontend/story-view.html new file mode 100644 index 0000000..dc8a0ea --- /dev/null +++ b/frontend/story-view.html @@ -0,0 +1,321 @@ + + + + + + ์Šคํ† ๋ฆฌ ๋ทฐ + + + + + + + + + +
+ + +
+
+ + +
+
+ +
+ + + +
+ + โ€ข + +
+
+ + +
+ + +
+
+
+ + +
+ +

์Šคํ† ๋ฆฌ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+

์œ„์—์„œ ํŠธ๋ฆฌ๋ฅผ ์„ ํƒํ•˜๋ฉด ๋ชฉ์ฐจ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค

+
+ + +
+ +

์Šคํ† ๋ฆฌ ๋…ธ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

ํŠธ๋ฆฌ ์—๋””ํ„ฐ์—์„œ ๋…ธ๋“œ๋“ค์„ ์ •์‚ฌ๋กœ ์„ค์ •ํ•ด์ฃผ์„ธ์š”

+ + ํŠธ๋ฆฌ ์—๋””ํ„ฐ๋กœ ๊ฐ€๊ธฐ + +
+ + +
+ + +
+

+

+
+ + + + +
+
+ + +
+
+

+ ๋ชฉ์ฐจ +

+
+
+
+ +
+
+
+ +
+
+
+ + +
+
+ +

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค

+

์Šคํ† ๋ฆฌ๋ฅผ ๋ณด๋ ค๋ฉด ๋จผ์ € ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”

+ +
+
+ + +
+
+
+
+

์ฑ•ํ„ฐ ํŽธ์ง‘

+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+

๋กœ๊ทธ์ธ

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + + diff --git a/frontend/system-settings.html b/frontend/system-settings.html new file mode 100644 index 0000000..a7cefc1 --- /dev/null +++ b/frontend/system-settings.html @@ -0,0 +1,368 @@ + + + + + + ์‹œ์Šคํ…œ ์„ค์ • - Document Server + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+
+ +

๊ถŒํ•œ์„ ํ™•์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...

+
+
+ + +
+
+ +

์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค

+

์‹œ์Šคํ…œ ์„ค์ •์— ์ ‘๊ทผํ•˜๋ ค๋ฉด ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

+ +
+
+ + +
+ +
+
+ +

์‹œ์Šคํ…œ ์„ค์ •

+
+

์‹œ์Šคํ…œ ์ „์ฒด ์„ค์ •์„ ๊ด€๋ฆฌํ•˜์„ธ์š”

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +

์‹œ์Šคํ…œ ์ •๋ณด

+
+ +
+
+
+ + ์ด ์‚ฌ์šฉ์ž ์ˆ˜ +
+
-
+
+ +
+
+ + ํ™œ์„ฑ ์‚ฌ์šฉ์ž +
+
-
+
+ +
+
+ + ๊ด€๋ฆฌ์ž ์ˆ˜ +
+
-
+
+
+
+ + +
+
+ +

๊ธฐ๋ณธ ์„ค์ •

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+ + +
+
+
+ +

์‚ฌ์šฉ์ž ๊ด€๋ฆฌ

+
+ + + ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ + +
+ +
+

โ€ข ์ƒˆ๋กœ์šด ์‚ฌ์šฉ์ž ๊ณ„์ • ์ƒ์„ฑ ๋ฐ ๊ด€๋ฆฌ

+

โ€ข ์‚ฌ์šฉ์ž ๊ถŒํ•œ ์„ค์ • (์„œ์ ๊ด€๋ฆฌ, ๋…ธํŠธ๊ด€๋ฆฌ, ์†Œ์„ค๊ด€๋ฆฌ)

+

โ€ข ๊ฐœ๋ณ„ ์‚ฌ์šฉ์ž ์„ธ์…˜ ํƒ€์ž„์•„์›ƒ ์„ค์ •

+

โ€ข ์‚ฌ์šฉ์ž ๊ณ„์ • ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™”

+
+
+ + +
+
+ +

์‹œ์Šคํ…œ ์œ ์ง€๋ณด์ˆ˜

+
+ +
+
+

์บ์‹œ ์ •๋ฆฌ

+

์‹œ์Šคํ…œ ์บ์‹œ๋ฅผ ์ •๋ฆฌํ•˜์—ฌ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•ฉ๋‹ˆ๋‹ค.

+ +
+ +
+

์‹œ์Šคํ…œ ์žฌ์‹œ์ž‘

+

์‹œ์Šคํ…œ์„ ์žฌ์‹œ์ž‘ํ•˜์—ฌ ์„ค์ •์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.

+ +
+
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/text-selector.html b/frontend/text-selector.html new file mode 100644 index 0000000..7d708c3 --- /dev/null +++ b/frontend/text-selector.html @@ -0,0 +1,519 @@ + + + + + + ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ + + + + + +
+
+
+ +
+

ํ…์ŠคํŠธ ์„ ํƒ ๋ชจ๋“œ

+

์—ฐ๊ฒฐํ•˜๊ณ  ์‹ถ์€ ํ…์ŠคํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+
+
+
+ + +
+
+
+ + +
+
+
+ +
+

ํ…์ŠคํŠธ ์„ ํƒ ๋ฐฉ๋ฒ•

+

+ ๋งˆ์šฐ์Šค๋กœ ์—ฐ๊ฒฐํ•˜๊ณ  ์‹ถ์€ ํ…์ŠคํŠธ๋ฅผ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ์„ ํƒํ•˜์„ธ์š”. + ์„ ํƒ์ด ์™„๋ฃŒ๋˜๋ฉด ์ž๋™์œผ๋กœ ๋ถ€๋ชจ ์ฐฝ์œผ๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. +

+
+
+
+
+ + +
+
+
+ +

๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ +
+
+ + + + + + + + + diff --git a/frontend/todos.html b/frontend/todos.html new file mode 100644 index 0000000..95bc096 --- /dev/null +++ b/frontend/todos.html @@ -0,0 +1,674 @@ + + + + + + ํ• ์ผ๊ด€๋ฆฌ - Document Server + + + + + + + + +
+ + +
+ +
+

+ + ํ• ์ผ๊ด€๋ฆฌ +

+

ํšจ์œจ์ ์ธ ์ผ์ • ๊ด€๋ฆฌ์™€ ์ƒ์‚ฐ์„ฑ ํ–ฅ์ƒ

+ + +
+
+ + ๊ฒ€ํ† ํ•„์š” ๊ฐœ +
+
+ + ์ง„ํ–‰์ค‘ ๊ฐœ +
+
+ + ์™„๋ฃŒ ๊ฐœ +
+
+
+ + +
+
+
+ + +
+
+ + Ctrl+Enter๋กœ ๋น ๋ฅด๊ฒŒ ์ €์žฅํ•˜์„ธ์š” +
+ + +
+
+
+
+ + +
+ +
+ + ํƒญ์„ ๋ˆŒ๋Ÿฌ์„œ ์ „ํ™˜ํ•˜์„ธ์š” +
+ +
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +

๊ฒ€ํ† ๊ฐ€ ํ•„์š”ํ•œ ํ• ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+
+
+ + +
+ + +
+ +

ํ•  ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+

๊ฒ€ํ† ํ•„์š”์—์„œ ์ผ์ •์„ ์„ค์ •ํ•ด๋ณด์„ธ์š”

+
+
+ + +
+ + +
+ +

์˜ˆ์ •๋œ ํ• ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+
+
+ + +
+ + +
+ +

์™„๋ฃŒ๋œ ํ• ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค

+
+
+
+
+ + +
+
+
+

+

+
+ +
+
+ + +
+ +
+ + +

+ + ํ•˜๋ฃจ 8์‹œ๊ฐ„ ์ดˆ๊ณผ ์‹œ ๊ฒฝ๊ณ ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค +

+
+ +
+ + +
+
+
+
+ + + +
+
+
+

ํ• ์ผ ๋ถ„ํ• 

+

2์‹œ๊ฐ„ ์ด์ƒ์˜ ์ž‘์—…์„ ์ž‘์€ ๋‹จ์œ„๋กœ ๋‚˜๋ˆ„์–ด ๊ด€๋ฆฌํ•˜์„ธ์š”

+
+ +
+
+ +
+ +
+ + ์ตœ๋Œ€ 10๊ฐœ๊นŒ์ง€ ๊ฐ€๋Šฅ +
+ +
+ + +
+
+
+
+ + +
+
+
+

๋ฉ”๋ชจ ์ž‘์„ฑ

+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+ + + + + + + + + diff --git a/frontend/upload.html b/frontend/upload.html new file mode 100644 index 0000000..7ab789b --- /dev/null +++ b/frontend/upload.html @@ -0,0 +1,340 @@ + + + + + + ๋ฌธ์„œ ์—…๋กœ๋“œ - Document Server + + + + + + + + + +
+ + +
+ +
+
+ +
+ +
+

๋ฌธ์„œ ์—…๋กœ๋“œ

+

HTML ๋ฐ PDF ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ณ  ์ •๋ฆฌํ•ด๋ณด์„ธ์š”

+
+
+ + +
+
+
+
1
+ ํŒŒ์ผ ์„ ํƒ +
+
+
+
2
+ ์„œ์  ์„ค์ • +
+
+
+
3
+ ์ˆœ์„œ ์ •๋ฆฌ +
+
+
+ + +
+ +
+
+ +

ํŒŒ์ผ์„ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ์—…๋กœ๋“œ

+

๋˜๋Š” ํด๋ฆญํ•˜์—ฌ ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”

+
+ + + + + +

HTML, PDF ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค

+
+ + +
+

+ ์„ ํƒ๋œ ํŒŒ์ผ (๊ฐœ) +

+ +
+ +
+ +
+ +
+
+
+ + +
+

์„œ์  ์„ค์ •

+ + +
+ +
+ + + +
+
+ + +
+ + + +
+ +
+ +
+

์„ ํƒ๋œ ์„œ์ :

+
+
+ + +
+
+ + +

+ ๐Ÿ’ก ๋™์ผํ•œ ์ œ๋ชฉ์˜ ์„œ์ ์ด ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ, ๊ธฐ์กด ์„œ์ ์— ์ถ”๊ฐ€ํ• ์ง€ ๋ฌป์Šต๋‹ˆ๋‹ค. +

+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+
+
+

๋ฌธ์„œ ์ˆœ์„œ ์ •๋ฆฌ

+

๋“œ๋ž˜๊ทธํ•˜๊ฑฐ๋‚˜ ๋ฒ„ํŠผ์œผ๋กœ ์ˆœ์„œ๋ฅผ ์กฐ์ •ํ•˜๊ณ , PDF ํŒŒ์ผ์„ HTML ๋ฌธ์„œ์™€ ๋งค์นญํ•˜์„ธ์š”

+
+
+ + + +
+
+ + +
+ +
+ + +
+ + +
+
+
+
+ + + + + + + + diff --git a/frontend/user-management.html b/frontend/user-management.html new file mode 100644 index 0000000..8e093f3 --- /dev/null +++ b/frontend/user-management.html @@ -0,0 +1,520 @@ + + + + + + ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ - Document Server + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+

์‚ฌ์šฉ์ž ๊ด€๋ฆฌ

+

์‹œ์Šคํ…œ ์‚ฌ์šฉ์ž๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ  ๊ถŒํ•œ์„ ์„ค์ •ํ•˜์„ธ์š”.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+

์‚ฌ์šฉ์ž ๋ชฉ๋ก

+
+ ์ด ๋ช…์˜ ์‚ฌ์šฉ์ž +
+
+ + +
+ + + + + + + + + + + + + + +
์‚ฌ์šฉ์ž์—ญํ• ๊ถŒํ•œ์ƒํƒœ๊ฐ€์ž…์ผ์ž‘์—…
+
+
+
+
+ + +
+
+
+

์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

0 = ๋ฌด์ œํ•œ (๋กœ๊ทธ์•„์›ƒ ์—†์Œ)

+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+

์‚ฌ์šฉ์ž ์ˆ˜์ •

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +

0 = ๋ฌด์ œํ•œ (๋กœ๊ทธ์•„์›ƒ ์—†์Œ)

+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+

์‚ฌ์šฉ์ž ์‚ญ์ œ

+

+ ์‚ฌ์šฉ์ž๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?
+ ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +

+ +
+ + +
+
+
+
+ + + + + + + + + diff --git a/frontend/viewer.html b/frontend/viewer.html new file mode 100644 index 0000000..f733950 --- /dev/null +++ b/frontend/viewer.html @@ -0,0 +1,1139 @@ + + + + + + ๋ฌธ์„œ ๋ทฐ์–ด - Document Server + + + + + + + + + + + +
+ +
+
+ +
+ +
+ + + + +
+ + + +
+
+ + +
+

+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + +
+
+ + +
+ +
+ ํ•˜์ด๋ผ์ดํŠธ + + + + +
+ + +
+ + + + +
+ + ๋ฐฑ๋งํฌ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + +
+ + + +
+
+
+
+ + +
+ +
+
+ +
+ +

๋ฌธ์„œ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ + +
+ +

+ +
+ + +
+ +
+ +
+ + +
+
+
+ +

+
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + / + + +
+
+ + + +
+
+ + +
+ +
+
+ + + + + +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•˜๋Š” ์ค‘...

+
+
+ + +
+
+ +

PDF๋ฅผ ๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค

+ + +
+
+
+
+ + +
+ +
+
+
+
+
+ + +
+
+ +
+

+ + PDF์—์„œ ๊ฒ€์ƒ‰ +

+ +
+ + +
+
+ +
+ +
+ +
+
+
+ Enter ํ‚ค๋ฅผ ๋ˆ„๋ฅด๊ฑฐ๋‚˜ ๊ฒ€์ƒ‰ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์„ธ์š” +
+
+ + +
+
+ + ๊ฐœ์˜ ๊ฒฐ๊ณผ๋ฅผ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค. +
+
+ +
+
+ + +
+
+ +

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+

๋‹ค๋ฅธ ๊ฒ€์ƒ‰์–ด๋กœ ์‹œ๋„ํ•ด๋ณด์„ธ์š”.

+
+
+ + +
+ + +
+
+
+
+ + +
+
+ +
+

+ + ๋งํฌ +

+ +
+ + +
+ + + + + +
+
+
+ + +
+
+ +
+

+ + ๋งํฌ ์ƒ์„ฑ +

+ +
+ + +
+ +
+

์„ ํƒ๋œ ํ…์ŠคํŠธ

+

+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + + + +
+ + +

+
+ + +
+ +
+ +
+

์„ ํƒ๋œ ๋Œ€์ƒ ํ…์ŠคํŠธ:

+

+
+
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+

+ + ๋ฉ”๋ชจ +

+ +
+
+ + + + +
+ +
+
+
+
+ + +
+ +
+
+

+ + ๋ฉ”๋ชจ ์ถ”๊ฐ€ +

+ +
+ +
+ +
+

์„ ํƒ๋œ ํ…์ŠคํŠธ:

+

+
+ + +

์ด ํ•˜์ด๋ผ์ดํŠธ์— ๋ฉ”๋ชจ๋ฅผ ์ถ”๊ฐ€ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?

+ + + + + + + + +
+ + +
+
+
+
+ + +
+ +
+
+

+ + ์ฑ…๊ฐˆํ”ผ +

+ +
+
+ + + + +
+ +
+
+
+
+ + +
+ +
+
+

+ + ๋ฐฑ๋งํฌ +

+ +
+
+ + + + +
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..d837f64 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,15 @@ +FROM nginx:1.24-alpine + +# ์„ค์ • ํŒŒ์ผ ๋ณต์‚ฌ +COPY nginx.conf /etc/nginx/nginx.conf +COPY default.conf /etc/nginx/conf.d/default.conf + +# ์ •์  ํŒŒ์ผ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +RUN mkdir -p /usr/share/nginx/html/uploads + +# ๊ถŒํ•œ ์„ค์ • +RUN chown -R nginx:nginx /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..f8edb38 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,123 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # ์ •์  ํŒŒ์ผ ์บ์‹ฑ + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # PDF ํŒŒ์ผ ์š”์ฒญ (iframe ํ—ˆ์šฉ) + location ~ ^/api/documents/[^/]+/pdf$ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ํƒ€์ž„์•„์›ƒ ์„ค์ • + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # ๋ฒ„ํผ๋ง ์„ค์ • + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋ฐฉ์ง€ + proxy_redirect off; + + # PDF iframe ํ—ˆ์šฉ ๋ฐ ์ธ๋ผ์ธ ํ‘œ์‹œ ์„ค์ • + add_header X-Frame-Options "SAMEORIGIN" always; + + # PDF ํŒŒ์ผ์ด ๋‹ค์šด๋กœ๋“œ๋˜์ง€ ์•Š๊ณ  ๋ธŒ๋ผ์šฐ์ €์—์„œ ํ‘œ์‹œ๋˜๋„๋ก ์„ค์ • + location ~ \.pdf$ { + add_header Content-Disposition "inline"; + } + } + + # API ์š”์ฒญ์„ ๋ฐฑ์—”๋“œ๋กœ ํ”„๋ก์‹œ + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ํƒ€์ž„์•„์›ƒ ์„ค์ • + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # ๋ฒ„ํผ๋ง ์„ค์ • + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋ฐฉ์ง€ + proxy_redirect off; + + # CORS ํ—ค๋” ์ถ”๊ฐ€ + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + + # OPTIONS ์š”์ฒญ ์ฒ˜๋ฆฌ + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Content-Length 0; + add_header Content-Type text/plain; + return 204; + } + } + + # ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ ํŒŒ์ผ ์„œ๋น™ + location /uploads/ { + alias /usr/share/nginx/html/uploads/; + + # ๋ณด์•ˆ์„ ์œ„ํ•ด ์‹คํ–‰ ํŒŒ์ผ ์ฐจ๋‹จ + location ~* \.(php|pl|py|jsp|asp|sh|cgi)$ { + deny all; + } + + # HTML ํŒŒ์ผ์€ iframe์—์„œ ์•ˆ์ „ํ•˜๊ฒŒ ๋กœ๋“œ + location ~* \.html$ { + add_header X-Frame-Options "SAMEORIGIN"; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline'"; + } + } + + # ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ + location / { + try_files $uri $uri/ /index.html; + + # SPA๋ฅผ ์œ„ํ•œ ํžˆ์Šคํ† ๋ฆฌ API ์ง€์› + location ~* ^.+\.(html|htm)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + } + + # ํ—ฌ์Šค์ฒดํฌ ์—”๋“œํฌ์ธํŠธ + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # ์—๋Ÿฌ ํŽ˜์ด์ง€ + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..db0ae88 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,56 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ๋กœ๊ทธ ํฌ๋งท + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # ์„ฑ๋Šฅ ์ตœ์ ํ™” + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + # Gzip ์••์ถ• + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # ๋ณด์•ˆ ํ—ค๋” (PDF ํŒŒ์ผ์€ ์ œ์™ธ) + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # ๊ฐ€์ƒ ํ˜ธ์ŠคํŠธ ์„ค์ • ํฌํ•จ + include /etc/nginx/conf.d/*.conf; +} diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..0d0df05 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Document Server ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ +# ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ + +BACKUP_DIR="/volume1/docker/document-server/backups" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +CONTAINER_NAME="document-server-db" + +# ๋ฐฑ์—… ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +mkdir -p "$BACKUP_DIR" + +echo "๐Ÿ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… ์‹œ์ž‘: $TIMESTAMP" + +# PostgreSQL ๋ฐฑ์—… +docker exec $CONTAINER_NAME pg_dump -U docuser -d document_db > "$BACKUP_DIR/document_db_$TIMESTAMP.sql" + +# ์••์ถ• +gzip "$BACKUP_DIR/document_db_$TIMESTAMP.sql" + +# 7์ผ ์ด์ƒ ๋œ ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete + +echo "โœ… ๋ฐฑ์—… ์™„๋ฃŒ: $BACKUP_DIR/document_db_$TIMESTAMP.sql.gz" + +# ์—…๋กœ๋“œ ํŒŒ์ผ ๋ฐฑ์—… (์„ ํƒ์‚ฌํ•ญ) +if [ "$1" = "--include-uploads" ]; then + echo "๐Ÿ”„ ์—…๋กœ๋“œ ํŒŒ์ผ ๋ฐฑ์—… ์‹œ์ž‘..." + tar -czf "$BACKUP_DIR/uploads_$TIMESTAMP.tar.gz" -C /volume1/docker/document-server uploads/ + echo "โœ… ์—…๋กœ๋“œ ํŒŒ์ผ ๋ฐฑ์—… ์™„๋ฃŒ: $BACKUP_DIR/uploads_$TIMESTAMP.tar.gz" +fi + +echo "๐ŸŽ‰ ์ „์ฒด ๋ฐฑ์—… ์ž‘์—… ์™„๋ฃŒ" diff --git a/scripts/cleanup-for-production.sh b/scripts/cleanup-for-production.sh new file mode 100755 index 0000000..ebaf837 --- /dev/null +++ b/scripts/cleanup-for-production.sh @@ -0,0 +1,198 @@ +#!/bin/bash + +# ============================================================================= +# Document Server - ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ์šฉ ์ •๋ฆฌ ์Šคํฌ๋ฆฝํŠธ +# ํ…Œ์ŠคํŠธ ํŒŒ์ผ, ๊ฐœ๋ฐœ์šฉ ๋ฐ์ดํ„ฐ, ๋กœ๊ทธ ํŒŒ์ผ ๋“ฑ์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค +# ============================================================================= + +set -e + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# ํ™˜๊ฒฝ ์„ค์ • +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +echo "=== ๐Ÿงน ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ์šฉ ์ •๋ฆฌ ์‹œ์ž‘ ===" +echo "" + +# 1. ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ œ๊ฑฐ +log_info "๐Ÿ—‘๏ธ ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ œ๊ฑฐ ์ค‘..." + +# ํ…Œ์ŠคํŠธ HTML ํŒŒ์ผ๋“ค +TEST_FILES=( + "test-document.html" + "test-upload.html" + "test.html" + "cache-buster.html" + "frontend/test-upload.html" + "frontend/test.html" +) + +for file in "${TEST_FILES[@]}"; do + if [ -f "$file" ]; then + rm "$file" + log_success "์ œ๊ฑฐ: $file" + fi +done + +# 2. ๊ฐœ๋ฐœ์šฉ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์ œ๊ฑฐ +log_info "๐Ÿ–ผ๏ธ ํ…Œ์ŠคํŠธ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์ œ๊ฑฐ ์ค‘..." + +# RAF ์ด๋ฏธ์ง€ ํŒŒ์ผ๋“ค (ํ…Œ์ŠคํŠธ์šฉ) +find . -name "*.RAF_compressed.JPEG" -delete 2>/dev/null || true +find . -name "*.RAF" -delete 2>/dev/null || true + +log_success "ํ…Œ์ŠคํŠธ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์ œ๊ฑฐ ์™„๋ฃŒ" + +# 3. ๋กœ๊ทธ ํŒŒ์ผ ์ •๋ฆฌ +log_info "๐Ÿ“ ๋กœ๊ทธ ํŒŒ์ผ ์ •๋ฆฌ ์ค‘..." + +LOG_FILES=( + "backend.log" + "frontend.log" +) + +for file in "${LOG_FILES[@]}"; do + if [ -f "$file" ]; then + rm "$file" + log_success "์ œ๊ฑฐ: $file" + fi +done + +# 4. Python ์บ์‹œ ํŒŒ์ผ ์ •๋ฆฌ +log_info "๐Ÿ Python ์บ์‹œ ํŒŒ์ผ ์ •๋ฆฌ ์ค‘..." + +find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true +find . -name "*.pyc" -delete 2>/dev/null || true +find . -name "*.pyo" -delete 2>/dev/null || true + +log_success "Python ์บ์‹œ ํŒŒ์ผ ์ •๋ฆฌ ์™„๋ฃŒ" + +# 5. ๊ฐœ๋ฐœ์šฉ ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ฆฌ +log_info "๐Ÿ“ ๊ฐœ๋ฐœ์šฉ ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ฆฌ ์ค‘..." + +# ๋ฐฑ์—”๋“œ uploads ๋””๋ ‰ํ† ๋ฆฌ +if [ -d "backend/uploads" ]; then + rm -rf backend/uploads/documents/* 2>/dev/null || true + rm -rf backend/uploads/thumbnails/* 2>/dev/null || true + log_success "๋ฐฑ์—”๋“œ ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ฆฌ ์™„๋ฃŒ" +fi + +# ํ”„๋ก ํŠธ์—”๋“œ uploads ๋””๋ ‰ํ† ๋ฆฌ +if [ -d "frontend/uploads" ]; then + rm -rf frontend/uploads/* 2>/dev/null || true + log_success "ํ”„๋ก ํŠธ์—”๋“œ ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ฆฌ ์™„๋ฃŒ" +fi + +# ๋ฃจํŠธ uploads ๋””๋ ‰ํ† ๋ฆฌ +if [ -d "uploads" ]; then + rm -rf uploads/documents/* 2>/dev/null || true + rm -rf uploads/pdfs/* 2>/dev/null || true + rm -rf uploads/thumbnails/* 2>/dev/null || true + log_success "๋ฃจํŠธ ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ฆฌ ์™„๋ฃŒ" +fi + +# 6. ๊ฐ€์ƒํ™˜๊ฒฝ ์ œ๊ฑฐ (ํ”„๋กœ๋•์…˜์—์„œ๋Š” Docker ์‚ฌ์šฉ) +log_info "๐Ÿ ๊ฐ€์ƒํ™˜๊ฒฝ ์ œ๊ฑฐ ์ค‘..." + +if [ -d "backend/venv" ]; then + rm -rf backend/venv + log_success "๊ฐ€์ƒํ™˜๊ฒฝ ์ œ๊ฑฐ ์™„๋ฃŒ" +fi + +# 7. ๊ฐœ๋ฐœ์šฉ ์„ค์ • ํŒŒ์ผ ์ •๋ฆฌ +log_info "โš™๏ธ ๊ฐœ๋ฐœ์šฉ ์„ค์ • ํŒŒ์ผ ์ •๋ฆฌ ์ค‘..." + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ๋“ค (ํ”„๋กœ๋•์…˜์—์„œ ์ƒˆ๋กœ ์ƒ์„ฑ) +DEV_CONFIG_FILES=( + ".env" + ".env.local" + ".env.development" + "backend/.env" +) + +for file in "${DEV_CONFIG_FILES[@]}"; do + if [ -f "$file" ]; then + rm "$file" + log_success "์ œ๊ฑฐ: $file" + fi +done + +# 8. ๋ถˆํ•„์š”ํ•œ ๋ฌธ์„œ ํŒŒ์ผ ์ •๋ฆฌ +log_info "๐Ÿ“š ๊ฐœ๋ฐœ์šฉ ๋ฌธ์„œ ํŒŒ์ผ ์ •๋ฆฌ ์ค‘..." + +DEV_DOCS=( + "VIEWER_REFACTORING.md" +) + +for file in "${DEV_DOCS[@]}"; do + if [ -f "$file" ]; then + rm "$file" + log_success "์ œ๊ฑฐ: $file" + fi +done + +# 9. ๋นˆ ๋””๋ ‰ํ† ๋ฆฌ ์ •๋ฆฌ +log_info "๐Ÿ“‚ ๋นˆ ๋””๋ ‰ํ† ๋ฆฌ ์ •๋ฆฌ ์ค‘..." + +find . -type d -empty -delete 2>/dev/null || true + +# 10. ๊ถŒํ•œ ์ •๋ฆฌ +log_info "๐Ÿ” ํŒŒ์ผ ๊ถŒํ•œ ์ •๋ฆฌ ์ค‘..." + +# ์Šคํฌ๋ฆฝํŠธ ํŒŒ์ผ ์‹คํ–‰ ๊ถŒํ•œ ํ™•์ธ +chmod +x scripts/*.sh 2>/dev/null || true + +# ์„ค์ • ํŒŒ์ผ ๊ถŒํ•œ ์„ค์ • +find . -name "*.conf" -exec chmod 644 {} \; 2>/dev/null || true +find . -name "*.yml" -exec chmod 644 {} \; 2>/dev/null || true +find . -name "*.yaml" -exec chmod 644 {} \; 2>/dev/null || true + +log_success "ํŒŒ์ผ ๊ถŒํ•œ ์ •๋ฆฌ ์™„๋ฃŒ" + +# 11. ์ •๋ฆฌ ๊ฒฐ๊ณผ ์š”์•ฝ +echo "" +echo "=== ๐Ÿ“Š ์ •๋ฆฌ ๊ฒฐ๊ณผ ===" + +# ํ˜„์žฌ ๋””๋ ‰ํ† ๋ฆฌ ํฌ๊ธฐ +TOTAL_SIZE=$(du -sh . | cut -f1) +echo "์ „์ฒด ํฌ๊ธฐ: $TOTAL_SIZE" + +# ์ฃผ์š” ๋””๋ ‰ํ† ๋ฆฌ ํฌ๊ธฐ +echo "" +echo "์ฃผ์š” ๋””๋ ‰ํ† ๋ฆฌ:" +du -sh backend frontend scripts nginx 2>/dev/null | while read size dir; do + echo " $dir: $size" +done + +echo "" +echo "=== โœ… ์ •๋ฆฌ ์™„๋ฃŒ ===" +echo "" +echo "๐Ÿš€ ์ด์ œ ํ”„๋กœ๋•์…˜ ๋ฐฐํฌ ์ค€๋น„๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" +echo "" +echo "๋‹ค์Œ ๋‹จ๊ณ„:" +echo "1. NAS์— ์—…๋กœ๋“œ" +echo "2. ./scripts/deploy-synology.sh ์‹คํ–‰" +echo "3. ๋ฐฐํฌ ์™„๋ฃŒ!" + +log_success "ํ”„๋กœ๋•์…˜ ์ •๋ฆฌ ์Šคํฌ๋ฆฝํŠธ ์™„๋ฃŒ" diff --git a/scripts/deploy-synology.sh b/scripts/deploy-synology.sh new file mode 100755 index 0000000..ce36b4b --- /dev/null +++ b/scripts/deploy-synology.sh @@ -0,0 +1,399 @@ +#!/bin/bash + +# ============================================================================= +# Document Server - Synology DS1525+ ์ตœ์ ํ™” ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ +# +# ํ•˜๋“œ์›จ์–ด ์‚ฌ์–‘: +# - CPU: AMD Ryzen R1600 (4์ฝ”์–ด/8์Šค๋ ˆ๋“œ) +# - RAM: 32GB DDR4 ECC +# - SSD: ์ฝ๊ธฐ/์“ฐ๊ธฐ ์บ์‹œ ํ™œ์„ฑํ™” +# - Storage: Volume1(SSD), Volume2(HDD) +# ============================================================================= + +set -e # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์Šคํฌ๋ฆฝํŠธ ์ค‘๋‹จ + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ๋กœ๊ทธ ํ•จ์ˆ˜ +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +COMPOSE_FILE="docker-compose.synology.yml" + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ํ™•์ธ +ENV_FILE="$PROJECT_DIR/.env.synology" + +if [ ! -f "$ENV_FILE" ]; then + log_info "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. ์„ค์ •์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." + "$SCRIPT_DIR/setup-env.sh" +fi + +# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๋กœ๋“œ +if [ -f "$ENV_FILE" ]; then + log_info "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์„ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค: $ENV_FILE" + set -a # ์ž๋™์œผ๋กœ export + source "$ENV_FILE" + set +a +else + log_error "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + exit 1 +fi + +log_info "๐Ÿš€ Synology DS1525+ ์ตœ์ ํ™” ๋ฐฐํฌ ์‹œ์ž‘" +log_info "๐Ÿ“ ํ”„๋กœ์ ํŠธ ๋””๋ ‰ํ† ๋ฆฌ: $PROJECT_DIR" + +# 1. ์‹œ์Šคํ…œ ์š”๊ตฌ์‚ฌํ•ญ ํ™•์ธ +log_info "๐Ÿ” ์‹œ์Šคํ…œ ์š”๊ตฌ์‚ฌํ•ญ ํ™•์ธ ์ค‘..." + +# Docker ๋ฐ Docker Compose ํ™•์ธ +if ! command -v docker &> /dev/null; then + log_error "Docker๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + exit 1 +fi + +if ! command -v docker-compose &> /dev/null; then + log_error "Docker Compose๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค." + exit 1 +fi + +# ๋ฉ”๋ชจ๋ฆฌ ํ™•์ธ (์ตœ์†Œ 16GB ๊ถŒ์žฅ) +TOTAL_MEM=$(free -g | awk '/^Mem:/{print $2}') +if [ "$TOTAL_MEM" -lt 16 ]; then + log_warning "๋ฉ”๋ชจ๋ฆฌ๊ฐ€ ${TOTAL_MEM}GB์ž…๋‹ˆ๋‹ค. ์ตœ์†Œ 16GB๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค." +fi + +log_success "์‹œ์Šคํ…œ ์š”๊ตฌ์‚ฌํ•ญ ํ™•์ธ ์™„๋ฃŒ" + +# 2. ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ +log_info "๐Ÿ“‚ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ ์ค‘..." + +# SSD ๋””๋ ‰ํ† ๋ฆฌ (์„ฑ๋Šฅ ์ตœ์šฐ์„ ) - Volume3 +SSD_DIRS=( + "/volume3/docker/document-server/database" + "/volume3/docker/document-server/redis" + "/volume3/docker/document-server/logs" + "/volume3/docker/document-server/logs/nginx" + "/volume3/docker/document-server/config" + "/volume3/docker/document-server/nginx/conf.d" + "/volume3/docker/document-server/nginx/cache" + "/volume3/docker/document-server/cache" +) + +# HDD ๋””๋ ‰ํ† ๋ฆฌ (๋Œ€์šฉ๋Ÿ‰ ์ €์žฅ) - Volume1 +HDD_DIRS=( + "/volume1/document-storage/uploads" + "/volume1/document-storage/documents" + "/volume1/document-storage/thumbnails" + "/volume1/document-storage/backups" + "/volume1/document-storage/archives" +) + +# SSD ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +for dir in "${SSD_DIRS[@]}"; do + if [ ! -d "$dir" ]; then + sudo mkdir -p "$dir" + log_info "SSD ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ: $dir" + fi +done + +# HDD ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ +for dir in "${HDD_DIRS[@]}"; do + if [ ! -d "$dir" ]; then + sudo mkdir -p "$dir" + log_info "HDD ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ: $dir" + fi +done + +# ๊ถŒํ•œ ์„ค์ • +sudo chown -R 1000:1000 /volume3/docker/document-server/ +sudo chown -R 1000:1000 /volume1/document-storage/ + +log_success "๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์ƒ์„ฑ ์™„๋ฃŒ" + +# 3. ์„ค์ • ํŒŒ์ผ ๋ณต์‚ฌ +log_info "โš™๏ธ ์„ค์ • ํŒŒ์ผ ์ƒ์„ฑ ์ค‘..." + +# PostgreSQL ์„ค์ • (32GB RAM ์ตœ์ ํ™”) +cat > /volume3/docker/document-server/config/postgresql.synology.conf << 'EOF' +# PostgreSQL ์„ค์ • - Synology DS1525+ 32GB RAM ์ตœ์ ํ™” + +# ๋ฉ”๋ชจ๋ฆฌ ์„ค์ • (32GB RAM ๊ธฐ์ค€) +shared_buffers = 8GB # RAM์˜ 25% +effective_cache_size = 24GB # RAM์˜ 75% +work_mem = 512MB # ๋ณต์žกํ•œ ์ฟผ๋ฆฌ์šฉ (์ฆ๊ฐ€) +maintenance_work_mem = 4GB # ์ธ๋ฑ์Šค ๊ตฌ์ถ•์šฉ (์ฆ๊ฐ€) + +# ์ฒดํฌํฌ์ธํŠธ ์„ค์ • (SSD ์ตœ์ ํ™”) +checkpoint_completion_target = 0.9 +wal_buffers = 128MB # WAL ๋ฒ„ํผ (์ฆ๊ฐ€) +checkpoint_timeout = 15min +max_wal_size = 4GB +min_wal_size = 1GB + +# SSD ์ตœ์ ํ™” +random_page_cost = 1.1 # SSD ํ™˜๊ฒฝ +effective_io_concurrency = 200 # SSD ๋™์‹œ I/O +seq_page_cost = 1.0 + +# ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ (4์ฝ”์–ด/8์Šค๋ ˆ๋“œ ์ตœ์ ํ™”) +max_worker_processes = 8 +max_parallel_workers_per_gather = 4 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 4 + +# ์—ฐ๊ฒฐ ์„ค์ • +max_connections = 200 +shared_preload_libraries = 'pg_stat_statements' + +# ๋กœ๊น… ์„ค์ • +log_min_duration_statement = 1000 # 1์ดˆ ์ด์ƒ ์ฟผ๋ฆฌ ๋กœ๊น… +log_checkpoints = on +log_connections = on +log_disconnections = on +log_lock_waits = on + +# ์ž๋™ VACUUM ์„ค์ • +autovacuum = on +autovacuum_max_workers = 4 +autovacuum_naptime = 30s +EOF + +# Nginx ์„ค์ • (SSD ์บ์‹œ ์ตœ์ ํ™”) +cat > /volume3/docker/document-server/nginx/conf.d/default.conf << 'EOF' +# Nginx ์„ค์ • - SSD ์บ์‹œ ์ตœ์ ํ™” + +# ์—…์ŠคํŠธ๋ฆผ ๋ฐฑ์—”๋“œ +upstream backend { + server backend:8000; + keepalive 32; +} + +# ์บ์‹œ ์กด ์ •์˜ (SSD์— ์ €์žฅ) +proxy_cache_path /var/cache/nginx/documents + levels=1:2 + keys_zone=documents:100m + max_size=2g + inactive=60m + use_temp_path=off; + +proxy_cache_path /var/cache/nginx/api + levels=1:2 + keys_zone=api:50m + max_size=500m + inactive=10m + use_temp_path=off; + +server { + listen 80; + server_name _; + + # ํด๋ผ์ด์–ธํŠธ ์„ค์ • + client_max_body_size 500M; + client_body_timeout 300s; + client_header_timeout 300s; + + # Gzip ์••์ถ• + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # ์ •์  ํŒŒ์ผ (SSD์—์„œ ์„œ๋น™) + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + + # ์ •์  ํŒŒ์ผ ์บ์‹œ + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # ์—…๋กœ๋“œ๋œ ๋ฌธ์„œ (HDD์—์„œ ์„œ๋น™, SSD ์บ์‹œ) + location /uploads/ { + alias /usr/share/nginx/html/uploads/; + + # ๋ฌธ์„œ ์บ์‹œ (์ž์ฃผ ์ ‘๊ทผํ•˜๋Š” ๋ฌธ์„œ๋Š” SSD์— ์บ์‹œ) + proxy_cache documents; + proxy_cache_valid 200 60m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cache-Status $upstream_cache_status; + + expires 1h; + } + + # API ์š”์ฒญ + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # API ์‘๋‹ต ์บ์‹œ (GET ์š”์ฒญ๋งŒ) + proxy_cache api; + proxy_cache_methods GET HEAD; + proxy_cache_valid 200 5m; + proxy_cache_bypass $http_pragma $http_authorization; + add_header X-Cache-Status $upstream_cache_status; + + # ํƒ€์ž„์•„์›ƒ ์„ค์ • + proxy_connect_timeout 30s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # ํ—ฌ์Šค์ฒดํฌ + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} +EOF + +log_success "์„ค์ • ํŒŒ์ผ ์ƒ์„ฑ ์™„๋ฃŒ" + +# 4. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ ์ƒ์„ฑ +log_info "๐Ÿ” ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ์ค‘..." + +cat > "$PROJECT_DIR/.env.synology" << EOF +# Synology DS1525+ ๋ฐฐํฌ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +DB_PASSWORD=$DB_PASSWORD +SECRET_KEY=$SECRET_KEY +ADMIN_EMAIL=$ADMIN_EMAIL +ADMIN_PASSWORD=$ADMIN_PASSWORD +DOMAIN_NAME=$DOMAIN_NAME + +# ์„ฑ๋Šฅ ์ตœ์ ํ™” ์„ค์ • +POSTGRES_SHARED_BUFFERS=8GB +POSTGRES_EFFECTIVE_CACHE_SIZE=24GB +REDIS_MAXMEMORY=8gb + +# ๊ฒฝ๋กœ ์„ค์ • +SSD_PATH=/volume1/docker/document-server +HDD_PATH=/volume2/document-storage +EOF + +log_success "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ์™„๋ฃŒ" + +# 5. Docker Compose ๋ฐฐํฌ +log_info "๐Ÿณ Docker ์ปจํ…Œ์ด๋„ˆ ๋ฐฐํฌ ์ค‘..." + +cd "$PROJECT_DIR" + +# ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ค‘์ง€ ๋ฐ ์ œ๊ฑฐ (์žˆ๋Š” ๊ฒฝ์šฐ) +if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then + log_warning "๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ค‘์ง€ํ•ฉ๋‹ˆ๋‹ค..." + docker-compose -f "$COMPOSE_FILE" down +fi + +# ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ +log_info "์ด๋ฏธ์ง€ ๋นŒ๋“œ ์ค‘..." +docker-compose -f "$COMPOSE_FILE" build --no-cache + +log_info "์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ ์ค‘..." +docker-compose -f "$COMPOSE_FILE" up -d + +# 6. ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ +log_info "๐Ÿ” ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ ์ค‘..." + +# ์ปจํ…Œ์ด๋„ˆ ์‹œ์ž‘ ๋Œ€๊ธฐ +sleep 30 + +# ํ—ฌ์Šค์ฒดํฌ +services=("database" "redis" "backend" "nginx") +for service in "${services[@]}"; do + if docker-compose -f "$COMPOSE_FILE" ps "$service" | grep -q "Up"; then + log_success "$service ์„œ๋น„์Šค ์ •์ƒ ์‹คํ–‰ ์ค‘" + else + log_error "$service ์„œ๋น„์Šค ์‹คํ–‰ ์‹คํŒจ" + docker-compose -f "$COMPOSE_FILE" logs "$service" + fi +done + +# 7. ๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ ์„ค์ • +log_info "๐Ÿ’พ ๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ ์„ค์ • ์ค‘..." + +cat > /volume1/docker/document-server/backup.sh << 'EOF' +#!/bin/bash +# ์ž๋™ ๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ + +BACKUP_DIR="/volume2/document-storage/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… +docker exec document-server-db pg_dump -U docuser document_db > "$BACKUP_DIR/db_backup_$DATE.sql" + +# ์„ค์ • ํŒŒ์ผ ๋ฐฑ์—… +tar -czf "$BACKUP_DIR/config_backup_$DATE.tar.gz" /volume1/docker/document-server/config/ + +# 7์ผ ์ด์ƒ ๋œ ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ +find "$BACKUP_DIR" -name "*.sql" -mtime +7 -delete +find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete + +echo "๋ฐฑ์—… ์™„๋ฃŒ: $DATE" +EOF + +chmod +x /volume1/docker/document-server/backup.sh + +log_success "๋ฐฑ์—… ์Šคํฌ๋ฆฝํŠธ ์„ค์ • ์™„๋ฃŒ" + +# 8. ๋ฐฐํฌ ์™„๋ฃŒ ์ •๋ณด ์ถœ๋ ฅ +log_success "๐ŸŽ‰ Synology DS1525+ ๋ฐฐํฌ ์™„๋ฃŒ!" + +echo "" +echo "=== ๋ฐฐํฌ ์ •๋ณด ===" +echo "๐ŸŒ ์›น ์ธํ„ฐํŽ˜์ด์Šค: http://localhost:24100" +echo "๐Ÿ”ง API ์„œ๋ฒ„: http://localhost:24102" +echo "๐Ÿ—„๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค: localhost:24101" +echo "๐Ÿ’พ Redis: localhost:24103" +echo "" +echo "=== ๊ด€๋ฆฌ์ž ๊ณ„์ • ===" +echo "๐Ÿ“ง ์ด๋ฉ”์ผ: $ADMIN_EMAIL" +echo "๐Ÿ”‘ ๋น„๋ฐ€๋ฒˆํ˜ธ: $ADMIN_PASSWORD" +echo "" +echo "=== ์Šคํ† ๋ฆฌ์ง€ ๊ตฌ์„ฑ ===" +echo "๐Ÿ’ฟ SSD (์„ฑ๋Šฅ): /volume1/docker/document-server/" +echo "๐Ÿ’พ HDD (์šฉ๋Ÿ‰): /volume2/document-storage/" +echo "" +echo "=== ์ž๋™ ๋ฐฑ์—… ===" +echo "๐Ÿ“… ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ ์ž๋™ ๋ฐฑ์—… (Synology ์ž‘์—… ์Šค์ผ€์ค„๋Ÿฌ์—์„œ ์„ค์ •)" +echo "๐Ÿ“‚ ๋ฐฑ์—… ์œ„์น˜: /volume2/document-storage/backups/" +echo "" +echo "=== ๋ชจ๋‹ˆํ„ฐ๋ง ๋ช…๋ น์–ด ===" +echo "docker-compose -f $COMPOSE_FILE ps" +echo "docker-compose -f $COMPOSE_FILE logs -f" +echo "docker stats" + +log_info "๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ ์™„๋ฃŒ" diff --git a/scripts/monitor-synology.sh b/scripts/monitor-synology.sh new file mode 100755 index 0000000..ecbaa06 --- /dev/null +++ b/scripts/monitor-synology.sh @@ -0,0 +1,249 @@ +#!/bin/bash + +# ============================================================================= +# Document Server - Synology DS1525+ ๋ชจ๋‹ˆํ„ฐ๋ง ์Šคํฌ๋ฆฝํŠธ +# ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค ๋ฐ ์„œ๋น„์Šค ์ƒํƒœ ๋ชจ๋‹ˆํ„ฐ๋ง +# ============================================================================= + +set -e + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# ๋กœ๊ทธ ํ•จ์ˆ˜ +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# ํ™˜๊ฒฝ ์„ค์ • +COMPOSE_FILE="docker-compose.synology.yml" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +echo "=== ๐Ÿ“Š Synology DS1525+ Document Server ๋ชจ๋‹ˆํ„ฐ๋ง ===" +echo "$(date '+%Y-%m-%d %H:%M:%S')" +echo "" + +# 1. ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค ํ™•์ธ +log_info "๐Ÿ–ฅ๏ธ ์‹œ์Šคํ…œ ๋ฆฌ์†Œ์Šค ์ƒํƒœ" + +# CPU ์‚ฌ์šฉ๋ฅ  +CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | awk -F'%' '{print $1}') +echo -e "CPU ์‚ฌ์šฉ๋ฅ : ${CYAN}${CPU_USAGE}%${NC}" + +# ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ  (32GB ๊ธฐ์ค€) +MEMORY_INFO=$(free -h | grep "Mem:") +TOTAL_MEM=$(echo $MEMORY_INFO | awk '{print $2}') +USED_MEM=$(echo $MEMORY_INFO | awk '{print $3}') +AVAILABLE_MEM=$(echo $MEMORY_INFO | awk '{print $7}') +MEM_PERCENT=$(free | grep "Mem:" | awk '{printf "%.1f", ($3/$2) * 100.0}') + +echo -e "๋ฉ”๋ชจ๋ฆฌ: ${CYAN}${USED_MEM}${NC}/${CYAN}${TOTAL_MEM}${NC} (${CYAN}${MEM_PERCENT}%${NC}) | ์‚ฌ์šฉ ๊ฐ€๋Šฅ: ${GREEN}${AVAILABLE_MEM}${NC}" + +# ๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ  +echo -e "\n${BLUE}๐Ÿ’พ ๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ :${NC}" +df -h /volume1 /volume2 | grep -E "(volume1|volume2)" | while read line; do + USAGE=$(echo $line | awk '{print $5}' | sed 's/%//') + MOUNT=$(echo $line | awk '{print $6}') + USED=$(echo $line | awk '{print $3}') + TOTAL=$(echo $line | awk '{print $2}') + + if [ "$USAGE" -gt 90 ]; then + echo -e " ${RED}${MOUNT}${NC}: ${RED}${USED}${NC}/${TOTAL} (${RED}${USAGE}%${NC}) โš ๏ธ" + elif [ "$USAGE" -gt 80 ]; then + echo -e " ${YELLOW}${MOUNT}${NC}: ${YELLOW}${USED}${NC}/${TOTAL} (${YELLOW}${USAGE}%${NC})" + else + echo -e " ${GREEN}${MOUNT}${NC}: ${CYAN}${USED}${NC}/${TOTAL} (${GREEN}${USAGE}%${NC})" + fi +done + +echo "" + +# 2. Docker ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ +log_info "๐Ÿณ Docker ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ" + +if [ -f "$COMPOSE_FILE" ]; then + # ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ + CONTAINERS=$(docker-compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.State}}\t{{.Ports}}") + echo "$CONTAINERS" + + echo "" + + # ๊ฐ ์„œ๋น„์Šค๋ณ„ ์ƒํƒœ ํ™•์ธ + SERVICES=("database" "redis" "backend" "nginx") + for service in "${SERVICES[@]}"; do + STATUS=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service" 2>/dev/null) + if [ -n "$STATUS" ]; then + HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker-compose -f "$COMPOSE_FILE" ps -q "$service") 2>/dev/null || echo "no-healthcheck") + if [ "$HEALTH" = "healthy" ]; then + log_success "$service: ์ •์ƒ (healthy)" + elif [ "$HEALTH" = "unhealthy" ]; then + log_error "$service: ๋น„์ •์ƒ (unhealthy)" + else + log_warning "$service: ํ—ฌ์Šค์ฒดํฌ ์—†์Œ" + fi + else + log_error "$service: ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์Œ" + fi + done +else + log_error "Docker Compose ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $COMPOSE_FILE" +fi + +echo "" + +# 3. ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰ (์ปจํ…Œ์ด๋„ˆ๋ณ„) +log_info "๐Ÿ“ˆ ์ปจํ…Œ์ด๋„ˆ๋ณ„ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰" + +if command -v docker &> /dev/null; then + # Docker stats ์ •๋ณด (1ํšŒ์„ฑ) + docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" | head -10 +else + log_error "Docker๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค" +fi + +echo "" + +# 4. ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ ์ƒํƒœ +log_info "๐ŸŒ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ ์ƒํƒœ" + +# ํฌํŠธ ํ™•์ธ +PORTS=("24100:nginx" "24101:database" "24102:backend" "24103:redis") +for port_info in "${PORTS[@]}"; do + PORT=$(echo $port_info | cut -d: -f1) + SERVICE=$(echo $port_info | cut -d: -f2) + + if netstat -tuln | grep -q ":$PORT "; then + log_success "$SERVICE (ํฌํŠธ $PORT): ๋ฆฌ์Šค๋‹ ์ค‘" + else + log_error "$SERVICE (ํฌํŠธ $PORT): ๋ฆฌ์Šค๋‹ํ•˜์ง€ ์•Š์Œ" + fi +done + +echo "" + +# 5. ๋กœ๊ทธ ํŒŒ์ผ ํฌ๊ธฐ ํ™•์ธ +log_info "๐Ÿ“ ๋กœ๊ทธ ํŒŒ์ผ ์ƒํƒœ" + +LOG_DIRS=( + "/volume1/docker/document-server/logs" + "/volume1/docker/document-server/logs/nginx" +) + +for log_dir in "${LOG_DIRS[@]}"; do + if [ -d "$log_dir" ]; then + LOG_SIZE=$(du -sh "$log_dir" 2>/dev/null | cut -f1) + echo -e " ${CYAN}${log_dir}${NC}: ${LOG_SIZE}" + + # ํฐ ๋กœ๊ทธ ํŒŒ์ผ ๊ฒฝ๊ณ  (1GB ์ด์ƒ) + LOG_SIZE_MB=$(du -sm "$log_dir" 2>/dev/null | cut -f1) + if [ "$LOG_SIZE_MB" -gt 1024 ]; then + log_warning "๋กœ๊ทธ ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ 1GB๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค: $log_dir" + fi + else + log_warning "๋กœ๊ทธ ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค: $log_dir" + fi +done + +echo "" + +# 6. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +log_info "๐Ÿ—„๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ" + +if docker-compose -f "$COMPOSE_FILE" ps -q database >/dev/null 2>&1; then + DB_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T database pg_isready -U docuser -d document_db 2>/dev/null) + if echo "$DB_STATUS" | grep -q "accepting connections"; then + log_success "PostgreSQL: ์—ฐ๊ฒฐ ๊ฐ€๋Šฅ" + + # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํฌ๊ธฐ ํ™•์ธ + DB_SIZE=$(docker-compose -f "$COMPOSE_FILE" exec -T database psql -U docuser -d document_db -t -c "SELECT pg_size_pretty(pg_database_size('document_db'));" 2>/dev/null | xargs) + echo -e " ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํฌ๊ธฐ: ${CYAN}${DB_SIZE}${NC}" + else + log_error "PostgreSQL: ์—ฐ๊ฒฐ ์‹คํŒจ" + fi +else + log_error "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค" +fi + +echo "" + +# 7. Redis ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ +log_info "๐Ÿ’พ Redis ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ" + +if docker-compose -f "$COMPOSE_FILE" ps -q redis >/dev/null 2>&1; then + REDIS_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli ping 2>/dev/null) + if [ "$REDIS_STATUS" = "PONG" ]; then + log_success "Redis: ์—ฐ๊ฒฐ ๊ฐ€๋Šฅ" + + # Redis ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ + REDIS_MEMORY=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli info memory | grep "used_memory_human" | cut -d: -f2 | tr -d '\r') + echo -e " ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰: ${CYAN}${REDIS_MEMORY}${NC}" + else + log_error "Redis: ์—ฐ๊ฒฐ ์‹คํŒจ" + fi +else + log_error "Redis ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค" +fi + +echo "" + +# 8. ๋ฐฑ์—… ์ƒํƒœ ํ™•์ธ +log_info "๐Ÿ’พ ๋ฐฑ์—… ์ƒํƒœ ํ™•์ธ" + +BACKUP_DIR="/volume2/document-storage/backups" +if [ -d "$BACKUP_DIR" ]; then + BACKUP_COUNT=$(find "$BACKUP_DIR" -name "*.sql" -mtime -1 | wc -l) + LATEST_BACKUP=$(find "$BACKUP_DIR" -name "*.sql" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- | xargs basename 2>/dev/null || echo "์—†์Œ") + + echo -e " ๋ฐฑ์—… ๋””๋ ‰ํ† ๋ฆฌ: ${CYAN}${BACKUP_DIR}${NC}" + echo -e " ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋ฐฑ์—…: ${CYAN}${BACKUP_COUNT}๊ฐœ${NC}" + echo -e " ์ตœ์‹  ๋ฐฑ์—…: ${CYAN}${LATEST_BACKUP}${NC}" + + if [ "$BACKUP_COUNT" -eq 0 ]; then + log_warning "์ตœ๊ทผ 24์‹œ๊ฐ„ ๋‚ด ๋ฐฑ์—…์ด ์—†์Šต๋‹ˆ๋‹ค" + fi +else + log_error "๋ฐฑ์—… ๋””๋ ‰ํ† ๋ฆฌ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค: $BACKUP_DIR" +fi + +echo "" + +# 9. ๊ถŒ์žฅ ์‚ฌํ•ญ +log_info "๐Ÿ’ก ๊ถŒ์žฅ ์‚ฌํ•ญ" + +# ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ ์ด ๋†’์€ ๊ฒฝ์šฐ +if [ "${MEM_PERCENT%.*}" -gt 80 ]; then + log_warning "๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋ฅ ์ด ๋†’์Šต๋‹ˆ๋‹ค (${MEM_PERCENT}%). ๋ชจ๋‹ˆํ„ฐ๋ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." +fi + +# ๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ  ํ™•์ธ +HIGH_DISK_USAGE=$(df /volume1 /volume2 | awk 'NR>1 {gsub(/%/, "", $5); if ($5 > 85) print $6 " (" $5 "%)"}') +if [ -n "$HIGH_DISK_USAGE" ]; then + log_warning "๋””์Šคํฌ ์‚ฌ์šฉ๋ฅ ์ด ๋†’์€ ๋ณผ๋ฅจ: $HIGH_DISK_USAGE" +fi + +echo "" +echo "=== ๋ชจ๋‹ˆํ„ฐ๋ง ์™„๋ฃŒ ===" +echo "๋‹ค์Œ ๋ช…๋ น์–ด๋กœ ์‹ค์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง ๊ฐ€๋Šฅ:" +echo " watch -n 5 '$0'" +echo " docker-compose -f $COMPOSE_FILE logs -f" +echo " docker stats" diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..5309385 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Document Server ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณต์› ์Šคํฌ๋ฆฝํŠธ +# ์‹œ๋†€๋กœ์ง€ NAS ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ + +if [ $# -eq 0 ]; then + echo "์‚ฌ์šฉ๋ฒ•: $0 <๋ฐฑ์—…ํŒŒ์ผ๋ช…>" + echo "์˜ˆ์‹œ: $0 document_db_20241201_143000.sql.gz" + exit 1 +fi + +BACKUP_FILE="$1" +BACKUP_DIR="/volume1/docker/document-server/backups" +CONTAINER_NAME="document-server-db" + +if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then + echo "โŒ ๋ฐฑ์—… ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $BACKUP_DIR/$BACKUP_FILE" + exit 1 +fi + +echo "โš ๏ธ ์ฃผ์˜: ํ˜„์žฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค!" +read -p "๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "๋ณต์›์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + exit 1 +fi + +echo "๐Ÿ”„ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณต์› ์‹œ์ž‘..." + +# ์••์ถ• ํ•ด์ œ (ํ•„์š”ํ•œ ๊ฒฝ์šฐ) +if [[ $BACKUP_FILE == *.gz ]]; then + echo "๐Ÿ“ฆ ๋ฐฑ์—… ํŒŒ์ผ ์••์ถ• ํ•ด์ œ ์ค‘..." + gunzip -c "$BACKUP_DIR/$BACKUP_FILE" > "/tmp/restore_temp.sql" + SQL_FILE="/tmp/restore_temp.sql" +else + SQL_FILE="$BACKUP_DIR/$BACKUP_FILE" +fi + +# ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ญ์ œ ๋ฐ ์žฌ์ƒ์„ฑ +echo "๐Ÿ—‘๏ธ ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ญ์ œ ์ค‘..." +docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "DROP DATABASE IF EXISTS document_db;" +docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "CREATE DATABASE document_db;" + +# ๋ฐฑ์—… ๋ณต์› +echo "๐Ÿ“ฅ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณต์› ์ค‘..." +docker exec -i $CONTAINER_NAME psql -U docuser -d document_db < "$SQL_FILE" + +# ์ž„์‹œ ํŒŒ์ผ ์ •๋ฆฌ +if [ -f "/tmp/restore_temp.sql" ]; then + rm "/tmp/restore_temp.sql" +fi + +echo "โœ… ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ณต์› ์™„๋ฃŒ" +echo "๐Ÿ”„ ๋ฐฑ์—”๋“œ ์„œ๋น„์Šค ์žฌ์‹œ์ž‘ ์ค‘..." +docker restart document-server-backend + +echo "๐ŸŽ‰ ๋ณต์› ์ž‘์—… ์™„๋ฃŒ" diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh new file mode 100755 index 0000000..3a68976 --- /dev/null +++ b/scripts/setup-env.sh @@ -0,0 +1,257 @@ +#!/bin/bash + +# ============================================================================= +# Document Server - ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ์Šคํฌ๋ฆฝํŠธ +# ๋Œ€ํ™”ํ˜•์œผ๋กœ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜๊ณ  .env ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค +# ============================================================================= + +set -e + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# ๋กœ๊ทธ ํ•จ์ˆ˜ +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# ๋ณด์•ˆ ํ‚ค ์ƒ์„ฑ ํ•จ์ˆ˜ +generate_secure_key() { + openssl rand -base64 32 | tr -d "=+/" | cut -c1-32 +} + +generate_jwt_key() { + openssl rand -base64 64 | tr -d "=+/" | cut -c1-64 +} + +generate_password() { + openssl rand -base64 16 | tr -d "=+/" | cut -c1-12 +} + +# ํ™˜๊ฒฝ ์„ค์ • +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_DIR/.env.synology" + +cd "$PROJECT_DIR" + +echo "=== ๐Ÿ”ง Document Server ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • ===" +echo "" + +# ๊ธฐ์กด .env ํŒŒ์ผ ํ™•์ธ +if [ -f "$ENV_FILE" ]; then + log_warning "๊ธฐ์กด ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์ด ์žˆ์Šต๋‹ˆ๋‹ค: $ENV_FILE" + echo "" + read -p "๊ธฐ์กด ์„ค์ •์„ ๋ฎ์–ด์“ฐ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "๊ธฐ์กด ์„ค์ •์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค" + exit 0 + fi + + # ๊ธฐ์กด ํŒŒ์ผ ๋ฐฑ์—… + cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%Y%m%d_%H%M%S)" + log_info "๊ธฐ์กด ํŒŒ์ผ์„ ๋ฐฑ์—…ํ–ˆ์Šต๋‹ˆ๋‹ค" +fi + +echo "" +log_info "ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์—”ํ„ฐ๋ฅผ ๋ˆ„๋ฅด๋ฉด ๊ธฐ๋ณธ๊ฐ’/์ž๋™์ƒ์„ฑ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค." +echo "" + +# 1. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋น„๋ฐ€๋ฒˆํ˜ธ +echo -e "${CYAN}1. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋น„๋ฐ€๋ฒˆํ˜ธ${NC}" +DEFAULT_DB_PASSWORD=$(generate_password) +echo " ๊ธฐ๋ณธ๊ฐ’: $DEFAULT_DB_PASSWORD (์ž๋™์ƒ์„ฑ)" +read -p " ์ž…๋ ฅ: " DB_PASSWORD +DB_PASSWORD=${DB_PASSWORD:-$DEFAULT_DB_PASSWORD} + +# 2. JWT ์‹œํฌ๋ฆฟ ํ‚ค +echo "" +echo -e "${CYAN}2. JWT ์‹œํฌ๋ฆฟ ํ‚ค (๋ณด์•ˆ์šฉ)${NC}" +DEFAULT_SECRET_KEY=$(generate_jwt_key) +echo " ๊ธฐ๋ณธ๊ฐ’: ${DEFAULT_SECRET_KEY:0:20}... (์ž๋™์ƒ์„ฑ)" +read -p " ์ž…๋ ฅ: " SECRET_KEY +SECRET_KEY=${SECRET_KEY:-$DEFAULT_SECRET_KEY} + +# 3. ๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ +echo "" +echo -e "${CYAN}3. ๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ${NC}" +DEFAULT_ADMIN_EMAIL="admin@document-server.local" +echo " ๊ธฐ๋ณธ๊ฐ’: $DEFAULT_ADMIN_EMAIL" +read -p " ์ž…๋ ฅ: " ADMIN_EMAIL +ADMIN_EMAIL=${ADMIN_EMAIL:-$DEFAULT_ADMIN_EMAIL} + +# 4. ๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ +echo "" +echo -e "${CYAN}4. ๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ${NC}" +DEFAULT_ADMIN_PASSWORD=$(generate_password) +echo " ๊ธฐ๋ณธ๊ฐ’: $DEFAULT_ADMIN_PASSWORD (์ž๋™์ƒ์„ฑ)" +read -p " ์ž…๋ ฅ: " ADMIN_PASSWORD +ADMIN_PASSWORD=${ADMIN_PASSWORD:-$DEFAULT_ADMIN_PASSWORD} + +# 5. ๋„๋ฉ”์ธ ์ด๋ฆ„ +echo "" +echo -e "${CYAN}5. ๋„๋ฉ”์ธ ์ด๋ฆ„ (์™ธ๋ถ€ ์ ‘์†์šฉ)${NC}" +DEFAULT_DOMAIN="localhost" +echo " ๊ธฐ๋ณธ๊ฐ’: $DEFAULT_DOMAIN" +echo " ์˜ˆ์‹œ: mydomain.com, nas.mydomain.com" +read -p " ์ž…๋ ฅ: " DOMAIN_NAME +DOMAIN_NAME=${DOMAIN_NAME:-$DEFAULT_DOMAIN} + +# 6. ์™ธ๋ถ€ ํฌํŠธ (์„ ํƒ์‚ฌํ•ญ) +echo "" +echo -e "${CYAN}6. ์™ธ๋ถ€ ํฌํŠธ (๊ธฐ๋ณธ: 24100)${NC}" +DEFAULT_PORT="24100" +echo " ๊ธฐ๋ณธ๊ฐ’: $DEFAULT_PORT" +read -p " ์ž…๋ ฅ: " EXTERNAL_PORT +EXTERNAL_PORT=${EXTERNAL_PORT:-$DEFAULT_PORT} + +# .env ํŒŒ์ผ ์ƒ์„ฑ +echo "" +log_info "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ ์ƒ์„ฑ ์ค‘..." + +cat > "$ENV_FILE" << EOF +# ============================================================================= +# Document Server - Synology DS1525+ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +# ์ƒ์„ฑ์ผ: $(date '+%Y-%m-%d %H:%M:%S') +# ============================================================================= + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์„ค์ • +DB_PASSWORD=$DB_PASSWORD +POSTGRES_PASSWORD=$DB_PASSWORD + +# ๋ณด์•ˆ ์„ค์ • +SECRET_KEY=$SECRET_KEY +JWT_SECRET_KEY=$SECRET_KEY + +# ๊ด€๋ฆฌ์ž ๊ณ„์ • +ADMIN_EMAIL=$ADMIN_EMAIL +ADMIN_PASSWORD=$ADMIN_PASSWORD + +# ๋„คํŠธ์›Œํฌ ์„ค์ • +DOMAIN_NAME=$DOMAIN_NAME +EXTERNAL_PORT=$EXTERNAL_PORT + +# CORS ์„ค์ • (๋„๋ฉ”์ธ์— ๋”ฐ๋ผ ์ž๋™ ์„ค์ •) +ALLOWED_ORIGINS=http://localhost:$EXTERNAL_PORT,http://$DOMAIN_NAME:$EXTERNAL_PORT + +# ์„ฑ๋Šฅ ์ตœ์ ํ™” ์„ค์ • (DS1525+ 32GB RAM) +POSTGRES_SHARED_BUFFERS=8GB +POSTGRES_EFFECTIVE_CACHE_SIZE=24GB +POSTGRES_WORK_MEM=512MB +POSTGRES_MAINTENANCE_WORK_MEM=4GB + +# Redis ์„ค์ • +REDIS_MAXMEMORY=8gb +REDIS_MAXMEMORY_POLICY=allkeys-lru + +# ๋กœ๊ทธ ๋ ˆ๋ฒจ +LOG_LEVEL=INFO +DEBUG=false + +# ํŒŒ์ผ ์—…๋กœ๋“œ ์„ค์ • +MAX_FILE_SIZE=500000000 +UPLOAD_DIR=/app/uploads + +# ๋ฐฑ์—… ์„ค์ • +BACKUP_RETENTION_DAYS=30 +AUTO_BACKUP_ENABLED=true + +# ๋ชจ๋‹ˆํ„ฐ๋ง ์„ค์ • +HEALTH_CHECK_INTERVAL=30s +HEALTH_CHECK_TIMEOUT=10s +HEALTH_CHECK_RETRIES=3 + +# ์Šคํ† ๋ฆฌ์ง€ ๊ฒฝ๋กœ (Synology ์ตœ์ ํ™”) +SSD_PATH=/volume3/docker/document-server +HDD_PATH=/volume1/document-storage + +# ํƒ€์ž„์กด ์„ค์ • +TZ=Asia/Seoul +EOF + +# ํŒŒ์ผ ๊ถŒํ•œ ์„ค์ • (๋ณด์•ˆ) +chmod 600 "$ENV_FILE" + +log_success "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: $ENV_FILE" + +# ์„ค์ • ์š”์•ฝ ์ถœ๋ ฅ +echo "" +echo "=== ๐Ÿ“‹ ์„ค์ • ์š”์•ฝ ===" +echo -e "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋น„๋ฐ€๋ฒˆํ˜ธ: ${CYAN}$DB_PASSWORD${NC}" +echo -e "๊ด€๋ฆฌ์ž ์ด๋ฉ”์ผ: ${CYAN}$ADMIN_EMAIL${NC}" +echo -e "๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ: ${CYAN}$ADMIN_PASSWORD${NC}" +echo -e "๋„๋ฉ”์ธ: ${CYAN}$DOMAIN_NAME${NC}" +echo -e "ํฌํŠธ: ${CYAN}$EXTERNAL_PORT${NC}" +echo "" + +# ๋ณด์•ˆ ์ •๋ณด ์ €์žฅ +SECURITY_INFO_FILE="/volume1/document-storage/backups/security-info-$(date +%Y%m%d_%H%M%S).txt" +mkdir -p "$(dirname "$SECURITY_INFO_FILE")" 2>/dev/null || true + +cat > "$SECURITY_INFO_FILE" << EOF +Document Server ๋ณด์•ˆ ์ •๋ณด +์ƒ์„ฑ์ผ: $(date '+%Y-%m-%d %H:%M:%S') + +=== ๊ด€๋ฆฌ์ž ๊ณ„์ • === +์ด๋ฉ”์ผ: $ADMIN_EMAIL +๋น„๋ฐ€๋ฒˆํ˜ธ: $ADMIN_PASSWORD + +=== ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค === +์‚ฌ์šฉ์ž: docuser +๋น„๋ฐ€๋ฒˆํ˜ธ: $DB_PASSWORD + +=== ์ ‘์† ์ •๋ณด === +์›น ์ธํ„ฐํŽ˜์ด์Šค: http://$DOMAIN_NAME:$EXTERNAL_PORT +API ๋ฌธ์„œ: http://$DOMAIN_NAME:$((EXTERNAL_PORT + 2))/docs + +=== ์ค‘์š” ์•ˆ๋‚ด === +- ์ด ํŒŒ์ผ์€ ์•ˆ์ „ํ•œ ๊ณณ์— ๋ณด๊ด€ํ•˜์„ธ์š” +- ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ •๊ธฐ์ ์œผ๋กœ ๋ณ€๊ฒฝํ•˜์„ธ์š” +- ์™ธ๋ถ€ ์ ‘์† ์‹œ HTTPS ์‚ฌ์šฉ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค +EOF + +chmod 600 "$SECURITY_INFO_FILE" 2>/dev/null || true + +log_success "๋ณด์•ˆ ์ •๋ณด๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: $SECURITY_INFO_FILE" + +# ๋‹ค์Œ ๋‹จ๊ณ„ ์•ˆ๋‚ด +echo "" +echo "=== ๐Ÿš€ ๋‹ค์Œ ๋‹จ๊ณ„ ===" +echo "1. ๋ฐฐํฌ ์‹คํ–‰:" +echo " ${CYAN}./scripts/deploy-synology.sh${NC}" +echo "" +echo "2. ์ƒํƒœ ํ™•์ธ:" +echo " ${CYAN}./scripts/monitor-synology.sh${NC}" +echo "" +echo "3. ์›น ์ ‘์†:" +echo " ${CYAN}http://$DOMAIN_NAME:$EXTERNAL_PORT${NC}" +echo "" + +# SSL ์„ค์ • ๊ถŒ์žฅ์‚ฌํ•ญ +if [ "$DOMAIN_NAME" != "localhost" ]; then + echo "=== ๐Ÿ”’ ๋ณด์•ˆ ๊ถŒ์žฅ์‚ฌํ•ญ ===" + echo "์™ธ๋ถ€ ๋„๋ฉ”์ธ์„ ์‚ฌ์šฉํ•˜์‹œ๋Š” ๊ฒฝ์šฐ SSL ์ธ์ฆ์„œ ์„ค์ •์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค:" + echo "" + echo "1. Let's Encrypt ์ธ์ฆ์„œ ๋ฐœ๊ธ‰:" + echo " ${CYAN}certbot certonly --webroot -w /volume2/document-storage/documents -d $DOMAIN_NAME${NC}" + echo "" + echo "2. Nginx SSL ์„ค์ • ์ถ”๊ฐ€" + echo "3. ๋ฐฉํ™”๋ฒฝ์—์„œ HTTPS(443) ํฌํŠธ ๊ฐœ๋ฐฉ" + echo "" +fi + +log_success "ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!" diff --git a/scripts/update-synology.sh b/scripts/update-synology.sh new file mode 100755 index 0000000..f40347e --- /dev/null +++ b/scripts/update-synology.sh @@ -0,0 +1,303 @@ +#!/bin/bash + +# ============================================================================= +# Document Server - Synology ์—…๋ฐ์ดํŠธ ์Šคํฌ๋ฆฝํŠธ +# Git์„ ํ†ตํ•œ ๋ฌด์ค‘๋‹จ ์—…๋ฐ์ดํŠธ ๋ฐ ๋กค๋ฐฑ ์ง€์› +# ============================================================================= + +set -e + +# ์ƒ‰์ƒ ์ •์˜ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# ๋กœ๊ทธ ํ•จ์ˆ˜ +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# ํ™˜๊ฒฝ ์„ค์ • +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +COMPOSE_FILE="docker-compose.synology.yml" +BACKUP_DIR="/volume2/document-storage/backups" +UPDATE_LOG="/volume1/docker/document-server/logs/update.log" + +# ์—…๋ฐ์ดํŠธ ๋ชจ๋“œ ์„ค์ • +UPDATE_MODE="${1:-safe}" # safe, force, rollback + +log_info "๐Ÿ”„ Document Server ์—…๋ฐ์ดํŠธ ์‹œ์ž‘ (๋ชจ๋“œ: $UPDATE_MODE)" +echo "$(date '+%Y-%m-%d %H:%M:%S') - ์—…๋ฐ์ดํŠธ ์‹œ์ž‘: $UPDATE_MODE" >> "$UPDATE_LOG" + +cd "$PROJECT_DIR" + +# ํ˜„์žฌ ์ƒํƒœ ํ™•์ธ +log_info "๐Ÿ“Š ํ˜„์žฌ ์ƒํƒœ ํ™•์ธ ์ค‘..." + +# Git ์ƒํƒœ ํ™•์ธ +if [ ! -d ".git" ]; then + log_error "Git ์ €์žฅ์†Œ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. Git ํด๋ก ์œผ๋กœ ์„ค์น˜ํ•ด์ฃผ์„ธ์š”." + exit 1 +fi + +# ํ˜„์žฌ ์ปค๋ฐ‹ ํ•ด์‹œ ์ €์žฅ (๋กค๋ฐฑ์šฉ) +CURRENT_COMMIT=$(git rev-parse HEAD) +CURRENT_BRANCH=$(git branch --show-current) +echo "์ด์ „ ์ปค๋ฐ‹: $CURRENT_COMMIT" >> "$UPDATE_LOG" + +log_info "ํ˜„์žฌ ๋ธŒ๋žœ์น˜: $CURRENT_BRANCH" +log_info "ํ˜„์žฌ ์ปค๋ฐ‹: ${CURRENT_COMMIT:0:8}" + +# ์ปจํ…Œ์ด๋„ˆ ์ƒํƒœ ํ™•์ธ +if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then + CONTAINERS_RUNNING=true + log_info "์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค" +else + CONTAINERS_RUNNING=false + log_warning "์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค" +fi + +# ๋กค๋ฐฑ ๋ชจ๋“œ +if [ "$UPDATE_MODE" = "rollback" ]; then + log_warning "๐Ÿ”™ ๋กค๋ฐฑ ๋ชจ๋“œ ์‹คํ–‰" + + # ๋งˆ์ง€๋ง‰ ์„ฑ๊ณตํ•œ ์ปค๋ฐ‹์œผ๋กœ ๋กค๋ฐฑ + LAST_SUCCESS=$(tail -n 20 "$UPDATE_LOG" | grep "์—…๋ฐ์ดํŠธ ์„ฑ๊ณต" | tail -n 1 | awk '{print $6}') + + if [ -z "$LAST_SUCCESS" ]; then + log_error "๋กค๋ฐฑํ•  ์ปค๋ฐ‹์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" + exit 1 + fi + + log_info "๋กค๋ฐฑ ๋Œ€์ƒ ์ปค๋ฐ‹: $LAST_SUCCESS" + + # ๋กค๋ฐฑ ์‹คํ–‰ + git reset --hard "$LAST_SUCCESS" + + # ์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ + if [ "$CONTAINERS_RUNNING" = true ]; then + log_info "์ปจํ…Œ์ด๋„ˆ ์žฌ์‹œ์ž‘ ์ค‘..." + docker-compose -f "$COMPOSE_FILE" down + docker-compose -f "$COMPOSE_FILE" up -d --build + fi + + log_success "๋กค๋ฐฑ ์™„๋ฃŒ: $LAST_SUCCESS" + echo "$(date '+%Y-%m-%d %H:%M:%S') - ๋กค๋ฐฑ ์™„๋ฃŒ: $LAST_SUCCESS" >> "$UPDATE_LOG" + exit 0 +fi + +# ์—…๋ฐ์ดํŠธ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ +log_info "๐Ÿ” ์—…๋ฐ์ดํŠธ ํ™•์ธ ์ค‘..." + +# ์›๊ฒฉ ์ €์žฅ์†Œ์—์„œ ์ตœ์‹  ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ +git fetch origin + +# ์—…๋ฐ์ดํŠธ ๊ฐ€๋Šฅํ•œ ์ปค๋ฐ‹ ์ˆ˜ ํ™•์ธ +COMMITS_BEHIND=$(git rev-list --count HEAD..origin/$CURRENT_BRANCH) + +if [ "$COMMITS_BEHIND" -eq 0 ]; then + log_success "์ด๋ฏธ ์ตœ์‹  ๋ฒ„์ „์ž…๋‹ˆ๋‹ค" + exit 0 +fi + +log_info "์—…๋ฐ์ดํŠธ ๊ฐ€๋Šฅํ•œ ์ปค๋ฐ‹: $COMMITS_BEHIND๊ฐœ" + +# ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +log_info "๐Ÿ“ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋ฏธ๋ฆฌ๋ณด๊ธฐ:" +git log --oneline HEAD..origin/$CURRENT_BRANCH | head -10 + +# Safe ๋ชจ๋“œ์—์„œ ์‚ฌ์šฉ์ž ํ™•์ธ +if [ "$UPDATE_MODE" = "safe" ]; then + echo "" + read -p "์—…๋ฐ์ดํŠธ๋ฅผ ์ง„ํ–‰ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "์—…๋ฐ์ดํŠธ๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค" + exit 0 + fi +fi + +# ๋ฐฑ์—… ์ƒ์„ฑ +log_info "๐Ÿ’พ ๋ฐฑ์—… ์ƒ์„ฑ ์ค‘..." + +BACKUP_TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_NAME="pre_update_${BACKUP_TIMESTAMP}" + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… +if [ "$CONTAINERS_RUNNING" = true ]; then + docker-compose -f "$COMPOSE_FILE" exec -T database pg_dump -U docuser document_db > "$BACKUP_DIR/db_${BACKUP_NAME}.sql" + log_success "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฐฑ์—… ์™„๋ฃŒ" +fi + +# ์„ค์ • ํŒŒ์ผ ๋ฐฑ์—… +tar -czf "$BACKUP_DIR/config_${BACKUP_NAME}.tar.gz" \ + /volume1/docker/document-server/config/ \ + .env.synology 2>/dev/null || true + +log_success "์„ค์ • ํŒŒ์ผ ๋ฐฑ์—… ์™„๋ฃŒ" + +# Git ์—…๋ฐ์ดํŠธ ์‹คํ–‰ +log_info "๐Ÿ“ฅ ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ ์ค‘..." + +# ๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ž„์‹œ ์ €์žฅ (์žˆ๋Š” ๊ฒฝ์šฐ) +if ! git diff --quiet; then + log_warning "๋กœ์ปฌ ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์ž„์‹œ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค" + git stash push -m "Auto-stash before update $BACKUP_TIMESTAMP" +fi + +# ์—…๋ฐ์ดํŠธ ์‹คํ–‰ +git pull origin "$CURRENT_BRANCH" + +NEW_COMMIT=$(git rev-parse HEAD) +log_success "์ฝ”๋“œ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ: ${NEW_COMMIT:0:8}" + +# Docker ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ ํ™•์ธ +log_info "๐Ÿณ Docker ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ ํ™•์ธ ์ค‘..." + +# Dockerfile์ด๋‚˜ requirements.txt ๋ณ€๊ฒฝ ํ™•์ธ +NEED_REBUILD=false + +if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(Dockerfile|requirements.txt|pyproject.toml|package.json)" > /dev/null; then + NEED_REBUILD=true + log_info "์˜์กด์„ฑ ๋ณ€๊ฒฝ ๊ฐ์ง€ - ์ด๋ฏธ์ง€ ์žฌ๋นŒ๋“œ ํ•„์š”" +fi + +# ์ปจํ…Œ์ด๋„ˆ ์—…๋ฐ์ดํŠธ +if [ "$CONTAINERS_RUNNING" = true ]; then + log_info "๐Ÿ”„ ์„œ๋น„์Šค ์—…๋ฐ์ดํŠธ ์ค‘..." + + if [ "$NEED_REBUILD" = true ]; then + log_info "์ด๋ฏธ์ง€ ์žฌ๋นŒ๋“œ ์ค‘..." + + # ๋ฌด์ค‘๋‹จ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ๋‹จ๊ณ„๋ณ„ ์žฌ์‹œ์ž‘ + docker-compose -f "$COMPOSE_FILE" build --no-cache backend + docker-compose -f "$COMPOSE_FILE" up -d --no-deps backend + + # ํ—ฌ์Šค์ฒดํฌ ๋Œ€๊ธฐ + log_info "๋ฐฑ์—”๋“œ ํ—ฌ์Šค์ฒดํฌ ๋Œ€๊ธฐ ์ค‘..." + sleep 30 + + # Nginx ์—…๋ฐ์ดํŠธ (ํ•„์š”์‹œ) + if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(nginx|frontend)" > /dev/null; then + docker-compose -f "$COMPOSE_FILE" build --no-cache nginx + docker-compose -f "$COMPOSE_FILE" up -d --no-deps nginx + fi + else + log_info "์„ค์ • ํŒŒ์ผ๋งŒ ์—…๋ฐ์ดํŠธ - ์žฌ์‹œ์ž‘ ์ค‘..." + docker-compose -f "$COMPOSE_FILE" restart backend nginx + fi + + # ์„œ๋น„์Šค ์ƒํƒœ ํ™•์ธ + sleep 10 + + # ํ—ฌ์Šค์ฒดํฌ + HEALTH_CHECK_FAILED=false + + # ๋ฐฑ์—”๋“œ ํ—ฌ์Šค์ฒดํฌ + if ! curl -f http://localhost:24102/health > /dev/null 2>&1; then + log_error "๋ฐฑ์—”๋“œ ํ—ฌ์Šค์ฒดํฌ ์‹คํŒจ" + HEALTH_CHECK_FAILED=true + fi + + # ํ”„๋ก ํŠธ์—”๋“œ ํ—ฌ์Šค์ฒดํฌ + if ! curl -f http://localhost:24100/ > /dev/null 2>&1; then + log_error "ํ”„๋ก ํŠธ์—”๋“œ ํ—ฌ์Šค์ฒดํฌ ์‹คํŒจ" + HEALTH_CHECK_FAILED=true + fi + + # ํ—ฌ์Šค์ฒดํฌ ์‹คํŒจ ์‹œ ๋กค๋ฐฑ + if [ "$HEALTH_CHECK_FAILED" = true ]; then + log_error "ํ—ฌ์Šค์ฒดํฌ ์‹คํŒจ - ์ž๋™ ๋กค๋ฐฑ ์‹คํ–‰" + + # ์ด์ „ ์ปค๋ฐ‹์œผ๋กœ ๋กค๋ฐฑ + git reset --hard "$CURRENT_COMMIT" + + # ์ปจํ…Œ์ด๋„ˆ ๋กค๋ฐฑ + docker-compose -f "$COMPOSE_FILE" down + docker-compose -f "$COMPOSE_FILE" up -d --build + + log_error "์—…๋ฐ์ดํŠธ ์‹คํŒจ - ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋กค๋ฐฑ๋จ" + echo "$(date '+%Y-%m-%d %H:%M:%S') - ์—…๋ฐ์ดํŠธ ์‹คํŒจ (๋กค๋ฐฑ): $NEW_COMMIT -> $CURRENT_COMMIT" >> "$UPDATE_LOG" + exit 1 + fi + + log_success "์„œ๋น„์Šค ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ" +else + log_info "์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์•„ ์„œ๋น„์Šค ์—…๋ฐ์ดํŠธ๋ฅผ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค" +fi + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ™•์ธ +log_info "๐Ÿ—„๏ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ™•์ธ ์ค‘..." + +if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(migrations|models)" > /dev/null; then + log_warning "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ ๊ฐ์ง€" + + if [ "$CONTAINERS_RUNNING" = true ]; then + log_info "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰ ์ค‘..." + docker-compose -f "$COMPOSE_FILE" exec -T backend python -m alembic upgrade head || true + log_success "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ" + else + log_warning "์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‹คํ–‰ ์ค‘์ด์ง€ ์•Š์•„ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค" + fi +fi + +# ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ +log_success "๐ŸŽ‰ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ!" + +echo "" +echo "=== ์—…๋ฐ์ดํŠธ ์ •๋ณด ===" +echo "์ด์ „ ์ปค๋ฐ‹: ${CURRENT_COMMIT:0:8}" +echo "์ƒˆ ์ปค๋ฐ‹: ${NEW_COMMIT:0:8}" +echo "์—…๋ฐ์ดํŠธ๋œ ์ปค๋ฐ‹ ์ˆ˜: $COMMITS_BEHIND" +echo "๋ฐฑ์—… ์œ„์น˜: $BACKUP_DIR/*_${BACKUP_NAME}.*" +echo "" + +# ๋ณ€๊ฒฝ์‚ฌํ•ญ ์š”์•ฝ +echo "=== ์ฃผ์š” ๋ณ€๊ฒฝ์‚ฌํ•ญ ===" +git log --oneline "$CURRENT_COMMIT".."$NEW_COMMIT" | head -5 + +echo "" +echo "=== ์„œ๋น„์Šค ์ƒํƒœ ===" +if [ "$CONTAINERS_RUNNING" = true ]; then + docker-compose -f "$COMPOSE_FILE" ps + echo "" + echo "๐ŸŒ ์›น ์ธํ„ฐํŽ˜์ด์Šค: http://localhost:24100" + echo "๐Ÿ”ง API ๋ฌธ์„œ: http://localhost:24102/docs" +fi + +# ์„ฑ๊ณต ๋กœ๊ทธ ๊ธฐ๋ก +echo "$(date '+%Y-%m-%d %H:%M:%S') - ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต: $CURRENT_COMMIT -> $NEW_COMMIT" >> "$UPDATE_LOG" + +# ์ •๋ฆฌ ์ž‘์—… +log_info "๐Ÿงน ์ •๋ฆฌ ์ž‘์—… ์ค‘..." + +# ์˜ค๋ž˜๋œ ๋ฐฑ์—… ํŒŒ์ผ ์ •๋ฆฌ (30์ผ ์ด์ƒ) +find "$BACKUP_DIR" -name "pre_update_*" -mtime +30 -delete 2>/dev/null || true + +# Docker ์ด๋ฏธ์ง€ ์ •๋ฆฌ +docker system prune -f > /dev/null 2>&1 || true + +log_success "์—…๋ฐ์ดํŠธ ํ”„๋กœ์„ธ์Šค ์™„๋ฃŒ" + +# ๋ชจ๋‹ˆํ„ฐ๋ง ์‹คํ–‰ ์ œ์•ˆ +echo "" +echo "๐Ÿ’ก ์—…๋ฐ์ดํŠธ ํ›„ ์‹œ์Šคํ…œ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๋ ค๋ฉด:" +echo " ./scripts/monitor-synology.sh" +echo "" +echo "๐Ÿ”™ ๋ฌธ์ œ๊ฐ€ ์žˆ์œผ๋ฉด ๋กค๋ฐฑํ•˜๋ ค๋ฉด:" +echo " ./scripts/update-synology.sh rollback" diff --git a/uploads/pdfs/047196c4-c041-4b36-a88b-cc81dc00b374.pdf b/uploads/pdfs/047196c4-c041-4b36-a88b-cc81dc00b374.pdf new file mode 100644 index 0000000..87ac4f3 Binary files /dev/null and b/uploads/pdfs/047196c4-c041-4b36-a88b-cc81dc00b374.pdf differ diff --git a/uploads/pdfs/07c1c740-77c5-4ae9-b859-04f13ed8fb6d.pdf b/uploads/pdfs/07c1c740-77c5-4ae9-b859-04f13ed8fb6d.pdf new file mode 100644 index 0000000..3395bd3 Binary files /dev/null and b/uploads/pdfs/07c1c740-77c5-4ae9-b859-04f13ed8fb6d.pdf differ diff --git a/uploads/pdfs/0b20c70c-f389-45cf-8f7f-b5263dcda651.pdf b/uploads/pdfs/0b20c70c-f389-45cf-8f7f-b5263dcda651.pdf new file mode 100644 index 0000000..5bcce9b Binary files /dev/null and b/uploads/pdfs/0b20c70c-f389-45cf-8f7f-b5263dcda651.pdf differ diff --git a/uploads/pdfs/0e26c39f-87e3-4685-b621-20789de0ae24.pdf b/uploads/pdfs/0e26c39f-87e3-4685-b621-20789de0ae24.pdf new file mode 100644 index 0000000..6f96304 Binary files /dev/null and b/uploads/pdfs/0e26c39f-87e3-4685-b621-20789de0ae24.pdf differ diff --git a/uploads/pdfs/16912a03-5e8b-4eaf-a69b-f6396445376f.pdf b/uploads/pdfs/16912a03-5e8b-4eaf-a69b-f6396445376f.pdf new file mode 100644 index 0000000..5b8aa1b Binary files /dev/null and b/uploads/pdfs/16912a03-5e8b-4eaf-a69b-f6396445376f.pdf differ diff --git a/uploads/pdfs/181f8f2d-4eaf-43f8-a9fd-17fd64fe9439.pdf b/uploads/pdfs/181f8f2d-4eaf-43f8-a9fd-17fd64fe9439.pdf new file mode 100644 index 0000000..89f1575 Binary files /dev/null and b/uploads/pdfs/181f8f2d-4eaf-43f8-a9fd-17fd64fe9439.pdf differ diff --git a/uploads/pdfs/2567758e-d631-4b5a-9be5-2f2e35cb9d5e.pdf b/uploads/pdfs/2567758e-d631-4b5a-9be5-2f2e35cb9d5e.pdf new file mode 100644 index 0000000..5c18c84 Binary files /dev/null and b/uploads/pdfs/2567758e-d631-4b5a-9be5-2f2e35cb9d5e.pdf differ diff --git a/uploads/pdfs/273f8299-54db-4652-a602-3fbe4acb7d98.pdf b/uploads/pdfs/273f8299-54db-4652-a602-3fbe4acb7d98.pdf new file mode 100644 index 0000000..964c901 Binary files /dev/null and b/uploads/pdfs/273f8299-54db-4652-a602-3fbe4acb7d98.pdf differ diff --git a/uploads/pdfs/2c7b2c83-75bf-486f-a06f-a885e440c962.pdf b/uploads/pdfs/2c7b2c83-75bf-486f-a06f-a885e440c962.pdf new file mode 100644 index 0000000..13fedcb Binary files /dev/null and b/uploads/pdfs/2c7b2c83-75bf-486f-a06f-a885e440c962.pdf differ diff --git a/uploads/pdfs/3a436477-538d-4d35-b134-21d3d2e0b91a.pdf b/uploads/pdfs/3a436477-538d-4d35-b134-21d3d2e0b91a.pdf new file mode 100644 index 0000000..ed650ee Binary files /dev/null and b/uploads/pdfs/3a436477-538d-4d35-b134-21d3d2e0b91a.pdf differ diff --git a/uploads/pdfs/5b227846-dc77-4048-9154-87fe90885949.pdf b/uploads/pdfs/5b227846-dc77-4048-9154-87fe90885949.pdf new file mode 100644 index 0000000..770ca89 Binary files /dev/null and b/uploads/pdfs/5b227846-dc77-4048-9154-87fe90885949.pdf differ diff --git a/uploads/pdfs/5dc57bcf-5898-40f7-9ca2-1d57a14ddc76.pdf b/uploads/pdfs/5dc57bcf-5898-40f7-9ca2-1d57a14ddc76.pdf new file mode 100644 index 0000000..917cae1 Binary files /dev/null and b/uploads/pdfs/5dc57bcf-5898-40f7-9ca2-1d57a14ddc76.pdf differ diff --git a/uploads/pdfs/6dc6f6a6-f040-4079-ac3b-46d3f59202de.pdf b/uploads/pdfs/6dc6f6a6-f040-4079-ac3b-46d3f59202de.pdf new file mode 100644 index 0000000..1543a1d Binary files /dev/null and b/uploads/pdfs/6dc6f6a6-f040-4079-ac3b-46d3f59202de.pdf differ diff --git a/uploads/pdfs/8091c28f-31ee-49af-b83d-80c86e7cd6a9.pdf b/uploads/pdfs/8091c28f-31ee-49af-b83d-80c86e7cd6a9.pdf new file mode 100644 index 0000000..36d5d3d Binary files /dev/null and b/uploads/pdfs/8091c28f-31ee-49af-b83d-80c86e7cd6a9.pdf differ diff --git a/uploads/pdfs/850e918e-b906-48fd-ac1c-f823f1b91449.pdf b/uploads/pdfs/850e918e-b906-48fd-ac1c-f823f1b91449.pdf new file mode 100644 index 0000000..31e9200 Binary files /dev/null and b/uploads/pdfs/850e918e-b906-48fd-ac1c-f823f1b91449.pdf differ diff --git a/uploads/pdfs/87a91e5e-212b-4f09-9d31-6bc935411843.pdf b/uploads/pdfs/87a91e5e-212b-4f09-9d31-6bc935411843.pdf new file mode 100644 index 0000000..aa4cae2 Binary files /dev/null and b/uploads/pdfs/87a91e5e-212b-4f09-9d31-6bc935411843.pdf differ diff --git a/uploads/pdfs/8ce986e2-8e52-48fc-bbdc-589f63bd3ba4.pdf b/uploads/pdfs/8ce986e2-8e52-48fc-bbdc-589f63bd3ba4.pdf new file mode 100644 index 0000000..17d4af6 Binary files /dev/null and b/uploads/pdfs/8ce986e2-8e52-48fc-bbdc-589f63bd3ba4.pdf differ diff --git a/uploads/pdfs/8f219650-cb4f-4e17-b100-cd1853d45ca4.pdf b/uploads/pdfs/8f219650-cb4f-4e17-b100-cd1853d45ca4.pdf new file mode 100644 index 0000000..cf684d2 Binary files /dev/null and b/uploads/pdfs/8f219650-cb4f-4e17-b100-cd1853d45ca4.pdf differ diff --git a/uploads/pdfs/9b9d44d3-7bc7-447d-a37d-a3a73a33c679.pdf b/uploads/pdfs/9b9d44d3-7bc7-447d-a37d-a3a73a33c679.pdf new file mode 100644 index 0000000..b6955bd Binary files /dev/null and b/uploads/pdfs/9b9d44d3-7bc7-447d-a37d-a3a73a33c679.pdf differ diff --git a/uploads/pdfs/a658fdec-0ca0-4c11-9767-d74077d4bb69.pdf b/uploads/pdfs/a658fdec-0ca0-4c11-9767-d74077d4bb69.pdf new file mode 100644 index 0000000..ba4029d Binary files /dev/null and b/uploads/pdfs/a658fdec-0ca0-4c11-9767-d74077d4bb69.pdf differ diff --git a/uploads/pdfs/a6ad93df-a288-4073-826d-109afde024d7.pdf b/uploads/pdfs/a6ad93df-a288-4073-826d-109afde024d7.pdf new file mode 100644 index 0000000..f01d887 Binary files /dev/null and b/uploads/pdfs/a6ad93df-a288-4073-826d-109afde024d7.pdf differ diff --git a/uploads/pdfs/b0fe24d1-3bdd-499d-ad48-e3ebc0db0518.pdf b/uploads/pdfs/b0fe24d1-3bdd-499d-ad48-e3ebc0db0518.pdf new file mode 100644 index 0000000..556d136 Binary files /dev/null and b/uploads/pdfs/b0fe24d1-3bdd-499d-ad48-e3ebc0db0518.pdf differ diff --git a/uploads/pdfs/bf770f95-45c0-4275-8aa6-f723126a17ae.pdf b/uploads/pdfs/bf770f95-45c0-4275-8aa6-f723126a17ae.pdf new file mode 100644 index 0000000..d083544 Binary files /dev/null and b/uploads/pdfs/bf770f95-45c0-4275-8aa6-f723126a17ae.pdf differ diff --git a/uploads/pdfs/c3006793-98de-44ab-ab39-c2f3cba2680a.pdf b/uploads/pdfs/c3006793-98de-44ab-ab39-c2f3cba2680a.pdf new file mode 100644 index 0000000..e04dca4 Binary files /dev/null and b/uploads/pdfs/c3006793-98de-44ab-ab39-c2f3cba2680a.pdf differ diff --git a/uploads/pdfs/c90e3080-d430-4e58-bf60-c749dc6ff4fc.pdf b/uploads/pdfs/c90e3080-d430-4e58-bf60-c749dc6ff4fc.pdf new file mode 100644 index 0000000..575593f Binary files /dev/null and b/uploads/pdfs/c90e3080-d430-4e58-bf60-c749dc6ff4fc.pdf differ diff --git a/uploads/pdfs/ca2e7cd9-e1ac-406e-995e-627fd15077ec.pdf b/uploads/pdfs/ca2e7cd9-e1ac-406e-995e-627fd15077ec.pdf new file mode 100644 index 0000000..39e6a79 Binary files /dev/null and b/uploads/pdfs/ca2e7cd9-e1ac-406e-995e-627fd15077ec.pdf differ diff --git a/uploads/pdfs/cde540ce-ec7c-424e-8187-83cb2f85ee6b.pdf b/uploads/pdfs/cde540ce-ec7c-424e-8187-83cb2f85ee6b.pdf new file mode 100644 index 0000000..9a93c5b Binary files /dev/null and b/uploads/pdfs/cde540ce-ec7c-424e-8187-83cb2f85ee6b.pdf differ diff --git a/uploads/pdfs/d912a6bb-9236-4924-b7a5-1d5fc9c64684.pdf b/uploads/pdfs/d912a6bb-9236-4924-b7a5-1d5fc9c64684.pdf new file mode 100644 index 0000000..ff4098b Binary files /dev/null and b/uploads/pdfs/d912a6bb-9236-4924-b7a5-1d5fc9c64684.pdf differ diff --git a/uploads/pdfs/e01903fc-33e3-4e95-835e-d7a9c3897719.pdf b/uploads/pdfs/e01903fc-33e3-4e95-835e-d7a9c3897719.pdf new file mode 100644 index 0000000..abc8059 Binary files /dev/null and b/uploads/pdfs/e01903fc-33e3-4e95-835e-d7a9c3897719.pdf differ diff --git a/uploads/pdfs/f90a3eaa-b607-449a-aa75-ee3763c52492.pdf b/uploads/pdfs/f90a3eaa-b607-449a-aa75-ee3763c52492.pdf new file mode 100644 index 0000000..bbacf3a Binary files /dev/null and b/uploads/pdfs/f90a3eaa-b607-449a-aa75-ee3763c52492.pdf differ