feat: 오피스 → ODF 변환 + 원본/편집본 분리 아키텍처
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# 미지원 포맷
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
@@ -113,14 +117,14 @@
|
||||
>편집</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if editUrl}
|
||||
{#if editInfo}
|
||||
<a
|
||||
href={editUrl}
|
||||
href={editInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
|
||||
>
|
||||
<ExternalLink size={12} /> 편집
|
||||
<ExternalLink size={12} /> {editInfo.label}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
|
||||
5
migrations/007_original_fields.sql
Normal file
5
migrations/007_original_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 원본/변환 분리 필드 추가
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_path TEXT;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_format VARCHAR(20);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_hash VARCHAR(64);
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS conversion_status VARCHAR(20) DEFAULT 'none';
|
||||
Reference in New Issue
Block a user