fix: export restorable CSV backups
ci / docker (push) Successful in 4m24s

This commit is contained in:
2026-09-08 10:19:13 +08:00
parent 84e14f39e0
commit 3528e880f6
4 changed files with 106 additions and 12 deletions
+73 -6
View File
@@ -1,6 +1,7 @@
import calendar import calendar
import csv import csv
import io import io
import json
import re import re
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path 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} audit(db, user.id, "import", "task", count=imported); await db.commit(); return {"imported": imported, "skipped": skipped}
@router.get("/export") def _serialize_export_value(value):
async def export_json(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): 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): 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} return {field: _serialize_export_value(getattr(row, field)) for field 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 { return {
"version": 1, "version": 1,
"exported_at": utcnow(), "exported_at": utcnow().isoformat(),
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders], "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], "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], "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") @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)): 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: if payload.get("version") != 1:
+15 -3
View File
@@ -309,13 +309,25 @@ async function revoke(id: string) {
function downloadBlob(blob: Blob, name: 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) 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() { async function restore() {
if (!restoreFile.value) return if (!restoreFile.value) return
if (!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?')) return if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
await safe(async () => { await safe(async () => {
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() const text = await restoreFile.value!.text()
await request('/restore?mode=merge', { method: 'POST', body: text }) await request('/restore?mode=merge', { method: 'POST', body: text })
}
emit('changed'); emit('notice', '数据已恢复') emit('changed'); emit('notice', '数据已恢复')
}) })
} }
@@ -417,7 +429,7 @@ onBeforeUnmount(() => {
<div><small>备份迁移与安全</small><h2>设置与数据</h2></div> <div><small>备份迁移与安全</small><h2>设置与数据</h2></div>
</header> </header>
<div class="settings-grid"> <div class="settings-grid">
<article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>下载完整 JSON 备份或从备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article> <article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>导出完整 CSV 数据或从 CSV / JSON 备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
<article class="tool-card password-card"><Activity /><h3>修改密码</h3><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改':'修改密码'}}</button></form></article> <article class="tool-card password-card"><Activity /><h3>修改密码</h3><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改':'修改密码'}}</button></form></article>
<article class="tool-card wide"><LogOut /><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话</p></article> <article class="tool-card wide"><LogOut /><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话</p></article>
<article v-if="audit.length" class="tool-card wide"><Activity /><h3>最近活动</h3><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article> <article v-if="audit.length" class="tool-card wide"><Activity /><h3>最近活动</h3><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article>
+4 -1
View File
@@ -194,7 +194,10 @@ describe('mobile touch targets', () => {
describe('settings data tools', () => { describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => { it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h3>数据导出与恢复</h3>') expect(mvpPanel).toContain('<h3>数据导出与恢复</h3>')
expect(mvpPanel).toContain('@click="exportData"') expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV')
expect(mvpPanel).not.toContain('导出 JSON')
expect(mvpPanel).toContain('@click="restore"') expect(mvpPanel).toContain('@click="restore"')
expect(mvpPanel).not.toContain('<h3>导入</h3>') expect(mvpPanel).not.toContain('<h3>导入</h3>')
expect(mvpPanel).not.toContain("request('/import/ticktick") expect(mvpPanel).not.toContain("request('/import/ticktick")
+12
View File
@@ -359,6 +359,18 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
assert "tags" not in exported.json() assert "tags" not in exported.json()
assert "task_tags" not in exported.json() assert "task_tags" not in exported.json()
exported_csv = client.get("/api/v1/export.csv")
assert exported_csv.status_code == 200
assert exported_csv.headers["content-type"].startswith("text/csv")
assert "dodo-export.csv" in exported_csv.headers["content-disposition"]
assert exported_csv.content.startswith(b"\xef\xbb\xbfentity,data")
restored_csv = client.post(
"/api/v1/restore.csv?mode=merge",
files={"file": ("dodo-export.csv", exported_csv.content, "text/csv")},
)
assert restored_csv.status_code == 200
client.post( client.post(
"/api/v1/habits", "/api/v1/habits",
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"}, json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},