diff --git a/app/api/documents.py b/app/api/documents.py
index 6a9ac08..6346223 100644
--- a/app/api/documents.py
+++ b/app/api/documents.py
@@ -37,6 +37,9 @@ class DocumentResponse(BaseModel):
ai_tags: list | None
ai_summary: str | None
user_note: str | None
+ original_path: str | None
+ original_format: str | None
+ conversion_status: str | None
edit_url: str | None
preview_status: str | None
source_channel: str | None
diff --git a/app/models/document.py b/app/models/document.py
index fa13499..fd69c46 100644
--- a/app/models/document.py
+++ b/app/models/document.py
@@ -47,6 +47,12 @@ class Document(Base):
# 사용자 메모
user_note: Mapped[str | None] = mapped_column(Text)
+ # 원본 보존 (변환 전)
+ original_path: Mapped[str | None] = mapped_column(Text)
+ original_format: Mapped[str | None] = mapped_column(String(20))
+ original_hash: Mapped[str | None] = mapped_column(String(64))
+ conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
+
# 외부 편집 URL
edit_url: Mapped[str | None] = mapped_column(Text)
diff --git a/app/workers/extract_worker.py b/app/workers/extract_worker.py
index 3ea3925..98608b2 100644
--- a/app/workers/extract_worker.py
+++ b/app/workers/extract_worker.py
@@ -117,6 +117,55 @@ async def process(document_id: int, session: AsyncSession) -> None:
raise RuntimeError(f"LibreOffice 텍스트 추출 timeout (60s)")
finally:
tmp_input.unlink(missing_ok=True)
+
+ # ─── ODF 변환 (편집용) ───
+ CONVERT_MAP = {
+ 'xlsx': 'ods', 'xls': 'ods',
+ 'docx': 'odt', 'doc': 'odt',
+ 'pptx': 'odp', 'ppt': 'odp',
+ }
+ target_fmt = CONVERT_MAP.get(fmt)
+ if target_fmt:
+ try:
+ from core.utils import file_hash as calc_hash
+ # 원본 메타 보존
+ doc.original_path = doc.file_path
+ doc.original_format = doc.file_format
+ doc.original_hash = doc.file_hash
+
+ # .derived 디렉토리에 변환
+ derived_dir = full_path.parent / ".derived"
+ derived_dir.mkdir(exist_ok=True)
+ tmp_input2 = tmp_dir / f"convert_{document_id}.{fmt}"
+ shutil.copy2(str(full_path), str(tmp_input2))
+
+ conv_result = subprocess.run(
+ ["libreoffice", "--headless", "--convert-to", target_fmt, "--outdir", str(tmp_dir), str(tmp_input2)],
+ capture_output=True, text=True, timeout=60,
+ )
+ tmp_input2.unlink(missing_ok=True)
+
+ conv_file = tmp_dir / f"convert_{document_id}.{target_fmt}"
+ if conv_file.exists():
+ final_path = derived_dir / f"{document_id}.{target_fmt}"
+ shutil.move(str(conv_file), str(final_path))
+
+ # DB 업데이트: current → ODF
+ nas_root = Path(settings.nas_mount_path)
+ doc.file_path = str(final_path.relative_to(nas_root))
+ doc.file_format = target_fmt
+ doc.file_hash = calc_hash(final_path)
+ doc.conversion_status = "done"
+ logger.info(f"[ODF변환] {doc.original_path} → {doc.file_path}")
+ else:
+ doc.conversion_status = "failed"
+ logger.warning(f"[ODF변환] 실패: {conv_result.stderr[:200]}")
+ except Exception as e:
+ doc.conversion_status = "failed"
+ logger.error(f"[ODF변환] {doc.file_path} 에러: {e}")
+ else:
+ doc.conversion_status = "none"
+
return
# 미지원 포맷
diff --git a/app/workers/file_watcher.py b/app/workers/file_watcher.py
index 4600314..c4cacca 100644
--- a/app/workers/file_watcher.py
+++ b/app/workers/file_watcher.py
@@ -22,6 +22,9 @@ def should_skip(path: Path) -> bool:
return True
if path.suffix.lower() in SKIP_EXTENSIONS:
return True
+ # .derived/ 및 .preview/ 디렉토리 내 파일 제외
+ if ".derived" in path.parts or ".preview" in path.parts:
+ return True
return False
diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte
index 8393067..268a71a 100644
--- a/frontend/src/lib/components/DocumentViewer.svelte
+++ b/frontend/src/lib/components/DocumentViewer.svelte
@@ -25,11 +25,15 @@
return 'unsupported';
}
- function getEditUrl(doc) {
+ const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
+
+ function getEditInfo(doc) {
// DB에 저장된 편집 URL 우선
- if (doc.edit_url) return doc.edit_url;
- // CAD는 AutoCAD Web
- if (['dwg', 'dxf'].includes(doc.file_format)) return 'https://web.autocad.com';
+ if (doc.edit_url) return { url: doc.edit_url, label: '편집' };
+ // ODF 포맷 → Synology Drive
+ if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
+ // CAD
+ if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
return null;
}
@@ -82,7 +86,7 @@
}
}
- let editUrl = $derived(fullDoc ? getEditUrl(fullDoc) : null);
+ let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
@@ -113,14 +117,14 @@
>편집
{/if}
{/if}
- {#if editUrl}
+ {#if editInfo}
- 편집
+ {editInfo.label}
{/if}