From 1b5fa95a9fb9e774e4f89a440f636adc48c91372 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 3 Apr 2026 13:11:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=BC=EC=8A=A4=20=E2=86=92?= =?UTF-8?q?=20ODF=20=EB=B3=80=ED=99=98=20+=20=EC=9B=90=EB=B3=B8/=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EB=B3=B8=20=EB=B6=84=EB=A6=AC=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - original_path/format/hash + conversion_status 필드 추가 (migration 007) - extract_worker: 텍스트 추출 후 xlsx→ods, docx→odt 등 ODF 변환 - 변환본은 .derived/{doc_id}.ods 에 저장 - 원본 메타 보존 (original_path/format/hash) - file_watcher: .derived/ .preview/ 디렉토리 제외 - DocumentViewer: ODF 포맷이면 편집 버튼 자동 표시 - edit_url 있으면 "편집", 없으면 "Synology Drive에서 열기" Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/documents.py | 3 ++ app/models/document.py | 6 +++ app/workers/extract_worker.py | 49 +++++++++++++++++++ app/workers/file_watcher.py | 3 ++ .../src/lib/components/DocumentViewer.svelte | 20 +++++--- migrations/007_original_fields.sql | 5 ++ 6 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 migrations/007_original_fields.sql 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}