数据导出与恢复
下载完整 JSON 备份,或从备份恢复。
diff --git a/backend/mvp.py b/backend/mvp.py index 880526b..c3992a7 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -1,6 +1,7 @@ import calendar import csv import io +import json import re from datetime import UTC, date, datetime, time, timedelta from pathlib import Path @@ -798,15 +799,21 @@ async def import_ticktick(file: UploadFile = File(...), user: User = Depends(cur audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped} -@router.get("/export") -async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): +def _serialize_export_value(value): + if isinstance(value, UUID): + return str(value) + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + +def _export_payload(folders, lists, tasks, recurrences, habits, countdowns): def serialize(row, fields): - return {f: (str(v) if isinstance((v := getattr(row, f)), UUID) else v.isoformat() if isinstance(v, (date, datetime)) else v) for f in fields} - folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()); lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()); tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()); habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()); countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all()) - recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all()) + return {field: _serialize_export_value(getattr(row, field)) for field in fields} + return { "version": 1, - "exported_at": utcnow(), + "exported_at": utcnow().isoformat(), "folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders], "lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists], "tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks], @@ -816,6 +823,66 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep } +async def _load_export_rows(user, db): + folders = list((await db.scalars(select(Folder).where(Folder.user_id == user.id))).all()) + lists = list((await db.scalars(select(TaskList).where(TaskList.user_id == user.id))).all()) + tasks = list((await db.scalars(select(Task).where(Task.user_id == user.id))).all()) + habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()) + countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all()) + recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all()) + return folders, lists, tasks, recurrences, habits, countdowns + + +@router.get("/export") +async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + return _export_payload(*(await _load_export_rows(user, db))) + + +@router.get("/export.csv") +async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + payload = _export_payload(*(await _load_export_rows(user, db))) + output = io.StringIO() + writer = csv.writer(output, lineterminator="\n") + writer.writerow(["entity", "data"]) + for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns"): + for row in payload[entity]: + writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))]) + content = "\ufeff" + output.getvalue() + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="dodo-export.csv"'}, + ) + + +@router.post("/restore.csv") +async def restore_csv( + file: UploadFile = File(...), + mode: str = Query("merge", pattern="^(merge|replace)$"), + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + text = (await file.read()).decode("utf-8-sig") + payload = { + "version": 1, + "folders": [], + "lists": [], + "tasks": [], + "recurrences": [], + "habits": [], + "countdowns": [], + } + try: + for row in csv.DictReader(io.StringIO(text)): + entity = row.get("entity", "") + if entity not in payload or entity == "version": + raise ValueError("unknown entity") + payload[entity].append(json.loads(row["data"])) + except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + raise HTTPException(422, "无效的 Dodo CSV 备份") from exc + return await restore_json(payload, mode, user, db) + + @router.post("/restore") async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): if payload.get("version") != 1: diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index 7515e8f..0b6a31c 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -309,13 +309,25 @@ async function revoke(id: string) { function downloadBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } -async function exportData() { await safe(async () => downloadBlob(await request('/export') as Blob, 'dodo-export.json')) } +async function exportData() { + await safe(async () => { + const response = await fetch('/api/v1/export.csv', { credentials: 'include' }) + if (!response.ok) throw new Error('导出失败') + downloadBlob(await response.blob(), 'dodo-export.csv') + }) +} async function restore() { if (!restoreFile.value) return - if (!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?')) return + if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return await safe(async () => { - const text = await restoreFile.value!.text() - await request('/restore?mode=merge', { method: 'POST', body: text }) + if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) { + const form = new FormData() + form.append('file', restoreFile.value!) + await request('/restore.csv?mode=merge', { method: 'POST', body: form }) + } else { + const text = await restoreFile.value!.text() + await request('/restore?mode=merge', { method: 'POST', body: text }) + } emit('changed'); emit('notice', '数据已恢复') }) } @@ -417,7 +429,7 @@ onBeforeUnmount(() => {
下载完整 JSON 备份,或从备份恢复。
导出完整 CSV 数据,或从 CSV / JSON 备份恢复。
修改后当前设备保持登录,其他设备会自动退出。
没有可显示的会话。