mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(export): 支持账号归档 ZIP 导出
- 新增账号归档导出后端接口,支持创建、查询、取消导出任务和下载 ZIP 文件 - 支持按账号打包数据库、元信息文件和资源文件,并使用 ZIP 存储模式提升大文件归档速度 - 前端接入账号归档导出 API,支持导出目录选择、导出内容勾选、进度轮询和取消导出 - 简化全局导出弹窗,将入口聚焦为数据库与资源文件归档导出 - 开启侧边栏导出入口,并补充 README 中的导出说明
This commit is contained in:
@@ -176,6 +176,10 @@ npm run dist
|
|||||||
|
|
||||||
## 使用指南
|
## 使用指南
|
||||||
|
|
||||||
|
### 导出
|
||||||
|
|
||||||
|
侧边栏提供“导出”入口,点击下载图标可打开统一导出弹窗。当前导出界面只保留两个选项:导出数据库、导出资源文件;用户选择导出目录后会生成一个账号归档 ZIP。当两个选项都勾选时,导出会按账号目录直接归档,使用 ZIP 存储模式打包,尽量避免二次压缩带来的耗时。
|
||||||
|
|
||||||
### 获取解密密钥
|
### 获取解密密钥
|
||||||
|
|
||||||
在使用本工具之前,您需要先获取微信数据库的解密密钥。推荐使用以下工具:
|
在使用本工具之前,您需要先获取微信数据库的解密密钥。推荐使用以下工具:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -148,7 +148,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="showGlobalExportEntry"
|
v-if="showGlobalExportEntry"
|
||||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||||
title="批量导出"
|
title="导出"
|
||||||
@click="openExportDialog"
|
@click="openExportDialog"
|
||||||
>
|
>
|
||||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||||
@@ -387,7 +387,7 @@ const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realti
|
|||||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||||
const { getChatAccountInfo, deleteChatAccount } = useApi()
|
const { getChatAccountInfo, deleteChatAccount } = useApi()
|
||||||
|
|
||||||
const showGlobalExportEntry = false
|
const showGlobalExportEntry = true
|
||||||
const accountDialogOpen = ref(false)
|
const accountDialogOpen = ref(false)
|
||||||
const exportDialogOpen = ref(false)
|
const exportDialogOpen = ref(false)
|
||||||
const accountInfoLoading = ref(false)
|
const accountInfoLoading = ref(false)
|
||||||
@@ -648,3 +648,4 @@ const toggleRealtime = async () => {
|
|||||||
color: var(--sidebar-rail-icon-active-color);
|
color: var(--sidebar-rail-icon-active-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -510,6 +510,30 @@ export const useApi = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account archive export (databases + resource files)
|
||||||
|
const createAccountArchiveExport = async (payload = {}) => {
|
||||||
|
return await request('/account/archive_export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
account: payload.account || null,
|
||||||
|
output_dir: payload.output_dir == null ? null : String(payload.output_dir || '').trim(),
|
||||||
|
include_databases: payload.include_databases == null ? true : !!payload.include_databases,
|
||||||
|
include_resources: payload.include_resources == null ? true : !!payload.include_resources,
|
||||||
|
file_name: payload.file_name || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAccountArchiveExport = async (exportId) => {
|
||||||
|
if (!exportId) throw new Error('Missing exportId')
|
||||||
|
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAccountArchiveExport = async (exportId) => {
|
||||||
|
if (!exportId) throw new Error('Missing exportId')
|
||||||
|
return await request(`/account/archive_export/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
// WeChat Wrapped(年度总结)
|
// WeChat Wrapped(年度总结)
|
||||||
const getWrappedAnnual = async (params = {}) => {
|
const getWrappedAnnual = async (params = {}) => {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@@ -662,6 +686,9 @@ export const useApi = () => {
|
|||||||
cancelSnsExport,
|
cancelSnsExport,
|
||||||
listChatContacts,
|
listChatContacts,
|
||||||
exportChatContacts,
|
exportChatContacts,
|
||||||
|
createAccountArchiveExport,
|
||||||
|
getAccountArchiveExport,
|
||||||
|
cancelAccountArchiveExport,
|
||||||
getWrappedAnnual,
|
getWrappedAnnual,
|
||||||
getWrappedAnnualMeta,
|
getWrappedAnnualMeta,
|
||||||
getWrappedAnnualCard,
|
getWrappedAnnualCard,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .routers.decrypt import router as _decrypt_router
|
|||||||
from .routers.import_decrypted import router as _import_decrypted_router
|
from .routers.import_decrypted import router as _import_decrypted_router
|
||||||
from .routers.health import router as _health_router
|
from .routers.health import router as _health_router
|
||||||
from .routers.admin import router as _admin_router
|
from .routers.admin import router as _admin_router
|
||||||
|
from .routers.account_archive_export import router as _account_archive_export_router
|
||||||
from .routers.keys import router as _keys_router
|
from .routers.keys import router as _keys_router
|
||||||
from .routers.media import router as _media_router
|
from .routers.media import router as _media_router
|
||||||
from .routers.sns import router as _sns_router
|
from .routers.sns import router as _sns_router
|
||||||
@@ -65,6 +66,7 @@ async def _log_server_errors(request: Request, call_next):
|
|||||||
|
|
||||||
app.include_router(_health_router)
|
app.include_router(_health_router)
|
||||||
app.include_router(_admin_router)
|
app.include_router(_admin_router)
|
||||||
|
app.include_router(_account_archive_export_router)
|
||||||
app.include_router(_wechat_detection_router)
|
app.include_router(_wechat_detection_router)
|
||||||
app.include_router(_import_decrypted_router)
|
app.include_router(_import_decrypted_router)
|
||||||
app.include_router(_decrypt_router)
|
app.include_router(_decrypt_router)
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..chat_helpers import _resolve_account_dir
|
||||||
|
from ..path_fix import PathFixRoute
|
||||||
|
|
||||||
|
router = APIRouter(route_class=PathFixRoute)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountArchiveExportRequest(BaseModel):
|
||||||
|
account: Optional[str] = Field(None, description="Account directory name. Defaults to the first available account.")
|
||||||
|
output_dir: Optional[str] = Field(None, description="Absolute output directory. Defaults to output/exports/{account}.")
|
||||||
|
include_databases: bool = Field(True, description="Whether to include decrypted database files.")
|
||||||
|
include_resources: bool = Field(True, description="Whether to include resource folders.")
|
||||||
|
file_name: Optional[str] = Field(None, description="Optional zip file name, with or without .zip.")
|
||||||
|
|
||||||
|
|
||||||
|
class AccountArchiveCancelled(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AccountArchiveFile:
|
||||||
|
path: Path
|
||||||
|
arcname: str
|
||||||
|
kind: str
|
||||||
|
size: int
|
||||||
|
mtime: float
|
||||||
|
mode: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountArchiveExportJob:
|
||||||
|
export_id: str
|
||||||
|
account: str = ""
|
||||||
|
status: str = "queued"
|
||||||
|
progress: int = 0
|
||||||
|
message: str = "Waiting to start..."
|
||||||
|
detail: str = ""
|
||||||
|
error: str = ""
|
||||||
|
zip_path: str = ""
|
||||||
|
file_name: str = ""
|
||||||
|
database_count: int = 0
|
||||||
|
resource_file_count: int = 0
|
||||||
|
total_bytes: int = 0
|
||||||
|
processed_bytes: int = 0
|
||||||
|
created_at: int = field(default_factory=lambda: int(time.time()))
|
||||||
|
updated_at: int = field(default_factory=lambda: int(time.time()))
|
||||||
|
cancel_requested: bool = False
|
||||||
|
|
||||||
|
def to_public_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"exportId": self.export_id,
|
||||||
|
"account": self.account,
|
||||||
|
"status": self.status,
|
||||||
|
"progress": max(0, min(100, int(self.progress or 0))),
|
||||||
|
"message": self.message,
|
||||||
|
"detail": self.detail,
|
||||||
|
"error": self.error,
|
||||||
|
"zipPath": self.zip_path,
|
||||||
|
"fileName": self.file_name,
|
||||||
|
"databaseCount": int(self.database_count or 0),
|
||||||
|
"resourceFileCount": int(self.resource_file_count or 0),
|
||||||
|
"totalBytes": int(self.total_bytes or 0),
|
||||||
|
"processedBytes": int(self.processed_bytes or 0),
|
||||||
|
"createdAt": int(self.created_at or 0),
|
||||||
|
"updatedAt": int(self.updated_at or 0),
|
||||||
|
"cancelRequested": bool(self.cancel_requested),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SAFE_NAME_RE = re.compile(r"[^0-9A-Za-z._-]+")
|
||||||
|
# 账号归档以账号目录为边界。数据库通常在账号目录顶层,资源文件通常在子目录中。
|
||||||
|
_DB_SUFFIXES = {".db", ".sqlite", ".sqlite3", ".db3"}
|
||||||
|
_META_FILE_NAMES = {"_source.json", "_media_keys.json", "_sns_realtime_sync_state.json"}
|
||||||
|
_JOBS: dict[str, AccountArchiveExportJob] = {}
|
||||||
|
_JOBS_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file_name(value: object, fallback: str) -> str:
|
||||||
|
text = str(value or "").strip().replace("\\", "/").split("/")[-1]
|
||||||
|
text = _SAFE_NAME_RE.sub("_", text).strip("._-")
|
||||||
|
return text or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_zip_name(value: object, fallback: str) -> str:
|
||||||
|
name = _safe_file_name(value, fallback)
|
||||||
|
if not name.lower().endswith(".zip"):
|
||||||
|
name += ".zip"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output_dir(account_dir: Path, output_dir_raw: object) -> Path:
|
||||||
|
raw = str(output_dir_raw or "").strip()
|
||||||
|
if raw:
|
||||||
|
return Path(raw).expanduser().resolve()
|
||||||
|
return (account_dir.parents[1] / "exports" / account_dir.name).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_database_files(account_dir: Path) -> list[Path]:
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in account_dir.iterdir()
|
||||||
|
if item.is_file()
|
||||||
|
and (
|
||||||
|
item.suffix.lower() in _DB_SUFFIXES
|
||||||
|
or item.name in _META_FILE_NAMES
|
||||||
|
)
|
||||||
|
),
|
||||||
|
key=lambda p: p.name.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_job(export_id: str) -> Optional[AccountArchiveExportJob]:
|
||||||
|
key = str(export_id or "").strip()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
with _JOBS_LOCK:
|
||||||
|
return _JOBS.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_job(export_id: str, **changes: Any) -> Optional[AccountArchiveExportJob]:
|
||||||
|
with _JOBS_LOCK:
|
||||||
|
job = _JOBS.get(str(export_id or "").strip())
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
for key, value in changes.items():
|
||||||
|
if hasattr(job, key):
|
||||||
|
setattr(job, key, value)
|
||||||
|
job.updated_at = int(time.time())
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cancel(job: AccountArchiveExportJob, tmp_path: Optional[Path] = None) -> None:
|
||||||
|
with _JOBS_LOCK:
|
||||||
|
cancelled = bool(job.cancel_requested)
|
||||||
|
if not cancelled:
|
||||||
|
return
|
||||||
|
if tmp_path is not None:
|
||||||
|
try:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise AccountArchiveCancelled()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_file(zip_file: zipfile.ZipFile, item: AccountArchiveFile) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
modified = time.localtime(item.mtime)[:6]
|
||||||
|
if modified[0] < 1980:
|
||||||
|
modified = (1980, 1, 1, 0, 0, 0)
|
||||||
|
info = zipfile.ZipInfo(item.arcname, modified)
|
||||||
|
info.compress_type = zipfile.ZIP_STORED
|
||||||
|
info.file_size = item.size
|
||||||
|
info.external_attr = (item.mode & 0xFFFF) << 16
|
||||||
|
with item.path.open("rb") as source, zip_file.open(info, "w", force_zip64=True) as target:
|
||||||
|
shutil.copyfileobj(source, target, length=1024 * 1024)
|
||||||
|
return int(item.size)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_database_or_meta_file(path: Path) -> bool:
|
||||||
|
return path.is_file() and (path.suffix.lower() in _DB_SUFFIXES or path.name in _META_FILE_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_relative_to(path: Path, parent: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(parent)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_selected_account_files(
|
||||||
|
*,
|
||||||
|
job: AccountArchiveExportJob,
|
||||||
|
account_dir: Path,
|
||||||
|
include_databases: bool,
|
||||||
|
include_resources: bool,
|
||||||
|
tmp_path: Optional[Path],
|
||||||
|
zip_path: Optional[Path],
|
||||||
|
output_dir: Optional[Path],
|
||||||
|
):
|
||||||
|
"""Fast metadata scan for selected account files.
|
||||||
|
|
||||||
|
Folder size is not stored as one reliable value by the filesystem. To show an
|
||||||
|
accurate total before packing, we still have to enumerate files, but os.scandir
|
||||||
|
reuses directory-entry metadata and avoids the heavier Path/os.walk/resolve path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
account_prefix = _safe_file_name(account_dir.name, "account")
|
||||||
|
pack_whole_account_folder = include_databases and include_resources
|
||||||
|
account_dir_str = os.path.abspath(os.fspath(account_dir))
|
||||||
|
excluded_files = set()
|
||||||
|
for candidate in (tmp_path, zip_path):
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
excluded_files.add(os.path.normcase(os.path.abspath(os.fspath(candidate))))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
skipped_output_dir: Optional[str] = None
|
||||||
|
if output_dir is not None:
|
||||||
|
try:
|
||||||
|
output_dir_str = os.path.abspath(os.fspath(output_dir))
|
||||||
|
# 如果用户把导出目录选在账号目录内部,避免把正在生成的导出文件再次打包进去。
|
||||||
|
if output_dir_str != account_dir_str and os.path.commonpath([account_dir_str, output_dir_str]) == account_dir_str:
|
||||||
|
skipped_output_dir = os.path.normcase(output_dir_str)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
skipped_output_dir = None
|
||||||
|
|
||||||
|
stack: list[tuple[str, bool]] = [(account_dir_str, True)]
|
||||||
|
while stack:
|
||||||
|
root, is_account_root = stack.pop()
|
||||||
|
_check_cancel(job, tmp_path)
|
||||||
|
normalized_root = os.path.normcase(os.path.abspath(root))
|
||||||
|
if skipped_output_dir is not None and normalized_root == skipped_output_dir:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with os.scandir(root) as entries:
|
||||||
|
entry_list = list(entries)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entry_list:
|
||||||
|
try:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
if is_account_root and not pack_whole_account_folder and not include_resources:
|
||||||
|
continue
|
||||||
|
stack.append((entry.path, False))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path_str = entry.path
|
||||||
|
if os.path.normcase(os.path.abspath(file_path_str)) in excluded_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = entry.name
|
||||||
|
suffix = os.path.splitext(name)[1].lower()
|
||||||
|
is_top_level_database = is_account_root and (suffix in _DB_SUFFIXES or name in _META_FILE_NAMES)
|
||||||
|
if pack_whole_account_folder:
|
||||||
|
kind = "database" if is_top_level_database else "resource"
|
||||||
|
elif include_databases and is_top_level_database:
|
||||||
|
kind = "database"
|
||||||
|
elif include_resources and not is_account_root:
|
||||||
|
kind = "resource"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = entry.stat(follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel = os.path.relpath(file_path_str, account_dir_str).replace(os.sep, "/")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield AccountArchiveFile(
|
||||||
|
path=Path(file_path_str),
|
||||||
|
arcname=f"{account_prefix}/{rel}",
|
||||||
|
kind=kind,
|
||||||
|
size=int(st.st_size),
|
||||||
|
mtime=float(st.st_mtime),
|
||||||
|
mode=int(st.st_mode),
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _run_account_archive_export(export_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
job = _update_job(export_id, status="running", progress=1, message="Preparing export...", detail="")
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
zip_path: Optional[Path] = None
|
||||||
|
tmp_path: Optional[Path] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
include_databases = bool(payload.get("include_databases"))
|
||||||
|
include_resources = bool(payload.get("include_resources"))
|
||||||
|
if not include_databases and not include_resources:
|
||||||
|
raise ValueError("Please select at least one export option.")
|
||||||
|
|
||||||
|
_check_cancel(job)
|
||||||
|
account_dir = _resolve_account_dir(payload.get("account"))
|
||||||
|
account_name = account_dir.name
|
||||||
|
output_dir = _resolve_output_dir(account_dir, payload.get("output_dir"))
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
fallback_name = f"wechat_archive_{_safe_file_name(account_name, 'account')}_{stamp}.zip"
|
||||||
|
zip_name = _normalize_zip_name(payload.get("file_name"), fallback_name)
|
||||||
|
zip_path = (output_dir / zip_name).resolve()
|
||||||
|
tmp_path = zip_path.with_suffix(zip_path.suffix + ".tmp")
|
||||||
|
|
||||||
|
_update_job(
|
||||||
|
export_id,
|
||||||
|
account=account_name,
|
||||||
|
file_name=zip_name,
|
||||||
|
zip_path=str(zip_path),
|
||||||
|
progress=1,
|
||||||
|
message="Scanning export content...",
|
||||||
|
detail="Calculating total archive size.",
|
||||||
|
total_bytes=0,
|
||||||
|
processed_bytes=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
|
||||||
|
selected_files = list(_iter_selected_account_files(
|
||||||
|
job=job,
|
||||||
|
account_dir=account_dir,
|
||||||
|
include_databases=include_databases,
|
||||||
|
include_resources=include_resources,
|
||||||
|
tmp_path=tmp_path,
|
||||||
|
zip_path=zip_path,
|
||||||
|
output_dir=output_dir,
|
||||||
|
))
|
||||||
|
if not selected_files:
|
||||||
|
raise FileNotFoundError("No exportable files found for this account.")
|
||||||
|
|
||||||
|
planned_db_count = sum(1 for item in selected_files if item.kind == "database")
|
||||||
|
planned_resource_count = sum(1 for item in selected_files if item.kind != "database")
|
||||||
|
total_files = len(selected_files)
|
||||||
|
total_bytes = sum(max(0, int(item.size or 0)) for item in selected_files)
|
||||||
|
if include_databases and not include_resources and planned_db_count <= 0:
|
||||||
|
raise FileNotFoundError("No database files found for this account.")
|
||||||
|
if include_resources and not include_databases and planned_resource_count <= 0:
|
||||||
|
raise FileNotFoundError("No resource files found for this account.")
|
||||||
|
|
||||||
|
_update_job(
|
||||||
|
export_id,
|
||||||
|
progress=5,
|
||||||
|
database_count=planned_db_count,
|
||||||
|
resource_file_count=planned_resource_count,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
processed_bytes=0,
|
||||||
|
message="Writing ZIP archive...",
|
||||||
|
detail=f"Ready to pack {total_files} files ({total_bytes / 1024 / 1024:.1f} MB).",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_count = 0
|
||||||
|
resource_file_count = 0
|
||||||
|
processed_bytes = 0
|
||||||
|
processed = 0
|
||||||
|
last_progress_at = time.monotonic()
|
||||||
|
|
||||||
|
# Use ZIP_STORED intentionally: account archives are mostly SQLite,
|
||||||
|
# images, videos and cache files. Re-compressing them is CPU-heavy and
|
||||||
|
# often saves little space. This makes archive export behave like a fast
|
||||||
|
# folder pack/copy operation.
|
||||||
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
|
||||||
|
for item in selected_files:
|
||||||
|
_check_cancel(job, tmp_path)
|
||||||
|
added_size = _add_file(zf, item)
|
||||||
|
if added_size is not None:
|
||||||
|
processed += 1
|
||||||
|
if item.kind == "database":
|
||||||
|
db_count += 1
|
||||||
|
else:
|
||||||
|
resource_file_count += 1
|
||||||
|
processed_bytes += added_size
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if processed <= 5 or processed % 20 == 0 or (now - last_progress_at) >= 0.5:
|
||||||
|
last_progress_at = now
|
||||||
|
if total_bytes > 0:
|
||||||
|
progress = min(95, 5 + int((processed_bytes / total_bytes) * 90))
|
||||||
|
else:
|
||||||
|
progress = min(95, 5 + int((processed / max(1, total_files)) * 90))
|
||||||
|
_update_job(
|
||||||
|
export_id,
|
||||||
|
progress=progress,
|
||||||
|
database_count=db_count,
|
||||||
|
resource_file_count=resource_file_count,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
processed_bytes=processed_bytes,
|
||||||
|
message="Writing ZIP archive...",
|
||||||
|
detail=(
|
||||||
|
f"Packed {processed}/{total_files} files "
|
||||||
|
f"({processed_bytes / 1024 / 1024:.1f}/{total_bytes / 1024 / 1024:.1f} MB)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_check_cancel(job, tmp_path)
|
||||||
|
_update_job(export_id, progress=97, message="Finalizing ZIP archive...", detail="Moving archive to target folder.")
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
shutil.move(str(tmp_path), str(zip_path))
|
||||||
|
|
||||||
|
_update_job(
|
||||||
|
export_id,
|
||||||
|
status="done",
|
||||||
|
progress=100,
|
||||||
|
message="Export completed.",
|
||||||
|
detail=f"Exported {db_count} database files and {resource_file_count} resource files.",
|
||||||
|
database_count=db_count,
|
||||||
|
resource_file_count=resource_file_count,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
processed_bytes=processed_bytes,
|
||||||
|
zip_path=str(zip_path),
|
||||||
|
file_name=zip_path.name,
|
||||||
|
)
|
||||||
|
except AccountArchiveCancelled:
|
||||||
|
try:
|
||||||
|
if tmp_path is not None and tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_update_job(export_id, status="cancelled", message="Export cancelled.", detail="Temporary archive has been removed.")
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
if tmp_path is not None and tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_update_job(export_id, status="error", error=str(exc), message="Export failed.", detail="")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/account/archive_export", summary="Create account archive export job")
|
||||||
|
async def export_account_archive(req: AccountArchiveExportRequest):
|
||||||
|
if not req.include_databases and not req.include_resources:
|
||||||
|
raise HTTPException(status_code=400, detail="Please select at least one export option.")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"account": req.account,
|
||||||
|
"output_dir": req.output_dir,
|
||||||
|
"include_databases": bool(req.include_databases),
|
||||||
|
"include_resources": bool(req.include_resources),
|
||||||
|
"file_name": req.file_name,
|
||||||
|
}
|
||||||
|
export_id = uuid.uuid4().hex
|
||||||
|
job = AccountArchiveExportJob(export_id=export_id)
|
||||||
|
with _JOBS_LOCK:
|
||||||
|
_JOBS[export_id] = job
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run_account_archive_export, args=(export_id, payload), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return {"status": "success", "job": job.to_public_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/account/archive_export/download", summary="Download account archive by file path")
|
||||||
|
async def download_account_archive(path: str):
|
||||||
|
zip_path = Path(str(path or "").strip()).expanduser().resolve()
|
||||||
|
if not zip_path.exists() or not zip_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Export file not found.")
|
||||||
|
if zip_path.suffix.lower() != ".zip":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid export file.")
|
||||||
|
return FileResponse(str(zip_path), media_type="application/zip", filename=zip_path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/account/archive_export/{export_id}", summary="Get account archive export job")
|
||||||
|
async def get_account_archive_export(export_id: str):
|
||||||
|
job = _get_job(export_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Export not found.")
|
||||||
|
return {"status": "success", "job": job.to_public_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/account/archive_export/{export_id}", summary="Cancel account archive export job")
|
||||||
|
async def cancel_account_archive_export(export_id: str):
|
||||||
|
job = _get_job(export_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Export not found.")
|
||||||
|
|
||||||
|
with _JOBS_LOCK:
|
||||||
|
if job.status in {"done", "error", "cancelled"}:
|
||||||
|
return {"status": "success", "job": job.to_public_dict()}
|
||||||
|
job.cancel_requested = True
|
||||||
|
job.message = "Cancelling export..."
|
||||||
|
job.detail = "Waiting for the current file operation to stop."
|
||||||
|
job.updated_at = int(time.time())
|
||||||
|
|
||||||
|
return {"status": "success", "job": job.to_public_dict()}
|
||||||
Reference in New Issue
Block a user