🚀 배포용: 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 컴포넌트 초기화 최적화
This commit is contained in:
hyungi
2025-09-05 07:13:49 +09:00
commit cfb9485d4f
170 changed files with 41113 additions and 0 deletions

113
.gitignore vendored Normal file
View File

@@ -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

276
QUICK-START.md Normal file
View File

@@ -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를 사용할 준비가 완료되었습니다!**

319
README-DEPLOYMENT.md Normal file
View File

@@ -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 <repository-url>
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` 사용 금지 (볼륨 삭제됨)
- **백업 확인**: 업데이트 전 반드시 백업 파일 존재 확인
- **롤백 준비**: 문제 발생 시 즉시 이전 버전으로 복구 가능해야 함
- **서비스 중단**: 최소한의 중단 시간으로 업데이트 진행

630
README.md Normal file
View File

@@ -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 <repository>
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 기반 문서 요약
- 문서 버전 관리

52
backend/Dockerfile Normal file
View File

@@ -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"]

35
backend/Dockerfile.dev Normal file
View File

@@ -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"]

View File

@@ -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;

View File

@@ -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 '정사 경로 문자열 표현';

View File

@@ -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();

View File

@@ -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 $$;

View File

@@ -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();

View File

@@ -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)';

View File

@@ -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 가능)';

View File

@@ -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();

View File

@@ -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(특정 텍스트 부분)';

View File

@@ -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- 내보내기 기능',
'<h1>노트 시스템 사용법</h1><h2>기본 기능</h2><ul><li>마크다운으로 노트 작성</li><li>HTML로 자동 변환</li><li>태그 기반 분류</li></ul><h2>고급 기능</h2><ul><li>서적과 링크 연결</li><li>계층 구조 지원</li><li>내보내기 기능</li></ul>',
'guide',
ARRAY['가이드', '사용법', '시스템'],
'Administrator',
true)
ON CONFLICT DO NOTHING;
COMMIT;

View File

@@ -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');

View File

@@ -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();

View File

@@ -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';

87
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,87 @@
[tool.poetry]
name = "document-server"
version = "0.1.0"
description = "HTML Document Management and Viewer System"
authors = ["Your Name <your.email@example.com>"]
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

3
backend/src/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Document Server Backend Package
"""

View File

@@ -0,0 +1,3 @@
"""
API 패키지 초기화
"""

View File

@@ -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"
)

View File

@@ -0,0 +1,3 @@
"""
API 라우터 패키지 초기화
"""

View File

@@ -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"}

View File

@@ -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
)

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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)}"
)

View File

@@ -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 ""

View File

@@ -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"}

View File

@@ -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"}

View File

@@ -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"}

View File

@@ -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"}

View File

@@ -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"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{note.title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
h1, h2, h3 {{ color: #333; }}
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="meta">
<strong>제목:</strong> {note.title}<br>
<strong>타입:</strong> {note.note_type}<br>
<strong>작성자:</strong> {note.created_by}<br>
<strong>작성일:</strong> {note.created_at.strftime('%Y-%m-%d %H:%M')}<br>
<strong>태그:</strong> {', '.join(note.tags) if note.tags else '없음'}
</div>
<hr>
{note.content or ''}
</body>
</html>"""
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}"}
)

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)}"
)

View File

@@ -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"}

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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

86
backend/src/main.py Normal file
View File

@@ -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,
)

View File

@@ -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"
]

View File

@@ -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"<Book(title='{self.title}', author='{self.author}')>"

View File

@@ -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"<BookCategory(name='{self.name}', book='{self.book.title if self.book else None}')>"

View File

@@ -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"<Bookmark(title='{self.title}', document='{self.document_id}')>"

View File

@@ -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"<Document(title='{self.title}', id='{self.id}')>"
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"<Tag(name='{self.name}', color='{self.color}')>"

View File

@@ -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"<DocumentLink(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -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"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -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])

View File

@@ -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"<Note(id='{self.id}', content='{self.content[:50]}...')>"

View File

@@ -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]

View File

@@ -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
)

View File

@@ -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"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
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"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -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"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -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

View File

@@ -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)

View File

@@ -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}, ...]

View File

@@ -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

108
backend/src/schemas/todo.py Normal file
View File

@@ -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]

View File

@@ -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' # 쿼리 통계 모듈

View File

@@ -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;

11
database/init/01_init.sql Normal file
View File

@@ -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';

68
docker-compose.dev.yml Normal file
View File

@@ -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

178
docker-compose.synology.yml Normal file
View File

@@ -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
# 볼륨 정의는 제거 (직접 경로 매핑 사용)

76
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>백업/복원 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="backupRestoreApp()">
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">백업/복원 관리</h1>
<p class="text-gray-600">시스템 데이터를 백업하고 복원할 수 있습니다.</p>
</div>
<!-- 백업 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-download text-blue-500 mr-3"></i>
데이터 백업
</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 전체 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">전체 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스와 업로드된 파일을 모두 백업합니다.</p>
<button @click="createBackup('full')"
:disabled="loading"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-database mr-2"></i>
<span x-show="!loading">전체 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
<!-- 데이터베이스만 백업 -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-semibold text-gray-900 mb-2">데이터베이스 백업</h3>
<p class="text-sm text-gray-600 mb-4">데이터베이스만 백업합니다 (파일 제외).</p>
<button @click="createBackup('db')"
:disabled="loading"
class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-table mr-2"></i>
<span x-show="!loading">DB 백업 생성</span>
<span x-show="loading">백업 중...</span>
</button>
</div>
</div>
<!-- 백업 상태 -->
<div x-show="backupStatus" class="mt-4 p-4 rounded-lg" :class="backupStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="backupStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="backupStatus.message"></span>
</div>
</div>
</div>
</div>
<!-- 백업 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-history text-green-500 mr-3"></i>
백업 목록
</h2>
</div>
<div class="p-6">
<div x-show="backups.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>백업 파일이 없습니다.</p>
</div>
<div x-show="backups.length > 0" class="space-y-4">
<template x-for="backup in backups" :key="backup.id">
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-archive text-blue-600"></i>
</div>
<div>
<h4 class="font-medium text-gray-900" x-text="backup.name"></h4>
<p class="text-sm text-gray-500">
<span x-text="backup.type === 'full' ? '전체 백업' : 'DB 백업'"></span>
<span x-text="backup.size"></span>
<span x-text="backup.date"></span>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadBackup(backup)"
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fas fa-download mr-1"></i>
다운로드
</button>
<button @click="deleteBackup(backup)"
class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 복원 섹션 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-upload text-orange-500 mr-3"></i>
데이터 복원
</h2>
</div>
<div class="p-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-600 mr-2"></i>
<span class="text-yellow-800 font-medium">주의사항</span>
</div>
<p class="text-yellow-700 text-sm mt-2">
복원 작업은 현재 데이터를 완전히 덮어씁니다. 복원 전에 반드시 현재 데이터를 백업하세요.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">백업 파일 선택</label>
<input type="file"
@change="handleFileSelect"
accept=".sql,.tar.gz,.zip"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<div x-show="selectedFile">
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-medium text-gray-900 mb-2">선택된 파일</h4>
<p class="text-sm text-gray-600" x-text="selectedFile?.name"></p>
<p class="text-sm text-gray-500" x-text="selectedFile ? formatFileSize(selectedFile.size) : ''"></p>
</div>
</div>
<button @click="restoreBackup()"
:disabled="!selectedFile || loading"
class="w-full bg-orange-600 text-white px-4 py-3 rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium">
<i class="fas fa-upload mr-2"></i>
<span x-show="!loading">복원 실행</span>
<span x-show="loading">복원 중...</span>
</button>
</div>
<!-- 복원 상태 -->
<div x-show="restoreStatus" class="mt-4 p-4 rounded-lg" :class="restoreStatus.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'">
<div class="flex items-center">
<i :class="restoreStatus.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="restoreStatus.message"></span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function backupRestoreApp() {
return {
loading: false,
backups: [],
selectedFile: null,
backupStatus: null,
restoreStatus: null,
init() {
this.loadBackups();
},
async loadBackups() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.backups = await window.api.getBackups();
// 임시 데모 데이터
this.backups = [
{
id: '1',
name: 'backup_2025_09_03_full.tar.gz',
type: 'full',
size: '125.4 MB',
date: '2025년 9월 3일 15:30'
},
{
id: '2',
name: 'backup_2025_09_02_db.sql',
type: 'db',
size: '2.1 MB',
date: '2025년 9월 2일 10:15'
}
];
} catch (error) {
console.error('백업 목록 로드 실패:', error);
}
},
async createBackup(type) {
this.loading = true;
this.backupStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const result = await window.api.createBackup(type);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 3000));
this.backupStatus = {
type: 'success',
message: `${type === 'full' ? '전체' : 'DB'} 백업이 성공적으로 생성되었습니다.`
};
this.loadBackups();
} catch (error) {
this.backupStatus = {
type: 'error',
message: '백업 생성 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
downloadBackup(backup) {
// 실제 구현에서는 백엔드에서 파일 다운로드
alert(`${backup.name} 다운로드를 시작합니다.`);
},
async deleteBackup(backup) {
if (!confirm(`${backup.name}을(를) 삭제하시겠습니까?`)) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.deleteBackup(backup.id);
this.backups = this.backups.filter(b => b.id !== backup.id);
alert('백업이 삭제되었습니다.');
} catch (error) {
alert('백업 삭제 중 오류가 발생했습니다: ' + error.message);
}
},
handleFileSelect(event) {
this.selectedFile = event.target.files[0];
this.restoreStatus = null;
},
async restoreBackup() {
if (!this.selectedFile) return;
if (!confirm('현재 데이터가 모두 삭제되고 백업 데이터로 복원됩니다. 계속하시겠습니까?')) {
return;
}
this.loading = true;
this.restoreStatus = null;
try {
// 실제 구현에서는 백엔드 API 호출
// const formData = new FormData();
// formData.append('backup_file', this.selectedFile);
// await window.api.restoreBackup(formData);
// 임시 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 5000));
this.restoreStatus = {
type: 'success',
message: '백업이 성공적으로 복원되었습니다. 페이지를 새로고침해주세요.'
};
} catch (error) {
this.restoreStatus = {
type: 'error',
message: '복원 중 오류가 발생했습니다: ' + error.message
};
} finally {
this.loading = false;
}
},
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];
}
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서적 문서 목록 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.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;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="bookDocumentsApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 뒤로가기 및 서적 정보 -->
<div class="mb-6">
<!-- 네비게이션 -->
<div class="flex items-center justify-between mb-4">
<button @click="goBack()"
class="flex items-center px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>
<span>뒤로</span>
</button>
<button @click="openBookEditor()"
class="flex items-center px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
<i class="fas fa-edit mr-2"></i>편집
</button>
</div>
<!-- 서적 정보 (간결한 스타일) -->
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
<i class="fas fa-book text-white"></i>
</div>
<div>
<h1 class="text-xl font-semibold text-gray-900" x-text="bookInfo.title || '서적 미분류'"></h1>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<span x-show="bookInfo.author" x-text="bookInfo.author"></span>
<span x-show="bookInfo.author" class="text-gray-300"></span>
<span x-text="documents.length + '개 문서'"></span>
</div>
</div>
</div>
</div>
<!-- 설명 (있을 때만 표시) -->
<div x-show="bookInfo.description" class="mt-3 pt-3 border-t border-gray-100">
<p class="text-sm text-gray-600" x-text="bookInfo.description"></p>
</div>
</div>
</div>
<!-- 문서 목록 -->
<div class="bg-white rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-100">
<h2 class="font-medium text-gray-900">문서 목록</h2>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="p-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">문서를 불러오는 중...</p>
</div>
<!-- 문서 목록 (데본씽크 스타일) -->
<div x-show="!loading && documents.length > 0" class="divide-y divide-gray-100">
<template x-for="doc in documents" :key="doc.id">
<div class="p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
@click="openDocument(doc.id)">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3 flex-1">
<!-- 문서 타입 아이콘 -->
<div class="w-10 h-10 rounded-lg flex items-center justify-center"
:class="doc.pdf_path ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'">
<i :class="doc.pdf_path ? 'fas fa-file-pdf' : 'fas fa-file-alt'" class="text-sm"></i>
</div>
<!-- 문서 정보 -->
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<h3 class="font-medium text-gray-900 truncate" x-text="doc.title"></h3>
<!-- PDF 연결 상태 표시 -->
<span x-show="doc.pdf_path"
class="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full">
PDF
</span>
</div>
<p class="text-sm text-gray-500 truncate mb-1" x-text="doc.description || '설명이 없습니다'"></p>
<!-- 메타 정보 -->
<div class="flex items-center space-x-3 text-xs text-gray-400">
<span x-text="formatDate(doc.created_at)"></span>
<span x-show="doc.uploader_name" x-text="doc.uploader_name"></span>
<!-- 태그 표시 (간단하게) -->
<span x-show="doc.tags && doc.tags.length > 0"
class="flex items-center">
<i class="fas fa-tags mr-1"></i>
<span x-text="doc.tags.length + '개 태그'"></span>
</span>
</div>
</div>
</div>
<!-- 액션 버튼들 -->
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click.stop="editDocument(doc)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors rounded-md hover:bg-blue-50"
title="문서 수정">
<i class="fas fa-edit text-sm"></i>
</button>
<button x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors rounded-md hover:bg-red-50"
title="문서 삭제">
<i class="fas fa-trash text-sm"></i>
</button>
<i class="fas fa-chevron-right text-gray-300 ml-2"></i>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && documents.length === 0" class="p-8 text-center">
<i class="fas fa-file-alt text-gray-400 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">문서가 없습니다</h3>
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
</div>
</div>
</main>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/book-documents.js?v=2025012401"></script>
</body>
</html>

199
frontend/book-editor.html Normal file
View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서적 편집 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style>
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen {
background-color: #f3f4f6;
}
.sortable-drag {
background-color: white;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="bookEditorApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 헤더 섹션 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<button @click="goBack()"
class="flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>서적으로 돌아가기
</button>
<button @click="saveChanges()"
:disabled="saving"
class="flex items-center px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-save mr-2"></i>
<span x-text="saving ? '저장 중...' : '변경사항 저장'"></span>
</button>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center mb-4">
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center mr-6">
<i class="fas fa-book text-white text-2xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900" x-text="bookInfo.title"></h1>
<p class="text-gray-600 mt-1" x-show="bookInfo.author" x-text="bookInfo.author"></p>
<p class="text-sm text-gray-500 mt-2">
<span x-text="documents.length"></span>개 문서 편집
</p>
</div>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-3xl text-gray-400 mb-4"></i>
<p class="text-gray-500">데이터를 불러오는 중...</p>
</div>
<!-- 편집 섹션 -->
<div x-show="!loading" class="space-y-8">
<!-- 서적 정보 편집 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-info-circle mr-2 text-blue-600"></i>
서적 정보
</h2>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">서적 제목</label>
<input type="text"
x-model="bookInfo.title"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">저자</label>
<input type="text"
x-model="bookInfo.author"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="bookInfo.description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
</div>
</div>
<!-- 문서 순서 및 PDF 매칭 편집 -->
<div class="bg-white rounded-lg shadow-sm border">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
<i class="fas fa-list-ol mr-2 text-green-600"></i>
문서 순서 및 PDF 매칭
</h2>
<div class="flex space-x-2">
<button @click="autoSortByName()"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors text-sm">
<i class="fas fa-sort-alpha-down mr-1"></i>이름순 정렬
</button>
<button @click="reverseOrder()"
class="px-3 py-1.5 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors text-sm">
<i class="fas fa-exchange-alt mr-1"></i>순서 뒤집기
</button>
</div>
</div>
</div>
<div class="p-6">
<div id="sortable-list" class="space-y-3">
<template x-for="(doc, index) in documents" :key="doc.id">
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-move hover:bg-gray-100 transition-colors"
:data-id="doc.id">
<div class="flex items-center justify-between">
<div class="flex items-center flex-1">
<!-- 드래그 핸들 -->
<div class="mr-4 text-gray-400">
<i class="fas fa-grip-vertical"></i>
</div>
<!-- 순서 번호 -->
<div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-medium mr-4">
<span x-text="index + 1"></span>
</div>
<!-- 문서 정보 -->
<div class="flex-1">
<h3 class="font-medium text-gray-900" x-text="doc.title"></h3>
<p class="text-sm text-gray-500" x-text="doc.description || '설명 없음'"></p>
</div>
</div>
<!-- PDF 매칭 및 컨트롤 -->
<div class="flex items-center space-x-3">
<!-- PDF 매칭 드롭다운 -->
<div class="min-w-48 relative">
<select x-model="doc.matched_pdf_id"
:class="doc.matched_pdf_id ? 'border-green-300 bg-green-50' : 'border-gray-300'"
class="w-full px-3 py-2 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">PDF 매칭 없음</option>
<template x-for="pdf in availablePDFs" :key="pdf.id">
<option :value="pdf.id" x-text="pdf.title"></option>
</template>
</select>
<!-- 매칭 상태 표시 -->
<div x-show="doc.matched_pdf_id" class="absolute -top-1 -right-1">
<div class="w-3 h-3 bg-green-500 rounded-full border-2 border-white"></div>
</div>
</div>
<!-- 이동 버튼 -->
<div class="flex flex-col space-y-1">
<button @click="moveUp(index)"
:disabled="index === 0"
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-chevron-up text-xs"></i>
</button>
<button @click="moveDown(index)"
:disabled="index === documents.length - 1"
class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-chevron-down text-xs"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="documents.length === 0" class="text-center py-8">
<i class="fas fa-file-alt text-gray-400 text-3xl mb-4"></i>
<p class="text-gray-500">편집할 문서가 없습니다</p>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/book-editor.js?v=2025012461"></script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>캐시 무효화 - Document Server</title>
<script>
// 강제 캐시 무효화
const timestamp = new Date().getTime();
console.log('🔧 캐시 무효화 타임스탬프:', timestamp);
// localStorage 캐시 정리
localStorage.removeItem('api_cache');
sessionStorage.clear();
// 3초 후 업로드 페이지로 리다이렉트
setTimeout(() => {
window.location.href = `upload.html?t=${timestamp}`;
}, 3000);
</script>
</head>
<body style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: Arial, sans-serif;">
<div style="text-align: center;">
<h1>🔧 캐시 무효화 중...</h1>
<p>잠시만 기다려주세요. 3초 후 업로드 페이지로 이동합니다.</p>
<div style="margin-top: 20px;">
<div style="width: 50px; height: 50px; border: 5px solid #f3f3f3; border-top: 5px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;"></div>
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</body>
</html>

View File

@@ -0,0 +1,585 @@
<!-- 공통 헤더 컴포넌트 -->
<header class="header-modern fade-in fixed top-0 left-0 right-0 z-50">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 -->
<div class="flex items-center space-x-2">
<i class="fas fa-book text-blue-600 text-xl"></i>
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
</div>
<!-- 메인 네비게이션 -->
<nav class="hidden md:flex items-center space-x-1 relative">
<!-- 문서 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link-modern" id="doc-nav-link">
<i class="fas fa-folder-open text-blue-600"></i>
<span>문서 관리</span>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-2 gap-2">
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-th-large text-blue-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">문서 관리</div>
<div class="text-xs text-gray-500">HTML 문서 관리</div>
</div>
</div>
</a>
<a href="pdf-manager.html" class="nav-dropdown-card" id="pdf-manager-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-pdf text-red-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">PDF 관리</div>
<div class="text-xs text-gray-500">PDF 파일 관리</div>
</div>
</div>
</a>
</div>
</div>
</div>
<!-- 통합 검색 -->
<a href="search.html" class="nav-link-modern" id="search-nav-link">
<i class="fas fa-search text-green-600"></i>
<span>통합 검색</span>
</a>
<!-- 할일관리 -->
<a href="todos.html" class="nav-link-modern" id="todos-nav-link">
<i class="fas fa-tasks text-indigo-600"></i>
<span>할일관리</span>
</a>
<!-- 소설 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link-modern" id="novel-nav-link">
<i class="fas fa-feather-alt text-purple-600"></i>
<span>소설 관리</span>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-2 gap-2">
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-sitemap text-purple-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">트리 뷰</div>
<div class="text-xs text-gray-500">계층형 메모 관리</div>
</div>
</div>
</a>
<a href="story-view.html" class="nav-dropdown-card" id="story-view-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book-open text-orange-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">스토리 뷰</div>
<div class="text-xs text-gray-500">스토리 읽기 모드</div>
</div>
</div>
</a>
</div>
</div>
</div>
<!-- 노트 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link-modern" id="notes-nav-link">
<i class="fas fa-sticky-note text-yellow-600"></i>
<span>노트 관리</span>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-3 gap-2">
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book text-blue-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
<div class="text-xs text-gray-500">그룹 관리</div>
</div>
</div>
</a>
<a href="notes.html" class="nav-dropdown-card" id="notes-list-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-list text-green-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">노트 목록</div>
<div class="text-xs text-gray-500">전체 보기</div>
</div>
</div>
</a>
<a href="note-editor.html" class="nav-dropdown-card" id="note-editor-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-edit text-purple-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">새 노트 작성</div>
<div class="text-xs text-gray-500">노트 만들기</div>
</div>
</div>
</a>
</div>
</div>
</div>
</nav>
<!-- 모바일 메뉴 버튼 -->
<div class="md:hidden">
<button x-data="{ open: false }" @click="open = !open" class="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
</div>
<!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4">
<!-- 사용자 계정 메뉴 -->
<div class="flex items-center space-x-3" id="user-menu">
<!-- 로그인된 사용자 드롭다운 -->
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<i class="fas fa-user text-white text-sm"></i>
</div>
<div class="hidden sm:block">
<div class="text-sm font-medium text-gray-900" id="user-name">User</div>
</div>
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<!-- 심플한 드롭다운 메뉴 -->
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<!-- 사용자 정보 헤더 -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="text-sm font-medium text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-500" id="dropdown-user-email">user@example.com</div>
</div>
<!-- 메뉴 항목들 -->
<div class="py-1">
<a href="profile.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-user-edit w-4 h-4 mr-3 text-gray-400"></i>
프로필 관리
</a>
<!-- 관리자 메뉴 (관리자만 표시) -->
<div class="hidden" id="admin-menu-section">
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2">
<div class="text-xs font-medium text-gray-500 uppercase tracking-wide">관리자</div>
</div>
<a href="user-management.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-users-cog w-4 h-4 mr-3 text-indigo-500"></i>
계정 관리
</a>
<a href="system-settings.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-server w-4 h-4 mr-3 text-green-500"></i>
시스템 설정
</a>
<a href="backup-restore.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-database w-4 h-4 mr-3 text-blue-500"></i>
백업/복원
</a>
<a href="logs.html" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<i class="fas fa-file-alt w-4 h-4 mr-3 text-orange-500"></i>
시스템 로그
</a>
</div>
<!-- 로그아웃 -->
<div class="border-t border-gray-100 my-1"></div>
<button onclick="handleLogout()" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="fas fa-sign-out-alt w-4 h-4 mr-3"></i>
로그아웃
</button>
</div>
</div>
</div>
<!-- 로그인 버튼 -->
<div class="" id="login-button">
<button id="login-btn" class="login-btn-modern">
<i class="fas fa-sign-in-alt"></i>
<span>로그인</span>
</button>
</div>
</div>
</div>
</div>
</div>
</header>
<!-- 헤더 관련 스타일 -->
<style>
/* 모던 네비게이션 링크 스타일 */
.nav-link-modern {
@apply flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
}
.nav-link-modern:hover {
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.nav-link-modern.active {
@apply text-blue-700 bg-blue-50 border-blue-200;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
}
/* 와이드 드롭다운 메뉴 스타일 */
.nav-dropdown-wide {
position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
margin-top: 0.5rem !important;
z-index: 9999 !important;
min-width: 400px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
padding: 1rem;
pointer-events: auto;
}
.nav-dropdown-card {
@apply block p-4 bg-white border border-gray-100 rounded-lg hover:border-gray-200 hover:shadow-md transition-all duration-200 cursor-pointer;
}
.nav-dropdown-card:hover {
@apply bg-gradient-to-br from-gray-50 to-blue-50 transform -translate-y-1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.nav-dropdown-card.active {
@apply border-blue-200 bg-blue-50;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
/* 드롭다운 컨테이너 안정성 */
nav > div.relative {
position: relative !important;
display: inline-block !important;
}
/* 애니메이션 중 위치 고정 */
.nav-dropdown-wide[x-show] {
position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
}
/* 모바일 메뉴 버튼 */
.mobile-menu-btn {
@apply p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200;
}
/* 사용자 메뉴 스타일 */
/* 사용자 메뉴 관련 스타일은 인라인으로 처리됨 */
/* 로그인 버튼 모던 스타일 */
.login-btn-modern {
@apply flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
}
.login-btn-modern:hover {
@apply from-blue-700 to-blue-800 transform -translate-y-0.5;
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
.login-btn-modern:active {
@apply transform translate-y-0;
}
/* 헤더 모던 스타일 */
.header-modern {
@apply bg-white/95 backdrop-blur-md border-b border-gray-200/50 shadow-lg;
transition: all 0.3s ease;
}
/* 헤더 호버 효과 */
.header-modern:hover {
@apply bg-white shadow-xl;
}
/* 언어 전환 스타일 */
.lang-ko .lang-en,
.lang-ko [lang="en"],
.lang-ko .english {
display: none !important;
}
.lang-en .lang-ko,
.lang-en [lang="ko"],
.lang-en .korean {
display: none !important;
}
</style>
<!-- 헤더 관련 JavaScript 함수들 -->
<script>
// 헤더 관련 유틸리티 함수들
window.headerUtils = {
getCurrentPage() {
const path = window.location.pathname;
const filename = path.split('/').pop().replace('.html', '');
return filename || 'index';
},
isDocumentPage() {
const page = this.getCurrentPage();
return ['index', 'hierarchy', 'pdf-manager'].includes(page);
},
isNovelPage() {
const page = this.getCurrentPage();
return ['memo-tree', 'story-view', 'story-reader'].includes(page);
},
isNotePage() {
const page = this.getCurrentPage();
return ['notes', 'note-editor'].includes(page);
}
};
// Alpine.js 전역 함수로 등록 - 즉시 실행
if (typeof Alpine !== 'undefined') {
// Alpine이 이미 로드된 경우
Alpine.store('header', {
getCurrentPage: () => headerUtils.getCurrentPage(),
isDocumentPage: () => headerUtils.isDocumentPage(),
isNovelPage: () => headerUtils.isNovelPage(),
isNotePage: () => headerUtils.isNotePage(),
});
} else {
// Alpine 로드 대기
document.addEventListener('alpine:init', () => {
Alpine.store('header', {
getCurrentPage: () => headerUtils.getCurrentPage(),
isDocumentPage: () => headerUtils.isDocumentPage(),
isNovelPage: () => headerUtils.isNovelPage(),
isNotePage: () => headerUtils.isNotePage(),
});
});
}
// 전역 함수로도 등록 (Alpine 외부에서도 사용 가능)
window.getCurrentPage = () => headerUtils.getCurrentPage();
window.isDocumentPage = () => headerUtils.isDocumentPage();
window.isMemoPage = () => headerUtils.isMemoPage();
// 로그인 관련 함수들
window.handleLogin = () => {
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
const currentUrl = encodeURIComponent(window.location.href);
window.location.href = `login.html?redirect=${currentUrl}`;
};
window.handleLogout = () => {
// 각 페이지의 로그아웃 함수 호출
if (typeof logout === 'function') {
logout();
} else {
console.log('로그아웃 함수를 찾을 수 없습니다.');
}
};
// 사용자 상태 업데이트 함수
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');
console.log('🔍 요소 찾기:', {
loggedInMenu: !!loggedInMenu,
loginButton: !!loginButton,
adminMenuSection: !!adminMenuSection
});
// 사용자 정보 요소들
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 = getRoleText(user.role);
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;
// 관리자 메뉴 표시/숨김
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
adminMenuSection.classList.remove('hidden');
} else {
adminMenuSection.classList.add('hidden');
}
}
} else {
// 로그아웃된 상태
if (loggedInMenu) loggedInMenu.classList.add('hidden');
if (loginButton) loginButton.classList.remove('hidden');
if (adminMenuSection) adminMenuSection.classList.add('hidden');
}
};
// 역할 텍스트 변환 함수
function getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
}
// 언어 토글 함수 (전역)
// 통합 언어 변경 함수
window.handleLanguageChange = (lang) => {
console.log('🌐 언어 변경 요청:', lang);
localStorage.setItem('preferred_language', lang);
// HTML lang 속성 변경
document.documentElement.lang = lang;
// body에 언어 클래스 추가/제거
document.body.classList.remove('lang-ko', 'lang-en');
document.body.classList.add(`lang-${lang}`);
// 뷰어 페이지인 경우 뷰어의 언어 전환 함수 호출
if (window.documentViewerInstance && typeof window.documentViewerInstance.toggleLanguage === 'function') {
window.documentViewerInstance.toggleLanguage();
}
// 문서 내용에서 언어별 요소 처리
toggleDocumentLanguage(lang);
// 헤더 언어 표시 업데이트
updateLanguageDisplay(lang);
console.log(`✅ 언어가 ${lang === 'ko' ? '한국어' : 'English'}로 설정되었습니다.`);
};
// 문서 내용 언어 전환
function toggleDocumentLanguage(lang) {
// 언어별 요소 숨기기/보이기
const koElements = document.querySelectorAll('[lang="ko"], .lang-ko, .korean');
const enElements = document.querySelectorAll('[lang="en"], .lang-en, .english');
if (lang === 'ko') {
koElements.forEach(el => el.style.display = '');
enElements.forEach(el => el.style.display = 'none');
} else {
koElements.forEach(el => el.style.display = 'none');
enElements.forEach(el => el.style.display = '');
}
console.log(`🔄 문서 언어 전환: ${koElements.length}개 한국어, ${enElements.length}개 영어 요소 처리`);
}
// 헤더 언어 표시 업데이트
function updateLanguageDisplay(lang) {
const langSpan = document.querySelector('.nav-link span:contains("한국어"), .nav-link span:contains("English")');
if (langSpan) {
langSpan.textContent = lang === 'ko' ? '한국어' : 'English';
}
}
// 기존 setLanguage 함수 (호환성 유지)
window.setLanguage = window.handleLanguageChange;
window.toggleLanguage = () => {
console.log('🌐 언어 토글 기능 (미구현)');
// 향후 다국어 지원 시 구현
};
// 헤더 로드 완료 후 이벤트 바인딩
document.addEventListener('headerLoaded', () => {
console.log('🔧 헤더 로드 완료 - 이벤트 바인딩 시작');
// 로그인 버튼 이벤트 리스너 추가
const loginBtn = document.getElementById('login-btn');
if (loginBtn) {
loginBtn.addEventListener('click', () => {
console.log('🔐 로그인 버튼 클릭됨');
if (typeof window.handleLogin === 'function') {
window.handleLogin();
} else {
console.error('❌ handleLogin 함수를 찾을 수 없습니다');
}
});
console.log('✅ 로그인 버튼 이벤트 리스너 등록 완료');
}
// 언어 설정 적용
const savedLang = localStorage.getItem('preferred_language') || 'ko';
console.log('💾 저장된 언어 설정 적용:', savedLang);
// 약간의 지연 후 적용 (DOM 완전 로드 대기)
setTimeout(() => {
handleLanguageChange(savedLang);
}, 100);
});
// DOMContentLoaded 백업 (헤더가 직접 로드된 경우)
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (typeof window.handleLanguageChange === 'function') {
const savedLang = localStorage.getItem('preferred_language') || 'ko';
console.log('💾 DOMContentLoaded - 언어 설정 적용:', savedLang);
handleLanguageChange(savedLang);
}
}, 200);
});
</script>

562
frontend/index.html Normal file
View File

@@ -0,0 +1,562 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Server</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📄</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/main.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 메인 앱 -->
<div x-data="documentApp()" x-init="init()">
<!-- 로그인 모달 -->
<div x-data="authModal()" x-show="showLoginModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">로그인</h2>
<button @click="showLoginModal = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="login">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" x-model="loginForm.email" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<input type="password" x-model="loginForm.password" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div x-show="loginError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="loginError"></span>
</div>
<button type="submit" :disabled="loginLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50">
<span x-show="!loginLoading">로그인</span>
<span x-show="loginLoading">로그인 중...</span>
</button>
</form>
</div>
</div>
<!-- 공통 헤더 컨테이너 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-8">
<!-- 로그인하지 않은 경우 -->
<template x-if="!isAuthenticated">
<div class="text-center py-16">
<i class="fas fa-file-alt text-6xl text-gray-400 mb-4"></i>
<h2 class="text-3xl font-bold text-gray-900 mb-4">Document Server에 오신 것을 환영합니다</h2>
<p class="text-xl text-gray-600 mb-8">HTML 문서를 관리하고 메모, 하이라이트를 추가해보세요</p>
<button @click="showLoginModal = true"
class="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700">
시작하기
</button>
</div>
</template>
<!-- 로그인한 경우 - 문서 목록 -->
<template x-if="isAuthenticated">
<div>
<!-- 필터 및 정렬 -->
<div class="mb-6">
<!-- 첫 번째 줄: 제목과 뷰 모드 -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-900">문서 목록</h2>
<div class="flex items-center space-x-2">
<button @click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-th-large mr-2"></i>전체 문서
</button>
<button @click="viewMode = 'books'"
:class="viewMode === 'books' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md">
<i class="fas fa-book mr-2"></i>서적별 보기
</button>
<button onclick="window.location.href='/notes.html'"
class="px-3 py-2 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">
<i class="fas fa-sticky-note mr-2"></i>노트
</button>
</div>
</div>
<!-- 두 번째 줄: 검색과 필터 -->
<div class="flex items-center space-x-4">
<!-- 검색 입력창 -->
<div class="flex-1 relative">
<input type="text"
x-model="searchQuery"
@input="filterDocuments"
placeholder="문서 제목, 내용, 태그로 검색..."
class="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm transition-all duration-200">
<i class="fas fa-search absolute left-3 top-3.5 text-gray-400"></i>
<!-- 검색 결과 개수 표시 -->
<span x-show="searchQuery && filteredDocuments.length !== documents.length"
class="absolute right-3 top-3 text-sm text-gray-500">
<span x-text="filteredDocuments.length"></span>개 결과
</span>
</div>
<!-- 태그 필터 -->
<select x-model="selectedTag" @change="filterDocuments"
class="px-4 py-2.5 border border-gray-200 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[140px]">
<option value="">모든 태그</option>
<template x-for="tag in tags" :key="tag.id">
<option :value="tag.name" x-text="tag.name"></option>
</template>
</select>
<!-- 업로드 버튼 -->
<button @click="openUploadPage()"
class="px-6 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center space-x-2">
<i class="fas fa-upload"></i>
<span>업로드</span>
</button>
<!-- 검색 초기화 버튼 -->
<button x-show="searchQuery || selectedTag"
@click="clearFilters"
class="px-4 py-2.5 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-all duration-200">
<i class="fas fa-times mr-2"></i>초기화
</button>
</div>
</div>
<!-- 기존 그리드 뷰 (모든 문서 평면적으로) -->
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="doc in filteredDocuments" :key="doc.id">
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow cursor-pointer"
@click="openDocument(doc.id)">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2" x-text="doc.title"></h3>
<i class="fas fa-file-alt text-blue-500 text-xl"></i>
</div>
<p class="text-gray-600 text-sm mb-3 line-clamp-3" x-text="doc.description || '설명 없음'"></p>
<!-- 서적 정보 -->
<div x-show="doc.book_title" class="mb-3 p-2 bg-green-50 border border-green-200 rounded-md">
<div class="flex items-center text-sm">
<i class="fas fa-book text-green-600 mr-2"></i>
<span class="font-medium text-green-800" x-text="doc.book_title"></span>
<span x-show="doc.book_author" class="text-green-600 ml-1" x-text="' by ' + doc.book_author"></span>
</div>
</div>
<div class="flex flex-wrap gap-1 mb-4">
<template x-for="tag in doc.tags" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
</div>
<div class="flex justify-between items-center text-sm text-gray-500">
<span x-text="formatDate(doc.created_at)"></span>
<div class="flex items-center space-x-2">
<span x-text="doc.uploader_name"></span>
<!-- 액션 버튼들 -->
<div class="flex space-x-1 ml-2">
<button @click.stop="editDocument(doc)"
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="문서 수정">
<i class="fas fa-edit text-sm"></i>
</button>
<!-- 관리자만 삭제 버튼 표시 -->
<button x-show="currentUser && currentUser.is_admin"
@click.stop="deleteDocument(doc.id)"
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
title="문서 삭제 (관리자 전용)">
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 서적별 그룹화 뷰 (데본씽크 스타일) -->
<div x-show="viewMode === 'books'" class="space-y-4">
<!-- 서적 목록 -->
<template x-for="bookGroup in groupedDocuments" :key="bookGroup.book?.id || 'no-book'">
<div class="bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-200">
<!-- 서적 헤더 -->
<div class="p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
@click="bookGroup.expanded = !bookGroup.expanded">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<!-- 서적 아이콘 -->
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-md flex items-center justify-center">
<i class="fas fa-book text-white text-sm"></i>
</div>
<!-- 서적 정보 -->
<div>
<h3 class="font-semibold text-gray-900" x-text="bookGroup.book?.title || '서적 미분류'"></h3>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<span x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></span>
<span class="text-gray-300"></span>
<span x-text="bookGroup.documents.length + '개 문서'"></span>
</div>
</div>
</div>
<!-- 확장/축소 아이콘 -->
<div class="flex items-center space-x-2">
<button @click.stop="openBookDocuments(bookGroup.book)"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors">
편집
</button>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
:class="{'rotate-180': bookGroup.expanded}"></i>
</div>
</div>
</div>
<!-- 문서 목록 (확장 시 표시) -->
<div x-show="bookGroup.expanded" x-collapse class="border-t border-gray-100">
<div class="divide-y divide-gray-50">
<template x-for="(doc, index) in bookGroup.documents.slice(0, 10)" :key="doc.id">
<div class="p-3 hover:bg-gray-50 cursor-pointer transition-colors flex items-center justify-between"
@click="openDocument(doc.id)">
<div class="flex items-center space-x-3 flex-1">
<!-- 문서 타입 아이콘 -->
<div class="w-8 h-8 rounded-md flex items-center justify-center"
:class="doc.pdf_path ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'">
<i :class="doc.pdf_path ? 'fas fa-file-pdf' : 'fas fa-file-alt'" class="text-xs"></i>
</div>
<!-- 문서 정보 -->
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate" x-text="doc.title"></h4>
<p class="text-xs text-gray-500 truncate" x-text="doc.description || '설명 없음'"></p>
</div>
</div>
<!-- 문서 메타 정보 -->
<div class="flex items-center space-x-2 text-xs text-gray-400">
<span x-text="formatDate(doc.created_at)"></span>
<i class="fas fa-chevron-right"></i>
</div>
</div>
</template>
<!-- 더 많은 문서가 있을 때 -->
<div x-show="bookGroup.documents.length > 10"
class="p-3 text-center border-t border-gray-100">
<button @click="openBookDocuments(bookGroup.book)"
class="text-sm text-blue-600 hover:text-blue-800 font-medium">
<span x-text="`${bookGroup.documents.length - 10}개 문서 더 보기`"></span>
<i class="fas fa-arrow-right ml-1"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- 서적이 없을 때 -->
<div x-show="groupedDocuments.length === 0" class="text-center py-12">
<i class="fas fa-book text-4xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">서적이 없습니다</h3>
<p class="text-gray-500 mb-4">문서를 업로드하고 서적으로 분류해보세요</p>
<button onclick="window.location.href='/upload.html'"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
문서 업로드하기
</button>
</div>
</div>
<!-- 빈 상태 -->
<template x-if="filteredDocuments.length === 0 && !loading">
<div class="text-center py-16">
<template x-if="searchQuery || selectedTag">
<!-- 검색 결과 없음 -->
<div>
<i class="fas fa-search text-6xl text-gray-400 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">검색 결과가 없습니다</h3>
<p class="text-gray-600 mb-6">다른 검색어나 필터를 시도해보세요</p>
<button @click="clearFilters"
class="bg-gray-600 text-white px-6 py-3 rounded-lg hover:bg-gray-700">
<i class="fas fa-times mr-2"></i>
필터 초기화
</button>
</div>
</template>
<template x-if="!searchQuery && !selectedTag">
<!-- 문서 없음 -->
<div>
<i class="fas fa-folder-open text-6xl text-gray-400 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">문서가 없습니다</h3>
<p class="text-gray-600 mb-6">첫 번째 문서를 업로드해보세요</p>
<button @click="showUploadModal = true"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
<i class="fas fa-upload mr-2"></i>
문서 업로드
</button>
</div>
</template>
</div>
</template>
</div>
</template>
</main>
<!-- 문서 업로드 모달 -->
<div x-show="showUploadModal" x-cloak class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-900">문서 업로드</h2>
<button @click="showUploadModal = false" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div x-data="uploadModal()">
<form @submit.prevent="upload">
<!-- 파일 업로드 영역 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">HTML 문서 *</label>
<div class="file-drop-zone"
@dragover.prevent="$el.classList.add('dragover')"
@dragleave.prevent="$el.classList.remove('dragover')"
@drop.prevent="handleFileDrop($event, 'html_file')">
<input type="file" @change="onFileSelect($event, 'html_file')"
accept=".html,.htm" class="hidden" id="html-file">
<label for="html-file" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-4"></i>
<p class="text-lg text-gray-600 mb-2">HTML 파일을 드래그하거나 클릭하여 선택</p>
<p class="text-sm text-gray-500">지원 형식: .html, .htm</p>
</label>
<div x-show="uploadForm.html_file" class="mt-4 p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<i class="fas fa-file-alt mr-2"></i>
<span x-text="uploadForm.html_file?.name"></span>
</p>
</div>
</div>
</div>
<!-- PDF 파일 (선택사항) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">PDF 원본 (선택사항)</label>
<div class="file-drop-zone"
@dragover.prevent="$el.classList.add('dragover')"
@dragleave.prevent="$el.classList.remove('dragover')"
@drop.prevent="handleFileDrop($event, 'pdf_file')">
<input type="file" @change="onFileSelect($event, 'pdf_file')"
accept=".pdf" class="hidden" id="pdf-file">
<label for="pdf-file" class="cursor-pointer">
<i class="fas fa-file-pdf text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-600">PDF 원본 파일 (선택사항)</p>
</label>
<div x-show="uploadForm.pdf_file" class="mt-4 p-3 bg-red-50 rounded-lg">
<p class="text-sm text-red-800">
<i class="fas fa-file-pdf mr-2"></i>
<span x-text="uploadForm.pdf_file?.name"></span>
</p>
</div>
</div>
</div>
<!-- 서적 선택/생성 -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<label class="block text-sm font-medium text-gray-700 mb-3">📚 서적 설정</label>
<!-- 서적 선택 방식 -->
<div class="flex space-x-4 mb-4">
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="existing"
class="mr-2 text-blue-600">
<span class="text-sm">기존 서적에 추가</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="new"
class="mr-2 text-blue-600">
<span class="text-sm">새 서적 생성</span>
</label>
<label class="flex items-center">
<input type="radio" x-model="bookSelectionMode" value="none"
class="mr-2 text-blue-600">
<span class="text-sm">서적 없이 업로드</span>
</label>
</div>
<!-- 기존 서적 선택 -->
<div x-show="bookSelectionMode === 'existing'" class="space-y-3">
<div>
<input type="text" x-model="bookSearchQuery" @input="searchBooks"
placeholder="서적 제목으로 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 검색 결과 -->
<div x-show="searchedBooks.length > 0" class="max-h-40 overflow-y-auto border border-gray-200 rounded-md">
<template x-for="book in searchedBooks" :key="book.id">
<div @click="selectBook(book)"
:class="selectedBook?.id === book.id ? 'bg-blue-100 border-blue-500' : 'hover:bg-gray-50'"
class="p-3 border-b border-gray-200 cursor-pointer">
<div class="font-medium text-sm" x-text="book.title"></div>
<div class="text-xs text-gray-500">
<span x-text="book.author || '저자 미상'"></span> ·
<span x-text="book.document_count + '개 문서'"></span>
</div>
</div>
</template>
</div>
<!-- 선택된 서적 표시 -->
<div x-show="selectedBook" class="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-blue-900" x-text="selectedBook?.title"></div>
<div class="text-sm text-blue-700" x-text="selectedBook?.author || '저자 미상'"></div>
</div>
<button @click="selectedBook = null" class="text-blue-500 hover:text-blue-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 새 서적 생성 -->
<div x-show="bookSelectionMode === 'new'" class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<input type="text" x-model="newBook.title" @input="getSuggestions"
placeholder="서적 제목 *" :required="bookSelectionMode === 'new'"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<input type="text" x-model="newBook.author"
placeholder="저자"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- 유사 서적 추천 -->
<div x-show="suggestions.length > 0" class="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="text-sm font-medium text-yellow-800 mb-2">💡 유사한 서적이 있습니다:</div>
<template x-for="suggestion in suggestions" :key="suggestion.id">
<div @click="selectExistingFromSuggestion(suggestion)"
class="p-2 bg-white border border-yellow-300 rounded cursor-pointer hover:bg-yellow-50 mb-1">
<div class="text-sm font-medium" x-text="suggestion.title"></div>
<div class="text-xs text-gray-600">
<span x-text="suggestion.author || '저자 미상'"></span> ·
<span x-text="Math.round(suggestion.similarity_score * 100) + '% 유사'"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 문서 정보 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text" x-model="uploadForm.title" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서 제목">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">문서 날짜</label>
<input type="date" x-model="uploadForm.document_date"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="uploadForm.description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="문서에 대한 간단한 설명"></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input type="text" x-model="uploadForm.tags"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="태그를 쉼표로 구분하여 입력 (예: 중요, 회의록, 2024)">
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" x-model="uploadForm.is_public" class="mr-2">
<span class="text-sm text-gray-700">공개 문서로 설정 (다른 사용자도 볼 수 있음)</span>
</label>
</div>
<!-- 에러 메시지 -->
<div x-show="uploadError" class="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<span x-text="uploadError"></span>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button" @click="showUploadModal = false; resetForm()"
class="px-6 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300">
취소
</button>
<button type="submit" :disabled="uploading || !uploadForm.html_file || !uploadForm.title"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!uploading">업로드</span>
<span x-show="uploading">
<i class="fas fa-spinner fa-spin mr-2"></i>업로드 중...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 인증 모달 컴포넌트 -->
<div x-data="authModal" x-ref="authModal"></div>
<!-- 스크립트는 하단에서 로드됩니다 -->
<style>
[x-cloak] { display: none !important; }
.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;
}
</style>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012380"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/main.js?v=2025012462"></script>
</body>
</html>

316
frontend/login.html Normal file
View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - Document Server</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
<style>
/* 배경 이미지 스타일 */
.login-background {
background: url('static/images/login-bg.jpg') center/cover;
background-attachment: fixed;
}
/* 기본 배경 (이미지가 없을 때) */
.login-background-fallback {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(147, 51, 234, 0.3));
}
/* 글래스모피즘 효과 */
.glass-effect {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 애니메이션 */
.fade-in {
animation: fadeIn 0.8s ease-out;
}
.slide-up {
animation: slideUp 0.6s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 입력 필드 포커스 효과 */
.input-glow:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* 로그인 버튼 호버 효과 */
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.4);
}
/* 파티클 애니메이션 */
.particle {
position: absolute;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
/* 로고 영역 개선 */
.logo-container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
</style>
</head>
<body class="min-h-screen login-background-fallback" x-data="loginApp()">
<!-- 배경 파티클 -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="particle w-2 h-2" style="left: 10%; top: 20%; animation-delay: 0s;"></div>
<div class="particle w-3 h-3" style="left: 20%; top: 80%; animation-delay: 2s;"></div>
<div class="particle w-1 h-1" style="left: 80%; top: 30%; animation-delay: 4s;"></div>
<div class="particle w-2 h-2" style="left: 90%; top: 70%; animation-delay: 1s;"></div>
<div class="particle w-1 h-1" style="left: 30%; top: 10%; animation-delay: 3s;"></div>
<div class="particle w-2 h-2" style="left: 70%; top: 90%; animation-delay: 5s;"></div>
</div>
<!-- 메인 컨테이너 -->
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative">
<!-- 로그인 영역 -->
<div class="max-w-md w-full space-y-8">
<!-- 로고 및 제목 -->
<div class="text-center fade-in">
<div class="mx-auto h-24 w-24 glass-effect rounded-full flex items-center justify-center mb-6 shadow-2xl">
<i class="fas fa-book text-white text-4xl"></i>
</div>
<h2 class="text-4xl font-bold text-white mb-2 drop-shadow-lg">Document Server</h2>
<p class="text-blue-100 text-lg">지식을 관리하고 공유하세요</p>
</div>
<!-- 로그인 폼 -->
<div class="glass-effect rounded-2xl shadow-2xl p-8 slide-up">
<div class="mb-6">
<h3 class="text-2xl font-semibold text-white text-center mb-2">로그인</h3>
<p class="text-blue-100 text-center text-sm">계정에 로그인하여 시작하세요</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-500/20 text-green-100 border border-green-400/30' : 'bg-red-500/20 text-red-100 border border-red-400/30'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-400' : 'fas fa-exclamation-circle text-red-400'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<form @submit.prevent="login()" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-blue-100 mb-2">
<i class="fas fa-envelope mr-2"></i>이메일
</label>
<input type="email" id="email" x-model="loginForm.email" required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300"
placeholder="이메일을 입력하세요">
</div>
<div>
<label for="password" class="block text-sm font-medium text-blue-100 mb-2">
<i class="fas fa-lock mr-2"></i>비밀번호
</label>
<div class="relative">
<input :type="showPassword ? 'text' : 'password'" id="password" x-model="loginForm.password" required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300 pr-12"
placeholder="비밀번호를 입력하세요">
<button type="button" @click="showPassword = !showPassword"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-blue-200 hover:text-white transition-colors">
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<!-- 로그인 유지 -->
<div class="flex items-center justify-between">
<label class="flex items-center text-sm text-blue-100">
<input type="checkbox" x-model="loginForm.remember"
class="mr-2 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
로그인 상태 유지
</label>
<a href="#" class="text-sm text-blue-200 hover:text-white transition-colors">
비밀번호를 잊으셨나요?
</a>
</div>
<button type="submit" :disabled="loading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed btn-login transition-all duration-300">
<i class="fas fa-spinner fa-spin mr-2" x-show="loading"></i>
<i class="fas fa-sign-in-alt mr-2" x-show="!loading"></i>
<span x-text="loading ? '로그인 중...' : '로그인'"></span>
</button>
</form>
<!-- 추가 옵션 -->
<div class="mt-6 text-center">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/20"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-transparent text-blue-200">또는</span>
</div>
</div>
<div class="mt-6">
<button @click="goToSetup()" class="text-blue-200 hover:text-white text-sm transition-colors">
<i class="fas fa-cog mr-1"></i>시스템 초기 설정
</button>
</div>
</div>
</div>
<!-- 푸터 -->
<div class="text-center text-blue-200 text-sm fade-in mt-8">
<p>&copy; 2024 Document Server. All rights reserved.</p>
<p class="mt-1">
<i class="fas fa-shield-alt mr-1"></i>
안전하고 신뢰할 수 있는 문서 관리 시스템
</p>
</div>
</div>
</div>
<!-- 배경 이미지 로드 스크립트 -->
<script>
// 배경 이미지 로드 시도
const img = new Image();
img.onload = function() {
document.body.classList.remove('login-background-fallback');
document.body.classList.add('login-background');
};
img.onerror = function() {
console.log('배경 이미지를 찾을 수 없어 기본 그라디언트를 사용합니다.');
};
img.src = 'static/images/login-bg.jpg';
</script>
<!-- API 스크립트 -->
<script src="static/js/api.js"></script>
<!-- 로그인 앱 스크립트 -->
<script>
function loginApp() {
return {
loading: false,
showPassword: false,
loginForm: {
email: '',
password: '',
remember: false
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔐 로그인 앱 초기화');
// 이미 로그인된 경우 메인 페이지로 리다이렉트
const token = localStorage.getItem('access_token');
if (token) {
try {
await api.getCurrentUser();
window.location.href = 'index.html';
return;
} catch (error) {
// 토큰이 유효하지 않으면 제거
localStorage.removeItem('access_token');
}
}
// URL 파라미터에서 리다이렉트 URL 확인
const urlParams = new URLSearchParams(window.location.search);
this.redirectUrl = urlParams.get('redirect') || 'index.html';
},
async login() {
if (!this.loginForm.email || !this.loginForm.password) {
this.showNotification('이메일과 비밀번호를 입력해주세요.', 'error');
return;
}
this.loading = true;
try {
console.log('🔐 로그인 시도:', this.loginForm.email);
const result = await api.login(this.loginForm.email, this.loginForm.password);
if (result.success) {
this.showNotification('로그인 성공! 페이지를 이동합니다...', 'success');
// 잠시 후 리다이렉트
setTimeout(() => {
window.location.href = this.redirectUrl || 'index.html';
}, 1000);
} else {
this.showNotification(result.message || '로그인에 실패했습니다.', 'error');
}
} catch (error) {
console.error('❌ 로그인 오류:', error);
this.showNotification('로그인 중 오류가 발생했습니다.', 'error');
} finally {
this.loading = false;
}
},
goToSetup() {
window.location.href = 'setup.html';
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
};
}
</script>
</body>
</html>

331
frontend/logs.html Normal file
View File

@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시스템 로그 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="static/js/auth-guard.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8" x-data="logsApp()">
<div class="max-w-6xl mx-auto">
<!-- 페이지 헤더 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">시스템 로그</h1>
<p class="text-gray-600">시스템 활동과 오류 로그를 확인할 수 있습니다.</p>
</div>
<!-- 필터 및 컨트롤 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 로그 레벨 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">로그 레벨</label>
<select x-model="filters.level" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="">전체</option>
<option value="INFO">정보</option>
<option value="WARNING">경고</option>
<option value="ERROR">오류</option>
<option value="DEBUG">디버그</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">날짜</label>
<input type="date" x-model="filters.date" @change="filterLogs()" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
<input type="text" x-model="filters.search" @input="filterLogs()" placeholder="메시지 검색..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
</div>
<!-- 컨트롤 버튼 -->
<div class="flex items-end space-x-2">
<button @click="refreshLogs()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
<i class="fas fa-sync-alt mr-1"></i>
새로고침
</button>
<button @click="clearLogs()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">
<i class="fas fa-trash mr-1"></i>
삭제
</button>
</div>
</div>
</div>
</div>
<!-- 로그 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-info-circle text-blue-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">정보</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.info"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-yellow-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">경고</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.warning"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">오류</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.error"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<i class="fas fa-bug text-gray-600"></i>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">디버그</p>
<p class="text-2xl font-semibold text-gray-900" x-text="stats.debug"></p>
</div>
</div>
</div>
</div>
<!-- 로그 목록 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
<i class="fas fa-list text-gray-500 mr-3"></i>
로그 목록
<span class="ml-2 text-sm font-normal text-gray-500">(<span x-text="filteredLogs.length"></span>개)</span>
</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">시간</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">레벨</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소스</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">메시지</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-for="log in paginatedLogs" :key="log.id">
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900" x-text="log.timestamp"></td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-800': log.level === 'INFO',
'bg-yellow-100 text-yellow-800': log.level === 'WARNING',
'bg-red-100 text-red-800': log.level === 'ERROR',
'bg-gray-100 text-gray-800': log.level === 'DEBUG'
}"
x-text="log.level">
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" x-text="log.source"></td>
<td class="px-6 py-4 text-sm text-gray-900">
<div class="max-w-md truncate" x-text="log.message" :title="log.message"></div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
<span x-text="(currentPage - 1) * pageSize + 1"></span>-<span x-text="Math.min(currentPage * pageSize, filteredLogs.length)"></span> / <span x-text="filteredLogs.length"></span>
</div>
<div class="flex space-x-2">
<button @click="currentPage > 1 && currentPage--"
:disabled="currentPage <= 1"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
이전
</button>
<button @click="currentPage < totalPages && currentPage++"
:disabled="currentPage >= totalPages"
class="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
다음
</button>
</div>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<script>
function logsApp() {
return {
logs: [],
filteredLogs: [],
currentPage: 1,
pageSize: 50,
filters: {
level: '',
date: '',
search: ''
},
stats: {
info: 0,
warning: 0,
error: 0,
debug: 0
},
get totalPages() {
return Math.ceil(this.filteredLogs.length / this.pageSize);
},
get paginatedLogs() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.filteredLogs.slice(start, end);
},
init() {
this.loadLogs();
// 자동 새로고침 (30초마다)
setInterval(() => {
this.refreshLogs();
}, 30000);
},
async loadLogs() {
try {
// 실제 구현에서는 백엔드 API 호출
// this.logs = await window.api.getLogs();
// 임시 데모 데이터
this.logs = this.generateDemoLogs();
this.filterLogs();
this.updateStats();
} catch (error) {
console.error('로그 로드 실패:', error);
}
},
generateDemoLogs() {
const levels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'];
const sources = ['auth', 'documents', 'database', 'nginx', 'system'];
const messages = [
'사용자 로그인 성공: admin@test.com',
'문서 업로드 완료: test.pdf',
'데이터베이스 연결 실패',
'API 요청 처리 완료',
'메모리 사용량 경고: 85%',
'백업 작업 시작',
'인증 토큰 만료',
'파일 업로드 오류',
'시스템 재시작 완료',
'캐시 정리 작업 완료'
];
const logs = [];
for (let i = 0; i < 200; i++) {
const date = new Date();
date.setMinutes(date.getMinutes() - i * 5);
logs.push({
id: i + 1,
timestamp: date.toLocaleString('ko-KR'),
level: levels[Math.floor(Math.random() * levels.length)],
source: sources[Math.floor(Math.random() * sources.length)],
message: messages[Math.floor(Math.random() * messages.length)]
});
}
return logs.reverse();
},
filterLogs() {
this.filteredLogs = this.logs.filter(log => {
let matches = true;
if (this.filters.level && log.level !== this.filters.level) {
matches = false;
}
if (this.filters.date) {
const logDate = new Date(log.timestamp).toISOString().split('T')[0];
if (logDate !== this.filters.date) {
matches = false;
}
}
if (this.filters.search && !log.message.toLowerCase().includes(this.filters.search.toLowerCase())) {
matches = false;
}
return matches;
});
this.currentPage = 1;
},
updateStats() {
this.stats = {
info: this.logs.filter(log => log.level === 'INFO').length,
warning: this.logs.filter(log => log.level === 'WARNING').length,
error: this.logs.filter(log => log.level === 'ERROR').length,
debug: this.logs.filter(log => log.level === 'DEBUG').length
};
},
async refreshLogs() {
await this.loadLogs();
},
async clearLogs() {
if (!confirm('모든 로그를 삭제하시겠습니까?')) {
return;
}
try {
// 실제 구현에서는 백엔드 API 호출
// await window.api.clearLogs();
this.logs = [];
this.filteredLogs = [];
this.updateStats();
alert('로그가 삭제되었습니다.');
} catch (error) {
alert('로그 삭제 중 오류가 발생했습니다: ' + error.message);
}
}
}
}
</script>
</body>
</html>

1281
frontend/memo-tree.html Normal file

File diff suppressed because it is too large Load Diff

202
frontend/note-editor.html Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>노트 편집기 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Quill.js (WYSIWYG HTML 에디터) -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<style>
.ql-editor {
min-height: 400px;
font-size: 16px;
line-height: 1.6;
}
.ql-toolbar {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
.ql-container {
border-bottom: 1px solid #ccc;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="noteEditorApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-edit text-blue-600 mr-3"></i>
<span x-text="isEditing ? '노트 편집' : '새 노트 작성'"></span>
</h1>
<p class="text-gray-600 mt-2">HTML 에디터로 풍부한 노트를 작성하세요</p>
</div>
<div class="flex space-x-3">
<button @click="goBack()"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>
<span>돌아가기</span>
</button>
<button @click="saveNote()"
:disabled="saving || !noteData.title"
:class="saving || !noteData.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="flex items-center px-4 py-2 text-white rounded-lg transition-colors">
<i class="fas fa-save mr-2" :class="{'fa-spin': saving}"></i>
<span x-text="saving ? '저장 중...' : '저장'"></span>
</button>
</div>
</div>
</div>
<!-- 노트 설정 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- 제목 -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text"
x-model="noteData.title"
placeholder="노트 제목을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 노트북 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
<select x-model="noteData.notebook_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.title"></option>
</template>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<!-- 노트 타입 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
<select x-model="noteData.note_type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="note">일반 노트</option>
<option value="research">연구 노트</option>
<option value="summary">요약</option>
<option value="idea">아이디어</option>
<option value="guide">가이드</option>
<option value="reference">참고 자료</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<!-- 태그 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">태그</label>
<input type="text"
x-model="tagInput"
@keydown.enter.prevent="addTag()"
placeholder="태그를 입력하고 Enter를 누르세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<!-- 태그 목록 -->
<div x-show="noteData.tags.length > 0" class="mt-3 flex flex-wrap gap-2">
<template x-for="(tag, index) in noteData.tags" :key="index">
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
<span x-text="tag"></span>
<button @click="removeTag(index)" class="ml-2 text-blue-600 hover:text-blue-800">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</template>
</div>
</div>
<!-- 공개 설정 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">공개 설정</label>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="radio"
x-model="noteData.is_published"
:value="false"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">초안</span>
</label>
<label class="flex items-center">
<input type="radio"
x-model="noteData.is_published"
:value="true"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">공개</span>
</label>
</div>
</div>
</div>
</div>
<!-- HTML 에디터 -->
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">노트 내용</h3>
<div class="flex items-center space-x-4">
<button @click="toggleEditorMode()"
class="text-sm text-gray-600 hover:text-gray-800">
<i class="fas fa-code mr-1"></i>
<span x-text="editorMode === 'wysiwyg' ? 'HTML 코드' : 'WYSIWYG'"></span>
</button>
<span class="text-sm text-gray-500" x-text="getWordCount() + '자'"></span>
</div>
</div>
</div>
<!-- WYSIWYG 에디터 -->
<div x-show="editorMode === 'wysiwyg'" id="quill-editor"></div>
<!-- HTML 코드 에디터 -->
<div x-show="editorMode === 'html'" class="p-4">
<textarea x-model="noteData.content"
rows="20"
placeholder="HTML 코드를 직접 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
style="resize: vertical;"></textarea>
</div>
</div>
<!-- 미리보기 -->
<div x-show="noteData.content" class="mt-6 bg-white rounded-lg shadow-sm border">
<div class="p-4 border-b bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">미리보기</h3>
</div>
<div class="p-6">
<div x-html="noteData.content" class="prose max-w-none"></div>
</div>
</div>
</main>
<!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/note-editor.js?v=2025012608"></script>
</body>
</html>

453
frontend/notebooks.html Normal file
View File

@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>노트북 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.notebook-card {
transition: all 0.3s ease;
border-left: 4px solid var(--notebook-color);
}
.notebook-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.notebook-icon {
color: var(--notebook-color);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.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;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="notebooksApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-book text-blue-600 mr-3"></i>
노트북 관리
</h1>
<p class="text-gray-600 mt-2">노트들을 체계적으로 분류하고 관리하세요</p>
</div>
<div class="flex space-x-3">
<button onclick="window.location.href='/notes.html'"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-sticky-note mr-2"></i>
<span>노트 관리</span>
</button>
<button @click="refreshNotebooks()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
<button @click="showCreateModal = true"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>
<span>새 노트북</span>
</button>
</div>
</div>
</div>
<!-- 통계 카드 -->
<div x-show="stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100">
<i class="fas fa-book text-blue-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">전체 노트북</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notebooks || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100">
<i class="fas fa-check-circle text-green-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">활성 노트북</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.active_notebooks || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100">
<i class="fas fa-sticky-note text-purple-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">전체 노트</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.total_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-orange-100">
<i class="fas fa-folder-open text-orange-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">미분류 노트</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats?.notes_without_notebook || 0"></p>
</div>
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
<div class="flex-1 max-w-md">
<div class="relative">
<input type="text"
x-model="searchQuery"
@input="debounceSearch()"
placeholder="노트북 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox"
x-model="activeOnly"
@change="loadNotebooks()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">활성 노트북만</span>
</label>
<select x-model="sortBy"
@change="loadNotebooks()"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="updated_at">최근 수정순</option>
<option value="created_at">생성일순</option>
<option value="title">제목순</option>
<option value="sort_order">정렬순</option>
</select>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-600">노트북을 불러오는 중...</p>
</div>
<!-- 오류 메시지 -->
<div x-show="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span x-text="error"></span>
</div>
</div>
<!-- 노트북 그리드 -->
<div x-show="!loading && notebooks.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="notebook in notebooks" :key="notebook.id">
<div class="notebook-card bg-white rounded-lg shadow-sm border p-6 cursor-pointer"
:style="`--notebook-color: ${notebook.color}`"
@click="openNotebook(notebook)">
<!-- 노트북 헤더 -->
<div class="flex items-start justify-between mb-4">
<div class="flex items-center">
<i :class="`fas fa-${notebook.icon} notebook-icon text-2xl mr-3`"></i>
<div>
<h3 class="text-lg font-semibold text-gray-900" x-text="notebook.title"></h3>
<p class="text-sm text-gray-500" x-text="`${notebook.note_count}개 노트`"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click.stop="editNotebook(notebook)"
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors">
<i class="fas fa-edit"></i>
</button>
<button @click.stop="deleteNotebook(notebook)"
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- 노트북 설명 -->
<div x-show="notebook.description" class="mb-4">
<p class="text-gray-600 text-sm line-clamp-2" x-text="notebook.description"></p>
</div>
<!-- 노트북 메타데이터 -->
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
<span x-text="formatDate(notebook.updated_at)"></span>
<div class="flex items-center space-x-2">
<span x-show="!notebook.is_active" class="px-2 py-1 bg-gray-100 text-gray-600 rounded-full">
비활성
</span>
<span class="px-2 py-1 rounded-full text-white"
:style="`background-color: ${notebook.color}`"
x-text="notebook.created_by">
</span>
</div>
</div>
<!-- 빠른 액션 -->
<div x-show="notebook.note_count === 0" class="text-center py-2">
<p class="text-xs text-gray-400 mb-2">노트가 없습니다</p>
<button @click.stop="createNoteInNotebook(notebook)"
class="text-xs bg-blue-50 text-blue-600 px-3 py-1 rounded-full hover:bg-blue-100 transition-colors">
<i class="fas fa-plus mr-1"></i>
첫 노트 작성
</button>
</div>
<div x-show="notebook.note_count > 0" class="flex items-center justify-between text-xs">
<span class="text-gray-400">
<i class="fas fa-clock mr-1"></i>
<span x-text="formatDate(notebook.last_note_created_at || notebook.updated_at)"></span>
</span>
<button @click.stop="createNoteInNotebook(notebook)"
class="text-blue-600 hover:text-blue-800 transition-colors">
<i class="fas fa-plus mr-1"></i>
노트 추가
</button>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && notebooks.length === 0" class="text-center py-16">
<i class="fas fa-book text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">노트북이 없습니다</h3>
<p class="text-gray-500 mb-6">첫 번째 노트북을 만들어 노트들을 정리해보세요</p>
<button @click="showCreateModal = true"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-plus mr-2"></i>
새 노트북 만들기
</button>
</div>
</main>
<!-- 토스트 알림 -->
<div x-show="notification.show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-full"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-x-0"
x-transition:leave-end="opacity-0 transform translate-x-full"
class="fixed top-4 right-4 z-50 max-w-sm">
<div class="rounded-lg shadow-lg border p-4"
:class="{
'bg-green-50 border-green-200 text-green-800': notification.type === 'success',
'bg-red-50 border-red-200 text-red-800': notification.type === 'error',
'bg-blue-50 border-blue-200 text-blue-800': notification.type === 'info'
}">
<div class="flex items-center">
<i :class="{
'fas fa-check-circle text-green-600': notification.type === 'success',
'fas fa-exclamation-circle text-red-600': notification.type === 'error',
'fas fa-info-circle text-blue-600': notification.type === 'info'
}" class="mr-2"></i>
<span x-text="notification.message"></span>
<button @click="notification.show = false" class="ml-auto text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- 노트북 생성/편집 모달 -->
<div x-show="showCreateModal || showEditModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-md w-full p-6"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900"
x-text="showEditModal ? '노트북 편집' : '새 노트북 만들기'"></h3>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="saveNotebook()">
<!-- 제목 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">제목 *</label>
<input type="text"
x-model="notebookForm.title"
placeholder="노트북 제목을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 설명 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="notebookForm.description"
rows="3"
placeholder="노트북에 대한 설명을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
<!-- 색상과 아이콘 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
<div class="flex flex-wrap gap-2">
<template x-for="color in availableColors" :key="color">
<button type="button"
@click="notebookForm.color = color"
:class="notebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
:style="`background-color: ${color}`">
</button>
</template>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
<select x-model="notebookForm.icon"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<template x-for="icon in availableIcons" :key="icon.value">
<option :value="icon.value" x-text="icon.label"></option>
</template>
</select>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="submit"
:disabled="saving || !notebookForm.title"
:class="saving || !notebookForm.title ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!saving" x-text="showEditModal ? '수정' : '생성'"></span>
<span x-show="saving">저장 중...</span>
</button>
</div>
</form>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div x-show="showDeleteModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-md w-full p-6"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<div class="flex items-center mb-4">
<div class="p-3 rounded-full bg-red-100 mr-4">
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">노트북 삭제</h3>
<p class="text-sm text-gray-600">이 작업은 되돌릴 수 없습니다.</p>
</div>
</div>
<div class="mb-6">
<p class="text-gray-700 mb-2">
<strong x-text="deletingNotebook?.title"></strong> 노트북을 삭제하시겠습니까?
</p>
<div x-show="deletingNotebook?.note_count > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div class="flex items-center">
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
<span class="text-sm text-yellow-800">
포함된 <strong x-text="deletingNotebook?.note_count"></strong>개의 노트는 미분류 상태가 됩니다.
</span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeDeleteModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="button"
@click="confirmDeleteNotebook()"
:disabled="deleting"
:class="deleting ? 'bg-gray-400 cursor-not-allowed' : 'bg-red-600 hover:bg-red-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!deleting">삭제</span>
<span x-show="deleting">삭제 중...</span>
</button>
</div>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/notebooks.js?v=2025012609"></script>
</body>
</html>

423
frontend/notes.html Normal file
View File

@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>노트 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.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;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="notesApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 flex items-center">
<i class="fas fa-sticky-note text-blue-600 mr-3"></i>
노트 관리
</h1>
<p class="text-gray-600 mt-2">마크다운으로 노트를 작성하고 서적과 연결하여 관리하세요</p>
</div>
<div class="flex space-x-3">
<button onclick="window.location.href='/'"
class="flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
<i class="fas fa-arrow-left mr-2"></i>
<span>돌아가기</span>
</button>
<button @click="refreshNotes()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
<button onclick="window.location.href='/note-editor.html'"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>
<span>새 노트 작성</span>
</button>
</div>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" x-show="stats">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-sticky-note text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">전체 노트</h3>
<p class="text-2xl font-bold text-blue-600" x-text="stats?.total_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-eye text-green-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">공개 노트</h3>
<p class="text-2xl font-bold text-green-600" x-text="stats?.published_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-edit text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">초안</h3>
<p class="text-2xl font-bold text-yellow-600" x-text="stats?.draft_notes || 0"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-clock text-purple-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">읽기 시간</h3>
<p class="text-2xl font-bold text-purple-600" x-text="(stats?.total_reading_time || 0) + '분'"></p>
</div>
</div>
</div>
</div>
<!-- 일괄 작업 도구 -->
<div x-show="selectedNotes.length > 0"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-blue-900">
<span x-text="selectedNotes.length"></span>개 노트 선택됨
</span>
<button @click="clearSelection()"
class="text-sm text-blue-600 hover:text-blue-800">
선택 해제
</button>
</div>
<div class="flex items-center space-x-3">
<!-- 노트북 할당 -->
<select x-model="bulkNotebookId"
class="px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
<option value="">노트북 선택...</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.title"></option>
</template>
</select>
<button @click="assignToNotebook()"
:disabled="!bulkNotebookId"
:class="!bulkNotebookId ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
class="px-4 py-2 text-white rounded-lg transition-colors text-sm">
노트북에 할당
</button>
<button @click="showCreateNotebookModal = true"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm">
새 노트북 만들기
</button>
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<!-- 검색 -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">검색</label>
<div class="relative">
<input type="text"
x-model="searchQuery"
@input="debounceSearch()"
placeholder="제목이나 내용으로 검색..."
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- 노트북 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">노트북</label>
<select x-model="selectedNotebook" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">전체</option>
<option value="unassigned">미분류</option>
<template x-for="notebook in availableNotebooks" :key="notebook.id">
<option :value="notebook.id" x-text="notebook.title"></option>
</template>
</select>
</div>
<!-- 노트 타입 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">타입</label>
<select x-model="selectedType" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">전체</option>
<option value="note">일반 노트</option>
<option value="research">연구 노트</option>
<option value="summary">요약</option>
<option value="idea">아이디어</option>
<option value="guide">가이드</option>
<option value="reference">참고 자료</option>
</select>
</div>
<!-- 공개 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">상태</label>
<select x-model="publishedOnly" @change="loadNotes()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option :value="false">전체</option>
<option :value="true">공개만</option>
</select>
</div>
</div>
</div>
<!-- 노트 목록 -->
<div class="bg-white rounded-lg shadow-sm border">
<!-- 로딩 상태 -->
<div x-show="loading" class="p-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">노트를 불러오는 중...</p>
</div>
<!-- 노트 카드들 -->
<div x-show="!loading && notes.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
<template x-for="note in notes" :key="note.id">
<div class="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer relative"
:class="selectedNotes.includes(note.id) ? 'ring-2 ring-blue-500 bg-blue-50' : ''"
@click="viewNote(note.id)">
<!-- 선택 체크박스 -->
<div class="absolute top-4 left-4">
<input type="checkbox"
:checked="selectedNotes.includes(note.id)"
@click.stop="toggleNoteSelection(note.id)"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
</div>
<!-- 노트 헤더 -->
<div class="flex items-start justify-between mb-4 ml-8">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-2 mb-2" x-text="note.title"></h3>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<span class="px-2 py-1 bg-gray-100 rounded-full text-xs" x-text="getNoteTypeLabel(note.note_type)"></span>
<span x-show="note.is_published" class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">공개</span>
<span x-show="!note.is_published" class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs">초안</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button @click.stop="editNote(note.id)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="편집">
<i class="fas fa-edit"></i>
</button>
<button @click.stop="deleteNote(note)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- 태그 -->
<div x-show="note.tags && note.tags.length > 0" class="mb-4">
<div class="flex flex-wrap gap-1">
<template x-for="tag in note.tags.slice(0, 3)" :key="tag">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full" x-text="tag"></span>
</template>
<span x-show="note.tags.length > 3" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
+<span x-text="note.tags.length - 3"></span>
</span>
</div>
</div>
<!-- 통계 정보 -->
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
<div class="flex items-center space-x-4">
<span class="flex items-center">
<i class="fas fa-font mr-1"></i>
<span x-text="note.word_count"></span>
</span>
<span class="flex items-center">
<i class="fas fa-clock mr-1"></i>
<span x-text="note.reading_time"></span>
</span>
<span x-show="note.child_count > 0" class="flex items-center">
<i class="fas fa-sitemap mr-1"></i>
<span x-text="note.child_count"></span>
</span>
</div>
</div>
<!-- 작성 정보 -->
<div class="text-xs text-gray-400 border-t pt-3">
<div class="flex items-center justify-between">
<span x-text="note.created_by"></span>
<span x-text="formatDate(note.updated_at)"></span>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && notes.length === 0" class="p-8 text-center">
<i class="fas fa-sticky-note text-gray-400 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">노트가 없습니다</h3>
<p class="text-gray-500 mb-6">첫 번째 노트를 작성해보세요</p>
<button @click="createNewNote()"
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700">
<i class="fas fa-plus mr-2"></i>
새 노트 작성
</button>
</div>
</div>
</main>
<!-- 노트북 생성 모달 -->
<div x-show="showCreateNotebookModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-md w-full p-6"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">새 노트북 만들기</h3>
<button @click="closeCreateNotebookModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form @submit.prevent="createNotebookAndAssign()">
<!-- 제목 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">노트북 이름 *</label>
<input type="text"
x-model="newNotebookForm.name"
placeholder="노트북 이름을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>
</div>
<!-- 설명 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea x-model="newNotebookForm.description"
rows="3"
placeholder="노트북에 대한 설명을 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
</div>
<!-- 색상과 아이콘 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">색상</label>
<div class="flex flex-wrap gap-2">
<template x-for="color in availableColors" :key="color">
<button type="button"
@click="newNotebookForm.color = color"
:class="newNotebookForm.color === color ? 'ring-2 ring-offset-2 ring-gray-400' : ''"
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
:style="`background-color: ${color}`">
</button>
</template>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">아이콘</label>
<select x-model="newNotebookForm.icon"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<template x-for="icon in availableIcons" :key="icon.value">
<option :value="icon.value" x-text="icon.label"></option>
</template>
</select>
</div>
</div>
<!-- 선택된 노트 정보 -->
<div x-show="selectedNotes.length > 0" class="mb-4 p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<i class="fas fa-info-circle mr-1"></i>
선택된 <span x-text="selectedNotes.length"></span>개의 노트가 이 노트북에 할당됩니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3">
<button type="button"
@click="closeCreateNotebookModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
취소
</button>
<button type="submit"
:disabled="creatingNotebook || !newNotebookForm.name"
:class="creatingNotebook || !newNotebookForm.name ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'"
class="px-4 py-2 text-white rounded-lg transition-colors">
<span x-show="!creatingNotebook">생성 및 할당</span>
<span x-show="creatingNotebook">생성 중...</span>
</button>
</div>
</form>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/notes.js?v=2025012610"></script>
</body>
</html>

444
frontend/pdf-manager.html Normal file
View File

@@ -0,0 +1,444 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF 파일 관리 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="pdfManagerApp()" x-init="init()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">PDF 파일 관리</h1>
<p class="text-gray-600 mt-2">업로드된 PDF 파일들을 관리하고 삭제할 수 있습니다</p>
</div>
<button @click="refreshPDFs()"
:disabled="loading"
class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors">
<i class="fas fa-sync-alt mr-2" :class="{'fa-spin': loading}"></i>
<span>새로고침</span>
</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">전체 PDF</h3>
<p class="text-2xl font-bold text-red-600" x-text="pdfDocuments.length"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-book text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">서적 포함</h3>
<p class="text-2xl font-bold text-blue-600" x-text="bookPDFs"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-link text-green-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">HTML 연결</h3>
<p class="text-2xl font-bold text-green-600" x-text="linkedPDFs"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-unlink text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">독립 파일</h3>
<p class="text-2xl font-bold text-yellow-600" x-text="standalonePDFs"></p>
</div>
</div>
</div>
</div>
<!-- 뷰 모드 및 필터 -->
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">PDF 파일 관리</h2>
<!-- 뷰 모드 선택 -->
<div class="flex items-center space-x-2">
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md text-sm">
<i class="fas fa-list mr-2"></i>전체 목록
</button>
<button @click="viewMode = 'books'"
:class="viewMode === 'books' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-2 rounded-md text-sm">
<i class="fas fa-book mr-2"></i>서적별 보기
</button>
</div>
</div>
<!-- 필터 버튼 (목록 뷰에서만 표시) -->
<div x-show="viewMode === 'list'" class="flex flex-wrap gap-2">
<button @click="filterType = 'all'"
:class="filterType === 'all' ? 'bg-gray-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
전체
</button>
<button @click="filterType = 'book'"
:class="filterType === 'book' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
서적 포함
</button>
<button @click="filterType = 'linked'"
:class="filterType === 'linked' ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
HTML 연결
</button>
<button @click="filterType = 'standalone'"
:class="filterType === 'standalone' ? 'bg-yellow-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
독립 파일
</button>
</div>
</div>
<!-- 전체 목록 뷰 -->
<div x-show="viewMode === 'list'" class="bg-white rounded-lg border border-gray-200">
<!-- 로딩 상태 -->
<div x-show="loading" class="p-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">PDF 파일을 불러오는 중...</p>
</div>
<!-- PDF 목록 -->
<div x-show="!loading && filteredPDFs.length > 0" class="divide-y divide-gray-100">
<template x-for="pdf in filteredPDFs" :key="pdf.id">
<div class="p-6 hover:bg-gray-50 transition-colors">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-4 flex-1">
<!-- PDF 아이콘 -->
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
</div>
<!-- PDF 정보 -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 mb-1" x-text="pdf.title"></h3>
<p class="text-sm text-gray-500 mb-2" x-text="pdf.original_filename"></p>
<p class="text-sm text-gray-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p>
<!-- 서적 정보 및 연결 상태 -->
<div class="mt-3 space-y-2">
<!-- 서적 정보 -->
<div x-show="pdf.book_title" class="flex items-center space-x-2">
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
<i class="fas fa-book mr-1"></i>
<span x-text="pdf.book_title"></span>
</span>
<span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<i class="fas fa-link mr-1"></i>
HTML 연결됨
</span>
</div>
<!-- 서적 없는 경우 -->
<div x-show="!pdf.book_title" class="flex items-center space-x-2">
<span class="inline-flex items-center px-3 py-1 bg-gray-100 text-gray-600 text-sm rounded-full">
<i class="fas fa-file mr-1"></i>
서적 미분류
</span>
<span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<i class="fas fa-link mr-1"></i>
HTML 연결됨
</span>
<span x-show="!pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
<i class="fas fa-unlink mr-1"></i>
독립 파일
</span>
</div>
<!-- 업로드 날짜 -->
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">
<i class="fas fa-calendar mr-1"></i>
<span x-text="formatDate(pdf.created_at)"></span>
</span>
<span x-show="pdf.uploaded_by" class="text-sm text-gray-500">
<i class="fas fa-user mr-1"></i>
<span x-text="pdf.uploaded_by"></span>
</span>
</div>
</div>
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex items-center space-x-2 ml-4">
<button @click="previewPDF(pdf)"
class="p-2 text-gray-400 hover:text-green-600 transition-colors"
title="PDF 미리보기">
<i class="fas fa-eye"></i>
</button>
<button @click="downloadPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title="PDF 다운로드">
<i class="fas fa-download"></i>
</button>
<button x-show="currentUser && currentUser.is_admin"
@click="deletePDF(pdf)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
title="PDF 삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</template>
</div>
<!-- 빈 상태 -->
<div x-show="!loading && filteredPDFs.length === 0" class="p-8 text-center">
<i class="fas fa-file-pdf text-gray-400 text-4xl mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
<p class="text-gray-500">
<span x-show="filterType === 'all'">업로드된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'book'">서적에 포함된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'linked'">HTML과 연결된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'standalone'">독립 PDF 파일이 없습니다</span>
</p>
</div>
</div>
<!-- 서적별 뷰 (데본씽크 스타일) -->
<div x-show="viewMode === 'books'" class="space-y-4">
<!-- 로딩 상태 -->
<div x-show="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-500">PDF 파일을 불러오는 중...</p>
</div>
<!-- 서적별 그룹 -->
<template x-for="bookGroup in groupedPDFs" :key="bookGroup.book?.id || 'no-book'">
<div class="bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-200">
<!-- 서적 헤더 -->
<div class="p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
@click="bookGroup.expanded = !bookGroup.expanded">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<!-- 서적 아이콘 -->
<div class="w-10 h-10 bg-gradient-to-br from-red-500 to-red-600 rounded-md flex items-center justify-center">
<i class="fas fa-file-pdf text-white text-sm"></i>
</div>
<!-- 서적 정보 -->
<div>
<h3 class="font-semibold text-gray-900" x-text="bookGroup.book?.title || 'PDF 미분류'"></h3>
<div class="flex items-center space-x-2 text-sm text-gray-500">
<span x-show="bookGroup.book?.author" x-text="bookGroup.book.author"></span>
<span x-show="bookGroup.book?.author" class="text-gray-300"></span>
<span x-text="bookGroup.pdfs.length + '개 PDF'"></span>
<span class="text-gray-300"></span>
<span x-text="bookGroup.linkedCount + '개 연결됨'"></span>
</div>
</div>
</div>
<!-- 확장/축소 아이콘 -->
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500" x-text="bookGroup.pdfs.length + '개'"></span>
<i class="fas fa-chevron-down text-gray-400 transition-transform duration-200"
:class="{'rotate-180': bookGroup.expanded}"></i>
</div>
</div>
</div>
<!-- PDF 목록 (확장 시 표시) -->
<div x-show="bookGroup.expanded" x-collapse class="border-t border-gray-100">
<div class="divide-y divide-gray-50">
<template x-for="(pdf, index) in bookGroup.pdfs.slice(0, 20)" :key="pdf.id">
<div class="p-3 hover:bg-gray-50 cursor-pointer transition-colors flex items-center justify-between group"
@click="previewPDF(pdf)">
<div class="flex items-center space-x-3 flex-1">
<!-- PDF 아이콘 -->
<div class="w-8 h-8 bg-red-100 text-red-600 rounded-md flex items-center justify-center">
<i class="fas fa-file-pdf text-xs"></i>
</div>
<!-- PDF 정보 -->
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 truncate" x-text="pdf.title"></h4>
<div class="flex items-center space-x-2 text-xs text-gray-500">
<span x-text="pdf.original_filename"></span>
<span x-show="pdf.isLinked" class="flex items-center text-green-600">
<i class="fas fa-link mr-1"></i>HTML
</span>
</div>
</div>
</div>
<!-- 액션 버튼들 -->
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button @click.stop="downloadPDF(pdf)"
class="p-2 text-gray-400 hover:text-blue-600 transition-colors rounded-md hover:bg-blue-50"
title="다운로드">
<i class="fas fa-download text-xs"></i>
</button>
<button @click.stop="deletePDF(pdf.id)"
class="p-2 text-gray-400 hover:text-red-600 transition-colors rounded-md hover:bg-red-50"
title="삭제">
<i class="fas fa-trash text-xs"></i>
</button>
<i class="fas fa-chevron-right text-gray-300 ml-2"></i>
</div>
</div>
</template>
<!-- 더 많은 PDF가 있을 때 -->
<div x-show="bookGroup.pdfs.length > 20"
class="p-3 text-center border-t border-gray-100">
<button @click="viewMode = 'list'; filterType = 'book'"
class="text-sm text-blue-600 hover:text-blue-800 font-medium">
<span x-text="`${bookGroup.pdfs.length - 20}개 PDF 더 보기`"></span>
<i class="fas fa-arrow-right ml-1"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- 서적이 없을 때 -->
<div x-show="!loading && groupedPDFs.length === 0" class="text-center py-12">
<i class="fas fa-file-pdf text-4xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
<p class="text-gray-500 mb-4">PDF 파일을 업로드하고 서적으로 분류해보세요</p>
<button onclick="window.location.href='/upload.html'"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
파일 업로드하기
</button>
</div>
</div>
</main>
<!-- PDF 미리보기 모달 -->
<div x-show="showPreviewModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
@click.self="closePreview()">
<div class="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
<!-- 헤더 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex items-center space-x-3">
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
<div>
<h3 class="text-xl font-bold text-gray-900" x-text="previewPdf?.title"></h3>
<p class="text-sm text-gray-500" x-text="previewPdf?.original_filename"></p>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="downloadPDF(previewPdf)"
class="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<i class="fas fa-download"></i>
<span>다운로드</span>
</button>
<button @click="closePreview()"
class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
</div>
<!-- PDF 뷰어 -->
<div class="p-6 overflow-y-auto" style="max-height: calc(90vh - 120px);">
<!-- PDF 미리보기 -->
<div x-show="previewPdf" class="mb-4">
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 600px;">
<!-- PDF iframe 뷰어 -->
<iframe x-show="!pdfPreviewError && !pdfPreviewLoading && pdfPreviewSrc"
class="w-full h-full border-0"
:src="pdfPreviewSrc"
@load="pdfPreviewLoaded = true"
@error="handlePdfPreviewError()">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfPreviewLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-3xl text-gray-500 mb-4"></i>
<p class="text-gray-600 text-lg">PDF를 로드하는 중...</p>
</div>
</div>
<!-- PDF 에러 상태 -->
<div x-show="pdfPreviewError" class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl mb-4 text-red-500"></i>
<p class="text-lg mb-4">PDF를 로드할 수 없습니다</p>
<button @click="retryPdfPreview()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
다시 시도
</button>
<button @click="downloadPDF(previewPdf)"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
파일 다운로드
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/pdf-manager.js?v=2025012627"></script>
<!-- Alpine.js (JavaScript 파일들 다음에 로드) -->
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</body>
</html>

363
frontend/profile.html Normal file
View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로필 관리 - Document Server</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
</head>
<body class="bg-gray-50 min-h-screen" x-data="profileApp()">
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- 페이지 제목 -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">프로필 관리</h1>
<p class="text-gray-600">개인 정보와 계정 설정을 관리하세요.</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<!-- 프로필 카드 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
<div class="p-6">
<div class="flex items-center space-x-6 mb-6">
<!-- 프로필 아바타 -->
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
<i class="fas fa-user text-blue-600 text-2xl"></i>
</div>
<!-- 기본 정보 -->
<div>
<h2 class="text-2xl font-bold text-gray-900" x-text="user.full_name || user.email"></h2>
<p class="text-gray-600" x-text="user.email"></p>
<div class="flex items-center mt-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
<span x-text="getRoleText(user.role)"></span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="mb-8">
<nav class="flex space-x-8" aria-label="Tabs">
<button @click="activeTab = 'profile'"
:class="activeTab === 'profile' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-user mr-2"></i>프로필 정보
</button>
<button @click="activeTab = 'security'"
:class="activeTab === 'security' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-lock mr-2"></i>보안 설정
</button>
<button @click="activeTab = 'preferences'"
:class="activeTab === 'preferences' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-cog mr-2"></i>환경 설정
</button>
</nav>
</div>
<!-- 프로필 정보 탭 -->
<div x-show="activeTab === 'profile'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">프로필 정보</h3>
<form @submit.prevent="updateProfile()" class="space-y-6">
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">이름</label>
<input type="text" id="full_name" x-model="profileForm.full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
<input type="email" id="email" x-model="user.email" disabled
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
<p class="mt-1 text-sm text-gray-500">이메일은 변경할 수 없습니다.</p>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="profileLoading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="profileLoading"></i>
<span x-text="profileLoading ? '저장 중...' : '프로필 저장'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- 보안 설정 탭 -->
<div x-show="activeTab === 'security'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">비밀번호 변경</h3>
<form @submit.prevent="changePassword()" class="space-y-6">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
<input type="password" id="current_password" x-model="passwordForm.current_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required>
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
<input type="password" id="new_password" x-model="passwordForm.new_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required minlength="6">
<p class="mt-1 text-sm text-gray-500">최소 6자 이상 입력해주세요.</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="passwordLoading || passwordForm.new_password !== passwordForm.confirm_password"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="passwordLoading"></i>
<span x-text="passwordLoading ? '변경 중...' : '비밀번호 변경'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- 환경 설정 탭 -->
<div x-show="activeTab === 'preferences'" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-6">환경 설정</h3>
<form @submit.prevent="updatePreferences()" class="space-y-6">
<div>
<label for="theme" class="block text-sm font-medium text-gray-700 mb-2">테마</label>
<select id="theme" x-model="preferencesForm.theme"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="light">라이트 모드</option>
<option value="dark">다크 모드</option>
<option value="auto">시스템 설정 따름</option>
</select>
</div>
<div>
<label for="language" class="block text-sm font-medium text-gray-700 mb-2">언어</label>
<select id="language" x-model="preferencesForm.language"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</div>
<div>
<label for="timezone" class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
<select id="timezone" x-model="preferencesForm.timezone"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="Asia/Seoul">서울 (UTC+9)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">뉴욕 (UTC-5)</option>
<option value="Europe/London">런던 (UTC+0)</option>
</select>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="preferencesLoading"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="preferencesLoading"></i>
<span x-text="preferencesLoading ? '저장 중...' : '설정 저장'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 공통 스크립트 -->
<script src="static/js/api.js"></script>
<script src="static/js/header-loader.js"></script>
<!-- 프로필 관리 스크립트 -->
<script>
function profileApp() {
return {
user: {},
activeTab: 'profile',
profileLoading: false,
passwordLoading: false,
preferencesLoading: false,
profileForm: {
full_name: ''
},
passwordForm: {
current_password: '',
new_password: '',
confirm_password: ''
},
preferencesForm: {
theme: 'light',
language: 'ko',
timezone: 'Asia/Seoul'
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔧 프로필 앱 초기화');
await this.loadUserProfile();
},
async loadUserProfile() {
try {
const response = await api.get('/users/me');
this.user = response;
// 폼 데이터 초기화
this.profileForm.full_name = response.full_name || '';
this.preferencesForm.theme = response.theme || 'light';
this.preferencesForm.language = response.language || 'ko';
this.preferencesForm.timezone = response.timezone || 'Asia/Seoul';
// 헤더 사용자 메뉴 업데이트
if (window.updateUserMenu) {
window.updateUserMenu(response);
}
console.log('✅ 사용자 프로필 로드 완료:', response);
} catch (error) {
console.error('❌ 사용자 프로필 로드 실패:', error);
this.showNotification('사용자 정보를 불러올 수 없습니다.', 'error');
}
},
async updateProfile() {
this.profileLoading = true;
try {
const response = await api.put('/users/me', this.profileForm);
this.user = { ...this.user, ...response };
// 헤더 사용자 메뉴 업데이트
if (window.updateUserMenu) {
window.updateUserMenu(this.user);
}
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
console.log('✅ 프로필 업데이트 완료');
} catch (error) {
console.error('❌ 프로필 업데이트 실패:', error);
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
} finally {
this.profileLoading = false;
}
},
async changePassword() {
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.passwordLoading = true;
try {
await api.post('/users/me/change-password', {
current_password: this.passwordForm.current_password,
new_password: this.passwordForm.new_password
});
// 폼 초기화
this.passwordForm = {
current_password: '',
new_password: '',
confirm_password: ''
};
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
console.log('✅ 비밀번호 변경 완료');
} catch (error) {
console.error('❌ 비밀번호 변경 실패:', error);
this.showNotification('비밀번호 변경에 실패했습니다. 현재 비밀번호를 확인해주세요.', 'error');
} finally {
this.passwordLoading = false;
}
},
async updatePreferences() {
this.preferencesLoading = true;
try {
const response = await api.put('/users/me', this.preferencesForm);
this.user = { ...this.user, ...response };
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
console.log('✅ 환경 설정 업데이트 완료');
} catch (error) {
console.error('❌ 환경 설정 업데이트 실패:', error);
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
} finally {
this.preferencesLoading = false;
}
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
},
getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
}
};
}
</script>
</body>
</html>

740
frontend/search.html Normal file
View File

@@ -0,0 +1,740 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>통합 검색 - Document Server</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- PDF.js 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
// PDF.js 워커 설정 (전역)
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
</script>
<style>
[x-cloak] { display: none !important; }
.search-result-card {
transition: all 0.3s ease;
}
.search-result-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.highlight-text {
background: linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%);
color: #92400e;
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.search-filter-chip {
transition: all 0.2s ease;
}
.search-filter-chip.active {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.search-stats {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
border-left: 4px solid #3b82f6;
}
.result-type-badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-document {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.badge-note {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
}
.badge-memo {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
}
.badge-highlight {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.badge-highlight_note {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: white;
}
.badge-document_content {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
color: white;
}
.search-input-container {
position: relative;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 2px solid #e5e7eb;
transition: all 0.3s ease;
}
.search-input-container:focus-within {
border-color: #3b82f6;
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.2);
transform: translateY(-2px);
}
.search-input {
background: transparent;
border: none;
outline: none;
font-size: 18px;
padding: 20px 60px 20px 24px;
width: 100%;
border-radius: 16px;
}
.search-button {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border: none;
border-radius: 12px;
padding: 12px 16px;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.search-button:hover {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.empty-state {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 2px dashed #cbd5e1;
border-radius: 16px;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen" x-data="searchApp()" x-init="init()" x-cloak>
<!-- 헤더 -->
<div id="header-container"></div>
<!-- 메인 컨테이너 -->
<div class="container mx-auto px-4 pt-20 pb-8">
<!-- 페이지 헤더 -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
<i class="fas fa-search text-blue-600 mr-3"></i>
통합 검색
</h1>
<p class="text-xl text-gray-600">문서, 노트, 메모를 한 번에 검색하세요</p>
</div>
<!-- 검색 입력 -->
<div class="max-w-4xl mx-auto mb-8">
<form @submit.prevent="performSearch()">
<div class="search-input-container">
<input
type="text"
x-model="searchQuery"
placeholder="검색어를 입력하세요..."
class="search-input"
@input="debounceSearch()"
>
<button type="submit" class="search-button" :disabled="loading">
<i class="fas fa-search" :class="{'loading-spinner': loading}"></i>
</button>
</div>
</form>
</div>
<!-- 검색 필터 -->
<div class="max-w-4xl mx-auto mb-8" x-show="searchResults.length > 0 || hasSearched">
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex flex-wrap items-center gap-4">
<!-- 타입 필터 -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">타입:</span>
<button
@click="typeFilter = ''"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === '' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
전체
</button>
<button
@click="typeFilter = 'document'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'document' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-alt mr-1"></i>문서
</button>
<button
@click="typeFilter = 'note'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-sticky-note mr-1"></i>노트
</button>
<button
@click="typeFilter = 'memo'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'memo' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-tree mr-1"></i>메모
</button>
<button
@click="typeFilter = 'highlight'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'highlight' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-highlighter mr-1"></i>하이라이트
</button>
<button
@click="typeFilter = 'highlight_note'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'highlight_note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-comment mr-1"></i>메모
</button>
<button
@click="typeFilter = 'document_content'"
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
:class="typeFilter === 'document_content' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-text mr-1"></i>본문
</button>
</div>
<!-- 파일 타입 필터 -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">파일 타입:</span>
<button
@click="fileTypeFilter = fileTypeFilter === 'PDF' ? '' : 'PDF'; applyFilters()"
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
:class="fileTypeFilter === 'PDF' ? 'bg-red-100 text-red-800 border-red-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-pdf mr-1"></i>PDF
</button>
<button
@click="fileTypeFilter = fileTypeFilter === 'HTML' ? '' : 'HTML'; applyFilters()"
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
:class="fileTypeFilter === 'HTML' ? 'bg-orange-100 text-orange-800 border-orange-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-code mr-1"></i>HTML
</button>
</div>
<!-- 정렬 -->
<div class="flex items-center space-x-2 ml-auto">
<span class="text-sm font-medium text-gray-700">정렬:</span>
<select x-model="sortBy" @change="applyFilters()"
class="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="relevance">관련도순</option>
<option value="date_desc">최신순</option>
<option value="date_asc">오래된순</option>
<option value="title">제목순</option>
</select>
</div>
</div>
</div>
</div>
<!-- 검색 통계 -->
<div class="max-w-4xl mx-auto mb-6" x-show="searchResults.length > 0">
<div class="search-stats rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-gray-700">
<strong x-text="filteredResults.length"></strong>개 결과
<span x-show="searchQuery" class="text-gray-500">
"<span x-text="searchQuery"></span>" 검색
</span>
</span>
<div class="flex items-center space-x-3 text-xs text-gray-500">
<span x-show="getResultCount('document') > 0">
📄 문서 <strong x-text="getResultCount('document')"></strong>
</span>
<span x-show="getResultCount('note') > 0">
📝 노트 <strong x-text="getResultCount('note')"></strong>
</span>
<span x-show="getResultCount('memo') > 0">
🌳 메모 <strong x-text="getResultCount('memo')"></strong>
</span>
<span x-show="getResultCount('highlight') > 0">
🖍️ 하이라이트 <strong x-text="getResultCount('highlight')"></strong>
</span>
<span x-show="getResultCount('highlight_note') > 0">
💬 메모 <strong x-text="getResultCount('highlight_note')"></strong>
</span>
<span x-show="getResultCount('document_content') > 0">
📖 본문 <strong x-text="getResultCount('document_content')"></strong>
</span>
</div>
</div>
<div class="text-xs text-gray-500">
<i class="fas fa-clock mr-1"></i>
<span x-text="searchTime"></span>ms
</div>
</div>
</div>
</div>
<!-- 로딩 상태 -->
<div x-show="loading" class="max-w-4xl mx-auto text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
<p class="text-gray-600">검색 중...</p>
</div>
<!-- 검색 결과 -->
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
<template x-for="result in filteredResults" :key="result.unique_id || result.id">
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
<!-- 결과 헤더 -->
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="result-type-badge"
:class="`badge-${result.type}`"
x-text="getTypeLabel(result.type)"></span>
<!-- 파일 타입 정보 (PDF/HTML) -->
<span x-show="result.highlight_info?.file_type"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="{
'bg-red-100 text-red-800': result.highlight_info?.file_type === 'PDF',
'bg-orange-100 text-orange-800': result.highlight_info?.file_type === 'HTML'
}">
<i class="fas mr-1"
:class="{
'fa-file-pdf': result.highlight_info?.file_type === 'PDF',
'fa-code': result.highlight_info?.file_type === 'HTML'
}"></i>
<span x-text="result.highlight_info?.file_type"></span>
</span>
<!-- 매치 개수 -->
<span x-show="result.highlight_info && result.highlight_info.match_count > 0"
class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
<i class="fas fa-search mr-1"></i>
<span x-text="result.highlight_info?.match_count || 0"></span>개 매치
</span>
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
<i class="fas fa-star text-yellow-500 mr-1"></i>
<span x-text="Math.round(result.relevance_score * 100) + '%'"></span>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2 cursor-pointer hover:text-blue-600"
@click="openResult(result)"
x-html="highlightText(result.title, searchQuery)"></h3>
<p class="text-sm text-gray-600 mb-2" x-text="result.document_title"></p>
</div>
<div class="ml-4 flex space-x-2">
<button @click="showPreview(result)"
class="px-3 py-1 bg-gray-600 text-white rounded-lg text-sm hover:bg-gray-700 transition-colors">
<i class="fas fa-eye mr-1"></i>미리보기
</button>
<button @click="openResult(result)"
class="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors">
<i class="fas fa-external-link-alt mr-1"></i>열기
</button>
</div>
</div>
<!-- 결과 내용 -->
<div class="text-gray-700 text-sm leading-relaxed"
x-html="highlightText(truncateText(result.content, 200), searchQuery)"></div>
<!-- 하이라이트 정보 -->
<div x-show="result.type === 'highlight' && result.highlight_info" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="text-xs text-yellow-800 mb-1">
<i class="fas fa-highlighter mr-1"></i>하이라이트 정보
</div>
<div class="text-sm text-yellow-900" x-show="result.highlight_info?.selected_text">
"<span x-text="result.highlight_info?.selected_text"></span>"
</div>
</div>
</div>
</template>
</div>
<!-- 빈 검색 결과 -->
<div x-show="!loading && hasSearched && filteredResults.length === 0" class="max-w-4xl mx-auto">
<div class="empty-state text-center py-16">
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색 결과가 없습니다</h3>
<p class="text-gray-500 mb-6">
<span x-show="searchQuery">
"<span x-text="searchQuery"></span>"에 대한 결과를 찾을 수 없습니다.
</span>
<span x-show="!searchQuery">검색어를 입력해주세요.</span>
</p>
<div class="text-sm text-gray-500">
<p class="mb-2">검색 팁:</p>
<ul class="text-left inline-block space-y-1">
<li>• 다른 키워드로 검색해보세요</li>
<li>• 검색어를 줄여보세요</li>
<li>• 필터를 변경해보세요</li>
</ul>
</div>
</div>
</div>
<!-- 초기 상태 -->
<div x-show="!loading && !hasSearched" class="max-w-4xl mx-auto">
<div class="text-center py-16">
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색을 시작하세요</h3>
<p class="text-gray-500 mb-8">문서, 노트, 메모, 하이라이트를 통합 검색할 수 있습니다</p>
<!-- 빠른 검색 예시 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-2xl mx-auto">
<button @click="searchQuery = '설계'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-blue-500 hover:bg-blue-50 transition-colors">
<i class="fas fa-drafting-compass text-blue-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">설계</div>
</button>
<button @click="searchQuery = '연구'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-green-500 hover:bg-green-50 transition-colors">
<i class="fas fa-flask text-green-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">연구</div>
</button>
<button @click="searchQuery = '프로젝트'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-purple-500 hover:bg-purple-50 transition-colors">
<i class="fas fa-project-diagram text-purple-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">프로젝트</div>
</button>
<button @click="searchQuery = '분석'; performSearch()"
class="p-4 bg-white rounded-lg border hover:border-orange-500 hover:bg-orange-50 transition-colors">
<i class="fas fa-chart-line text-orange-600 text-2xl mb-2"></i>
<div class="text-sm font-medium">분석</div>
</button>
</div>
</div>
</div>
</div>
<!-- 미리보기 모달 -->
<div x-show="showPreviewModal"
@keydown.escape.window="closePreview()"
@click.self="closePreview()"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between p-6 border-b">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<span class="result-type-badge"
:class="`badge-${previewResult?.type}`"
x-text="getTypeLabel(previewResult?.type)"></span>
<span class="text-sm text-gray-500" x-text="formatDate(previewResult?.created_at)"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900" x-text="previewResult?.title"></h3>
<p class="text-sm text-gray-600" x-text="previewResult?.document_title"></p>
</div>
<div class="flex items-center space-x-2">
<button @click="openResult(previewResult)"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-external-link-alt mr-2"></i>열기
</button>
<button @click="closePreview()"
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 모달 내용 -->
<div class="p-6 overflow-y-auto max-h-[60vh]">
<!-- PDF 미리보기 (데본씽크 스타일) -->
<div x-show="(previewResult?.type === 'document_content' || previewResult?.type === 'document') && previewResult?.highlight_info?.has_pdf"
class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-gray-800">
<i class="fas fa-file-pdf mr-2 text-red-600"></i>PDF 미리보기
</div>
<div class="flex items-center space-x-2">
<button @click="searchInPdf()"
class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">
<i class="fas fa-search mr-1"></i>PDF에서 검색
</button>
</div>
</div>
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
<!-- PDF iframe 뷰어 -->
<iframe id="pdf-preview-iframe"
x-show="!pdfError && !pdfLoading"
class="w-full h-full border-0"
:src="pdfSrc"
@load="pdfLoaded = true"
@error="handlePdfError()">
</iframe>
<!-- PDF 로딩 상태 -->
<div x-show="pdfLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-500 mb-2"></i>
<p class="text-gray-600">PDF를 로드하는 중...</p>
</div>
</div>
<div x-show="pdfError" class="flex items-center justify-center h-full text-gray-500">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<p>PDF를 로드할 수 없습니다</p>
<button @click="openResult(previewResult)"
class="mt-2 px-3 py-1 bg-blue-600 text-white rounded text-sm">
뷰어에서 열기
</button>
</div>
</div>
</div>
</div>
<!-- HTML 문서 미리보기 -->
<div x-show="(previewResult?.type === 'document' || previewResult?.type === 'document_content') && !previewResult?.highlight_info?.has_pdf"
class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-gray-800">
<i class="fas fa-code mr-2 text-green-600"></i>HTML 문서 미리보기
</div>
<div class="flex items-center space-x-2">
<button @click="toggleHtmlRaw()"
class="px-3 py-1 bg-gray-600 text-white rounded text-xs hover:bg-gray-700">
<i class="fas fa-code mr-1"></i><span x-text="htmlRawMode ? '렌더링' : '소스'"></span>
</button>
</div>
</div>
<div class="border rounded-lg overflow-hidden bg-white relative" style="height: 500px;">
<!-- HTML 렌더링 뷰 -->
<iframe id="htmlPreviewFrame"
x-show="!htmlRawMode && !htmlLoading"
class="w-full h-full border-0"
sandbox="allow-same-origin">
</iframe>
<!-- HTML 소스 뷰 -->
<div x-show="htmlRawMode && !htmlLoading"
class="w-full h-full overflow-auto p-4 bg-gray-900 text-green-400 font-mono text-sm">
<pre x-html="htmlSourceCode"></pre>
</div>
<!-- 로딩 상태 -->
<div x-show="htmlLoading" class="flex items-center justify-center h-full">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-500 mb-2"></i>
<p class="text-gray-600">HTML을 로드하는 중...</p>
</div>
</div>
</div>
</div>
<!-- 메모 트리 노드 미리보기 -->
<div x-show="previewResult?.type === 'memo'"
class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-gray-800">
<i class="fas fa-tree mr-2 text-purple-600"></i>메모 노드 미리보기
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-600" x-text="previewResult?.document_title"></span>
</div>
</div>
<div class="border rounded-lg overflow-hidden bg-white" style="height: 400px;">
<div class="h-full overflow-auto p-6">
<!-- 메모 제목 -->
<h3 class="text-xl font-bold text-gray-900 mb-4" x-text="previewResult?.title"></h3>
<!-- 메모 내용 -->
<div class="prose max-w-none">
<div class="text-gray-700 leading-relaxed whitespace-pre-wrap"
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
</div>
</div>
</div>
</div>
<!-- 노트 문서 미리보기 -->
<div x-show="previewResult?.type === 'note'"
class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-gray-800">
<i class="fas fa-sticky-note mr-2 text-blue-600"></i>노트 미리보기
</div>
<div class="flex items-center space-x-2">
<button @click="toggleNoteEdit()"
class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">
<i class="fas fa-edit mr-1"></i>편집기에서 열기
</button>
</div>
</div>
<div class="border rounded-lg overflow-hidden bg-white" style="height: 450px;">
<div class="h-full overflow-auto">
<!-- 노트 헤더 -->
<div class="p-4 border-b bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900" x-text="previewResult?.title"></h3>
<div class="text-sm text-gray-600 mt-1">
<span x-text="formatDate(previewResult?.created_at)"></span>
</div>
</div>
<!-- 노트 내용 -->
<div class="p-6">
<div class="prose max-w-none">
<div class="text-gray-700 leading-relaxed"
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 하이라이트 정보 -->
<div x-show="previewResult?.type === 'highlight' && previewResult?.highlight_info"
class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="text-sm font-medium text-yellow-800 mb-2">
<i class="fas fa-highlighter mr-2"></i>하이라이트된 텍스트
</div>
<div class="text-yellow-900 font-medium mb-2"
x-text="previewResult?.highlight_info?.selected_text"></div>
<div x-show="previewResult?.highlight_info?.note_content" class="text-sm text-yellow-800">
<strong>메모:</strong> <span x-text="previewResult?.highlight_info?.note_content"></span>
</div>
</div>
<!-- 메모 내용 -->
<div x-show="previewResult?.type === 'highlight_note' && previewResult?.highlight_info"
class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="text-sm font-medium text-blue-800 mb-2">
<i class="fas fa-quote-left mr-2"></i>원본 하이라이트
</div>
<div class="text-blue-900 mb-2" x-text="previewResult?.highlight_info?.selected_text"></div>
<div class="text-sm font-medium text-blue-800 mb-1">메모 내용:</div>
</div>
<!-- 본문 검색 결과 정보 -->
<div x-show="previewResult?.type === 'document_content' && previewResult?.highlight_info"
class="mb-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-gray-800">
<i class="fas fa-search mr-2"></i>본문 검색 결과
</div>
<div class="text-xs text-gray-600">
<span x-text="previewResult?.highlight_info?.file_type"></span>
<span x-text="previewResult?.highlight_info?.match_count"></span>개 매치
</div>
</div>
</div>
<!-- 본문 내용 (PDF/HTML이 아닌 경우) -->
<div x-show="!previewResult?.highlight_info?.has_pdf && !previewResult?.highlight_info?.has_html"
class="prose max-w-none">
<div class="text-gray-700 leading-relaxed"
style="white-space: pre-wrap; word-wrap: break-word; max-height: 400px; overflow-y: auto;"
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
</div>
<!-- 기본 텍스트 내용 (fallback) -->
<div x-show="!previewResult?.highlight_info?.has_pdf && !previewResult?.highlight_info?.has_html && (!previewResult?.content || previewResult?.content.length < 10)"
class="text-center py-8 text-gray-500">
<i class="fas fa-file-alt text-3xl mb-3"></i>
<p>미리보기할 수 있는 내용이 없습니다.</p>
<button @click="openResult(previewResult)"
class="mt-3 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
원본에서 보기
</button>
</div>
<!-- 추가 정보 -->
<div x-show="previewResult?.type === 'memo'" class="mt-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
<div class="text-sm font-medium text-purple-800 mb-1">
<i class="fas fa-tree mr-2"></i>메모 트리 정보
</div>
<div class="text-purple-700 text-sm" x-text="previewResult?.document_title"></div>
</div>
<!-- 로딩 상태 -->
<div x-show="previewLoading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
<p class="text-gray-600">내용을 불러오는 중...</p>
</div>
</div>
</div>
</div>
<!-- JavaScript 파일들 -->
<script src="/static/js/header-loader.js?v=2025012603"></script>
<script src="/static/js/api.js?v=2025012607"></script>
<script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/search.js?v=2025012610"></script>
</body>
</html>

274
frontend/setup.html Normal file
View File

@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시스템 초기 설정 - Document Server</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 공통 스타일 -->
<link rel="stylesheet" href="static/css/common.css">
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen" x-data="setupApp()">
<!-- 메인 컨테이너 -->
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- 로고 및 제목 -->
<div class="text-center">
<div class="mx-auto h-20 w-20 bg-blue-600 rounded-full flex items-center justify-center mb-6">
<i class="fas fa-book text-white text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2">Document Server</h2>
<p class="text-gray-600">시스템 초기 설정</p>
</div>
<!-- 설정 상태 확인 중 -->
<div x-show="loading" class="text-center">
<div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-blue-600 bg-white">
<i class="fas fa-spinner fa-spin mr-2"></i>
시스템 상태 확인 중...
</div>
</div>
<!-- 이미 설정된 시스템 -->
<div x-show="!loading && !setupRequired" class="bg-white rounded-lg shadow-md p-8 text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">시스템이 이미 설정되었습니다</h3>
<p class="text-gray-600 mb-6">Document Server가 정상적으로 구성되어 있습니다.</p>
<a href="index.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
</a>
</div>
<!-- 초기 설정 폼 -->
<div x-show="!loading && setupRequired" class="bg-white rounded-lg shadow-md p-8">
<div class="mb-6">
<h3 class="text-xl font-semibold text-gray-900 mb-2">관리자 계정 생성</h3>
<p class="text-gray-600">시스템 관리자(Root) 계정을 생성해주세요.</p>
</div>
<!-- 알림 메시지 -->
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
<div class="flex items-center">
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
<span x-text="notification.message"></span>
</div>
</div>
<form @submit.prevent="initializeSystem()" class="space-y-6">
<div>
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-envelope mr-1"></i>관리자 이메일
</label>
<input type="email" id="admin_email" x-model="setupForm.admin_email" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="admin@example.com">
</div>
<div>
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>관리자 비밀번호
</label>
<input type="password" id="admin_password" x-model="setupForm.admin_password" required minlength="6"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="최소 6자 이상">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>비밀번호 확인
</label>
<input type="password" id="confirm_password" x-model="confirmPassword" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="비밀번호를 다시 입력하세요">
</div>
<div>
<label for="admin_full_name" class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-user mr-1"></i>관리자 이름 (선택사항)
</label>
<input type="text" id="admin_full_name" x-model="setupForm.admin_full_name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="시스템 관리자">
</div>
<!-- 주의사항 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<div class="text-sm text-yellow-800">
<p class="font-medium mb-1">주의사항:</p>
<ul class="list-disc list-inside space-y-1">
<li>이 계정은 시스템의 최고 관리자 권한을 가집니다.</li>
<li>안전한 비밀번호를 사용하고 잘 보관해주세요.</li>
<li>설정 완료 후에는 이 페이지에 다시 접근할 수 없습니다.</li>
</ul>
</div>
</div>
</div>
<button type="submit" :disabled="setupLoading || setupForm.admin_password !== confirmPassword"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-spinner fa-spin mr-2" x-show="setupLoading"></i>
<i class="fas fa-rocket mr-2" x-show="!setupLoading"></i>
<span x-text="setupLoading ? '설정 중...' : '시스템 초기화'"></span>
</button>
</form>
</div>
<!-- 설정 완료 -->
<div x-show="setupComplete" class="bg-white rounded-lg shadow-md p-8 text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">설정이 완료되었습니다!</h3>
<p class="text-gray-600 mb-6">Document Server가 성공적으로 초기화되었습니다.</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="text-sm text-blue-800">
<p class="font-medium mb-2">생성된 관리자 계정:</p>
<p><strong>이메일:</strong> <span x-text="createdAdmin.email"></span></p>
<p><strong>이름:</strong> <span x-text="createdAdmin.full_name"></span></p>
<p><strong>역할:</strong> 시스템 관리자</p>
</div>
</div>
<div class="space-y-3">
<a href="index.html" class="w-full inline-flex justify-center items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
</a>
<button @click="goToLogin()" class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
<i class="fas fa-sign-in-alt mr-2"></i>로그인하기
</button>
</div>
</div>
</div>
</div>
<!-- API 스크립트 -->
<script>
// 간단한 API 클라이언트
const setupApi = {
async get(endpoint) {
const response = await fetch(`/api${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
},
async post(endpoint, data) {
const response = await fetch(`/api${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return await response.json();
}
};
</script>
<!-- 설정 앱 스크립트 -->
<script>
function setupApp() {
return {
loading: true,
setupRequired: false,
setupComplete: false,
setupLoading: false,
confirmPassword: '',
createdAdmin: {},
setupForm: {
admin_email: '',
admin_password: '',
admin_full_name: ''
},
notification: {
show: false,
type: 'success',
message: ''
},
async init() {
console.log('🔧 설정 앱 초기화');
await this.checkSetupStatus();
},
async checkSetupStatus() {
try {
const status = await setupApi.get('/setup/status');
this.setupRequired = status.is_setup_required;
console.log('✅ 설정 상태 확인 완료:', status);
} catch (error) {
console.error('❌ 설정 상태 확인 실패:', error);
this.showNotification('시스템 상태를 확인할 수 없습니다.', 'error');
} finally {
this.loading = false;
}
},
async initializeSystem() {
if (this.setupForm.admin_password !== this.confirmPassword) {
this.showNotification('비밀번호가 일치하지 않습니다.', 'error');
return;
}
this.setupLoading = true;
try {
const result = await setupApi.post('/setup/initialize', this.setupForm);
this.createdAdmin = result.admin_user;
this.setupComplete = true;
this.setupRequired = false;
console.log('✅ 시스템 초기화 완료:', result);
} catch (error) {
console.error('❌ 시스템 초기화 실패:', error);
this.showNotification(error.message || '시스템 초기화에 실패했습니다.', 'error');
} finally {
this.setupLoading = false;
}
},
goToLogin() {
// 로그인 모달을 열거나 로그인 페이지로 이동
window.location.href = 'index.html';
},
showNotification(message, type = 'success') {
this.notification = {
show: true,
type,
message
};
setTimeout(() => {
this.notification.show = false;
}, 5000);
}
};
}
</script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,41 @@
# 로그인 페이지 이미지
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
## 필요한 이미지 파일
### 배경 이미지
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
## 이미지 사양
- **형식**: JPG, PNG 지원
- **품질**: 웹 최적화된 고품질 이미지
- **용량**: 1MB 이하 권장
- **비율**: 16:9 또는 16:10 비율 권장
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
## 폴백 동작
배경 이미지 파일이 없는 경우:
- 파란색-보라색 그라디언트 배경으로 자동 폴백
## 사용 예시
```
static/images/
└── login-bg.jpg (전체 배경)
```
## 배경 이미지 선택 가이드
- **문서/도서관 테마**: 책장, 도서관, 서재 등
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
- **미니멀 테마**: 단순한 패턴, 텍스처
## 변경 사항 (v2.0)
- 갤러리 액자 기능 제거
- 중앙 집중형 로그인 레이아웃으로 변경
- 배경 이미지만 사용하는 심플한 디자인

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

750
frontend/static/js/api.js Normal file
View File

@@ -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();

View File

@@ -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
};
})();

105
frontend/static/js/auth.js Normal file
View File

@@ -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);

View File

@@ -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 페이지 로드됨');
});

View File

@@ -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 페이지 로드됨');
});

Some files were not shown because too many files have changed in this diff Show More