feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -2,3 +2,15 @@ DODO_DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/
|
|||||||
DODO_COOKIE_SECURE=true
|
DODO_COOKIE_SECURE=true
|
||||||
DODO_SESSION_DAYS=30
|
DODO_SESSION_DAYS=30
|
||||||
DODO_TRUSTED_PROXIES=127.0.0.1
|
DODO_TRUSTED_PROXIES=127.0.0.1
|
||||||
|
|
||||||
|
# Task attachment storage and per-file upload limit.
|
||||||
|
DODO_ATTACHMENT_DIR=./data/attachments
|
||||||
|
DODO_ATTACHMENT_MAX_MB=20
|
||||||
|
|
||||||
|
# Complete ZIP v2 backup limits and preflight staging.
|
||||||
|
# Keep the staging directory and attachment directory on storage with enough free space.
|
||||||
|
DODO_BACKUP_MAX_ARCHIVE_MB=256
|
||||||
|
DODO_BACKUP_MAX_PENDING_PER_USER=3
|
||||||
|
DODO_BACKUP_MAX_STAGED_MB_PER_USER=768
|
||||||
|
DODO_BACKUP_PREFLIGHT_TTL_SECONDS=900
|
||||||
|
DODO_BACKUP_STAGING_DIR=./data/backup-staging
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
frontend/playwright-runtime/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.db
|
*.db
|
||||||
uploads/
|
uploads/
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
# dodo
|
# dodo
|
||||||
|
|
||||||
一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用。
|
一个自托管、移动端友好的任务与生活管理 PWA,数据由自己掌控。
|
||||||
|
|
||||||
## 第一阶段能力
|
## 当前主要能力
|
||||||
|
|
||||||
- 首次初始化管理员
|
- 文件夹、收集箱与自定义清单;清单归档后保留任务归属,恢复后原样可见
|
||||||
- 用户名密码登录,Cookie Session
|
- 任务与一层子任务、优先级、Markdown 备注、日期/时间、回收站、拖拽排序
|
||||||
- 文件夹、清单、任务基础 CRUD
|
- RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖
|
||||||
- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年和自定义重复(间隔、星期、月日期、次数或截止日期)
|
- 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息
|
||||||
- 收集箱系统清单
|
- 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档
|
||||||
- 习惯打卡与倒数纪念日
|
- 倒数日、纪念日、生日及公历/农历重复
|
||||||
- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复
|
- Markdown 备忘录及软删除/恢复
|
||||||
- Vue 3 + PWA 应用外壳
|
- 任务附件、登录设备管理与审计日志
|
||||||
- 手账生活感浅色 UI
|
- 完整 ZIP v2 备份(含附件字节、manifest 与 SHA-256)及预检后合并/替换恢复
|
||||||
|
- 兼容旧版 CSV / JSON v1 恢复
|
||||||
|
|
||||||
|
## 界面约定
|
||||||
|
|
||||||
|
- 弹层统一使用 `AppSheet` / `AppDialog`,共享遮罩、焦点陷阱、Escape、背景 inert 和嵌套栈行为
|
||||||
|
- 设置页按“数据、账户与安全、登录设备、活动、危险操作”连续分组
|
||||||
|
- 手机底栏直接进入今天、习惯、倒数日、设置,不再使用“更多”中转
|
||||||
|
- 新增入口是普通的圆形 Plus FAB,共用于任务、习惯、倒数日和备忘录
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
|
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
|
||||||
- Backend: FastAPI + SQLAlchemy 2 Async
|
- Backend: FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
|
||||||
- DB: PostgreSQL(测试环境使用 SQLite)
|
- DB: PostgreSQL(测试使用 SQLite)
|
||||||
- Package: uv + pnpm
|
- Package: uv + pnpm
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
@@ -33,18 +41,16 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境变量
|
配置项见 [`.env.example`](.env.example),API 与数据合同见 [`docs/api.md`](docs/api.md) 和 [`docs/data-model.md`](docs/data-model.md)。
|
||||||
|
|
||||||
```bash
|
|
||||||
DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo
|
|
||||||
DODO_COOKIE_SECURE=false
|
|
||||||
DODO_SESSION_DAYS=30
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q
|
|
||||||
uv run ruff check backend tests
|
uv run ruff check backend tests
|
||||||
cd frontend && pnpm run build
|
uv run pytest -q
|
||||||
|
cd frontend
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
cd ..
|
||||||
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
|
||||||
|
MAX_ENTRIES = 10_000
|
||||||
|
MAX_COMPRESSION_RATIO = 200
|
||||||
|
MAX_METADATA_ENTRY_BYTES = 16 * 1024 * 1024
|
||||||
|
_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StagedBlob:
|
||||||
|
archive_path: str
|
||||||
|
staging_path: Path
|
||||||
|
size: int
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedArchive:
|
||||||
|
backup_id: UUID
|
||||||
|
archive_sha256: str
|
||||||
|
entities: dict[str, list[dict]]
|
||||||
|
files: dict[str, StagedBlob]
|
||||||
|
staging_dir: Path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def backup_error(code: str, message: str, status_code: int = 422) -> HTTPException:
|
||||||
|
return HTTPException(status_code, {"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value) -> bytes:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_archive_to_path(
|
||||||
|
output_path: Path, backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, Path]
|
||||||
|
) -> None:
|
||||||
|
entity_entries = {f"data/{name}.json": canonical_json(rows) for name, rows in entities.items()}
|
||||||
|
checksums = {name: hashlib.sha256(data).hexdigest() for name, data in entity_entries.items()}
|
||||||
|
for name, path in files.items():
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
while chunk := source.read(_CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
checksums[name] = digest.hexdigest()
|
||||||
|
manifest = {
|
||||||
|
"format": "dodo-backup", "version": 2, "backup_id": str(backup_id),
|
||||||
|
"entities": {name: len(rows) for name, rows in entities.items()}, "checksums": checksums,
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||||
|
archive.writestr("manifest.json", canonical_json(manifest))
|
||||||
|
for name, data in entity_entries.items():
|
||||||
|
archive.writestr(name, data)
|
||||||
|
for name, path in files.items():
|
||||||
|
archive.write(path, name)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_archive(backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, bytes]) -> bytes:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
file_paths = {}
|
||||||
|
for index, (name, data) in enumerate(files.items()):
|
||||||
|
path = root / str(index)
|
||||||
|
path.write_bytes(data)
|
||||||
|
file_paths[name] = path
|
||||||
|
output = root / "backup.zip"
|
||||||
|
build_archive_to_path(output, backup_id, entities, file_paths)
|
||||||
|
return output.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(name: str) -> bool:
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
return bool(name) and not name.startswith("/") and "\\" not in name and ".." not in path.parts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _read_metadata(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
||||||
|
if info.file_size > MAX_METADATA_ENTRY_BYTES:
|
||||||
|
raise backup_error("backup_size_invalid", "备份元数据过大")
|
||||||
|
with archive.open(info) as source:
|
||||||
|
data = source.read(MAX_METADATA_ENTRY_BYTES + 1)
|
||||||
|
if len(data) > MAX_METADATA_ENTRY_BYTES:
|
||||||
|
raise backup_error("backup_size_invalid", "备份元数据过大")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_blob(archive: zipfile.ZipFile, info: zipfile.ZipInfo, destination: Path) -> tuple[int, str]:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with archive.open(info) as source, destination.open("xb") as output:
|
||||||
|
while chunk := source.read(_CHUNK_SIZE):
|
||||||
|
size += len(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
return size, digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES) -> ParsedArchive:
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size <= 0 or size > max_archive_bytes:
|
||||||
|
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
while chunk := source.read(_CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
staging_dir = Path(tempfile.mkdtemp(prefix="dodo-backup-blobs-"))
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
infos = archive.infolist()
|
||||||
|
names = [item.filename for item in infos]
|
||||||
|
if len(infos) > MAX_ENTRIES:
|
||||||
|
raise backup_error("backup_too_many_entries", "备份条目过多")
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
raise backup_error("backup_duplicate_entry", "备份包含重复条目")
|
||||||
|
if any(not _safe_name(item.filename) or item.is_dir() or item.flag_bits & 1 for item in infos):
|
||||||
|
raise backup_error("backup_unsafe_path", "备份包含不安全路径")
|
||||||
|
if "manifest.json" not in names:
|
||||||
|
raise backup_error("backup_manifest_missing", "备份缺少 manifest")
|
||||||
|
if sum(item.file_size for item in infos) > max_archive_bytes:
|
||||||
|
raise backup_error("backup_size_invalid", "备份解压后过大")
|
||||||
|
if any(item.file_size and item.compress_size == 0 for item in infos):
|
||||||
|
raise backup_error("backup_compression_invalid", "备份压缩比异常")
|
||||||
|
if any(item.compress_size and item.file_size / item.compress_size > MAX_COMPRESSION_RATIO for item in infos):
|
||||||
|
raise backup_error("backup_compression_invalid", "备份压缩比异常")
|
||||||
|
by_name = {item.filename: item for item in infos}
|
||||||
|
manifest = json.loads(_read_metadata(archive, by_name["manifest.json"]))
|
||||||
|
if manifest.get("format") != "dodo-backup" or manifest.get("version") != 2:
|
||||||
|
raise backup_error("backup_version_unsupported", "不支持的备份版本")
|
||||||
|
backup_id = UUID(manifest["backup_id"])
|
||||||
|
checksums = manifest["checksums"]
|
||||||
|
declared_entities = manifest["entities"]
|
||||||
|
if not isinstance(checksums, dict) or not isinstance(declared_entities, dict):
|
||||||
|
raise TypeError
|
||||||
|
content_names = set(names) - {"manifest.json"}
|
||||||
|
if set(checksums) != content_names:
|
||||||
|
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
|
||||||
|
entities: dict[str, list[dict]] = {}
|
||||||
|
files: dict[str, StagedBlob] = {}
|
||||||
|
for index, name in enumerate(sorted(content_names)):
|
||||||
|
info = by_name[name]
|
||||||
|
if name.startswith("data/") and name.endswith(".json"):
|
||||||
|
data = _read_metadata(archive, info)
|
||||||
|
actual_digest = hashlib.sha256(data).hexdigest()
|
||||||
|
value = json.loads(data)
|
||||||
|
if not isinstance(value, list) or any(not isinstance(row, dict) for row in value):
|
||||||
|
raise ValueError
|
||||||
|
entities[name[5:-5]] = value
|
||||||
|
else:
|
||||||
|
blob_path = staging_dir / str(index)
|
||||||
|
blob_size, actual_digest = _stream_blob(archive, info, blob_path)
|
||||||
|
files[name] = StagedBlob(name, blob_path, blob_size, actual_digest)
|
||||||
|
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
|
||||||
|
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||||
|
if set(declared_entities) != set(entities):
|
||||||
|
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||||
|
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
|
||||||
|
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
|
||||||
|
except HTTPException:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
except (zipfile.BadZipFile, OSError) as exc:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
raise backup_error("backup_invalid_zip", "无效的 ZIP 备份") from exc
|
||||||
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
raise backup_error("backup_manifest_invalid", "无效的备份 manifest 或数据") from exc
|
||||||
|
return ParsedArchive(backup_id, digest.hexdigest(), entities, files, staging_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def parse_archive(content: bytes) -> ParsedArchive:
|
||||||
|
if not content or len(content) > MAX_ARCHIVE_BYTES:
|
||||||
|
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||||
|
with tempfile.NamedTemporaryFile() as staged:
|
||||||
|
staged.write(content)
|
||||||
|
staged.flush()
|
||||||
|
return parse_archive_path(Path(staged.name))
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Legacy JSON/CSV backup compatibility remains in backend.mvp.
|
||||||
|
|
||||||
|
The v2 ZIP implementation is isolated in this package. This marker module documents
|
||||||
|
that the old endpoints intentionally remain available during the gradual migration.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
|
from backend.auth import current_user, hash_token
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_db
|
||||||
|
from backend.models import BackupPreflight, Countdown, User, new_id, utcnow
|
||||||
|
|
||||||
|
from .archive import MAX_ARCHIVE_BYTES as ARCHIVE_DEFAULT_LIMIT
|
||||||
|
from .archive import backup_error, parse_archive_path
|
||||||
|
from .schemas import RestoreRequest
|
||||||
|
from .service import BackupRepairPending, export_v2, restore_v2, validate_archive
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
|
||||||
|
MAX_ARCHIVE_BYTES = ARCHIVE_DEFAULT_LIMIT
|
||||||
|
MAX_PENDING_PREFLIGHTS_PER_USER = 3
|
||||||
|
_READ_CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _staging_root() -> Path:
|
||||||
|
root = Path(get_settings().backup_staging_dir).resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_staging_path(value: str) -> Path:
|
||||||
|
root = _staging_root()
|
||||||
|
path = Path(value).resolve()
|
||||||
|
if path.parent != root:
|
||||||
|
raise backup_error("backup_preflight_invalid", "预检暂存无效", 409)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _path_size(path: str | None) -> int:
|
||||||
|
if not path:
|
||||||
|
return 0
|
||||||
|
root = Path(path)
|
||||||
|
if not root.exists():
|
||||||
|
return 0
|
||||||
|
if root.is_file():
|
||||||
|
return root.stat().st_size
|
||||||
|
return sum(item.stat().st_size for item in root.rglob("*") if item.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
async def _prune(db: AsyncSession) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
rows = list((await db.scalars(select(BackupPreflight).where(
|
||||||
|
BackupPreflight.expires_at <= now,
|
||||||
|
BackupPreflight.status.in_((
|
||||||
|
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||||
|
"repair_pending",
|
||||||
|
)),
|
||||||
|
).with_for_update())).all())
|
||||||
|
from .storage import remove_quarantine, restore_quarantine_dir
|
||||||
|
|
||||||
|
attachment_root = Path(get_settings().attachment_dir).resolve()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
if row.status == "repair_pending":
|
||||||
|
if row.cleanup_path:
|
||||||
|
restore_quarantine_dir(attachment_root, Path(row.cleanup_path))
|
||||||
|
row.cleanup_path = None
|
||||||
|
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||||
|
row.status = "failed"
|
||||||
|
continue
|
||||||
|
if row.cleanup_path:
|
||||||
|
remove_quarantine(Path(row.cleanup_path))
|
||||||
|
row.cleanup_path = None
|
||||||
|
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if row.status == "cleanup_pending":
|
||||||
|
row.status = "consumed"
|
||||||
|
row.consumed_at = row.consumed_at or now
|
||||||
|
else:
|
||||||
|
await db.delete(row)
|
||||||
|
if rows:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export.zip")
|
||||||
|
async def export_zip(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
path = await export_v2(db, user)
|
||||||
|
await db.commit()
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename="dodo-backup-v2.zip",
|
||||||
|
background=BackgroundTask(path.unlink, missing_ok=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preflight")
|
||||||
|
async def preflight(
|
||||||
|
mode: str = Query(pattern="^(merge|replace)$"),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await _prune(db)
|
||||||
|
settings = get_settings()
|
||||||
|
configured_limit = settings.backup_max_archive_mb * 1024 * 1024
|
||||||
|
limit = min(MAX_ARCHIVE_BYTES, configured_limit)
|
||||||
|
max_pending = min(MAX_PENDING_PREFLIGHTS_PER_USER, settings.backup_max_pending_per_user)
|
||||||
|
max_staged = settings.backup_max_staged_mb_per_user * 1024 * 1024
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
staging = _staging_root() / f"{new_id()}.zip"
|
||||||
|
reservation = BackupPreflight(
|
||||||
|
token_hash=hash_token(token), user_id=user.id, backup_id=new_id(),
|
||||||
|
archive_sha256="0" * 64, archive_size=limit, staging_path=str(staging), mode=mode,
|
||||||
|
status="reserved", expires_at=utcnow() + timedelta(seconds=settings.backup_preflight_ttl_seconds),
|
||||||
|
)
|
||||||
|
# A user-row write serializes quota decisions across workers on PostgreSQL;
|
||||||
|
# SQLite serializes writers at the database level.
|
||||||
|
await db.execute(update(User).where(User.id == user.id).values(username=User.username))
|
||||||
|
pending_count, pending_bytes = (await db.execute(select(
|
||||||
|
func.count(BackupPreflight.id), func.coalesce(func.sum(BackupPreflight.archive_size), 0)
|
||||||
|
).where(
|
||||||
|
BackupPreflight.user_id == user.id,
|
||||||
|
BackupPreflight.status.in_((
|
||||||
|
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||||
|
"repair_pending",
|
||||||
|
)),
|
||||||
|
))).one()
|
||||||
|
pending_rows = list((await db.scalars(select(BackupPreflight).where(
|
||||||
|
BackupPreflight.user_id == user.id,
|
||||||
|
BackupPreflight.status.in_((
|
||||||
|
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||||
|
"repair_pending",
|
||||||
|
)),
|
||||||
|
))).all())
|
||||||
|
pending_bytes += sum(_path_size(item.cleanup_path) for item in pending_rows)
|
||||||
|
if pending_count >= max_pending or pending_bytes + limit > max_staged:
|
||||||
|
await db.rollback()
|
||||||
|
raise backup_error("backup_preflight_quota", "待处理预检配额已达上限", 429)
|
||||||
|
db.add(reservation)
|
||||||
|
await db.flush()
|
||||||
|
reservation_id = reservation.id
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
size = 0
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
archive = None
|
||||||
|
try:
|
||||||
|
with staging.open("xb") as output:
|
||||||
|
while chunk := await file.read(_READ_CHUNK):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > limit:
|
||||||
|
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||||
|
digest.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
archive = parse_archive_path(staging, max_archive_bytes=limit)
|
||||||
|
validate_archive(archive)
|
||||||
|
if mode == "merge":
|
||||||
|
incoming_pin_ids = {
|
||||||
|
str(item["id"])
|
||||||
|
for item in archive.entities["countdowns"]
|
||||||
|
if item.get("pinned") is True and item.get("archived_at") is None
|
||||||
|
}
|
||||||
|
existing_pin_ids = set((await db.scalars(select(Countdown.id).where(
|
||||||
|
Countdown.user_id == user.id,
|
||||||
|
Countdown.pinned.is_(True),
|
||||||
|
Countdown.archived_at.is_(None),
|
||||||
|
))).all())
|
||||||
|
if (
|
||||||
|
incoming_pin_ids
|
||||||
|
and existing_pin_ids
|
||||||
|
and incoming_pin_ids != {str(item) for item in existing_pin_ids}
|
||||||
|
):
|
||||||
|
raise backup_error("backup_constraint_invalid", "合并恢复会产生多个置顶倒数日")
|
||||||
|
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||||
|
if archive.archive_sha256 != digest.hexdigest():
|
||||||
|
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||||
|
reservation.backup_id = archive.backup_id
|
||||||
|
reservation.archive_sha256 = archive.archive_sha256
|
||||||
|
reservation.archive_size = size
|
||||||
|
reservation.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
if archive is not None:
|
||||||
|
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||||
|
failed = await db.get(BackupPreflight, reservation_id)
|
||||||
|
if failed is not None:
|
||||||
|
await db.delete(failed)
|
||||||
|
await db.commit()
|
||||||
|
staging.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return {"valid": True, "preflight_token": token, "backup_id": archive.backup_id,
|
||||||
|
"archive_sha256": archive.archive_sha256,
|
||||||
|
"entities": {name: len(rows) for name, rows in archive.entities.items()}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore")
|
||||||
|
async def restore(
|
||||||
|
payload: RestoreRequest,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await _prune(db)
|
||||||
|
token_hash = hash_token(payload.preflight_token)
|
||||||
|
row = await db.scalar(select(BackupPreflight).where(
|
||||||
|
BackupPreflight.token_hash == token_hash, BackupPreflight.user_id == user.id
|
||||||
|
).with_for_update())
|
||||||
|
if row is None or row.mode != payload.mode or row.expires_at <= utcnow():
|
||||||
|
raise backup_error("backup_preflight_invalid", "预检令牌无效或已过期", 409)
|
||||||
|
if row.status == "repair_pending" and row.cleanup_path:
|
||||||
|
from .storage import restore_quarantine_dir
|
||||||
|
root = Path(get_settings().attachment_dir).resolve()
|
||||||
|
try:
|
||||||
|
restore_quarantine_dir(root, Path(row.cleanup_path))
|
||||||
|
except OSError as exc:
|
||||||
|
raise backup_error("backup_repair_pending", "原附件复原尚未完成,请稍后重试", 503) from exc
|
||||||
|
row.status = "failed"
|
||||||
|
row.cleanup_path = None
|
||||||
|
await db.commit()
|
||||||
|
raise backup_error("backup_restore_retry", "附件已复原,请重新预检后重试", 409)
|
||||||
|
if row.status == "cleanup_pending":
|
||||||
|
from .storage import remove_quarantine
|
||||||
|
try:
|
||||||
|
if row.cleanup_path:
|
||||||
|
remove_quarantine(Path(row.cleanup_path))
|
||||||
|
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise backup_error("backup_cleanup_pending", "清理尚未完成,请稍后重试", 503) from exc
|
||||||
|
row.status = "consumed"
|
||||||
|
row.cleanup_path = None
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": 0, "mode": payload.mode, "already_imported": False, "cleanup_retried": True}
|
||||||
|
claimed = await db.execute(update(BackupPreflight).where(
|
||||||
|
BackupPreflight.id == row.id, BackupPreflight.status.in_(("pending", "failed"))
|
||||||
|
).values(status="consuming", consumed_at=utcnow()))
|
||||||
|
if claimed.rowcount != 1:
|
||||||
|
await db.rollback()
|
||||||
|
raise backup_error("backup_preflight_invalid", "预检令牌已使用", 409)
|
||||||
|
await db.commit()
|
||||||
|
staging = _safe_staging_path(row.staging_path)
|
||||||
|
archive = parse_archive_path(staging)
|
||||||
|
try:
|
||||||
|
if archive.archive_sha256 != row.archive_sha256 or archive.backup_id != row.backup_id:
|
||||||
|
row.status = "failed"
|
||||||
|
await db.commit()
|
||||||
|
raise backup_error("backup_preflight_invalid", "预检暂存已改变", 409)
|
||||||
|
result = await restore_v2(db, user, archive, payload.mode, operation=row)
|
||||||
|
except BackupRepairPending:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await db.refresh(row)
|
||||||
|
if row.status not in {"cleanup_pending", "repair_pending"}:
|
||||||
|
row.status = "failed"
|
||||||
|
await db.commit()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||||
|
row.status = "cleanup_pending"
|
||||||
|
await db.commit()
|
||||||
|
try:
|
||||||
|
staging.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise backup_error("backup_cleanup_pending", "数据已恢复,但暂存清理未完成;请使用同一令牌重试", 500) from exc
|
||||||
|
row.status = "consumed"
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
preflight_token: str = Field(min_length=32, max_length=128)
|
||||||
|
mode: str = Field(pattern="^(merge|replace)$")
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import tempfile
|
||||||
|
from datetime import date, datetime
|
||||||
|
from itertools import pairwise
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid5
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, Float, Integer, String, Text, Uuid, delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.models import (
|
||||||
|
Attachment,
|
||||||
|
BackupImport,
|
||||||
|
BackupImportEntity,
|
||||||
|
BackupPreflight,
|
||||||
|
Countdown,
|
||||||
|
Folder,
|
||||||
|
Habit,
|
||||||
|
HabitLog,
|
||||||
|
HabitPause,
|
||||||
|
Memo,
|
||||||
|
RecurrenceException,
|
||||||
|
RecurrenceTemplate,
|
||||||
|
Task,
|
||||||
|
TaskList,
|
||||||
|
User,
|
||||||
|
new_id,
|
||||||
|
)
|
||||||
|
from backend.mvp import (
|
||||||
|
CountdownInput,
|
||||||
|
HabitCreate,
|
||||||
|
MemoCreate,
|
||||||
|
PauseInput,
|
||||||
|
RecurrenceCreate,
|
||||||
|
is_occurrence,
|
||||||
|
parse_rrule,
|
||||||
|
)
|
||||||
|
from backend.schemas import FolderCreate, ListCreate, TaskCreate
|
||||||
|
|
||||||
|
from .archive import ParsedArchive, backup_error, build_archive_to_path, canonical_json
|
||||||
|
from .storage import contained_file, quarantine_files, remove_quarantine, restore_quarantine
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRepairPending(HTTPException):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(500, {
|
||||||
|
"code": "backup_repair_pending",
|
||||||
|
"message": "恢复失败且原附件尚未完全复原;请使用同一令牌重试",
|
||||||
|
})
|
||||||
|
|
||||||
|
ENTITY_MODELS = {
|
||||||
|
"folders": Folder,
|
||||||
|
"lists": TaskList,
|
||||||
|
"tasks": Task,
|
||||||
|
"recurrences": RecurrenceTemplate,
|
||||||
|
"recurrence_exceptions": RecurrenceException,
|
||||||
|
"habits": Habit,
|
||||||
|
"habit_logs": HabitLog,
|
||||||
|
"habit_pauses": HabitPause,
|
||||||
|
"countdowns": Countdown,
|
||||||
|
"memos": Memo,
|
||||||
|
"attachments": Attachment,
|
||||||
|
}
|
||||||
|
RELATIONS = {
|
||||||
|
"lists": {"folder_id": "folders"},
|
||||||
|
"tasks": {"list_id": "lists", "parent_id": "tasks"},
|
||||||
|
"recurrences": {"task_id": "tasks"},
|
||||||
|
"recurrence_exceptions": {"template_id": "recurrences"},
|
||||||
|
"habit_logs": {"habit_id": "habits"},
|
||||||
|
"habit_pauses": {"habit_id": "habits"},
|
||||||
|
"attachments": {"task_id": "tasks"},
|
||||||
|
}
|
||||||
|
ORDER = tuple(ENTITY_MODELS)
|
||||||
|
|
||||||
|
|
||||||
|
def _value(value):
|
||||||
|
if isinstance(value, (UUID, date, datetime)):
|
||||||
|
return value.isoformat() if not isinstance(value, UUID) else str(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _row(row, *, attachment_path: str | None = None) -> dict:
|
||||||
|
result = {
|
||||||
|
column.name: _value(getattr(row, column.name))
|
||||||
|
for column in row.__table__.columns
|
||||||
|
if column.name != "user_id" and column.name != "storage_name"
|
||||||
|
}
|
||||||
|
if attachment_path is not None:
|
||||||
|
result["archive_path"] = attachment_path
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def export_v2(db: AsyncSession, user: User) -> Path:
|
||||||
|
# Serialize exports with restores for this user. PostgreSQL keeps the row
|
||||||
|
# lock until this function's transaction is committed by the route.
|
||||||
|
await db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||||
|
entities: dict[str, list[dict]] = {}
|
||||||
|
files: dict[str, Path] = {}
|
||||||
|
root = Path(get_settings().attachment_dir).resolve()
|
||||||
|
total_size = 0
|
||||||
|
limit = get_settings().backup_max_archive_mb * 1024 * 1024
|
||||||
|
for name, model in ENTITY_MODELS.items():
|
||||||
|
if name in {"recurrence_exceptions", "habit_logs", "habit_pauses"}:
|
||||||
|
if name == "recurrence_exceptions":
|
||||||
|
ids = select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id)
|
||||||
|
rows = list((await db.scalars(select(model).where(model.template_id.in_(ids)))).all())
|
||||||
|
else:
|
||||||
|
ids = select(Habit.id).where(Habit.user_id == user.id)
|
||||||
|
rows = list((await db.scalars(select(model).where(model.habit_id.in_(ids)))).all())
|
||||||
|
else:
|
||||||
|
rows = list((await db.scalars(select(model).where(model.user_id == user.id))).all())
|
||||||
|
output = []
|
||||||
|
for item in rows:
|
||||||
|
if isinstance(item, Attachment):
|
||||||
|
archive_path = f"attachments/{item.id}/content"
|
||||||
|
try:
|
||||||
|
path = contained_file(root, item.storage_name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise backup_error("backup_attachment_path_invalid", "附件存储路径无效") from exc
|
||||||
|
if not path.is_file():
|
||||||
|
raise backup_error("backup_attachment_missing", "附件文件缺失")
|
||||||
|
actual_size = path.stat().st_size
|
||||||
|
if actual_size != item.size:
|
||||||
|
raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配")
|
||||||
|
total_size += actual_size
|
||||||
|
if total_size > limit:
|
||||||
|
raise backup_error("backup_size_invalid", "备份文件大小超出导入合同")
|
||||||
|
files[archive_path] = path
|
||||||
|
output.append(_row(item, attachment_path=archive_path))
|
||||||
|
else:
|
||||||
|
output.append(_row(item))
|
||||||
|
entities[name] = output
|
||||||
|
handle, output_name = tempfile.mkstemp(prefix="dodo-backup-", suffix=".zip")
|
||||||
|
import os
|
||||||
|
os.close(handle)
|
||||||
|
output_path = Path(output_name)
|
||||||
|
try:
|
||||||
|
build_archive_to_path(output_path, new_id(), entities, files)
|
||||||
|
if output_path.stat().st_size > limit:
|
||||||
|
raise backup_error("backup_size_invalid", "备份文件大小超出导入合同")
|
||||||
|
return output_path
|
||||||
|
except Exception:
|
||||||
|
output_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_scalar(entity: str, column, row: dict) -> None:
|
||||||
|
name = column.name
|
||||||
|
if name in {"user_id", "storage_name"}:
|
||||||
|
return
|
||||||
|
if name not in row:
|
||||||
|
if not column.nullable and column.default is None and not column.primary_key:
|
||||||
|
raise backup_error("backup_entity_invalid", f"{entity} 缺少必填字段")
|
||||||
|
return
|
||||||
|
value = row[name]
|
||||||
|
if value is None:
|
||||||
|
if not column.nullable and not column.primary_key:
|
||||||
|
raise backup_error("backup_entity_invalid", f"{entity}.{name} 不可为空")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
effective_type = getattr(column.type, "impl", column.type)
|
||||||
|
if isinstance(effective_type, Uuid):
|
||||||
|
UUID(str(value))
|
||||||
|
elif isinstance(effective_type, DateTime):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError
|
||||||
|
datetime.fromisoformat(value)
|
||||||
|
elif isinstance(effective_type, Date):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError
|
||||||
|
date.fromisoformat(value)
|
||||||
|
elif isinstance(effective_type, Boolean):
|
||||||
|
if type(value) is not bool:
|
||||||
|
raise TypeError
|
||||||
|
elif isinstance(effective_type, Integer):
|
||||||
|
if type(value) is not int:
|
||||||
|
raise TypeError
|
||||||
|
elif isinstance(effective_type, Float):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||||
|
raise TypeError
|
||||||
|
elif isinstance(effective_type, (String, Text)):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError
|
||||||
|
if isinstance(effective_type, String) and effective_type.length is not None and len(value) > effective_type.length:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
raise backup_error("backup_entity_invalid", f"{entity}.{name} 类型无效") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_habit_days(value: object, *, minimum: int, maximum: int) -> list[int] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value or any(part.strip() != part for part in value.split(",")):
|
||||||
|
raise ValueError
|
||||||
|
parts = value.split(",")
|
||||||
|
if any(not part.isascii() or not part.isdecimal() for part in parts):
|
||||||
|
raise ValueError
|
||||||
|
days = [int(part) for part in parts]
|
||||||
|
if value != ",".join(map(str, days)):
|
||||||
|
raise ValueError
|
||||||
|
if len(days) != len(set(days)) or any(day < minimum or day > maximum for day in days):
|
||||||
|
raise ValueError
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_habit_graph(parsed: ParsedArchive) -> None:
|
||||||
|
habits: dict[str, HabitCreate] = {}
|
||||||
|
try:
|
||||||
|
for row in parsed.entities["habits"]:
|
||||||
|
weekdays = _parse_habit_days(row.get("weekdays"), minimum=0, maximum=6)
|
||||||
|
month_days = _parse_habit_days(row.get("month_days"), minimum=1, maximum=31)
|
||||||
|
payload = {
|
||||||
|
field: row.get(field)
|
||||||
|
for field in HabitCreate.model_fields
|
||||||
|
if field in row
|
||||||
|
}
|
||||||
|
payload["weekdays"] = weekdays
|
||||||
|
payload["month_days"] = month_days
|
||||||
|
habit = HabitCreate.model_validate(payload)
|
||||||
|
if habit.kind == "boolean" and (row.get("target") != 1 or row.get("max_value") != 1):
|
||||||
|
raise ValueError
|
||||||
|
if habit.weekdays != weekdays or habit.month_days != month_days:
|
||||||
|
raise ValueError
|
||||||
|
if habit.interval_days != row.get("interval_days"):
|
||||||
|
raise ValueError
|
||||||
|
habits[str(row["id"])] = habit
|
||||||
|
|
||||||
|
for row in parsed.entities["habit_logs"]:
|
||||||
|
habit = habits[str(row["habit_id"])]
|
||||||
|
value = row["value"]
|
||||||
|
if not math.isfinite(value) or value < 0:
|
||||||
|
raise ValueError
|
||||||
|
if habit.kind == "boolean":
|
||||||
|
if value not in (0, 1):
|
||||||
|
raise ValueError
|
||||||
|
elif habit.max_value is not None and value > habit.max_value:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
pauses_by_habit: dict[str, list[tuple[date, date]]] = {}
|
||||||
|
for row in parsed.entities["habit_pauses"]:
|
||||||
|
pause = PauseInput.model_validate({
|
||||||
|
"start_date": row.get("start_date"),
|
||||||
|
"end_date": row.get("end_date"),
|
||||||
|
})
|
||||||
|
pauses_by_habit.setdefault(str(row["habit_id"]), []).append(
|
||||||
|
(pause.start_date, pause.end_date)
|
||||||
|
)
|
||||||
|
for pauses in pauses_by_habit.values():
|
||||||
|
pauses.sort()
|
||||||
|
if any(current[0] <= previous[1] for previous, current in pairwise(pauses)):
|
||||||
|
raise ValueError
|
||||||
|
except (KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||||
|
raise backup_error("backup_habit_invalid", "习惯数据不符合业务规则") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _required_trimmed(value: object, *, maximum: int | None = None) -> str:
|
||||||
|
if not isinstance(value, str) or value != value.strip() or not value:
|
||||||
|
raise ValueError
|
||||||
|
if maximum is not None and len(value) > maximum:
|
||||||
|
raise ValueError
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_datetimes(row: dict, earlier: str, later: str) -> None:
|
||||||
|
if row.get(earlier) and row.get(later) and datetime.fromisoformat(row[later]) < datetime.fromisoformat(row[earlier]):
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_entity_contracts(parsed: ParsedArchive) -> None:
|
||||||
|
try:
|
||||||
|
for row in parsed.entities["folders"]:
|
||||||
|
FolderCreate.model_validate({"name": row.get("name")})
|
||||||
|
_required_trimmed(row.get("name"), maximum=120)
|
||||||
|
if row.get("position", 0) < 0:
|
||||||
|
raise ValueError
|
||||||
|
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||||
|
for row in parsed.entities["lists"]:
|
||||||
|
ListCreate.model_validate({"name": row.get("name"), "folder_id": row.get("folder_id")})
|
||||||
|
_required_trimmed(row.get("name"), maximum=120)
|
||||||
|
if row.get("position", 0) < 0:
|
||||||
|
raise ValueError
|
||||||
|
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||||
|
for row in parsed.entities["tasks"]:
|
||||||
|
task = TaskCreate.model_validate({
|
||||||
|
"title": row.get("title"),
|
||||||
|
"list_id": row.get("list_id"),
|
||||||
|
"description": row.get("description", ""),
|
||||||
|
"priority": row.get("priority", 0),
|
||||||
|
"due_at": row.get("due_at"),
|
||||||
|
"due_has_time": row.get("due_has_time", False),
|
||||||
|
"parent_id": row.get("parent_id"),
|
||||||
|
})
|
||||||
|
_required_trimmed(row.get("title"), maximum=500)
|
||||||
|
if row.get("version", 1) < 1 or row.get("position", 0) < 0:
|
||||||
|
raise ValueError
|
||||||
|
if task.due_at is None and task.due_has_time:
|
||||||
|
raise ValueError
|
||||||
|
completed_at = row.get("completed_at")
|
||||||
|
if row.get("completed") is True and completed_at is None:
|
||||||
|
raise ValueError
|
||||||
|
if row.get("completed") is False and completed_at is not None:
|
||||||
|
raise ValueError
|
||||||
|
_ordered_datetimes(row, "created_at", "updated_at")
|
||||||
|
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||||
|
for row in parsed.entities["countdowns"]:
|
||||||
|
CountdownInput.model_validate({
|
||||||
|
field: row[field] for field in CountdownInput.model_fields if field in row
|
||||||
|
})
|
||||||
|
_required_trimmed(row.get("title"), maximum=200)
|
||||||
|
_required_trimmed(row.get("icon"), maximum=32)
|
||||||
|
_ordered_datetimes(row, "created_at", "updated_at")
|
||||||
|
_ordered_datetimes(row, "created_at", "archived_at")
|
||||||
|
for row in parsed.entities["memos"]:
|
||||||
|
MemoCreate.model_validate({"title": row.get("title"), "content": row.get("content", "")})
|
||||||
|
_required_trimmed(row.get("title"), maximum=200)
|
||||||
|
if row.get("version", 1) < 1:
|
||||||
|
raise ValueError
|
||||||
|
_ordered_datetimes(row, "created_at", "updated_at")
|
||||||
|
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||||
|
allowed_mime = {
|
||||||
|
"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png",
|
||||||
|
"image/gif", "application/json", "application/zip",
|
||||||
|
}
|
||||||
|
for row in parsed.entities["attachments"]:
|
||||||
|
filename = row.get("filename")
|
||||||
|
mime_type = row.get("mime_type")
|
||||||
|
if (
|
||||||
|
not isinstance(filename, str)
|
||||||
|
or not filename.strip()
|
||||||
|
or Path(filename).name != filename
|
||||||
|
or mime_type not in allowed_mime
|
||||||
|
or row.get("size", -1) < 0
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
except (KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||||
|
raise backup_error("backup_entity_invalid", "实体数据不符合业务规则") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
||||||
|
tasks = {str(row["id"]): row for row in parsed.entities["tasks"]}
|
||||||
|
try:
|
||||||
|
for row in parsed.entities["recurrences"]:
|
||||||
|
task = tasks[str(row["task_id"])]
|
||||||
|
recurrence = RecurrenceCreate.model_validate({
|
||||||
|
"task_id": row["task_id"],
|
||||||
|
"rrule": row.get("rrule"),
|
||||||
|
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||||
|
"after_completion_days": row.get("after_completion_days"),
|
||||||
|
})
|
||||||
|
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||||
|
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||||
|
last_completed_at = (
|
||||||
|
datetime.fromisoformat(row["last_completed_at"])
|
||||||
|
if row.get("last_completed_at")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if task.get("parent_id") is not None or task.get("due_at") is None:
|
||||||
|
raise ValueError
|
||||||
|
due_at = datetime.fromisoformat(task["due_at"])
|
||||||
|
if recurrence.trigger_mode == "scheduled":
|
||||||
|
if recurrence.rrule is None:
|
||||||
|
raise ValueError
|
||||||
|
parse_rrule(recurrence.rrule)
|
||||||
|
if starts_at != due_at:
|
||||||
|
raise ValueError
|
||||||
|
if ends_at is not None and ends_at < starts_at:
|
||||||
|
raise ValueError
|
||||||
|
if last_completed_at is not None and last_completed_at > starts_at:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
recurrences = {str(row["id"]): row for row in parsed.entities["recurrences"]}
|
||||||
|
for row in parsed.entities["recurrence_exceptions"]:
|
||||||
|
recurrence = recurrences[str(row["template_id"])]
|
||||||
|
if recurrence.get("trigger_mode", "scheduled") != "scheduled":
|
||||||
|
raise ValueError
|
||||||
|
occurrence_at = datetime.fromisoformat(row["occurrence_at"])
|
||||||
|
if not is_occurrence(
|
||||||
|
recurrence["rrule"], datetime.fromisoformat(recurrence["starts_at"]), occurrence_at
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
if recurrence.get("ends_at") and occurrence_at > datetime.fromisoformat(recurrence["ends_at"]):
|
||||||
|
raise ValueError
|
||||||
|
except (HTTPException, KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||||
|
raise backup_error("backup_recurrence_invalid", "重复规则不符合业务规则") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def validate_archive(parsed: ParsedArchive) -> None:
|
||||||
|
unknown = set(parsed.entities) - set(ENTITY_MODELS)
|
||||||
|
missing = set(ENTITY_MODELS) - set(parsed.entities)
|
||||||
|
if unknown:
|
||||||
|
raise backup_error("backup_entity_unknown", "备份包含未知实体")
|
||||||
|
if missing:
|
||||||
|
raise backup_error("backup_entity_missing", "备份缺少必需实体")
|
||||||
|
ids: dict[str, set[str]] = {}
|
||||||
|
for entity in ORDER:
|
||||||
|
rows = parsed.entities.get(entity, [])
|
||||||
|
model = ENTITY_MODELS[entity]
|
||||||
|
allowed = {column.name for column in model.__table__.columns} | ({"archive_path"} if entity == "attachments" else set())
|
||||||
|
entity_ids = []
|
||||||
|
for row in rows:
|
||||||
|
if set(row) - allowed or "user_id" in row or "storage_name" in row:
|
||||||
|
raise backup_error("backup_entity_invalid", f"{entity} 包含未知或受保护字段")
|
||||||
|
for column in model.__table__.columns:
|
||||||
|
_validate_scalar(entity, column, row)
|
||||||
|
try:
|
||||||
|
entity_ids.append(str(UUID(str(row["id"]))))
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise backup_error("backup_entity_invalid", f"{entity} 包含无效 ID") from exc
|
||||||
|
if len(entity_ids) != len(set(entity_ids)):
|
||||||
|
raise backup_error("backup_duplicate_id", f"{entity} 包含重复 ID")
|
||||||
|
ids[entity] = set(entity_ids)
|
||||||
|
for entity, fields in RELATIONS.items():
|
||||||
|
for row in parsed.entities.get(entity, []):
|
||||||
|
for field, target in fields.items():
|
||||||
|
value = row.get(field)
|
||||||
|
if value is not None and str(value) not in ids[target]:
|
||||||
|
raise backup_error("backup_reference_invalid", f"{entity}.{field} 引用不存在")
|
||||||
|
attachment_paths = set()
|
||||||
|
for row in parsed.entities.get("attachments", []):
|
||||||
|
path = row.get("archive_path")
|
||||||
|
if not isinstance(path, str) or path not in parsed.files or not path.startswith("attachments/"):
|
||||||
|
raise backup_error("backup_attachment_missing", "附件内容缺失")
|
||||||
|
if path in attachment_paths:
|
||||||
|
raise backup_error("backup_attachment_duplicate", "附件内容被重复引用")
|
||||||
|
attachment_paths.add(path)
|
||||||
|
blob = parsed.files[path]
|
||||||
|
if row.get("size") != blob.size:
|
||||||
|
raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配")
|
||||||
|
if set(parsed.files) != attachment_paths:
|
||||||
|
raise backup_error("backup_unreferenced_file", "备份包含未引用文件")
|
||||||
|
|
||||||
|
def unique(entity: str, fields: tuple[str, ...], *, ignore_null: bool = False) -> None:
|
||||||
|
seen = set()
|
||||||
|
for row in parsed.entities[entity]:
|
||||||
|
key = tuple(row.get(field) for field in fields)
|
||||||
|
if ignore_null and any(value is None for value in key):
|
||||||
|
continue
|
||||||
|
if key in seen:
|
||||||
|
raise backup_error("backup_constraint_invalid", f"{entity} 唯一约束冲突")
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
unique("recurrences", ("task_id",))
|
||||||
|
unique("recurrence_exceptions", ("template_id", "occurrence_at"))
|
||||||
|
unique("habit_logs", ("habit_id", "day"))
|
||||||
|
unique("tasks", ("external_id",), ignore_null=True)
|
||||||
|
active_pinned = sum(
|
||||||
|
row.get("pinned") is True and row.get("archived_at") is None
|
||||||
|
for row in parsed.entities["countdowns"]
|
||||||
|
)
|
||||||
|
if active_pinned > 1:
|
||||||
|
raise backup_error("backup_constraint_invalid", "最多只能有一个置顶倒数日")
|
||||||
|
_validate_entity_contracts(parsed)
|
||||||
|
_validate_habit_graph(parsed)
|
||||||
|
_validate_recurrence_graph(parsed)
|
||||||
|
inboxes = [row for row in parsed.entities["lists"] if row.get("is_inbox") is True]
|
||||||
|
if len(inboxes) != 1:
|
||||||
|
raise backup_error("backup_constraint_invalid", "备份必须包含且仅包含一个收集箱")
|
||||||
|
tasks = {str(row["id"]): row for row in parsed.entities["tasks"]}
|
||||||
|
for task_id, row in tasks.items():
|
||||||
|
parent_id = row.get("parent_id")
|
||||||
|
if parent_id is None:
|
||||||
|
continue
|
||||||
|
parent_id = str(parent_id)
|
||||||
|
if parent_id == task_id:
|
||||||
|
raise backup_error("backup_constraint_invalid", "任务不能以自身为父任务")
|
||||||
|
parent = tasks[parent_id]
|
||||||
|
if parent.get("parent_id") is not None:
|
||||||
|
raise backup_error("backup_constraint_invalid", "任务父子关系仅支持一层")
|
||||||
|
if parent.get("list_id") != row.get("list_id"):
|
||||||
|
raise backup_error("backup_constraint_invalid", "父子任务必须属于同一清单")
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_rows(entity: str, rows: list[dict]) -> list[dict]:
|
||||||
|
if entity != "tasks":
|
||||||
|
return rows
|
||||||
|
pending = {str(row["id"]): row for row in rows}
|
||||||
|
ordered: list[dict] = []
|
||||||
|
while pending:
|
||||||
|
ready = [row for row in pending.values() if row.get("parent_id") is None or str(row["parent_id"]) not in pending]
|
||||||
|
if not ready:
|
||||||
|
raise backup_error("backup_constraint_invalid", "任务父子关系存在环")
|
||||||
|
for row in ready:
|
||||||
|
ordered.append(row)
|
||||||
|
pending.pop(str(row["id"]))
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_values(values: dict) -> dict:
|
||||||
|
return {key: _value(value) for key, value in values.items() if key not in {"created_at", "updated_at"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID) -> dict:
|
||||||
|
values = {}
|
||||||
|
relations = RELATIONS.get(next(name for name, item in ENTITY_MODELS.items() if item is model), {})
|
||||||
|
for column in model.__table__.columns:
|
||||||
|
name = column.name
|
||||||
|
if name == "user_id":
|
||||||
|
values[name] = user_id
|
||||||
|
elif name == "storage_name":
|
||||||
|
continue
|
||||||
|
elif name in raw:
|
||||||
|
value = raw[name]
|
||||||
|
if name in relations and value is not None:
|
||||||
|
value = mapping[relations[name]][str(value)]
|
||||||
|
elif value is not None:
|
||||||
|
column_type = column.type
|
||||||
|
effective_type = getattr(column_type, "impl", column_type)
|
||||||
|
if isinstance(effective_type, Uuid):
|
||||||
|
value = UUID(str(value))
|
||||||
|
elif isinstance(effective_type, DateTime) and isinstance(value, str):
|
||||||
|
value = datetime.fromisoformat(value)
|
||||||
|
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||||
|
value = date.fromisoformat(value)
|
||||||
|
values[name] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _content_digest(entity: str, raw: dict) -> str:
|
||||||
|
# Source-form relations are deliberate: digest identity remains stable even
|
||||||
|
# when another user needs different target UUIDs.
|
||||||
|
payload = {key: value for key, value in raw.items() if key not in {"created_at", "updated_at", "storage_name"}}
|
||||||
|
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _all_uuid_primary_keys(db: AsyncSession) -> set[UUID]:
|
||||||
|
occupied: set[UUID] = set()
|
||||||
|
for table in User.metadata.sorted_tables:
|
||||||
|
primary_keys = list(table.primary_key.columns)
|
||||||
|
if len(primary_keys) != 1:
|
||||||
|
continue
|
||||||
|
column = primary_keys[0]
|
||||||
|
effective_type = getattr(column.type, "impl", column.type)
|
||||||
|
if isinstance(effective_type, Uuid):
|
||||||
|
occupied.update((await db.scalars(select(column))).all())
|
||||||
|
return occupied
|
||||||
|
|
||||||
|
|
||||||
|
async def _mapped_id(
|
||||||
|
db: AsyncSession, model, source: UUID, user_id: UUID, backup_id: UUID, entity: str,
|
||||||
|
reserved: set[UUID], incoming: set[UUID],
|
||||||
|
) -> UUID:
|
||||||
|
ledger = await db.scalar(select(BackupImportEntity).where(
|
||||||
|
BackupImportEntity.user_id == user_id,
|
||||||
|
BackupImportEntity.backup_id == backup_id,
|
||||||
|
BackupImportEntity.entity_type == entity,
|
||||||
|
BackupImportEntity.source_id == source,
|
||||||
|
))
|
||||||
|
if ledger is not None:
|
||||||
|
reserved.add(ledger.target_id)
|
||||||
|
return ledger.target_id
|
||||||
|
existing = await db.get(model, source)
|
||||||
|
owner_id = getattr(existing, "user_id", None)
|
||||||
|
if owner_id is None:
|
||||||
|
if isinstance(existing, RecurrenceException):
|
||||||
|
owner_id = await db.scalar(select(RecurrenceTemplate.user_id).where(RecurrenceTemplate.id == existing.template_id))
|
||||||
|
elif isinstance(existing, (HabitLog, HabitPause)):
|
||||||
|
owner_id = await db.scalar(select(Habit.user_id).where(Habit.id == existing.habit_id))
|
||||||
|
if existing is not None and owner_id == user_id:
|
||||||
|
reserved.add(source)
|
||||||
|
return source
|
||||||
|
if source not in reserved:
|
||||||
|
reserved.add(source)
|
||||||
|
return source
|
||||||
|
candidate = uuid5(user_id, str(source))
|
||||||
|
while candidate in reserved or candidate in incoming:
|
||||||
|
candidate = uuid5(user_id, str(candidate))
|
||||||
|
reserved.add(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_v2(
|
||||||
|
db: AsyncSession, user: User, parsed: ParsedArchive, mode: str,
|
||||||
|
*, operation: BackupPreflight | None = None,
|
||||||
|
) -> dict:
|
||||||
|
validate_archive(parsed)
|
||||||
|
# The user row is the cross-worker restore/export mutex on PostgreSQL.
|
||||||
|
await db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||||
|
existing_import = await db.scalar(select(BackupImport).where(
|
||||||
|
BackupImport.user_id == user.id, BackupImport.backup_id == parsed.backup_id
|
||||||
|
))
|
||||||
|
already_imported = existing_import is not None
|
||||||
|
if mode == "merge" and existing_import and existing_import.archive_sha256 != parsed.archive_sha256:
|
||||||
|
raise HTTPException(409, {"code": "backup_id_conflict", "message": "备份标识与内容不一致"})
|
||||||
|
|
||||||
|
root = Path(get_settings().attachment_dir).resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
quarantine = root.parent / f".backup-quarantine-{new_id()}"
|
||||||
|
moved = []
|
||||||
|
written: list[Path] = []
|
||||||
|
try:
|
||||||
|
if mode == "replace":
|
||||||
|
old_names = list((await db.scalars(
|
||||||
|
select(Attachment.storage_name).where(Attachment.user_id == user.id)
|
||||||
|
)).all())
|
||||||
|
moved = quarantine_files(root, old_names, quarantine)
|
||||||
|
await db.execute(delete(RecurrenceException).where(
|
||||||
|
RecurrenceException.template_id.in_(
|
||||||
|
select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
await db.execute(delete(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))
|
||||||
|
await db.execute(delete(HabitLog).where(
|
||||||
|
HabitLog.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||||
|
))
|
||||||
|
await db.execute(delete(HabitPause).where(
|
||||||
|
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||||
|
))
|
||||||
|
for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
|
||||||
|
await db.execute(delete(model).where(model.user_id == user.id))
|
||||||
|
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
|
||||||
|
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
|
||||||
|
|
||||||
|
mapping: dict[str, dict[str, UUID]] = {name: {} for name in ORDER}
|
||||||
|
incoming_ids: set[UUID] = {
|
||||||
|
UUID(str(raw["id"]))
|
||||||
|
for rows in parsed.entities.values()
|
||||||
|
for raw in rows
|
||||||
|
}
|
||||||
|
reserved_ids = await _all_uuid_primary_keys(db)
|
||||||
|
for entity in ORDER:
|
||||||
|
model = ENTITY_MODELS[entity]
|
||||||
|
for raw in _ordered_rows(entity, parsed.entities[entity]):
|
||||||
|
source = UUID(str(raw["id"]))
|
||||||
|
mapping[entity][str(source)] = await _mapped_id(
|
||||||
|
db, model, source, user.id, parsed.backup_id, entity,
|
||||||
|
reserved_ids, incoming_ids - {source},
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
for entity in ORDER:
|
||||||
|
model = ENTITY_MODELS[entity]
|
||||||
|
for raw in _ordered_rows(entity, parsed.entities[entity]):
|
||||||
|
source = UUID(str(raw["id"]))
|
||||||
|
target = mapping[entity][str(source)]
|
||||||
|
digest_payload = dict(raw)
|
||||||
|
if model is Attachment:
|
||||||
|
digest_payload["archive_sha256"] = parsed.files[raw["archive_path"]].sha256
|
||||||
|
content_digest = _content_digest(entity, digest_payload)
|
||||||
|
ledger = await db.scalar(select(BackupImportEntity).where(
|
||||||
|
BackupImportEntity.user_id == user.id,
|
||||||
|
BackupImportEntity.backup_id == parsed.backup_id,
|
||||||
|
BackupImportEntity.entity_type == entity,
|
||||||
|
BackupImportEntity.source_id == source,
|
||||||
|
))
|
||||||
|
existing = await db.get(model, target)
|
||||||
|
values = _coerce(model, raw, mapping, user.id)
|
||||||
|
values["id"] = target
|
||||||
|
if ledger is not None:
|
||||||
|
if ledger.target_id != target or ledger.content_digest != content_digest:
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射账本冲突"})
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_missing", "message": f"{entity} 映射目标不存在"})
|
||||||
|
current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None)
|
||||||
|
expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}}
|
||||||
|
if model is Attachment:
|
||||||
|
expected["archive_path"] = raw["archive_path"]
|
||||||
|
stored = contained_file(root, existing.storage_name)
|
||||||
|
file_digest = hashlib.sha256()
|
||||||
|
actual_size = 0
|
||||||
|
try:
|
||||||
|
with stored.open("rb") as source_file:
|
||||||
|
while chunk := source_file.read(1024 * 1024):
|
||||||
|
actual_size += len(chunk)
|
||||||
|
file_digest.update(chunk)
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_missing", "message": "attachments 映射目标文件不存在"}) from exc
|
||||||
|
blob = parsed.files[raw["archive_path"]]
|
||||||
|
if actual_size != blob.size or file_digest.hexdigest() != blob.sha256:
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_conflict", "message": "attachments 映射目标内容冲突"})
|
||||||
|
if _normalized_values(current) != _normalized_values(expected):
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射目标内容冲突"})
|
||||||
|
continue
|
||||||
|
if existing is not None and mode == "merge":
|
||||||
|
current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None)
|
||||||
|
expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}}
|
||||||
|
if model is Attachment:
|
||||||
|
expected["archive_path"] = raw["archive_path"]
|
||||||
|
stored = contained_file(root, existing.storage_name)
|
||||||
|
blob = parsed.files[raw["archive_path"]]
|
||||||
|
file_digest = hashlib.sha256()
|
||||||
|
actual_size = 0
|
||||||
|
try:
|
||||||
|
with stored.open("rb") as source_file:
|
||||||
|
while chunk := source_file.read(1024 * 1024):
|
||||||
|
actual_size += len(chunk)
|
||||||
|
file_digest.update(chunk)
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(409, {
|
||||||
|
"code": "backup_entity_missing",
|
||||||
|
"message": "attachments 已存在但文件不存在",
|
||||||
|
}) from exc
|
||||||
|
if actual_size != blob.size or file_digest.hexdigest() != blob.sha256:
|
||||||
|
raise HTTPException(409, {
|
||||||
|
"code": "backup_entity_conflict",
|
||||||
|
"message": "attachments 已存在不同内容",
|
||||||
|
})
|
||||||
|
if _normalized_values(current) != _normalized_values(expected):
|
||||||
|
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 已存在不同内容"})
|
||||||
|
else:
|
||||||
|
if model is Attachment:
|
||||||
|
archive_path = raw["archive_path"]
|
||||||
|
storage_name = str(new_id())
|
||||||
|
destination = contained_file(root, storage_name)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
copied_size = 0
|
||||||
|
blob = parsed.files[archive_path]
|
||||||
|
with blob.staging_path.open("rb") as source_file, destination.open("xb") as output:
|
||||||
|
while chunk := source_file.read(1024 * 1024):
|
||||||
|
copied_size += len(chunk)
|
||||||
|
digest.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
if copied_size != blob.size or digest.hexdigest() != blob.sha256:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise backup_error("backup_checksum_mismatch", "附件暂存校验失败", 409)
|
||||||
|
written.append(destination)
|
||||||
|
values["storage_name"] = storage_name
|
||||||
|
db.add(model(**values))
|
||||||
|
await db.flush()
|
||||||
|
restored += 1
|
||||||
|
db.add(BackupImportEntity(
|
||||||
|
user_id=user.id, backup_id=parsed.backup_id, entity_type=entity,
|
||||||
|
source_id=source, target_id=target, content_digest=content_digest,
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
if mode == "replace" or existing_import is None:
|
||||||
|
db.add(BackupImport(
|
||||||
|
user_id=user.id, backup_id=parsed.backup_id,
|
||||||
|
archive_sha256=parsed.archive_sha256, mode=mode,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
for path in written:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
try:
|
||||||
|
restore_quarantine(moved)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
if operation is not None:
|
||||||
|
operation.status = "repair_pending"
|
||||||
|
operation.cleanup_path = str(quarantine)
|
||||||
|
await db.commit()
|
||||||
|
raise BackupRepairPending()
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
for path in written:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
try:
|
||||||
|
restore_quarantine(moved)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
if operation is not None:
|
||||||
|
operation.status = "repair_pending"
|
||||||
|
operation.cleanup_path = str(quarantine)
|
||||||
|
await db.commit()
|
||||||
|
raise BackupRepairPending()
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
remove_quarantine(quarantine)
|
||||||
|
except OSError as exc:
|
||||||
|
if operation is not None:
|
||||||
|
operation.status = "cleanup_pending"
|
||||||
|
operation.cleanup_path = str(quarantine)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(500, {"code": "backup_cleanup_pending", "message": "数据已恢复,但旧附件清理未完成;请使用同一令牌重试"}) from exc
|
||||||
|
return {"restored": restored, "mode": mode, "already_imported": already_imported, "cleanup_retried": False}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def contained_file(root: Path, storage_name: str) -> Path:
|
||||||
|
root = root.resolve()
|
||||||
|
candidate = (root / storage_name).resolve()
|
||||||
|
if candidate.parent != root:
|
||||||
|
raise ValueError("attachment path escapes storage root")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def quarantine_files(root: Path, storage_names: list[str], quarantine: Path) -> list[tuple[Path, Path]]:
|
||||||
|
moved = []
|
||||||
|
quarantine.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
for name in storage_names:
|
||||||
|
source = contained_file(root, name)
|
||||||
|
if source.exists():
|
||||||
|
target = quarantine / name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.replace(target)
|
||||||
|
moved.append((source, target))
|
||||||
|
return moved
|
||||||
|
except Exception:
|
||||||
|
restore_quarantine(moved)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def restore_quarantine(moved: list[tuple[Path, Path]]) -> None:
|
||||||
|
for original, quarantined in reversed(moved):
|
||||||
|
if quarantined.exists():
|
||||||
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
quarantined.replace(original)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_quarantine_dir(root: Path, quarantine: Path) -> None:
|
||||||
|
if not quarantine.exists():
|
||||||
|
return
|
||||||
|
for quarantined in sorted(quarantine.rglob("*")):
|
||||||
|
if not quarantined.is_file():
|
||||||
|
continue
|
||||||
|
relative = quarantined.relative_to(quarantine)
|
||||||
|
original = contained_file(root, relative.as_posix())
|
||||||
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
quarantined.replace(original)
|
||||||
|
shutil.rmtree(quarantine)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_quarantine(path: Path) -> None:
|
||||||
|
if path.exists():
|
||||||
|
shutil.rmtree(path)
|
||||||
@@ -12,6 +12,11 @@ class Settings(BaseSettings):
|
|||||||
auto_create_schema: bool = False
|
auto_create_schema: bool = False
|
||||||
attachment_dir: str = "./data/attachments"
|
attachment_dir: str = "./data/attachments"
|
||||||
attachment_max_mb: int = 20
|
attachment_max_mb: int = 20
|
||||||
|
backup_max_archive_mb: int = 256
|
||||||
|
backup_max_pending_per_user: int = 3
|
||||||
|
backup_max_staged_mb_per_user: int = 768
|
||||||
|
backup_preflight_ttl_seconds: int = 900
|
||||||
|
backup_staging_dir: str = "./data/backup-staging"
|
||||||
login_attempts: int = 5
|
login_attempts: int = 5
|
||||||
login_window_seconds: int = 300
|
login_window_seconds: int = 300
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .auth import (
|
|||||||
session_token,
|
session_token,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
from .backup import router as backup_router
|
||||||
from .db import create_schema, get_db
|
from .db import create_schema, get_db
|
||||||
from .models import (
|
from .models import (
|
||||||
AppState,
|
AppState,
|
||||||
@@ -118,6 +119,7 @@ async def openapi(_: User = Depends(current_user)):
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(mvp_router)
|
app.include_router(mvp_router)
|
||||||
|
app.include_router(backup_router)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,52 @@ class Memo(Base):
|
|||||||
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPreflight(Base):
|
||||||
|
__tablename__ = "backup_preflights"
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||||
|
archive_sha256: Mapped[str] = mapped_column(String(64))
|
||||||
|
archive_size: Mapped[int] = mapped_column(Integer)
|
||||||
|
staging_path: Mapped[str] = mapped_column(String(1024), unique=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(16))
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), index=True)
|
||||||
|
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||||
|
cleanup_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupImport(Base):
|
||||||
|
__tablename__ = "backup_imports"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "backup_id", name="uq_backup_import_user_backup"),)
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||||
|
archive_sha256: Mapped[str] = mapped_column(String(64))
|
||||||
|
mode: Mapped[str] = mapped_column(String(16))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupImportEntity(Base):
|
||||||
|
__tablename__ = "backup_import_entities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id", "backup_id", "entity_type", "source_id",
|
||||||
|
name="uq_backup_import_entity_source",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
source_id: Mapped[UUID] = mapped_column(index=True)
|
||||||
|
target_id: Mapped[UUID] = mapped_column(index=True)
|
||||||
|
content_digest: Mapped[str] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||||
|
|||||||
+65
-12
@@ -11,7 +11,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
|
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
|
||||||
from sqlalchemy import case, delete, func, select, update
|
from sqlalchemy import case, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .auth import current_user
|
from .auth import current_user
|
||||||
@@ -44,6 +44,42 @@ from .models import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
LEGACY_BACKUP_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
LEGACY_BACKUP_MAX_RECORDS = 10_000
|
||||||
|
LEGACY_BACKUP_MAX_FIELD_BYTES = 1024 * 1024
|
||||||
|
_LEGACY_READ_CHUNK = 64 * 1024
|
||||||
|
_LEGACY_ENTITIES = ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos")
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_error(code: str, message: str) -> HTTPException:
|
||||||
|
return HTTPException(422, {"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_legacy_payload_limits(payload: dict) -> None:
|
||||||
|
total = 0
|
||||||
|
for entity in _LEGACY_ENTITIES:
|
||||||
|
rows = payload.get(entity, [])
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise _legacy_error("legacy_backup_invalid", "旧版备份实体格式无效")
|
||||||
|
total += len(rows)
|
||||||
|
if total > LEGACY_BACKUP_MAX_RECORDS:
|
||||||
|
raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多")
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise _legacy_error("legacy_backup_invalid", "旧版备份记录格式无效")
|
||||||
|
for value in row.values():
|
||||||
|
if isinstance(value, str) and len(value.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES:
|
||||||
|
raise _legacy_error("legacy_backup_field_too_large", "旧版备份字段过大")
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_legacy_upload(file: UploadFile) -> bytes:
|
||||||
|
content = bytearray()
|
||||||
|
while chunk := await file.read(_LEGACY_READ_CHUNK):
|
||||||
|
content.extend(chunk)
|
||||||
|
if len(content) > LEGACY_BACKUP_MAX_BYTES:
|
||||||
|
raise _legacy_error("legacy_backup_too_large", "旧版备份文件过大")
|
||||||
|
return bytes(content)
|
||||||
|
|
||||||
|
|
||||||
def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details):
|
def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details):
|
||||||
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
||||||
@@ -223,8 +259,10 @@ class RecurrenceCreate(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_mode(self):
|
def validate_mode(self):
|
||||||
if self.trigger_mode == "scheduled" and self.rrule is None:
|
if self.trigger_mode == "scheduled" and (
|
||||||
raise ValueError("scheduled recurrence requires rrule")
|
self.rrule is None or self.after_completion_days is not None
|
||||||
|
):
|
||||||
|
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||||
if self.trigger_mode == "after_completion" and (
|
if self.trigger_mode == "after_completion" and (
|
||||||
self.after_completion_days is None or self.rrule is not None
|
self.after_completion_days is None or self.rrule is not None
|
||||||
):
|
):
|
||||||
@@ -1209,6 +1247,16 @@ async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: As
|
|||||||
_ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"}
|
_ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/attachments")
|
||||||
|
async def list_attachments(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
await owned_task(db, user.id, task_id)
|
||||||
|
rows = (await db.scalars(select(Attachment).where(
|
||||||
|
Attachment.task_id == task_id, Attachment.user_id == user.id
|
||||||
|
).order_by(Attachment.created_at, Attachment.id))).all()
|
||||||
|
return [{"id": row.id, "task_id": row.task_id, "filename": row.filename,
|
||||||
|
"mime_type": row.mime_type, "size": row.size} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/attachments", status_code=201)
|
@router.post("/tasks/{task_id}/attachments", status_code=201)
|
||||||
async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
await owned_task(db, user.id, task_id)
|
await owned_task(db, user.id, task_id)
|
||||||
@@ -1349,7 +1397,11 @@ async def restore_csv(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
text = (await file.read()).decode("utf-8-sig")
|
try:
|
||||||
|
raw_content = await _read_legacy_upload(file)
|
||||||
|
text = raw_content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise _legacy_error("legacy_backup_invalid", "无效的 Dodo CSV 备份") from exc
|
||||||
payload = {
|
payload = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
@@ -1363,9 +1415,14 @@ async def restore_csv(
|
|||||||
try:
|
try:
|
||||||
for row in csv.DictReader(io.StringIO(text)):
|
for row in csv.DictReader(io.StringIO(text)):
|
||||||
entity = row.get("entity", "")
|
entity = row.get("entity", "")
|
||||||
|
data = row.get("data")
|
||||||
if entity not in payload or entity == "version":
|
if entity not in payload or entity == "version":
|
||||||
raise ValueError("unknown entity")
|
raise ValueError("unknown entity")
|
||||||
payload[entity].append(json.loads(row["data"]))
|
if not isinstance(data, str) or len(data.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES:
|
||||||
|
raise ValueError("field too large")
|
||||||
|
payload[entity].append(json.loads(data))
|
||||||
|
if sum(len(payload[name]) for name in _LEGACY_ENTITIES) > LEGACY_BACKUP_MAX_RECORDS:
|
||||||
|
raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多")
|
||||||
except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||||
raise HTTPException(422, "无效的 Dodo CSV 备份") from exc
|
raise HTTPException(422, "无效的 Dodo CSV 备份") from exc
|
||||||
return await restore_json(payload, mode, user, db)
|
return await restore_json(payload, mode, user, db)
|
||||||
@@ -1436,16 +1493,12 @@ def _validate_countdown_backups(payload: dict) -> list[dict]:
|
|||||||
|
|
||||||
@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 mode == "replace":
|
||||||
|
raise _legacy_error("legacy_replace_unsupported", "旧版备份仅支持合并恢复")
|
||||||
if payload.get("version") != 1:
|
if payload.get("version") != 1:
|
||||||
raise HTTPException(422, "不支持的备份版本")
|
raise HTTPException(422, "不支持的备份版本")
|
||||||
|
_validate_legacy_payload_limits(payload)
|
||||||
parsed_countdowns = _validate_countdown_backups(payload)
|
parsed_countdowns = _validate_countdown_backups(payload)
|
||||||
if mode == "replace":
|
|
||||||
await db.execute(delete(Memo).where(Memo.user_id == user.id))
|
|
||||||
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
|
|
||||||
await db.execute(delete(Task).where(Task.user_id == user.id))
|
|
||||||
await db.execute(delete(Habit).where(Habit.user_id == user.id))
|
|
||||||
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
|
|
||||||
await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
|
||||||
id_map = {}
|
id_map = {}
|
||||||
task_id_map = {}
|
task_id_map = {}
|
||||||
for raw in payload.get("folders", []):
|
for raw in payload.get("folders", []):
|
||||||
|
|||||||
+55
-39
@@ -1,59 +1,75 @@
|
|||||||
# API
|
# API
|
||||||
|
|
||||||
Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_session` Cookie。
|
Base path: `/api/v1`。除初始化、登录和健康检查外,接口均要求 `dodo_session` Cookie;同源写请求使用双提交 CSRF 校验。登录后可访问 `/api/docs` 和 `/api/openapi.json`。
|
||||||
|
|
||||||
## Setup / Auth
|
## 账户与会话
|
||||||
|
|
||||||
- `GET /setup/status`
|
- `GET /setup/status`、`POST /setup/initialize`
|
||||||
- `POST /setup/initialize`
|
- `POST /auth/login`、`POST /auth/logout`、`POST /auth/change-password`
|
||||||
- `POST /auth/login`
|
- `GET/PATCH /me`
|
||||||
- `POST /auth/logout`
|
- `GET /sessions`、`DELETE /sessions/{session_id}`、`DELETE /sessions/others`
|
||||||
- `GET /me`
|
- `GET /audit-logs`
|
||||||
|
|
||||||
## Folders
|
## 文件夹、清单与任务
|
||||||
|
|
||||||
- `GET /folders` — 仅返回未删除文件夹
|
- 文件夹:`GET/POST /folders`,`PATCH/DELETE /folders/{id}`,以及排序接口
|
||||||
- `POST /folders` — 创建文件夹
|
- 清单:`GET/POST /lists`,`PATCH/DELETE /lists/{id}`,归档恢复、永久删除、移动与排序接口
|
||||||
- `PATCH /folders/{folder_id}` — 重命名
|
- 归档清单只标记 `deleted_at`,不会把任务移到收集箱;清单归档期间,其任务在普通列表和详情中不可见,恢复清单后重新出现
|
||||||
- `DELETE /folders/{folder_id}` — 软删除;其清单移到根层级
|
- 任务:`GET/POST /tasks`、`GET/PATCH/DELETE /tasks/{id}`、`POST /tasks/{id}/restore`、`DELETE /trash/{id}`
|
||||||
|
- `GET /tasks` 和 `GET /trash` 使用不透明游标;`limit` 为 1–100
|
||||||
|
- `POST /tasks/batch` 支持批量完成、移动、截止时间与软删除;任务写入使用 `version` 乐观锁
|
||||||
|
- 一层子任务必须与父任务同清单;任务可带日期型或具体时间型截止时间
|
||||||
|
|
||||||
## Lists
|
## 重复任务
|
||||||
|
|
||||||
- `GET /lists` — 仅返回未删除清单,系统收集箱排首位
|
- `GET /tasks/{task_id}/recurrence`
|
||||||
- `POST /lists` — 创建清单,可指定 `folder_id`
|
- `POST /recurrences`、`PATCH/DELETE /recurrences/{id}`
|
||||||
- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409
|
- `POST /recurrences/{id}/complete`
|
||||||
- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409
|
- 支持 RRULE 计划重复与按用户本地完成日期计算的“完成后重复”
|
||||||
|
|
||||||
## Tasks
|
## 习惯、倒数日与备忘录
|
||||||
|
|
||||||
- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述和清单名
|
- 习惯:创建、列表/周网格、部分更新、排序、归档/恢复/永久删除、日志、暂停和统计
|
||||||
- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务
|
- 倒数日:创建、列表、编辑、置顶、归档/恢复/永久删除;支持公历/农历及周/月/年重复
|
||||||
- `GET /tasks/{task_id}` — 返回任务和一层子任务
|
- 备忘录:游标列表、创建、读取、乐观锁更新、软删除、恢复和归档后永久删除
|
||||||
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,原子比较更新;版本冲突返回 409
|
|
||||||
- `DELETE /tasks/{task_id}` — 软删除任务及其直接子任务
|
|
||||||
- `POST /tasks/{task_id}/restore` — 恢复任务及其直接子任务
|
|
||||||
- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间或软删除
|
|
||||||
|
|
||||||
批量请求字段:`task_ids`、`completed`、`list_id`、`due_at`、`soft_delete`。所有任务和目标清单在写入前完成归属校验;任一不存在则整批不修改。
|
## 附件
|
||||||
|
|
||||||
## Countdowns
|
- `GET/POST /tasks/{task_id}/attachments`
|
||||||
|
- `GET/DELETE /attachments/{attachment_id}`
|
||||||
|
- 文件保存在服务端附件目录;上传受大小与类型限制,访问始终校验当前用户归属
|
||||||
|
|
||||||
- `GET /countdowns?archived=false` — 查询倒数日;置顶项优先,其余按下一次发生日期排序
|
## 完整备份 ZIP v2
|
||||||
- `POST /countdowns` — 创建倒数日、纪念日或生日;支持 `none/weekly/monthly/yearly` 重复
|
|
||||||
- `PATCH /countdowns/{countdown_id}` — 编辑名称、日期、类型、重复与图标
|
|
||||||
- `POST /countdowns/{countdown_id}/pin` — 单一置顶,自动取消其他置顶项
|
|
||||||
- `DELETE /countdowns/{countdown_id}` — 归档
|
|
||||||
- `POST /countdowns/{countdown_id}/restore` — 恢复归档项
|
|
||||||
- `DELETE /countdowns/{countdown_id}/purge` — 永久删除已归档项
|
|
||||||
|
|
||||||
## Recycle bin
|
### `GET /backup/export.zip`
|
||||||
|
|
||||||
- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页
|
导出 `dodo-backup` version 2 ZIP。归档包含 `manifest.json`、每类实体的 `data/*.json`、附件元数据和附件原始字节。manifest 声明实体数量及每个条目的 SHA-256。
|
||||||
- `DELETE /trash/{task_id}` — 永久删除任务及其子任务
|
|
||||||
|
|
||||||
游标是不透明字符串。无效游标返回 422;`limit` 范围为 1–100。
|
实体范围:`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。会话、密码散列、审计日志及备份内部账本不导出。
|
||||||
|
|
||||||
## Health
|
### `POST /backup/preflight?mode=merge|replace`
|
||||||
|
|
||||||
|
以 multipart 字段 `file` 上传 ZIP。服务端流式暂存,并在返回令牌前验证:ZIP 路径与条目、压缩比/容量、manifest 版本与计数、全部校验和、字段与业务约束、关系拓扑、一层任务树、用户隔离以及附件元数据/字节一致性。
|
||||||
|
|
||||||
|
成功返回 `valid`、短期 `preflight_token`、`backup_id`、归档摘要和各实体数量。预检有每用户待处理数量/容量配额和过期时间;令牌绑定用户、文件摘要和恢复模式。
|
||||||
|
|
||||||
|
### `POST /backup/restore`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"preflight_token":"...","mode":"merge"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`merge` 使用持久化 source→target ID/内容摘要账本实现可重试合并;跨用户或同 ID 不同内容冲突会拒绝。`replace` 在事务内替换当前用户业务实体,并通过同文件系统隔离区协调附件删除和失败补偿。令牌单次消费;若数据库已提交但隔离区清理失败,同一令牌仅重试清理,不重复导入。
|
||||||
|
|
||||||
|
## 旧格式兼容
|
||||||
|
|
||||||
|
- `GET /export`、`GET /export.csv`:旧版 JSON/UTF-8-BOM CSV v1 轻量导出,不是完整备份
|
||||||
|
- `POST /restore?mode=merge|replace`、`POST /restore.csv?mode=merge|replace`:兼容旧 JSON/CSV v1
|
||||||
|
- 旧格式只覆盖文件夹、清单、任务、重复模板、习惯、倒数日和备忘录;不包含日志、暂停、重复例外和附件字节
|
||||||
|
|
||||||
|
## 健康检查
|
||||||
|
|
||||||
- `GET /health/live`
|
- `GET /health/live`
|
||||||
- `GET /health/ready`
|
- `GET /health/ready`
|
||||||
|
|||||||
+29
-80
@@ -1,97 +1,46 @@
|
|||||||
# dodo 数据模型
|
# dodo 数据模型
|
||||||
|
|
||||||
所有业务实体使用 UUIDv7 主键并按 `user_id` 隔离。生产使用 PostgreSQL,测试使用 SQLite;模型保持两者兼容。
|
业务主键使用 UUIDv7;带 `user_id` 的实体按用户隔离。时间点按 UTC 保存,用户时区用于日期语义与展示。生产使用 PostgreSQL,测试使用 SQLite。
|
||||||
|
|
||||||
## app_state
|
## 账户
|
||||||
|
|
||||||
- `key` 主键
|
- `app_state`:初始化状态
|
||||||
- `created_at`
|
- `users`:用户名、密码散列、时区
|
||||||
|
- `sessions`:会话令牌散列、过期/最近访问时间、IP 与 User-Agent
|
||||||
|
- `audit_logs`:用户、动作、实体、非敏感摘要和时间
|
||||||
|
|
||||||
## users
|
## 任务域
|
||||||
|
|
||||||
- `id`
|
- `folders`:名称、位置、软删除时间;删除文件夹只解除清单分组
|
||||||
- `username` 唯一
|
- `task_lists`:文件夹、名称、收集箱标记、位置、软删除时间
|
||||||
- `password_hash`
|
- `tasks`:清单、可空父任务、标题、Markdown 描述、优先级、完成/完成时间、截止时间、`due_has_time`、位置、版本、软删除与外部 ID
|
||||||
- `timezone`
|
- `recurrence_templates`:任务的一对一重复规则、开始/结束、计划或完成后触发模式、完成后间隔与最近完成时间
|
||||||
- `created_at`
|
- `recurrence_exceptions`:模板发生时间及标题/截止/完成/删除覆盖
|
||||||
|
- `attachments`:任务、原始文件名、服务端存储名、MIME、大小和创建时间
|
||||||
|
- `purge_operations`:清单永久删除时附件隔离区清理的补偿状态
|
||||||
|
|
||||||
## sessions
|
任务树只允许一层,父子任务属于同一清单。清单归档仅设置清单 `deleted_at`,保留所有任务的 `list_id`;查询隐藏归档清单内任务,恢复清单后原任务和完成状态重新可见。任务与子任务软删除/恢复按生命周期规则处理,永久删除会清理关联重复数据和附件。
|
||||||
|
|
||||||
- `id`
|
## 习惯、倒数日与备忘录
|
||||||
- `token_hash` 唯一
|
|
||||||
- `user_id` → users,级联删除
|
|
||||||
- `expires_at`
|
|
||||||
- `created_at`
|
|
||||||
|
|
||||||
## folders
|
- `habits`:完成型/数值型、目标/上限、日/周/月/间隔计划、开始日、排序和归档
|
||||||
|
- `habit_logs`:习惯与日期唯一的数值记录
|
||||||
|
- `habit_pauses`:习惯暂停区间
|
||||||
|
- `countdowns`:标题、日期、公历/农历字段、类型、重复、兼容保留的图标字段、置顶与归档
|
||||||
|
- `memos`:标题、Markdown 内容、乐观锁版本、创建/更新时间和软删除时间
|
||||||
|
|
||||||
- `id`
|
## 完整备份 v2 内部状态
|
||||||
- `user_id` → users
|
|
||||||
- `name`
|
|
||||||
- `position`
|
|
||||||
- `created_at`
|
|
||||||
- `deleted_at`,非空表示软删除
|
|
||||||
|
|
||||||
删除文件夹不会删除清单;清单的 `folder_id` 被置空。
|
- `backup_preflights`:令牌散列、用户、备份 ID/摘要/大小、暂存路径、模式、状态、过期/消费时间和待清理路径
|
||||||
|
- `backup_imports`:每用户已导入备份 ID、归档摘要、模式和时间;用于幂等判断
|
||||||
|
- `backup_import_entities`:源实体 ID 到目标 ID 的映射及内容摘要;用于 merge 冲突检测与可重试导入
|
||||||
|
|
||||||
## task_lists
|
这些表和账户/会话/审计信息不属于用户可迁移业务实体。ZIP v2 只导出:
|
||||||
|
|
||||||
- `id`
|
`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。
|
||||||
- `user_id` → users
|
|
||||||
- `folder_id` → folders,可空
|
|
||||||
- `name`
|
|
||||||
- `is_inbox`,每个用户初始化时创建一个受保护的系统收集箱
|
|
||||||
- `position`
|
|
||||||
- `created_at`
|
|
||||||
- `deleted_at`,非空表示软删除
|
|
||||||
|
|
||||||
删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除。
|
附件导出时去掉内部 `storage_name`,改用归档内安全路径并携带真实字节;恢复时生成目标存储名。每个实体文件和附件字节均由 manifest SHA-256 覆盖。
|
||||||
|
|
||||||
## tasks
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `user_id` → users
|
|
||||||
- `list_id` → task_lists
|
|
||||||
- `parent_id` → tasks,可空;仅允许一层子任务且必须与父任务同清单
|
|
||||||
- `title`
|
|
||||||
- `description`
|
|
||||||
- `priority`(0–3)
|
|
||||||
- `completed`
|
|
||||||
- `due_at`,可空
|
|
||||||
- `version`,乐观锁版本;单任务更新用 `id + user_id + version` 原子比较更新
|
|
||||||
- `position`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
- `deleted_at`,非空表示进入回收站
|
|
||||||
|
|
||||||
顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。
|
|
||||||
|
|
||||||
## countdowns
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `user_id` → users,级联删除
|
|
||||||
- `title`
|
|
||||||
- `event_date`,仅日期
|
|
||||||
- `kind`:`countdown` / `anniversary` / `birthday`
|
|
||||||
- `repeat_rule`:`none` / `weekly` / `monthly` / `yearly`
|
|
||||||
- `icon`
|
|
||||||
- `pinned`,每个用户仅保留一个置顶项
|
|
||||||
- `archived_at`,非空表示归档
|
|
||||||
- `created_at` / `updated_at`
|
|
||||||
|
|
||||||
## 迁移
|
## 迁移
|
||||||
|
|
||||||
- `0001_initial.py`:已部署的初始模式,不修改
|
迁移按 `0001` 至 `0019` 顺序应用;当前最新 `0019_backup_imports.py` 增加完整备份预检、导入及实体映射账本。历史迁移还覆盖任务管理、会话元数据、查询索引、习惯排序、倒数日/农历、日期型截止语义、重复触发模式、备忘录与 `completed_at` 等演进。
|
||||||
- `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引
|
|
||||||
- `0008_remove_calendar_subscriptions.py`:移除日历订阅表
|
|
||||||
- `0009_remove_tags.py`:移除历史标签表及任务标签关联表
|
|
||||||
- `0010_countdowns.py`:新增倒数日、纪念日与生日表
|
|
||||||
|
|
||||||
## 后续阶段预留
|
|
||||||
|
|
||||||
- task_reminders
|
|
||||||
- task_recurrence_templates
|
|
||||||
- task_recurrence_exceptions
|
|
||||||
- habits / habit_logs / habit_reminders
|
|
||||||
- attachments
|
|
||||||
- audit_logs
|
|
||||||
|
|||||||
+31
-102
@@ -1,117 +1,46 @@
|
|||||||
# dodo 产品与技术决策记录
|
# dodo 产品与技术决策记录
|
||||||
|
|
||||||
## 定位
|
## 定位与当前范围
|
||||||
|
|
||||||
dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标不是一比一复刻 TickTick,而是做一个数据归自己、界面温暖紧凑、适合个人长期使用的任务系统。
|
dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道。
|
||||||
|
|
||||||
## 当前边界
|
## 技术与数据
|
||||||
|
|
||||||
- 首版只做本地开发验证,不部署。
|
- Monorepo:Vue 3 + TypeScript + Vite 前端,FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic 后端
|
||||||
- 首版不做通知渠道:Web Push、Telegram、SMTP 暂不实现。
|
- PostgreSQL 生产、SQLite 测试;UUIDv7 主键,时间点使用 UTC,日历语义使用用户时区
|
||||||
- 首版附件只做本地存储,不实现 S3。
|
- 单 Docker 镜像,外部 PostgreSQL;AGPL-3.0
|
||||||
- 首版不做番茄钟。
|
- 用户业务读写必须按归属过滤;更新任务/备忘录使用乐观锁
|
||||||
- 首版不做自然语言创建任务。
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- Monorepo:`frontend/`、`backend/`
|
|
||||||
- 前端:Vue 3 + TypeScript + Vite + Tailwind CSS + Shadcn-vue/Reka UI
|
|
||||||
- 后端:FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
|
|
||||||
- 数据库:PostgreSQL,主键 UUIDv7,时间统一 UTC,用户配置时区
|
|
||||||
- 包管理:uv + pnpm
|
|
||||||
- 交付:单 Docker 镜像,外部 PostgreSQL
|
|
||||||
- 许可证:AGPL-3.0
|
|
||||||
|
|
||||||
## 数据库
|
|
||||||
|
|
||||||
本地开发数据库:
|
|
||||||
|
|
||||||
```text
|
|
||||||
postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
|
|
||||||
```
|
|
||||||
|
|
||||||
已从默认 `postgres` 库迁移到独立 `dodo` 库。误建在 `postgres.public` 的 dodo 表已清理。
|
|
||||||
|
|
||||||
## 产品模型
|
## 产品模型
|
||||||
|
|
||||||
### 任务
|
- 文件夹 → 清单 → 一层任务树;系统收集箱受保护
|
||||||
|
- 清单删除定义为归档:保留任务成员关系,归档期间隐藏,恢复后原样出现;永久删除仅允许作用于已归档清单
|
||||||
|
- 截止日期区分全天日期和具体时间;重复任务支持 RFC 5545 计划重复及“完成后重复”
|
||||||
|
- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档
|
||||||
|
- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档
|
||||||
|
- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除
|
||||||
|
|
||||||
- 文件夹 → 清单 → 任务
|
## UI 决策
|
||||||
- 系统内置收集箱,不允许删除
|
|
||||||
- 任务支持一层子任务
|
|
||||||
- 状态:未完成 / 已完成
|
|
||||||
- 优先级:无 / 低 / 中 / 高
|
|
||||||
- Markdown 描述
|
|
||||||
- 截止日期 + 具体时间
|
|
||||||
- 多提醒设计预留
|
|
||||||
- 删除为软删除,回收站手动清空
|
|
||||||
- 支持创建、修改、完成、恢复、删除操作历史
|
|
||||||
- 并发编辑使用原子乐观锁
|
|
||||||
|
|
||||||
### 重复任务
|
- 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航
|
||||||
|
- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转
|
||||||
|
- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物
|
||||||
|
- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作
|
||||||
|
- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复
|
||||||
|
- 桌面任务/备忘录详情可保持非模态,移动端由同一组件切为底部模态弹层
|
||||||
|
|
||||||
- RFC 5545 RRULE
|
## 备份决策
|
||||||
- 模板 + 实例
|
|
||||||
- 修改范围:仅本次 / 本次及以后 / 全部
|
|
||||||
- 删除单次保存为例外
|
|
||||||
- 每月 31 日在无 31 日月份跳过
|
|
||||||
- 逾期完成不影响下次计划日期
|
|
||||||
|
|
||||||
### 习惯
|
- “完整备份”专指 `dodo-backup` ZIP version 2,而不是旧 JSON/CSV
|
||||||
|
- v2 覆盖全部用户业务实体、历史/例外、附件元数据与附件字节;manifest 记录实体数量和每个条目的 SHA-256
|
||||||
- 完成型 + 数值型
|
- 恢复必须先预检,再用绑定用户、文件摘要和模式的短期单次令牌执行
|
||||||
- 每天 / 每周 / 每月 / 间隔天数
|
- 预检拒绝未知/缺失实体、不安全 ZIP 路径、重复条目、异常压缩比/容量、校验和错误、非法字段、破坏关系拓扑或一层任务树的数据
|
||||||
- 数值型当日累计,达标后封顶
|
- `merge` 通过持久化 ID/摘要账本保证幂等与冲突可见;`replace` 仅替换当前用户业务数据
|
||||||
- 允许补打和修改历史
|
- 附件恢复采用同文件系统暂存/隔离与补偿;数据库提交后的清理失败可用同一令牌重试清理,不会再次导入
|
||||||
- 支持暂停区间,暂停期不破坏连续记录
|
- 保留 JSON/CSV v1 恢复兼容,但明确其不包含日志、暂停、重复例外和附件字节,仅用于旧数据迁移
|
||||||
- 归档后保留历史统计
|
|
||||||
|
|
||||||
### 倒数纪念日
|
|
||||||
|
|
||||||
- 支持倒数日、纪念日、生日
|
|
||||||
- 支持不重复、每周、每月、每年重复
|
|
||||||
- 未来显示“还有 N 天”,当天显示“就是今天”,过去显示“已经 N 天”
|
|
||||||
- 支持单一置顶、归档恢复、编辑和删除
|
|
||||||
|
|
||||||
## UI 方向
|
|
||||||
|
|
||||||
- 手账生活感
|
|
||||||
- 中高信息密度
|
|
||||||
- 10–12px 中等圆角
|
|
||||||
- 细分割线为主,少量浅底色
|
|
||||||
- 强调色:`#F15A29`
|
|
||||||
- 只做浅色模式
|
|
||||||
- 系统字体栈
|
|
||||||
- 不使用猫猫元素
|
|
||||||
- 轻微动效
|
|
||||||
|
|
||||||
## 页面结构
|
|
||||||
|
|
||||||
- 桌面三栏:左导航 / 中任务列表 / 右任务详情
|
|
||||||
- 手机底部导航
|
|
||||||
- 顶部快速输入,手机悬浮新增按钮
|
|
||||||
- 桌面右侧详情栏,手机底部弹层
|
|
||||||
- 习惯首页:今日习惯列表 + 一周打卡格
|
|
||||||
- 搜索:顶部搜索框 + 全局搜索快捷键
|
|
||||||
|
|
||||||
## 工程质量
|
## 工程质量
|
||||||
|
|
||||||
- `/api/v1` API 路径
|
- API 基路径 `/api/v1`;Cookie Session、同源 CSRF、安全响应头、登录限流
|
||||||
- 统一错误码、可读提示和字段详情
|
- 附件与备份均有容量限制、路径包含检查和用户归属校验
|
||||||
- 页面内诊断信息 + Toast
|
- 后端使用 pytest + ruff,前端使用 Vitest + vue-tsc/Vite;变更结束运行全量测试、构建和 `git diff --check`
|
||||||
- readiness 检查数据库
|
|
||||||
- 首版不提供 Prometheus metrics
|
|
||||||
- 审计日志记录操作人、实体、动作、时间和变更摘要
|
|
||||||
- 手动 JSON 全量导出
|
|
||||||
- 附件默认 20MB,可用环境变量调整
|
|
||||||
- 默认允许常用文档与图片,拒绝危险文件类型
|
|
||||||
|
|
||||||
## 第一阶段验收
|
|
||||||
|
|
||||||
- 可初始化管理员
|
|
||||||
- 可登录
|
|
||||||
- 可创建清单和任务
|
|
||||||
- 后端测试通过
|
|
||||||
- 前端生产构建通过
|
|
||||||
- PostgreSQL 迁移成功
|
|
||||||
- 本地应用可以启动并访问
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import type { APIRequestContext, Page } from '@playwright/test'
|
||||||
|
import { expect, test } from './fixtures'
|
||||||
|
import { unzipSync } from 'fflate'
|
||||||
|
|
||||||
|
function bottomTab(page: Page, name: string) {
|
||||||
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csrf(request: APIRequestContext, baseURL: string) {
|
||||||
|
const state = await request.storageState()
|
||||||
|
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
|
||||||
|
?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value
|
||||||
|
?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
|
||||||
|
const token = await csrf(request, baseURL)
|
||||||
|
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => {
|
||||||
|
const suffix = testInfo.project.name
|
||||||
|
const taskTitle = `E2E 备份任务 ${suffix}`
|
||||||
|
const habitName = `E2E 备份习惯 ${suffix}`
|
||||||
|
const countdownTitle = `E2E 备份倒数日 ${suffix}`
|
||||||
|
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||||
|
expect(bootstrap.ok()).toBeTruthy()
|
||||||
|
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
|
expect(inbox).toBeTruthy()
|
||||||
|
|
||||||
|
const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } })
|
||||||
|
expect(task.ok()).toBeTruthy()
|
||||||
|
const taskData = await task.json() as { id: string; version: number }
|
||||||
|
const attachmentName = `原始附件-${suffix}.txt`
|
||||||
|
const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255])
|
||||||
|
const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, {
|
||||||
|
method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } },
|
||||||
|
})
|
||||||
|
expect(attachmentResponse.ok()).toBeTruthy()
|
||||||
|
const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string }
|
||||||
|
expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' })
|
||||||
|
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } })
|
||||||
|
expect(habitResponse.ok()).toBeTruthy()
|
||||||
|
const habit = await habitResponse.json()
|
||||||
|
const day = new Date().toLocaleDateString('sv-SE')
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy()
|
||||||
|
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } })
|
||||||
|
expect(countdownResponse.ok()).toBeTruthy()
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await bottomTab(page, '设置').click()
|
||||||
|
const downloadPromise = page.waitForEvent('download')
|
||||||
|
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
||||||
|
const download = await downloadPromise
|
||||||
|
const zipPath = await download.path()
|
||||||
|
expect(zipPath).not.toBeNull()
|
||||||
|
const zipBytes = new Uint8Array(await readFile(zipPath!))
|
||||||
|
const files = unzipSync(zipBytes)
|
||||||
|
const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record<string, number>; checksums: Record<string, string> }
|
||||||
|
expect(manifest.format).toBe('dodo-backup')
|
||||||
|
expect(manifest.version).toBe(2)
|
||||||
|
expect(manifest.entities.tasks).toBeGreaterThan(0)
|
||||||
|
expect(manifest.entities.habit_logs).toBeGreaterThan(0)
|
||||||
|
expect(manifest.entities.attachments).toBeGreaterThan(0)
|
||||||
|
const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }>
|
||||||
|
const archivedAttachment = attachmentRows.find(item => item.id === attachment.id)
|
||||||
|
expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||||
|
expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes)
|
||||||
|
expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex'))
|
||||||
|
for (const [entry, digest] of Object.entries(manifest.checksums)) {
|
||||||
|
expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy()
|
||||||
|
expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy()
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy()
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||||
|
expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([])
|
||||||
|
|
||||||
|
const chooser = page.locator('input[type=file]')
|
||||||
|
await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) })
|
||||||
|
await page.getByLabel('恢复方式').selectOption('replace')
|
||||||
|
await page.getByRole('button', { name: '开始预检' }).click()
|
||||||
|
const preflight = page.locator('.backup-preflight')
|
||||||
|
await expect(preflight).toContainText('预检通过')
|
||||||
|
await expect(preflight).toContainText('附件')
|
||||||
|
await page.getByRole('button', { name: '替换并恢复' }).click()
|
||||||
|
const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' })
|
||||||
|
await confirm.getByRole('button', { name: '确认', exact: true }).click()
|
||||||
|
await expect(page.getByRole('status')).toContainText('数据已恢复')
|
||||||
|
|
||||||
|
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
|
||||||
|
expect(restoredTasksResponse.ok()).toBeTruthy()
|
||||||
|
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
|
||||||
|
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
|
||||||
|
const restoredTask = restoredTasks.find(item => item.title === taskTitle)!
|
||||||
|
const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`)
|
||||||
|
expect(restoredAttachmentsResponse.ok()).toBeTruthy()
|
||||||
|
const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }>
|
||||||
|
expect(restoredAttachments).toHaveLength(1)
|
||||||
|
expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||||
|
const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`)
|
||||||
|
expect(restoredBlob.ok()).toBeTruthy()
|
||||||
|
expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes)
|
||||||
|
await bottomTab(page, '习惯').click()
|
||||||
|
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
|
||||||
|
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
|
||||||
|
await expect(page.locator('.habit-history__row')).toContainText('2 / 2')
|
||||||
|
await page.getByRole('button', { name: '关闭习惯详情' }).click()
|
||||||
|
await bottomTab(page, '倒数日').click()
|
||||||
|
await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, test as base } from '@playwright/test'
|
||||||
|
|
||||||
|
export const expectedErrors = new WeakMap<object, Set<string>>()
|
||||||
|
export function allowExpectedError(page: object, fragment: string) {
|
||||||
|
expectedErrors.get(page)?.add(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
const failures: string[] = []
|
||||||
|
const allowed = new Set<string>()
|
||||||
|
expectedErrors.set(page, allowed)
|
||||||
|
const record = (message: string) => {
|
||||||
|
if (![...allowed].some(pattern => message.includes(pattern))) failures.push(message)
|
||||||
|
}
|
||||||
|
page.on('pageerror', error => record(`pageerror: ${error.message}`))
|
||||||
|
page.on('console', message => { if (message.type() === 'error') record(`console.error: ${message.text()}`) })
|
||||||
|
page.on('requestfailed', request => record(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`))
|
||||||
|
page.on('response', response => {
|
||||||
|
const url = new URL(response.url())
|
||||||
|
const baseURL = new URL(page.url() || 'http://127.0.0.1:5173')
|
||||||
|
if (url.origin === baseURL.origin && response.status() >= 400) record(`http ${response.status()}: ${response.request().method()} ${url.pathname}`)
|
||||||
|
})
|
||||||
|
await use(page)
|
||||||
|
expect(failures, 'unexpected browser/runtime errors').toEqual([])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { expect }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { chromium, type FullConfig } from '@playwright/test'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export default async function globalSetup(config: FullConfig) {
|
||||||
|
const projectName = process.env.DODO_E2E_PROJECT
|
||||||
|
if (!projectName) throw new Error('DODO_E2E_PROJECT is required')
|
||||||
|
const baseURL = config.projects[0]?.use.baseURL as string
|
||||||
|
const storageState = path.resolve('playwright-runtime', projectName, 'auth.json')
|
||||||
|
mkdirSync(path.dirname(storageState), { recursive: true })
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ baseURL })
|
||||||
|
const page = await context.newPage()
|
||||||
|
const readyDeadline = Date.now() + 90_000
|
||||||
|
let ready = false
|
||||||
|
while (Date.now() < readyDeadline) {
|
||||||
|
try {
|
||||||
|
const [health, frontend, proxy] = await Promise.all([
|
||||||
|
page.request.get('/health/ready'), page.request.get('/'), page.request.get('/api/v1/setup/status'),
|
||||||
|
])
|
||||||
|
if (health.ok() && frontend.ok() && proxy.ok()) { ready = true; break }
|
||||||
|
} catch {}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
if (!ready) throw new Error('isolated frontend/backend/proxy did not become ready')
|
||||||
|
const initialized = await page.request.get('/api/v1/setup/status')
|
||||||
|
if (!initialized.ok()) throw new Error(`setup status failed: ${initialized.status()}`)
|
||||||
|
if ((await initialized.json()).initialized) throw new Error(`isolated ${projectName} runtime was already initialized`)
|
||||||
|
const response = await page.request.post('/api/v1/setup/initialize', { data: { username: `e2e-owner-${projectName}`, password: 'e2e-password-1234' } })
|
||||||
|
if (!response.ok()) throw new Error(`initialize failed: ${response.status()} ${await response.text()}`)
|
||||||
|
await context.storageState({ path: storageState })
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
import { allowExpectedError, expect, test } from './fixtures'
|
||||||
|
|
||||||
|
async function bottomTab(page: Page, name: string) {
|
||||||
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskRow(page: Page, title: string) {
|
||||||
|
return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertInsideViewport(locator: Locator, page: Page) {
|
||||||
|
const box = await locator.boundingBox()
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(viewport).not.toBeNull()
|
||||||
|
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||||
|
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
|
||||||
|
const title = `E2E 今日任务 ${testInfo.project.name}`
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
|
||||||
|
await page.getByRole('button', { name: '添加任务' }).click()
|
||||||
|
await page.getByLabel('任务名称').fill(title)
|
||||||
|
await page.getByRole('button', { name: '添加任务', exact: true }).click()
|
||||||
|
|
||||||
|
const row = taskRow(page, title)
|
||||||
|
await expect(row).toHaveCount(1)
|
||||||
|
const rowMain = row.locator('.task-main')
|
||||||
|
await rowMain.click()
|
||||||
|
const detail = page.getByRole('dialog', { name: '任务详情' })
|
||||||
|
await expect(detail).toBeVisible()
|
||||||
|
await assertInsideViewport(detail, page)
|
||||||
|
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||||
|
await expect(rowMain).toBeFocused()
|
||||||
|
|
||||||
|
await row.getByRole('button', { name: `完成${title}` }).click()
|
||||||
|
await expect(row).toHaveClass(/done/)
|
||||||
|
await page.reload()
|
||||||
|
const persisted = taskRow(page, title)
|
||||||
|
await expect(persisted).toHaveCount(1)
|
||||||
|
await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible()
|
||||||
|
await persisted.getByRole('button', { name: `重新打开${title}` }).click()
|
||||||
|
await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => {
|
||||||
|
const name = `E2E 数量习惯 ${testInfo.project.name}`
|
||||||
|
await page.goto('/')
|
||||||
|
await (await bottomTab(page, '习惯')).click()
|
||||||
|
await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page')
|
||||||
|
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||||
|
await page.getByLabel('新习惯名称').fill(name)
|
||||||
|
await page.getByLabel('习惯类型').selectOption('numeric')
|
||||||
|
await page.getByLabel('目标值').fill('2')
|
||||||
|
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
|
||||||
|
|
||||||
|
const row = page.locator('.habit-row').filter({ hasText: name })
|
||||||
|
await expect(row).toHaveCount(1)
|
||||||
|
const check = row.getByRole('button', { name: `完成${name}一次` })
|
||||||
|
await check.click()
|
||||||
|
await expect(row).toContainText('1 / 2')
|
||||||
|
await check.click()
|
||||||
|
await expect(row).toContainText('2 / 2')
|
||||||
|
await row.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||||
|
const detail = page.getByRole('dialog', { name: name })
|
||||||
|
await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible()
|
||||||
|
await expect(detail.locator('.habit-history__row')).toContainText('2 / 2')
|
||||||
|
await detail.getByRole('button', { name: '编辑习惯' }).click()
|
||||||
|
await page.getByLabel('目标值').fill('3')
|
||||||
|
await page.getByRole('button', { name: '保存修改' }).click()
|
||||||
|
await expect(row).toContainText('2 / 3')
|
||||||
|
await row.getByRole('button', { name: `完成${name}一次` }).click()
|
||||||
|
await expect(row).toContainText('3 / 3')
|
||||||
|
await page.reload()
|
||||||
|
const persistedRow = page.locator('.habit-row').filter({ hasText: name })
|
||||||
|
await expect(persistedRow).toContainText('3 / 3')
|
||||||
|
await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||||
|
const persistedDetail = page.getByRole('dialog', { name })
|
||||||
|
await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => {
|
||||||
|
const title = `E2E 倒数日 ${testInfo.project.name}`
|
||||||
|
await page.goto('/')
|
||||||
|
await (await bottomTab(page, '倒数日')).click()
|
||||||
|
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||||
|
await page.getByLabel('倒数日名称').fill(title)
|
||||||
|
await page.getByRole('button', { name: '保存', exact: true }).click()
|
||||||
|
|
||||||
|
const item = page.getByRole('button').filter({ hasText: title })
|
||||||
|
await expect(item).toHaveCount(1)
|
||||||
|
await item.click()
|
||||||
|
const detail = page.getByRole('dialog', { name: title })
|
||||||
|
await expect(detail).toBeVisible()
|
||||||
|
// The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet.
|
||||||
|
allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/')
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204),
|
||||||
|
detail.getByRole('button', { name: '归档' }).click(),
|
||||||
|
])
|
||||||
|
await page.getByRole('button', { name: /已归档(1)/ }).click()
|
||||||
|
const archived = page.locator('.archived-countdowns article').filter({ hasText: title })
|
||||||
|
await expect(archived).toHaveCount(1)
|
||||||
|
await archived.getByRole('button', { name: '恢复' }).click()
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.getByText(title, { exact: true })).toHaveCount(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import { expect, test } from './fixtures'
|
||||||
|
|
||||||
|
function bottomTab(page: Page, name: string) {
|
||||||
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskComposer(page: Page) {
|
||||||
|
await page.getByRole('button', { name: '添加任务' }).click()
|
||||||
|
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await bottomTab(page, '设置').click()
|
||||||
|
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
||||||
|
const groups = page.locator('.settings-group')
|
||||||
|
await expect(groups).toHaveCount(5)
|
||||||
|
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||||
|
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
|
||||||
|
return {
|
||||||
|
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(layout.bodyOverflow).toBe(0)
|
||||||
|
expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy()
|
||||||
|
const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ })
|
||||||
|
for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) {
|
||||||
|
const box = await target.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||||
|
}
|
||||||
|
for (const target of await sessionButtons.all()) {
|
||||||
|
const box = await target.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(page.locator('#app')).toHaveAttribute('inert', '')
|
||||||
|
const metrics = await dialog.evaluate(element => {
|
||||||
|
const panel = element as HTMLElement
|
||||||
|
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
|
||||||
|
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer, footer')
|
||||||
|
return {
|
||||||
|
horizontalOverflow: panel.scrollWidth - panel.clientWidth,
|
||||||
|
headerVisible: !header || header.getBoundingClientRect().top >= 0,
|
||||||
|
footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await openTaskComposer(page)
|
||||||
|
await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ }))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await bottomTab(page, '习惯').click()
|
||||||
|
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||||
|
await assertModal(page.getByRole('dialog', { name: '添加习惯' }))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await bottomTab(page, '倒数日').click()
|
||||||
|
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||||
|
await assertModal(page.getByRole('dialog', { name: '新建倒数日' }))
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||||
|
await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click()
|
||||||
|
await page.getByRole('button', { name: '添加备忘录' }).click()
|
||||||
|
await assertModal(page.getByRole('dialog', { name: '备忘录详情' }))
|
||||||
|
await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`)
|
||||||
|
await page.getByRole('button', { name: '关闭备忘录' }).click()
|
||||||
|
const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' })
|
||||||
|
await assertModal(confirmation)
|
||||||
|
await confirmation.getByRole('button', { name: '取消' }).click()
|
||||||
|
await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('备忘录标题')).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const opener = page.getByRole('button', { name: '添加任务' })
|
||||||
|
await opener.focus()
|
||||||
|
const dialog = await openTaskComposer(page)
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(page.getByLabel('任务名称')).toBeFocused()
|
||||||
|
const background = page.locator('#app')
|
||||||
|
await expect(background).toHaveAttribute('aria-hidden', 'true')
|
||||||
|
await expect(background).toHaveAttribute('inert', '')
|
||||||
|
const geometry = await page.locator('.app-overlay').evaluate(element => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const hit = document.elementFromPoint(2, 2)
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element }
|
||||||
|
})
|
||||||
|
expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true })
|
||||||
|
|
||||||
|
const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true })
|
||||||
|
const first = focusable.first()
|
||||||
|
const last = focusable.last()
|
||||||
|
await first.focus()
|
||||||
|
await page.keyboard.press('Shift+Tab')
|
||||||
|
await expect(last).toBeFocused()
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(dialog).toBeHidden()
|
||||||
|
await expect(opener).toBeFocused()
|
||||||
|
|
||||||
|
await openTaskComposer(page)
|
||||||
|
await page.mouse.click(2, 2)
|
||||||
|
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden()
|
||||||
|
})
|
||||||
@@ -1 +1 @@
|
|||||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run --exclude 'e2e/**'","test:e2e:mobile":"node scripts/playwright-mobile.mjs"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@playwright/test":"^1.63.0","@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","fflate":"^0.8.3","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const projectName = process.env.DODO_E2E_PROJECT
|
||||||
|
if (!projectName) throw new Error('DODO_E2E_PROJECT is required; use pnpm test:e2e:mobile')
|
||||||
|
const project = projectName === 'mobile-390'
|
||||||
|
? { name: 'mobile-390', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } }
|
||||||
|
: projectName === 'mobile-375'
|
||||||
|
? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }
|
||||||
|
: null
|
||||||
|
if (!project) throw new Error(`unknown DODO_E2E_PROJECT: ${projectName}`)
|
||||||
|
|
||||||
|
const runtimeRoot = path.resolve('playwright-runtime', projectName)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 45_000,
|
||||||
|
expect: { timeout: 8_000 },
|
||||||
|
outputDir: path.join(runtimeRoot, 'test-results'),
|
||||||
|
reporter: [['line'], ['html', { outputFolder: path.join(runtimeRoot, 'report'), open: 'never' }]],
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:5173',
|
||||||
|
storageState: path.join(runtimeRoot, 'auth.json'),
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [project],
|
||||||
|
webServer: {
|
||||||
|
command: 'node scripts/playwright-mobile-server.mjs',
|
||||||
|
url: 'http://127.0.0.1:5173',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+36
@@ -39,6 +39,9 @@ importers:
|
|||||||
specifier: latest
|
specifier: latest
|
||||||
version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.63.0
|
||||||
|
version: 1.63.0
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.3.3([email protected](@types/[email protected])([email protected]))
|
version: 4.3.3([email protected](@types/[email protected])([email protected]))
|
||||||
@@ -48,6 +51,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 26.4.1
|
version: 26.4.1
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^30.0.1
|
specifier: ^30.0.1
|
||||||
version: 30.0.1
|
version: 30.0.1
|
||||||
@@ -180,6 +186,11 @@ packages:
|
|||||||
'@oxc-project/[email protected]':
|
'@oxc-project/[email protected]':
|
||||||
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
|
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
|
||||||
|
|
||||||
|
'@playwright/[email protected]':
|
||||||
|
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
|
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -614,6 +625,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -878,6 +892,16 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==}
|
resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -1303,6 +1327,10 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/[email protected]': {}
|
'@oxc-project/[email protected]': {}
|
||||||
|
|
||||||
|
'@playwright/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.63.0
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1674,6 +1702,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1901,6 +1931,12 @@ snapshots:
|
|||||||
exsolve: 1.1.1
|
exsolve: 1.1.1
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.63.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.18
|
nanoid: 3.3.18
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const frontend = process.cwd()
|
||||||
|
const root = path.resolve(frontend, '..')
|
||||||
|
const project = process.env.DODO_E2E_PROJECT
|
||||||
|
if (!project) throw new Error('DODO_E2E_PROJECT is required')
|
||||||
|
const runtimeBase = path.join(frontend, 'playwright-runtime', project)
|
||||||
|
const runtime = path.join(runtimeBase, `run-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`)
|
||||||
|
const attachments = path.join(runtime, 'attachments')
|
||||||
|
const staging = path.join(runtime, 'backup-staging')
|
||||||
|
mkdirSync(attachments, { recursive: true })
|
||||||
|
mkdirSync(staging, { recursive: true })
|
||||||
|
writeFileSync(path.join(runtimeBase, 'latest.json'), JSON.stringify({ runtime, database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||||
|
writeFileSync(path.join(runtime, 'runtime.json'), JSON.stringify({ database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||||
|
|
||||||
|
const logs = {
|
||||||
|
backend: createWriteStream(path.join(runtime, 'backend.log'), { flags: 'a' }),
|
||||||
|
frontend: createWriteStream(path.join(runtime, 'frontend.log'), { flags: 'a' }),
|
||||||
|
}
|
||||||
|
const children = []
|
||||||
|
function start(command, args, options, log) {
|
||||||
|
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
child.stdout.pipe(log)
|
||||||
|
child.stderr.pipe(log)
|
||||||
|
children.push(child)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
start(path.join(root, '.venv/bin/uvicorn'), ['backend.main:app', '--host', '127.0.0.1', '--port', '8781'], {
|
||||||
|
cwd: root,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DODO_DATABASE_URL: `sqlite+aiosqlite:///${path.join(runtime, 'dodo.sqlite3')}`,
|
||||||
|
DODO_AUTO_CREATE_SCHEMA: 'true',
|
||||||
|
DODO_COOKIE_SECURE: 'false',
|
||||||
|
DODO_ATTACHMENT_DIR: attachments,
|
||||||
|
DODO_BACKUP_STAGING_DIR: staging,
|
||||||
|
},
|
||||||
|
}, logs.backend)
|
||||||
|
start('pnpm', ['exec', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { cwd: frontend, env: process.env }, logs.frontend)
|
||||||
|
|
||||||
|
let stopping = false
|
||||||
|
function stop(signal = 'SIGTERM') {
|
||||||
|
if (stopping) return
|
||||||
|
stopping = true
|
||||||
|
for (const child of children) if (!child.killed) child.kill(signal)
|
||||||
|
setTimeout(() => { for (const child of children) if (!child.killed) child.kill('SIGKILL') }, 3000).unref()
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', () => stop())
|
||||||
|
process.on('SIGINT', () => stop())
|
||||||
|
process.on('exit', () => stop())
|
||||||
|
|
||||||
|
await Promise.all(children.map(child => new Promise((resolve, reject) => {
|
||||||
|
child.once('exit', (code, signal) => stopping ? resolve() : reject(new Error(`server exited code=${code} signal=${signal}`)))
|
||||||
|
})))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
const projects = ['mobile-390', 'mobile-375']
|
||||||
|
for (const project of projects) {
|
||||||
|
const code = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('pnpm', ['exec', 'playwright', 'test', '--project', project], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, DODO_E2E_PROJECT: project },
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
child.once('error', reject)
|
||||||
|
child.once('exit', value => resolve(value ?? 1))
|
||||||
|
})
|
||||||
|
if (code !== 0) process.exit(code)
|
||||||
|
}
|
||||||
+35
-79
@@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf'
|
|||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
|
||||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
@@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
|||||||
import CalendarPicker from './components/CalendarPicker.vue'
|
import CalendarPicker from './components/CalendarPicker.vue'
|
||||||
import TaskDueDisplay from './components/TaskDueDisplay.vue'
|
import TaskDueDisplay from './components/TaskDueDisplay.vue'
|
||||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
||||||
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||||
|
|
||||||
@@ -49,8 +50,6 @@ let archivedListActionTrigger: HTMLElement | null = null
|
|||||||
const purgeListTarget = ref<TaskList | null>(null)
|
const purgeListTarget = ref<TaskList | null>(null)
|
||||||
const purgeListSubmitting = ref(false)
|
const purgeListSubmitting = ref(false)
|
||||||
const purgeListError = ref('')
|
const purgeListError = ref('')
|
||||||
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
|
|
||||||
const purgeListDialog = ref<HTMLElement | null>(null)
|
|
||||||
let purgeListTrigger: HTMLElement | null = null
|
let purgeListTrigger: HTMLElement | null = null
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const overdueTasks = ref<Task[]>([])
|
const overdueTasks = ref<Task[]>([])
|
||||||
@@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock()
|
|||||||
const mobileSidebar = ref(false)
|
const mobileSidebar = ref(false)
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const mobileDetail = ref(false)
|
const mobileDetail = ref(false)
|
||||||
const mobileMore = ref(false)
|
|
||||||
const moreSettingsOpen = ref(false)
|
const moreSettingsOpen = ref(false)
|
||||||
const markdownPreview = ref(false)
|
const markdownPreview = ref(false)
|
||||||
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||||
@@ -296,39 +294,16 @@ function toggleSidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalVisible = ref(false)
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
const modalTitle = ref('')
|
async function confirmAction(title: string, description?: string, danger = false) {
|
||||||
const modalLabel = ref('')
|
return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
|
||||||
const modalValue = ref('')
|
}
|
||||||
const modalError = ref('')
|
async function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||||
const modalConfirmText = ref('确定')
|
const result = await appDialog.value?.show({
|
||||||
const modalResolve = ref<((value: string | null) => void) | null>(null)
|
title, label, initial, confirmText,
|
||||||
function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
validate: label ? (value) => normalizeRequiredName(value).error : undefined,
|
||||||
return new Promise<string | null>((resolve) => {
|
|
||||||
modalTitle.value = title
|
|
||||||
modalLabel.value = label
|
|
||||||
modalValue.value = initial
|
|
||||||
modalError.value = ''
|
|
||||||
modalConfirmText.value = confirmText
|
|
||||||
modalVisible.value = true
|
|
||||||
modalResolve.value = resolve
|
|
||||||
})
|
})
|
||||||
}
|
return typeof result === 'string' ? result.trim() : null
|
||||||
function closeModal() {
|
|
||||||
modalVisible.value = false
|
|
||||||
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
|
|
||||||
}
|
|
||||||
function confirmModal() {
|
|
||||||
if (modalLabel.value) {
|
|
||||||
const normalized = normalizeRequiredName(modalValue.value)
|
|
||||||
if (normalized.error) {
|
|
||||||
modalError.value = normalized.error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modalValue.value = normalized.value
|
|
||||||
}
|
|
||||||
modalVisible.value = false
|
|
||||||
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeName = computed(() => {
|
const activeName = computed(() => {
|
||||||
@@ -679,7 +654,7 @@ async function loadTrash() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function switchView(view: View, listId?: string) {
|
async function switchView(view: View, listId?: string) {
|
||||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||||
taskMutationNavigation.value += 1
|
taskMutationNavigation.value += 1
|
||||||
taskReorderMode.value = false
|
taskReorderMode.value = false
|
||||||
cancelTaskReorder()
|
cancelTaskReorder()
|
||||||
@@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) {
|
|||||||
if (listId) activeList.value = listId
|
if (listId) activeList.value = listId
|
||||||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||||||
page.value = 1
|
page.value = 1
|
||||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||||
if (view !== 'memos') memoDetailOpen.value = false
|
if (view !== 'memos') memoDetailOpen.value = false
|
||||||
if (view === 'trash') await loadTrash()
|
if (view === 'trash') await loadTrash()
|
||||||
else if (view === 'today') await loadTodayView()
|
else if (view === 'today') await loadTodayView()
|
||||||
@@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function removeTask(task: Task) {
|
async function removeTask(task: Task) {
|
||||||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
|
||||||
try {
|
try {
|
||||||
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
||||||
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
||||||
@@ -1014,7 +989,7 @@ async function restoreTask(task: Task) {
|
|||||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
|
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
|
||||||
}
|
}
|
||||||
async function purgeTask(task: Task) {
|
async function purgeTask(task: Task) {
|
||||||
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
|
||||||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
|
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
|
||||||
}
|
}
|
||||||
async function addSubtask() {
|
async function addSubtask() {
|
||||||
@@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) {
|
|||||||
purgeListError.value = ''
|
purgeListError.value = ''
|
||||||
archivedListAction.value = null
|
archivedListAction.value = null
|
||||||
archivedListActionTrigger = null
|
archivedListActionTrigger = null
|
||||||
nextTick(() => purgeCancelButton.value?.focus())
|
|
||||||
}
|
}
|
||||||
function focusPurgeListTrigger() {
|
function focusPurgeListTrigger() {
|
||||||
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
||||||
@@ -1164,15 +1138,6 @@ function closePurgeList() {
|
|||||||
purgeListError.value = ''
|
purgeListError.value = ''
|
||||||
focusPurgeListTrigger()
|
focusPurgeListTrigger()
|
||||||
}
|
}
|
||||||
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
|
||||||
if (event.key !== 'Tab' || !purgeListDialog.value) return
|
|
||||||
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
|
|
||||||
if (!controls.length) return
|
|
||||||
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
|
|
||||||
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
|
|
||||||
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
|
|
||||||
}
|
|
||||||
async function confirmPurgeList() {
|
async function confirmPurgeList() {
|
||||||
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
||||||
purgeListSubmitting.value = true
|
purgeListSubmitting.value = true
|
||||||
@@ -1467,8 +1432,8 @@ onUnmounted(() => {
|
|||||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||||
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction">
|
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
|
||||||
<section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`">
|
<template v-if="sidebarAction">
|
||||||
<template v-if="!listMoveMenuOpen">
|
<template v-if="!listMoveMenuOpen">
|
||||||
<header class="app-sheet__header sidebar-action-header">
|
<header class="app-sheet__header sidebar-action-header">
|
||||||
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
||||||
@@ -1505,8 +1470,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</template>
|
||||||
</div>
|
</AppSheet>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||||
@@ -1573,8 +1538,8 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" @close="closeTaskDetail">
|
||||||
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||||
<div class="detail-form">
|
<div class="detail-form">
|
||||||
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
||||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||||
@@ -1608,14 +1573,11 @@ onUnmounted(() => {
|
|||||||
</div></details>
|
</div></details>
|
||||||
<div class="detail-actions"><button class="secondary" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
<div class="detail-actions"><button class="secondary" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</AppSheet>
|
||||||
|
|
||||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
|
||||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||||
<Transition name="task-compose">
|
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
|
||||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
|
||||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
||||||
@@ -1638,28 +1600,22 @@ onUnmounted(() => {
|
|||||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||||
</form>
|
</AppSheet>
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
|
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
|
||||||
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
|
<template v-if="purgeListTarget">
|
||||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
<div class="app-sheet__body">
|
||||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||||
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
|
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
<footer class="app-sheet__footer"><button class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></footer>
|
||||||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
</template>
|
||||||
<div class="modal-box" role="dialog" aria-modal="true">
|
</AppSheet>
|
||||||
<h3>{{ modalTitle }}</h3>
|
<AppDialog ref="appDialog" />
|
||||||
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
|
|
||||||
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, h, nextTick } from 'vue'
|
||||||
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
|
import { invalidateCountdownCache } from './lib/mvp-utils'
|
||||||
|
|
||||||
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
|
const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' }
|
||||||
|
const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
|
||||||
|
function deferred<T>() { let resolve!: (value:T)=>void; let reject!: (reason?:unknown)=>void; const promise = new Promise<T>((yes,no)=>{ resolve=yes; reject=no }); return { promise, resolve, reject } }
|
||||||
|
async function flush() { await Promise.resolve(); await new Promise((resolve)=>setTimeout(resolve, 0)); await Promise.resolve(); await nextTick() }
|
||||||
|
async function mountWithFetch(fetchMock: ReturnType<typeof vi.fn>) {
|
||||||
|
invalidateCountdownCache()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const notices:string[]=[]
|
||||||
|
const host=document.createElement('div'); document.body.append(host)
|
||||||
|
const app=createApp(()=>h(CountdownPanel,{ onNotice:(message:string)=>notices.push(message) })); app.mount(host)
|
||||||
|
const unmount=()=>{ app.unmount(); host.remove() }; cleanups.push(unmount)
|
||||||
|
await flush(); return { host, notices, unmount }
|
||||||
|
}
|
||||||
|
function clickCountdown(host:HTMLElement, title:string) {
|
||||||
|
const button=[...host.querySelectorAll<HTMLButtonElement>('.countdown-focus,.countdown-row')].find((candidate)=>candidate.textContent?.includes(title))
|
||||||
|
expect(button).toBeTruthy(); button!.click()
|
||||||
|
}
|
||||||
|
function detailButton(label:string) {
|
||||||
|
return [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet button')].find((button)=>button.textContent?.includes(label))!
|
||||||
|
}
|
||||||
|
function json(value: unknown) { return new Response(JSON.stringify(value), { status:200, headers:{ 'content-type':'application/json' } }) }
|
||||||
|
afterEach(()=>{ cleanups.splice(0).forEach((cleanup)=>cleanup()); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||||
|
|
||||||
describe('countdown modal accessibility', () => {
|
describe('countdown modal accessibility', () => {
|
||||||
it('names the dialog and supports focus and Escape close', () => {
|
it('names the shared dialog contract and delegates focus and Escape handling', () => {
|
||||||
expect(source).toContain('aria-labelledby="countdown-dialog-title"')
|
expect(source).toContain('title-id="countdown-dialog-title"')
|
||||||
expect(source).toContain('id="countdown-dialog-title"')
|
expect(source).toContain('id="countdown-dialog-title"')
|
||||||
expect(source).toContain('@keydown.esc="closeDialog"')
|
expect(source).toContain('initial-focus="input[aria-label=\'倒数日名称\']"')
|
||||||
expect(source).toContain('ref="titleInput"')
|
expect(source).not.toContain('trapDialogFocus')
|
||||||
expect(source).toContain('titleInput.value?.focus()')
|
expect(source).not.toContain('ref="titleInput"')
|
||||||
|
expect(source).not.toContain('ref="detailCloseButton"')
|
||||||
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -21,10 +48,10 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).toContain('item.id !== focusItem.value?.id')
|
expect(source).toContain('item.id !== focusItem.value?.id')
|
||||||
expect(source).toContain('`kind-${item.kind}`')
|
expect(source).toContain('`kind-${item.kind}`')
|
||||||
expect(source).toContain(':class="`kind-${focusItem.kind}`"')
|
expect(source).toContain(':class="`kind-${focusItem.kind}`"')
|
||||||
expect(source).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
expect(source).toContain('panel-class="countdown-detail-sheet"')
|
||||||
expect(source).toContain('ref="detailCloseButton"')
|
expect(source).not.toContain('ref="detailCloseButton"')
|
||||||
expect(source).toContain('detailCloseButton.value?.focus()')
|
expect(source).toContain('initial-focus="button[aria-label=\'关闭详情\']"')
|
||||||
expect(source).toContain('@keydown="trapDetailFocus"')
|
expect(source).not.toContain('trapDetailFocus')
|
||||||
expect(source).not.toContain('class="countdown-actions"')
|
expect(source).not.toContain('class="countdown-actions"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,8 +78,8 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
||||||
expect(source).toContain('const generation = getCountdownCacheGeneration()')
|
expect(source).toContain('const generation = getCountdownCacheGeneration()')
|
||||||
expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return')
|
expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return')
|
||||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) error.value=')
|
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=')
|
||||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prevents duplicate submits and sends the edit precondition', () => {
|
it('prevents duplicate submits and sends the edit precondition', () => {
|
||||||
@@ -85,4 +112,92 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).toContain('添加第一个重要日子')
|
expect(source).toContain('添加第一个重要日子')
|
||||||
expect(source).toContain('@click="openFromEmpty"')
|
expect(source).toContain('@click="openFromEmpty"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends only one pin request on a rapid double click and disables all detail writes', async () => {
|
||||||
|
const pin = deferred<Response>()
|
||||||
|
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
||||||
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||||
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||||
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||||
|
throw new Error(`unexpected ${url} ${options?.method}`)
|
||||||
|
})
|
||||||
|
const { host } = await mountWithFetch(fetchMock)
|
||||||
|
host.querySelector<HTMLButtonElement>('.countdown-focus')!.click(); await nextTick()
|
||||||
|
const pinButton = [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].find((button)=>button.textContent?.includes('置顶'))!
|
||||||
|
pinButton.click(); pinButton.click(); await nextTick()
|
||||||
|
expect(fetchMock.mock.calls.filter(([url])=>String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
||||||
|
expect([...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].every((button)=>button.disabled)).toBe(true)
|
||||||
|
pin.resolve(json({})); await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a stale successful detail write after close and opening another countdown', async () => {
|
||||||
|
const pin = deferred<Response>()
|
||||||
|
const fetchMock = vi.fn((url: string) => {
|
||||||
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||||
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||||
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||||
|
throw new Error(`unexpected ${url}`)
|
||||||
|
})
|
||||||
|
const { host, notices } = await mountWithFetch(fetchMock)
|
||||||
|
clickCountdown(host, '发布日'); await nextTick()
|
||||||
|
detailButton('置顶').click(); await nextTick()
|
||||||
|
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||||
|
clickCountdown(host, '旅行日'); await nextTick()
|
||||||
|
|
||||||
|
pin.resolve(json({})); await flush()
|
||||||
|
|
||||||
|
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||||
|
expect(notices).toEqual([])
|
||||||
|
expect(detailButton('置顶').disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a stale failed detail write without polluting the new detail or unlocking its operation', async () => {
|
||||||
|
const oldPin = deferred<Response>()
|
||||||
|
const newPin = deferred<Response>()
|
||||||
|
const fetchMock = vi.fn((url: string) => {
|
||||||
|
if (url.endsWith('/countdowns/c1/pin')) return oldPin.promise
|
||||||
|
if (url.endsWith('/countdowns/c2/pin')) return newPin.promise
|
||||||
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||||
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||||
|
throw new Error(`unexpected ${url}`)
|
||||||
|
})
|
||||||
|
const { host, notices } = await mountWithFetch(fetchMock)
|
||||||
|
clickCountdown(host, '发布日'); await nextTick()
|
||||||
|
detailButton('置顶').click(); await nextTick()
|
||||||
|
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||||
|
clickCountdown(host, '旅行日'); await nextTick()
|
||||||
|
detailButton('置顶').click(); await nextTick()
|
||||||
|
|
||||||
|
oldPin.reject(new Error('旧请求失败')); await flush()
|
||||||
|
|
||||||
|
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||||
|
expect(host.querySelector('.inline-error')?.textContent ?? '').not.toContain('旧请求失败')
|
||||||
|
expect(notices).toEqual([])
|
||||||
|
expect(detailButton('置顶').disabled).toBe(true)
|
||||||
|
newPin.resolve(json({})); await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the normal current-detail pin flow working', async () => {
|
||||||
|
const pin = deferred<Response>()
|
||||||
|
const fetchMock = vi.fn((url: string) => {
|
||||||
|
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||||
|
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||||
|
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||||
|
throw new Error(`unexpected ${url}`)
|
||||||
|
})
|
||||||
|
const { host, notices } = await mountWithFetch(fetchMock)
|
||||||
|
clickCountdown(host, '发布日'); await nextTick()
|
||||||
|
detailButton('置顶').click(); pin.resolve(json({})); await flush()
|
||||||
|
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
||||||
|
expect(host.querySelector('.countdown-view')?.classList.contains('loading')).toBe(false)
|
||||||
|
expect(notices).toEqual(['已置顶'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guards mutation commits and cleanup with the captured detail context', () => {
|
||||||
|
expect(source).toContain('const operationGeneration = ref(0)')
|
||||||
|
expect(source).toContain('const detailGeneration = ref(0)')
|
||||||
|
expect(source).toContain('context.detailGeneration === detailGeneration.value')
|
||||||
|
expect(source).toContain("context.detailId === (detailItem.value?.id ?? null)")
|
||||||
|
expect(source).toContain('if (currentContext(context)) busy.value=false')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
|
import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
||||||
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
|
||||||
type Countdown = {
|
type Countdown = {
|
||||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||||
@@ -17,34 +19,21 @@ const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
|||||||
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
||||||
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
|
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
|
||||||
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
||||||
|
const operationGeneration = ref(0)
|
||||||
|
const detailGeneration = ref(0)
|
||||||
|
type OperationContext = { generation:number; detailId:string|null; detailGeneration:number }
|
||||||
|
let activeOperation:OperationContext|null = null
|
||||||
|
let mounted = true
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
||||||
const form = ref<Form>(freshForm())
|
const form = ref<Form>(freshForm())
|
||||||
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||||
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
|
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
|
||||||
const titleInput = ref<HTMLInputElement | null>(null)
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
const detailCloseButton = ref<HTMLButtonElement | null>(null)
|
|
||||||
let previousFocus: HTMLElement | null = null
|
|
||||||
|
|
||||||
function focusDialog() {
|
|
||||||
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
||||||
void nextTick(() => titleInput.value?.focus())
|
|
||||||
}
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
open.value = false
|
open.value = false
|
||||||
showAdvanced.value = false
|
showAdvanced.value = false
|
||||||
void nextTick(() => previousFocus?.focus())
|
|
||||||
}
|
|
||||||
function trapDialogFocus(event: KeyboardEvent) {
|
|
||||||
if (event.key !== 'Tab') return
|
|
||||||
const dialog = event.currentTarget as HTMLElement
|
|
||||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
|
||||||
.filter((item) => !item.hasAttribute('disabled'))
|
|
||||||
if (!controls.length) return
|
|
||||||
const first = controls[0]
|
|
||||||
const last = controls[controls.length - 1]
|
|
||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryDate(item: Countdown) {
|
function primaryDate(item: Countdown) {
|
||||||
@@ -99,7 +88,25 @@ async function request(path:string, options:RequestInit={}) {
|
|||||||
}
|
}
|
||||||
return response.status === 204 ? null : response.json()
|
return response.status === 204 ? null : response.json()
|
||||||
}
|
}
|
||||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
|
function currentContext(context:OperationContext) {
|
||||||
|
return mounted && context.generation === operationGeneration.value && context.detailGeneration === detailGeneration.value && context.detailId === (detailItem.value?.id ?? null)
|
||||||
|
}
|
||||||
|
async function safe(detailId:string|null, work:(context:OperationContext)=>Promise<void>) {
|
||||||
|
if (activeOperation && currentContext(activeOperation)) return
|
||||||
|
const context={ generation:++operationGeneration.value, detailId, detailGeneration:detailGeneration.value }
|
||||||
|
activeOperation=context
|
||||||
|
busy.value=true
|
||||||
|
error.value=''
|
||||||
|
try {
|
||||||
|
await work(context)
|
||||||
|
} catch(reason) {
|
||||||
|
if (!currentContext(context)) return
|
||||||
|
error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||||
|
} finally {
|
||||||
|
if (activeOperation === context) activeOperation=null
|
||||||
|
if (currentContext(context)) busy.value=false
|
||||||
|
}
|
||||||
|
}
|
||||||
async function fetchCountdowns() {
|
async function fetchCountdowns() {
|
||||||
const [active, archivedItems] = await Promise.all([
|
const [active, archivedItems] = await Promise.all([
|
||||||
request('/countdowns') as Promise<Countdown[]>,
|
request('/countdowns') as Promise<Countdown[]>,
|
||||||
@@ -107,31 +114,36 @@ async function fetchCountdowns() {
|
|||||||
])
|
])
|
||||||
return { items: active, archived: archivedItems }
|
return { items: active, archived: archivedItems }
|
||||||
}
|
}
|
||||||
async function load(force = false) {
|
async function load(force = false, manageBusy = true) {
|
||||||
const generation = getCountdownCacheGeneration()
|
const generation = getCountdownCacheGeneration()
|
||||||
const cached = readCountdownCache<Countdown>()
|
const cached = readCountdownCache<Countdown>()
|
||||||
if (cached) { items.value=cached.items; archived.value=cached.archived }
|
if (cached) { items.value=cached.items; archived.value=cached.archived }
|
||||||
if (!cached) busy.value=true
|
if (!cached && manageBusy) busy.value=true
|
||||||
error.value=''
|
if (manageBusy) error.value=''
|
||||||
try {
|
try {
|
||||||
const data = await loadCountdownCache(fetchCountdowns, { force })
|
const data = await loadCountdownCache(fetchCountdowns, { force })
|
||||||
if (!isCountdownCacheGenerationCurrent(generation)) return
|
if (!isCountdownCacheGenerationCurrent(generation)) return
|
||||||
items.value=data.items
|
items.value=data.items
|
||||||
archived.value=data.archived
|
archived.value=data.archived
|
||||||
} catch(reason) {
|
} catch(reason) {
|
||||||
if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||||
} finally {
|
} finally {
|
||||||
if (isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function selectDetail(item:Countdown|null) {
|
||||||
|
detailGeneration.value += 1
|
||||||
|
detailItem.value=item
|
||||||
|
error.value=''
|
||||||
|
if (!activeOperation || !currentContext(activeOperation)) busy.value=false
|
||||||
|
}
|
||||||
function edit(item:Countdown) {
|
function edit(item:Countdown) {
|
||||||
detailItem.value=null
|
selectDetail(null)
|
||||||
editingId.value=item.id
|
editingId.value=item.id
|
||||||
editingItem.value=item
|
editingItem.value=item
|
||||||
showAdvanced.value=false
|
showAdvanced.value=false
|
||||||
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
||||||
open.value=true
|
open.value=true
|
||||||
focusDialog()
|
|
||||||
}
|
}
|
||||||
function applyKindDefaults() {
|
function applyKindDefaults() {
|
||||||
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
|
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
|
||||||
@@ -140,40 +152,35 @@ function applyKindDefaults() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
if (!form.value.title.trim()) return
|
if (!form.value.title.trim()) return
|
||||||
await safe(async()=>{
|
await safe(null, async(context)=>{
|
||||||
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
||||||
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
|
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
|
||||||
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
||||||
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||||
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
||||||
invalidateCountdownCache(); closeDialog(); await load(true); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
if (!currentContext(context)) return
|
||||||
|
invalidateCountdownCache(); closeDialog(); await load(true, false)
|
||||||
|
if (!currentContext(context)) return
|
||||||
|
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已置顶')})}
|
async function pin(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已置顶');closeDetail();await load(true,false)})}
|
||||||
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})}
|
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
|
||||||
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})}
|
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
|
||||||
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);emit('notice','已永久删除')})}
|
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已永久删除')})}
|
||||||
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`}
|
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`}
|
||||||
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
|
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
|
||||||
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
||||||
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
|
function openDetail(item:Countdown){selectDetail(item)}
|
||||||
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
|
function closeDetail(){selectDetail(null)}
|
||||||
function trapDetailFocus(event: KeyboardEvent) {
|
|
||||||
if (event.key !== 'Tab') return
|
|
||||||
const dialog = event.currentTarget as HTMLElement
|
|
||||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
|
||||||
.filter((item) => !item.hasAttribute('disabled'))
|
|
||||||
if (!controls.length) return
|
|
||||||
const first = controls[0]
|
|
||||||
const last = controls[controls.length - 1]
|
|
||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
||||||
}
|
|
||||||
function openFromEmpty(){openCountdownComposer()}
|
function openFromEmpty(){openCountdownComposer()}
|
||||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;selectDetail(null);editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true}
|
||||||
defineExpose({ openCountdownComposer })
|
defineExpose({ openCountdownComposer })
|
||||||
onMounted(() => { void load() })
|
onMounted(() => { void load() })
|
||||||
onBeforeUnmount(() => { previousFocus = null })
|
onBeforeUnmount(() => {
|
||||||
|
mounted = false
|
||||||
|
operationGeneration.value += 1
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -199,21 +206,20 @@ onBeforeUnmount(() => { previousFocus = null })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
||||||
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
<button v-if="archived.length" class="archived-toggle" :disabled="busy" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||||
</div>
|
</div>
|
||||||
<Transition name="countdown-detail">
|
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
||||||
<div v-if="detailItem" class="countdown-detail-mask app-sheet-mask" @click.self="closeDetail"><article class="countdown-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail" @keydown="trapDetailFocus">
|
<template v-if="detailItem">
|
||||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||||
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
||||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" :disabled="busy" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||||
</article></div>
|
</template>
|
||||||
</Transition>
|
</AppSheet>
|
||||||
<Transition name="countdown-compose">
|
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
|
||||||
<div v-if="open" class="countdown-modal-mask app-sheet-mask" @click.self="closeDialog"><form class="countdown-modal app-sheet app-sheet--create" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
|
<header class="app-sheet__header"><div><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
||||||
<header class="app-sheet__header"><div><small>{{editingId?'调整重要日子':'快速记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
<div class="app-sheet__body"><label>名称<input v-model="form.title" aria-label="倒数日名称" maxlength="200" required placeholder="例如:去北海道旅行"></label>
|
||||||
<div class="app-sheet__body"><label>名称<input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></label>
|
|
||||||
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
|
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
|
||||||
<label>类型<select v-model="form.kind" @change="applyKindDefaults"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
<label>类型<select v-model="form.kind" @change="applyKindDefaults"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
||||||
<details class="countdown-advanced" :open="showAdvanced" @toggle="showAdvanced=($event.target as HTMLDetailsElement).open"><summary><span>更多设置</span><ChevronDown/></summary>
|
<details class="countdown-advanced" :open="showAdvanced" @toggle="showAdvanced=($event.target as HTMLDetailsElement).open"><summary><span>更多设置</span><ChevronDown/></summary>
|
||||||
@@ -228,7 +234,7 @@ onBeforeUnmount(() => { previousFocus = null })
|
|||||||
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||||
</details></div>
|
</details></div>
|
||||||
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
|
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
|
||||||
</form></div>
|
</AppSheet>
|
||||||
</Transition>
|
<AppDialog ref="appDialog" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('memo shell integration', () => {
|
|||||||
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
|
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
|
||||||
expect(panel).toContain("import MemoRow")
|
expect(panel).toContain("import MemoRow")
|
||||||
expect(panel).toContain("import MemoEditor")
|
expect(panel).toContain("import MemoEditor")
|
||||||
expect(app).toContain('<span>任务详情</span>')
|
expect(app).toContain('<span id="task-detail-title">任务详情</span>')
|
||||||
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +46,16 @@ describe('memo shell integration', () => {
|
|||||||
expect(css).toContain('.memo-markdown-preview{min-height:250px')
|
expect(css).toContain('.memo-markdown-preview{min-height:250px')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses AppSheet for mobile memo detail while keeping desktop detail non-modal', () => {
|
||||||
|
expect(panel).toContain("import AppSheet from './components/AppSheet.vue'")
|
||||||
|
expect(panel).toContain('<AppSheet :open="Boolean(selected)" :modal="mobileDetail"')
|
||||||
|
expect(panel).toContain('panel-class="memo-editor"')
|
||||||
|
expect(panel).toContain('title-id="memo-editor-title"')
|
||||||
|
expect(panel).not.toContain('memo-editor-scrim')
|
||||||
|
expect(editor).not.toContain('aria-modal')
|
||||||
|
expect(editor).not.toContain("event.key !== 'Tab'")
|
||||||
|
})
|
||||||
|
|
||||||
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
|
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
|
||||||
expect(app).toContain('const memoDetailOpen = ref(false)')
|
expect(app).toContain('const memoDetailOpen = ref(false)')
|
||||||
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
|
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
|
||||||
@@ -68,6 +78,5 @@ describe('memo shell integration', () => {
|
|||||||
expect(css).toContain('height:min(92dvh,820px)')
|
expect(css).toContain('height:min(92dvh,820px)')
|
||||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
||||||
expect(editor).toContain("window.addEventListener('beforeunload'")
|
expect(editor).toContain("window.addEventListener('beforeunload'")
|
||||||
expect(editor).toContain("event.key !== 'Tab'")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ function deferred<T>() {
|
|||||||
return { promise, resolve, reject }
|
return { promise, resolve, reject }
|
||||||
}
|
}
|
||||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||||
|
async function answerDialog(confirm: boolean) {
|
||||||
|
await nextTick()
|
||||||
|
const selector = confirm ? '.app-dialog button[type="submit"]' : '.app-dialog .secondary'
|
||||||
|
document.querySelector<HTMLButtonElement>(selector)!.click()
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
|
async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
@@ -85,7 +91,6 @@ describe('MemoPanel', () => {
|
|||||||
['success', null],
|
['success', null],
|
||||||
['error', new Error('迟到保存失败')],
|
['error', new Error('迟到保存失败')],
|
||||||
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => {
|
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
||||||
const pending = deferred<unknown>()
|
const pending = deferred<unknown>()
|
||||||
const notices: string[] = []
|
const notices: string[] = []
|
||||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH'
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH'
|
||||||
@@ -96,7 +101,7 @@ describe('MemoPanel', () => {
|
|||||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||||
if (failure) pending.reject(failure)
|
if (failure) pending.reject(failure)
|
||||||
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
||||||
await flush()
|
await flush()
|
||||||
@@ -119,8 +124,7 @@ describe('MemoPanel', () => {
|
|||||||
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
|
||||||
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
||||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -130,7 +134,6 @@ describe('MemoPanel', () => {
|
|||||||
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||||
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||||
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => {
|
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
||||||
const scoped = { ...item, deleted_at: deletedAt }
|
const scoped = { ...item, deleted_at: deletedAt }
|
||||||
const notices: string[] = []
|
const notices: string[] = []
|
||||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
@@ -141,13 +144,14 @@ describe('MemoPanel', () => {
|
|||||||
const { host } = await mount(request, (message) => notices.push(message))
|
const { host } = await mount(request, (message) => notices.push(message))
|
||||||
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
||||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
host.querySelector<HTMLButtonElement>(selector)!.click()
|
||||||
|
if (_name !== 'restore') await answerDialog(true)
|
||||||
|
await flush()
|
||||||
expect(notices).toEqual([message])
|
expect(notices).toEqual([message])
|
||||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
|
||||||
const details: boolean[] = []
|
const details: boolean[] = []
|
||||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
? { ...item, content: '正文' }
|
? { ...item, content: '正文' }
|
||||||
@@ -157,7 +161,7 @@ describe('MemoPanel', () => {
|
|||||||
row.click(); await flush()
|
row.click(); await flush()
|
||||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(false)
|
||||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||||
expect(details).toEqual([true])
|
expect(details).toEqual([true])
|
||||||
expect(document.activeElement).toBe(content)
|
expect(document.activeElement).toBe(content)
|
||||||
@@ -174,7 +178,7 @@ describe('MemoPanel', () => {
|
|||||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||||
window.dispatchEvent(new Event('resize')); await nextTick()
|
window.dispatchEvent(new Event('resize')); await nextTick()
|
||||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
||||||
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
expect(document.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,8 +251,8 @@ describe('MemoPanel', () => {
|
|||||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||||
if (lifecycle === 'delete') {
|
if (lifecycle === 'delete') {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
|
||||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||||
|
await answerDialog(true)
|
||||||
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||||
await flush()
|
await flush()
|
||||||
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
||||||
@@ -510,8 +514,7 @@ describe('MemoPanel', () => {
|
|||||||
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
|
||||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||||
|
|
||||||
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 })
|
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 })
|
||||||
@@ -539,8 +542,7 @@ describe('MemoPanel', () => {
|
|||||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
@@ -574,8 +576,7 @@ describe('MemoPanel', () => {
|
|||||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
@@ -613,8 +614,7 @@ describe('MemoPanel', () => {
|
|||||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||||
|
|
||||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||||
@@ -742,7 +742,6 @@ describe('MemoPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
||||||
const remove = deferred<unknown>()
|
const remove = deferred<unknown>()
|
||||||
const other = { ...item, id: 'm2', title: '第二条' }
|
const other = { ...item, id: 'm2', title: '第二条' }
|
||||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
@@ -754,6 +753,7 @@ describe('MemoPanel', () => {
|
|||||||
const { host } = await mount(request)
|
const { host } = await mount(request)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||||
|
await answerDialog(true)
|
||||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||||
remove.resolve(undefined); await flush()
|
remove.resolve(undefined); await flush()
|
||||||
|
|
||||||
@@ -762,7 +762,6 @@ describe('MemoPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
||||||
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
||||||
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
||||||
if (path === '/memos/m1') return { ...item, content: '正文' }
|
if (path === '/memos/m1') return { ...item, content: '正文' }
|
||||||
@@ -770,7 +769,7 @@ describe('MemoPanel', () => {
|
|||||||
})
|
})
|
||||||
const { host } = await mount(request)
|
const { host } = await mount(request)
|
||||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await answerDialog(true)
|
||||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -791,6 +790,26 @@ describe('MemoPanel', () => {
|
|||||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses AppDialog for dirty draft creation and honors cancel then confirm', async () => {
|
||||||
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
|
? { ...item, content: '正文' }
|
||||||
|
: { items: [item], total: 1 })
|
||||||
|
const nativeConfirm = vi.spyOn(window, 'confirm')
|
||||||
|
const { host, vm } = await mount(request)
|
||||||
|
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||||
|
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||||
|
title.value = '未保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||||
|
|
||||||
|
void vm.createMemo(); await nextTick()
|
||||||
|
expect(document.querySelector('.app-dialog')?.textContent).toContain('放弃未保存的更改')
|
||||||
|
await answerDialog(false)
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('未保存')
|
||||||
|
|
||||||
|
void vm.createMemo(); await nextTick(); await answerDialog(true)
|
||||||
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||||
|
expect(nativeConfirm).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('switches between active and trash and opens a local draft through the exposed FAB action', async () => {
|
it('switches between active and trash and opens a local draft through the exposed FAB action', async () => {
|
||||||
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||||
const { host, vm } = await mount(request)
|
const { host, vm } = await mount(request)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|||||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
import { Archive, FileText, Search } from 'lucide-vue-next'
|
||||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||||
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
|
||||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const props = defineProps<{ request: RequestFn }>()
|
const props = defineProps<{ request: RequestFn }>()
|
||||||
@@ -18,6 +20,7 @@ const error = ref('')
|
|||||||
const selected = ref<MemoEditorValue | null>(null)
|
const selected = ref<MemoEditorValue | null>(null)
|
||||||
const selectedToken = ref(0)
|
const selectedToken = ref(0)
|
||||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||||
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
const mobileDetail = ref(window.innerWidth <= 930)
|
const mobileDetail = ref(window.innerWidth <= 930)
|
||||||
let detailOpener: HTMLElement | null = null
|
let detailOpener: HTMLElement | null = null
|
||||||
@@ -87,13 +90,20 @@ function closeDetail() {
|
|||||||
detailOpener = null
|
detailOpener = null
|
||||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||||
}
|
}
|
||||||
|
function showConfirm(options: AppDialogOptions) {
|
||||||
|
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
|
||||||
|
}
|
||||||
|
function confirmDiscard(description: string) {
|
||||||
|
if (!editor.value?.dirty) return Promise.resolve(true)
|
||||||
|
return showConfirm({ title: '放弃未保存的更改?', description, danger: true, confirmText: '放弃更改' })
|
||||||
|
}
|
||||||
async function setScope(next: 'active' | 'trash') {
|
async function setScope(next: 'active' | 'trash') {
|
||||||
if (next === scope.value) return
|
if (next === scope.value) return
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||||
closeDetail(); scope.value = next; emit('scope', next); await load()
|
closeDetail(); scope.value = next; emit('scope', next); await load()
|
||||||
}
|
}
|
||||||
async function selectMemo(id: string, opener?: EventTarget | null) {
|
async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||||
if (opener instanceof HTMLElement) detailOpener = opener
|
if (opener instanceof HTMLElement) detailOpener = opener
|
||||||
const token = ++detailGeneration
|
const token = ++detailGeneration
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +115,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
|
|||||||
}
|
}
|
||||||
async function createMemo() {
|
async function createMemo() {
|
||||||
if (scope.value === 'trash') return
|
if (scope.value === 'trash') return
|
||||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
if (editor.value?.dirty && !(await confirmDiscard('新建后,当前草稿不会保存。'))) return
|
||||||
const token = ++detailGeneration
|
const token = ++detailGeneration
|
||||||
selectedToken.value = token
|
selectedToken.value = token
|
||||||
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||||
@@ -195,13 +205,15 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
|
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
|
||||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
<AppSheet :open="Boolean(selected)" :modal="mobileDetail" variant="detail" panel-class="memo-editor" title-id="memo-editor-title" initial-focus="input[aria-label='备忘录标题']" :close-on-scrim="mobileDetail" @close="editor?.requestClose()">
|
||||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" :confirm-action="showConfirm" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||||
|
</AppSheet>
|
||||||
|
<AppDialog ref="appDialog" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+114
-50
@@ -1,10 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
|
||||||
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
|
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
|
||||||
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
|
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
||||||
|
import AppSheet from './components/AppSheet.vue'
|
||||||
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||||
@@ -51,12 +55,30 @@ const habitHistoryNextTo = ref('')
|
|||||||
const habitHistoryHasMore = ref(false)
|
const habitHistoryHasMore = ref(false)
|
||||||
let habitHistoryRequest = 0
|
let habitHistoryRequest = 0
|
||||||
const habitDetailClickSuppressed = ref(false)
|
const habitDetailClickSuppressed = ref(false)
|
||||||
const habitDetailSheet = ref<HTMLElement | null>(null)
|
|
||||||
let habitDetailOpener: HTMLElement | null = null
|
let habitDetailOpener: HTMLElement | null = null
|
||||||
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||||
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
|
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
|
||||||
const habitNameInput = ref<HTMLInputElement | null>(null)
|
const habitNameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
|
async function confirmAction(title: string, description?: string) {
|
||||||
|
return await appDialog.value?.show({ title, description, danger: true, confirmText: '确认' }) === true
|
||||||
|
}
|
||||||
const restoreFile = ref<File | null>(null)
|
const restoreFile = ref<File | null>(null)
|
||||||
|
const restoreMode = ref<BackupMode>('merge')
|
||||||
|
const restorePreflight = ref<BackupPreflight | null>(null)
|
||||||
|
const backupBusy = ref(false)
|
||||||
|
const backupError = ref('')
|
||||||
|
const restoreInput = ref<HTMLInputElement | null>(null)
|
||||||
|
let preflightGeneration = 0
|
||||||
|
let preflightController: AbortController | null = null
|
||||||
|
let acceptedPreflightSnapshot: BackupFileSnapshot | null = null
|
||||||
|
const legacyRestore = computed(() => Boolean(restoreFile.value && isLegacyBackup(restoreFile.value)))
|
||||||
|
function cancelPreflight() {
|
||||||
|
preflightGeneration += 1
|
||||||
|
preflightController?.abort()
|
||||||
|
preflightController = null
|
||||||
|
backupBusy.value = false
|
||||||
|
}
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
@@ -101,18 +123,16 @@ watch(habitReorderAvailable, (available) => {
|
|||||||
})
|
})
|
||||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
async function request(path: string, options: RequestInit = {}) {
|
async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
|
if (options.body instanceof FormData) {
|
||||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
const file = options.body.get('file')
|
||||||
const csrf = csrfHeader(options.method)
|
if (file instanceof File) return uploadJson<T>(path, file, options)
|
||||||
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
|
||||||
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}))
|
|
||||||
throw new Error(formatHabitApiError((body as { detail?: unknown }).detail))
|
|
||||||
}
|
}
|
||||||
const type = response.headers.get('content-type') || ''
|
let body: unknown = undefined
|
||||||
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
|
if (typeof options.body === 'string') {
|
||||||
|
try { body = JSON.parse(options.body) } catch { body = options.body }
|
||||||
|
}
|
||||||
|
return requestJson<T>(path, { ...options, body })
|
||||||
}
|
}
|
||||||
async function safe(work: () => Promise<void>) {
|
async function safe(work: () => Promise<void>) {
|
||||||
busy.value = true; error.value = ''
|
busy.value = true; error.value = ''
|
||||||
@@ -485,7 +505,6 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
|||||||
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
|
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
|
||||||
selectedHabit.value = h
|
selectedHabit.value = h
|
||||||
void loadHabitHistory(true)
|
void loadHabitHistory(true)
|
||||||
void nextTick(() => habitDetailSheet.value?.focus())
|
|
||||||
}
|
}
|
||||||
function closeHabitDetail() {
|
function closeHabitDetail() {
|
||||||
habitHistoryRequest += 1
|
habitHistoryRequest += 1
|
||||||
@@ -503,7 +522,7 @@ function closeHabitDetail() {
|
|||||||
}
|
}
|
||||||
defineExpose({ openHabitComposer, refreshHabits: loadHabits })
|
defineExpose({ openHabitComposer, refreshHabits: loadHabits })
|
||||||
async function archiveHabit(h: Habit) {
|
async function archiveHabit(h: Habit) {
|
||||||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
|
||||||
await safe(async () => {
|
await safe(async () => {
|
||||||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||||
selectedHabit.value = null
|
selectedHabit.value = null
|
||||||
@@ -532,7 +551,7 @@ async function restoreHabit(h: Habit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function deleteHabit(h: Habit) {
|
async function deleteHabit(h: Habit) {
|
||||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
if (!h.archived_at || !(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||||
@@ -587,7 +606,7 @@ async function loadHabits() {
|
|||||||
}
|
}
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
await safe(async () => {
|
await safe(async () => {
|
||||||
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
|
const [s, a] = await Promise.all([request<Session[] | { items?: Session[] }>('/sessions').catch(() => []), request<any[] | { items?: any[] }>('/audit-logs?limit=20').catch(() => [])])
|
||||||
sessions.value = mergePage<Session>(s).items
|
sessions.value = mergePage<Session>(s).items
|
||||||
audit.value = mergePage<any>(a).items
|
audit.value = mergePage<any>(a).items
|
||||||
})
|
})
|
||||||
@@ -596,7 +615,7 @@ async function revoke(id: string) {
|
|||||||
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||||||
}
|
}
|
||||||
async function revokeOtherSessions() {
|
async function revokeOtherSessions() {
|
||||||
if (!confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')) return
|
if (!(await confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。'))) return
|
||||||
await safe(async () => {
|
await safe(async () => {
|
||||||
await request('/sessions/others', { method: 'DELETE' })
|
await request('/sessions/others', { method: 'DELETE' })
|
||||||
await loadSettings()
|
await loadSettings()
|
||||||
@@ -607,26 +626,74 @@ 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() {
|
async function exportData() {
|
||||||
await safe(async () => {
|
backupBusy.value = true; backupError.value = ''
|
||||||
const response = await fetch('/api/v1/export.csv', { credentials: 'include' })
|
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
||||||
if (!response.ok) throw new Error('导出失败')
|
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
||||||
downloadBlob(await response.blob(), 'dodo-export.csv')
|
finally { backupBusy.value = false }
|
||||||
})
|
}
|
||||||
|
function selectRestoreFile(event: Event) {
|
||||||
|
cancelPreflight()
|
||||||
|
restoreFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||||
|
if (restoreFile.value && isLegacyBackup(restoreFile.value)) restoreMode.value = 'merge'
|
||||||
|
restorePreflight.value = null
|
||||||
|
acceptedPreflightSnapshot = null
|
||||||
|
backupError.value = ''
|
||||||
|
}
|
||||||
|
watch(restoreMode, () => {
|
||||||
|
cancelPreflight()
|
||||||
|
restorePreflight.value = null
|
||||||
|
acceptedPreflightSnapshot = null
|
||||||
|
backupError.value = ''
|
||||||
|
})
|
||||||
|
const backupEntityTotal = computed(() => Object.values(restorePreflight.value?.entities ?? {}).reduce((sum, count) => sum + count, 0))
|
||||||
|
async function runPreflight() {
|
||||||
|
const file = restoreFile.value
|
||||||
|
if (!file || isLegacyBackup(file)) return
|
||||||
|
cancelPreflight()
|
||||||
|
const generation = preflightGeneration
|
||||||
|
const snapshot = backupFileSnapshot(file, restoreMode.value)
|
||||||
|
const controller = new AbortController()
|
||||||
|
preflightController = controller
|
||||||
|
backupBusy.value = true; backupError.value = ''; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||||
|
try {
|
||||||
|
const result = await preflightBackup(file, snapshot.mode, controller.signal)
|
||||||
|
if (!shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)) return
|
||||||
|
restorePreflight.value = result
|
||||||
|
acceptedPreflightSnapshot = snapshot
|
||||||
|
} catch (reason) {
|
||||||
|
if (generation !== preflightGeneration || controller.signal.aborted) return
|
||||||
|
backupError.value = reason instanceof Error ? reason.message : '备份预检失败'
|
||||||
|
} finally {
|
||||||
|
if (generation === preflightGeneration) {
|
||||||
|
backupBusy.value = false
|
||||||
|
preflightController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function restore() {
|
async function restore() {
|
||||||
if (!restoreFile.value) return
|
const file = restoreFile.value
|
||||||
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
|
const mode = restoreMode.value
|
||||||
await safe(async () => {
|
const preview = restorePreflight.value
|
||||||
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) {
|
const legacy = Boolean(file && isLegacyBackup(file))
|
||||||
const form = new FormData()
|
if (!file || mode !== restoreMode.value || (legacy ? mode !== 'merge' : !preview?.valid || !acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode))) return
|
||||||
form.append('file', restoreFile.value!)
|
const destructive = legacy ? '旧格式将在恢复时由服务端校验,仅支持合并恢复。' : mode === 'replace' ? '现有数据将被备份内容替换,此操作不可撤销。' : '同名或相同标识的数据将按合并规则处理。'
|
||||||
await request('/restore.csv?mode=merge', { method: 'POST', body: form })
|
if (!(await confirmAction(mode === 'replace' ? '确认替换全部数据?' : '确认合并备份?', destructive))) return
|
||||||
|
if (file !== restoreFile.value || mode !== restoreMode.value || (!legacy && (!acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode)))) return
|
||||||
|
backupBusy.value = true; backupError.value = ''
|
||||||
|
try {
|
||||||
|
if (!legacy) {
|
||||||
|
if (!preview?.preflight_token) throw new Error('预检令牌无效,请重新预检')
|
||||||
|
await restoreBackup(preview.preflight_token, mode)
|
||||||
|
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
await uploadJson('/restore.csv?mode=merge', file)
|
||||||
} else {
|
} else {
|
||||||
const text = await restoreFile.value!.text()
|
await requestJson('/restore?mode=merge', { method: 'POST', body: JSON.parse(await file.text()) })
|
||||||
await request('/restore?mode=merge', { method: 'POST', body: text })
|
|
||||||
}
|
}
|
||||||
|
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||||
|
if (restoreInput.value) restoreInput.value.value = ''
|
||||||
emit('changed'); emit('notice', '数据已恢复')
|
emit('changed'); emit('notice', '数据已恢复')
|
||||||
})
|
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||||
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
passwordError.value = ''
|
passwordError.value = ''
|
||||||
@@ -665,6 +732,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
cancelPreflight()
|
||||||
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -694,9 +762,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 完整习惯列表 -->
|
<!-- 完整习惯列表 -->
|
||||||
<Transition name="task-compose">
|
<AppSheet :open="habitComposerOpen" variant="create" panel-class="task-compose-sheet habit-compose-sheet" title-id="habit-compose-title" initial-focus="input[aria-label='新习惯名称']" :busy="busy" :style="habitComposeStyle" @close="closeHabitComposer" @submit.prevent="saveHabit">
|
||||||
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
|
|
||||||
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
|
|
||||||
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
||||||
@@ -708,9 +774,7 @@ onBeforeUnmount(() => {
|
|||||||
<label v-if="habitSchedule === 'interval'">间隔天数<input v-model.number="habitIntervalDays" type="number" min="1" step="1" :aria-invalid="Boolean(habitErrors.interval_days)" aria-describedby="habit-interval-error"><small v-if="habitErrors.interval_days" id="habit-interval-error" class="field-error" role="alert">{{ habitErrors.interval_days }}</small></label>
|
<label v-if="habitSchedule === 'interval'">间隔天数<input v-model.number="habitIntervalDays" type="number" min="1" step="1" :aria-invalid="Boolean(habitErrors.interval_days)" aria-describedby="habit-interval-error"><small v-if="habitErrors.interval_days" id="habit-interval-error" class="field-error" role="alert">{{ habitErrors.interval_days }}</small></label>
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中…' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
|
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中…' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
|
||||||
</form>
|
</AppSheet>
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
|
|
||||||
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
|
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||||
@@ -742,9 +806,8 @@ onBeforeUnmount(() => {
|
|||||||
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<Transition name="countdown-detail">
|
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
||||||
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
<template v-if="selectedHabit">
|
||||||
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
|
||||||
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||||
@@ -767,9 +830,8 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
||||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||||
</article>
|
</template>
|
||||||
</div>
|
</AppSheet>
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 设置与数据 -->
|
<!-- 设置与数据 -->
|
||||||
@@ -777,12 +839,14 @@ onBeforeUnmount(() => {
|
|||||||
<header class="view-intro">
|
<header class="view-intro">
|
||||||
<div><small>备份、迁移与安全</small></div>
|
<div><small>备份、迁移与安全</small></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="settings-grid">
|
<div class="settings-sections">
|
||||||
<article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><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>
|
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件;CSV / JSON 继续用于旧格式兼容。</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
||||||
<article class="tool-card password-card"><Activity /><h2>修改密码</h2><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>
|
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录,其他设备自动退出。</p></header><form class="password-form settings-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></section>
|
||||||
<article class="tool-card wide"><LogOut /><h2>登录会话</h2><div class="session-card-actions"><p>可单独撤销设备,也可以一次撤销除当前设备外的全部会话。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div><div v-for="s in sessions" :key="s.id" class="session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||||
<article v-if="audit.length" class="tool-card wide"><Activity /><h2>最近活动</h2><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><div class="audit-copy"><span class="audit-action" :title="row.action ?? row.event ?? undefined">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div></article>
|
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作。</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||||
|
<section class="settings-group settings-danger"><header><h2>危险操作</h2><p>替换恢复会覆盖当前数据,请先导出完整备份。</p></header></section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<AppDialog ref="appDialog" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, requestBlob, requestJson, uploadJson } from './http'
|
||||||
|
import { downloadFullBackup, preflightBackup, restoreBackup } from './backups'
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
function response(body: BodyInit | null, init: ResponseInit = {}) {
|
||||||
|
return new Response(body, { status: 200, ...init })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('typed HTTP client', () => {
|
||||||
|
it('sends credentials, CSRF, JSON and AbortSignal consistently', async () => {
|
||||||
|
document.cookie = 'dodo_csrf=csrf-token'
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response('{"ok":true}', { headers: { 'content-type': 'application/json' } }))
|
||||||
|
|
||||||
|
await requestJson<{ ok: boolean }>('/example', { method: 'POST', body: { value: 1 }, signal })
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/example', expect.objectContaining({ credentials: 'include', signal, method: 'POST', body: '{"value":1}' }))
|
||||||
|
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers)
|
||||||
|
expect(headers.get('content-type')).toBe('application/json')
|
||||||
|
expect(headers.get('x-csrf-token')).toBe('csrf-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes FastAPI validation details without losing the status or code', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(JSON.stringify({ detail: [{ loc: ['body', 'name'], msg: '必填', type: 'missing' }], code: 'invalid_backup' }), { status: 422, headers: { 'content-type': 'application/json' } }))
|
||||||
|
|
||||||
|
const error = await requestJson('/bad').catch((reason) => reason)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ApiError)
|
||||||
|
const apiError = error as ApiError
|
||||||
|
expect(apiError).toMatchObject({ status: 422, code: 'invalid_backup' })
|
||||||
|
expect(apiError.message).toContain('name')
|
||||||
|
expect(apiError.message).toContain('必填')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps blob and multipart requests typed without forcing JSON content type', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||||
|
.mockResolvedValueOnce(response('{"valid":true}', { headers: { 'content-type': 'application/json' } }))
|
||||||
|
const blob = await requestBlob('/backup/export.zip')
|
||||||
|
expect(blob.size).toBe(8)
|
||||||
|
expect(blob.type).toBe('application/zip')
|
||||||
|
await uploadJson('/backup/preflight?mode=merge', new File(['zip'], 'backup.zip'))
|
||||||
|
const uploadHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers)
|
||||||
|
expect(uploadHeaders.has('content-type')).toBe(false)
|
||||||
|
expect(fetchMock.mock.calls[1][1]?.body).toBeInstanceOf(FormData)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('backup API', () => {
|
||||||
|
it('uses the versioned ZIP export, preflight and restore endpoints', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||||
|
.mockResolvedValueOnce(response(JSON.stringify({ valid: true, preflight_token: 'token', backup_id: 'backup', archive_sha256: 'sha', entities: { tasks: 2, attachments: 1 } }), { headers: { 'content-type': 'application/json' } }))
|
||||||
|
.mockResolvedValueOnce(response(JSON.stringify({ restored: 3, mode: 'replace' }), { headers: { 'content-type': 'application/json' } }))
|
||||||
|
|
||||||
|
await downloadFullBackup()
|
||||||
|
const preview = await preflightBackup(new File(['zip'], 'dodo.zip'), 'replace')
|
||||||
|
await restoreBackup(preview.preflight_token!, 'replace')
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||||
|
'/api/v1/backup/export.zip',
|
||||||
|
'/api/v1/backup/preflight?mode=replace',
|
||||||
|
'/api/v1/backup/restore',
|
||||||
|
])
|
||||||
|
expect(preview.entities.tasks).toBe(2)
|
||||||
|
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ preflight_token: 'token', mode: 'replace' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { requestBlob, requestJson, uploadJson } from './http'
|
||||||
|
|
||||||
|
export type BackupMode = 'merge' | 'replace'
|
||||||
|
export type BackupPreflight = {
|
||||||
|
valid: boolean
|
||||||
|
preflight_token?: string
|
||||||
|
backup_id?: string
|
||||||
|
archive_sha256?: string
|
||||||
|
version?: number
|
||||||
|
entities: Record<string, number>
|
||||||
|
attachment_count?: number
|
||||||
|
attachment_bytes?: number
|
||||||
|
warnings?: string[]
|
||||||
|
destructive_summary?: string[]
|
||||||
|
}
|
||||||
|
export type BackupRestoreResult = { restored: number; mode: BackupMode; already_imported?: boolean; cleanup_retried?: boolean }
|
||||||
|
|
||||||
|
export function downloadFullBackup(signal?: AbortSignal) {
|
||||||
|
return requestBlob('/backup/export.zip', { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preflightBackup(file: File, mode: BackupMode, signal?: AbortSignal) {
|
||||||
|
return uploadJson<BackupPreflight>(`/backup/preflight?mode=${mode}`, file, { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreBackup(preflightToken: string, mode: BackupMode, signal?: AbortSignal) {
|
||||||
|
return requestJson<BackupRestoreResult>('/backup/restore', { method: 'POST', body: { preflight_token: preflightToken, mode }, signal })
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type FastApiValidationIssue = { loc?: Array<string | number>; msg?: string; type?: string }
|
||||||
|
|
||||||
|
export function formatApiErrorDetail(detail: unknown): string {
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) return detail.map((issue) => {
|
||||||
|
if (!issue || typeof issue !== 'object') return String(issue)
|
||||||
|
const value = issue as FastApiValidationIssue
|
||||||
|
const location = value.loc?.filter((part) => part !== 'body').join('.')
|
||||||
|
return [location, value.msg].filter(Boolean).join(':') || value.type || '请求数据无效'
|
||||||
|
}).join(';')
|
||||||
|
if (detail && typeof detail === 'object') {
|
||||||
|
const value = detail as { message?: unknown; code?: unknown }
|
||||||
|
if (typeof value.message === 'string') return value.message
|
||||||
|
if (typeof value.code === 'string') return value.code
|
||||||
|
}
|
||||||
|
return '请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
readonly code?: string
|
||||||
|
readonly detail: unknown
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code?: string, detail?: unknown) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.code = code
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { csrfHeader } from '../lib/csrf'
|
||||||
|
import { ApiError, formatApiErrorDetail } from './errors'
|
||||||
|
|
||||||
|
export type JsonRequestOptions = Omit<RequestInit, 'body'> & { body?: unknown }
|
||||||
|
|
||||||
|
function apiUrl(path: string) {
|
||||||
|
return path.startsWith('/api/') ? path : `/api/v1${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, options: RequestInit = {}) {
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
const csrf = csrfHeader(options.method)
|
||||||
|
if (csrf['x-csrf-token']) headers.set('x-csrf-token', csrf['x-csrf-token'])
|
||||||
|
const response = await fetch(apiUrl(path), { credentials: 'include', ...options, headers })
|
||||||
|
if (!response.ok) {
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const body = contentType.includes('json') ? await response.json().catch(() => ({})) : await response.text().catch(() => '')
|
||||||
|
const detail = body && typeof body === 'object' && 'detail' in body ? body.detail : body
|
||||||
|
const code = body && typeof body === 'object' && typeof body.code === 'string'
|
||||||
|
? body.code
|
||||||
|
: detail && typeof detail === 'object' && !Array.isArray(detail) && typeof detail.code === 'string' ? detail.code : undefined
|
||||||
|
throw new ApiError(formatApiErrorDetail(detail), response.status, code, detail)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestJson<T>(path: string, options: JsonRequestOptions = {}): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
const { body: ignoredBody, ...requestOptions } = options
|
||||||
|
void ignoredBody
|
||||||
|
const body = options.body === undefined ? undefined : JSON.stringify(options.body)
|
||||||
|
if (body !== undefined) headers.set('content-type', 'application/json')
|
||||||
|
const response = await apiFetch(path, { ...requestOptions, headers, body })
|
||||||
|
return response.status === 204 ? undefined as T : await response.json() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestBlob(path: string, options: RequestInit = {}): Promise<Blob> {
|
||||||
|
return (await apiFetch(path, options)).blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadJson<T>(path: string, file: File, options: Omit<RequestInit, 'body'> = {}): Promise<T> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const response = await apiFetch(path, { ...options, method: options.method ?? 'POST', body: form })
|
||||||
|
return response.status === 204 ? undefined as T : await response.json() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError } from './errors'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './errors'
|
||||||
|
export * from './http'
|
||||||
|
export * from './backups'
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import AppSheet from './AppSheet.vue'
|
||||||
|
|
||||||
|
export type AppDialogOptions = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
label?: string
|
||||||
|
initial?: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
danger?: boolean
|
||||||
|
validate?: (value: string) => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const options = ref<AppDialogOptions>({ title: '' })
|
||||||
|
const value = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
let resolveDialog: ((value: boolean | string | null) => void) | null = null
|
||||||
|
|
||||||
|
function finish(result: boolean | string | null) {
|
||||||
|
if (!open.value || busy.value) return
|
||||||
|
open.value = false
|
||||||
|
resolveDialog?.(result)
|
||||||
|
resolveDialog = null
|
||||||
|
}
|
||||||
|
function cancel() { finish(options.value.label ? null : false) }
|
||||||
|
function confirm() {
|
||||||
|
if (options.value.label) {
|
||||||
|
const message = options.value.validate?.(value.value) ?? null
|
||||||
|
if (message) { error.value = message; void nextTick(() => document.querySelector<HTMLElement>('#app-dialog-error')?.focus()); return }
|
||||||
|
finish(value.value)
|
||||||
|
} else finish(true)
|
||||||
|
}
|
||||||
|
function show(next: AppDialogOptions) {
|
||||||
|
if (resolveDialog) resolveDialog(options.value.label ? null : false)
|
||||||
|
options.value = next
|
||||||
|
value.value = next.initial ?? ''
|
||||||
|
error.value = ''
|
||||||
|
open.value = true
|
||||||
|
return new Promise<boolean | string | null>((resolve) => { resolveDialog = resolve })
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resolveDialog?.(options.value.label ? null : false)
|
||||||
|
resolveDialog = null
|
||||||
|
})
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppSheet :open="open" variant="actions" panel-class="app-dialog" title-id="app-dialog-title" :description-id="options.description ? 'app-dialog-description' : undefined" initial-focus="[data-dialog-initial]" :busy="busy" @close="cancel" @submit.prevent="confirm">
|
||||||
|
<header class="app-sheet__header"><div><h2 id="app-dialog-title">{{ options.title }}</h2><p v-if="options.description" id="app-dialog-description">{{ options.description }}</p></div></header>
|
||||||
|
<div v-if="options.label" class="app-sheet__body"><label>{{ options.label }}<input v-model="value" data-dialog-initial class="modal-input" :aria-invalid="Boolean(error)" :aria-describedby="error ? 'app-dialog-error' : undefined" @input="error=''" /></label><small v-if="error" id="app-dialog-error" class="field-error" role="alert" tabindex="-1">{{ error }}</small></div>
|
||||||
|
<footer class="app-sheet__footer"><button type="button" class="secondary" data-dialog-initial :disabled="busy" @click="cancel">{{ options.cancelText ?? '取消' }}</button><button type="submit" :class="options.danger ? 'danger-button' : 'primary-small'" :disabled="busy">{{ options.confirmText ?? '确定' }}</button></footer>
|
||||||
|
</AppSheet>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, h, nextTick, ref } from 'vue'
|
||||||
|
import AppSheet from './AppSheet.vue'
|
||||||
|
import AppDialog from './AppDialog.vue'
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
|
afterEach(() => { cleanups.splice(0).forEach((fn) => fn()); document.body.innerHTML = '' })
|
||||||
|
|
||||||
|
async function mountSheet(options: { busy?: boolean; initialFocus?: string; modal?: boolean } = {}) {
|
||||||
|
const host = document.createElement('main')
|
||||||
|
const opener = document.createElement('button')
|
||||||
|
opener.textContent = 'open'
|
||||||
|
document.body.append(host, opener)
|
||||||
|
opener.focus()
|
||||||
|
const open = ref(true)
|
||||||
|
const close = vi.fn(() => { open.value = false })
|
||||||
|
const app = createApp({
|
||||||
|
setup: () => () => h(AppSheet, {
|
||||||
|
open: open.value,
|
||||||
|
titleId: 'sheet-title',
|
||||||
|
descriptionId: 'sheet-description',
|
||||||
|
busy: options.busy,
|
||||||
|
modal: options.modal,
|
||||||
|
initialFocus: options.initialFocus,
|
||||||
|
onClose: close,
|
||||||
|
}, {
|
||||||
|
default: () => [h('h2', { id: 'sheet-title' }, '标题'), h('p', { id: 'sheet-description' }, '说明'), h('button', { id: 'first' }, 'first'), h('button', { id: 'last' }, 'last')],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
app.mount(host)
|
||||||
|
cleanups.push(() => app.unmount())
|
||||||
|
for (const element of document.querySelectorAll<HTMLElement>('#first,#last')) {
|
||||||
|
Object.defineProperty(element, 'getClientRects', { configurable: true, value: () => [{ width: 20, height: 20 }] })
|
||||||
|
}
|
||||||
|
await nextTick(); await nextTick()
|
||||||
|
return { host, opener, open, close }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppSheet', () => {
|
||||||
|
it('teleports an accessible modal and makes application background inert', async () => {
|
||||||
|
const { host } = await mountSheet({ initialFocus: '#last' })
|
||||||
|
const dialog = document.querySelector<HTMLElement>('#overlay-root [role="dialog"]')!
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||||
|
expect(dialog.getAttribute('aria-labelledby')).toBe('sheet-title')
|
||||||
|
expect(dialog.getAttribute('aria-describedby')).toBe('sheet-description')
|
||||||
|
expect(document.activeElement?.id).toBe('last')
|
||||||
|
expect(host.hasAttribute('inert')).toBe(true)
|
||||||
|
expect(host.getAttribute('aria-hidden')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traps Tab and restores focus after closing', async () => {
|
||||||
|
const { opener } = await mountSheet({ initialFocus: '#first' })
|
||||||
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
const first = document.querySelector<HTMLButtonElement>('#first')!
|
||||||
|
const last = document.querySelector<HTMLButtonElement>('#last')!
|
||||||
|
last.focus()
|
||||||
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
|
||||||
|
expect(document.activeElement).toBe(first)
|
||||||
|
first.focus()
|
||||||
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
|
||||||
|
expect(document.activeElement).toBe(last)
|
||||||
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||||
|
await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement).toBe(opener)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks scrim and Escape closing while busy', async () => {
|
||||||
|
const { close } = await mountSheet({ busy: true })
|
||||||
|
document.querySelector<HTMLElement>('.app-overlay')!.click()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||||
|
expect(close).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a real form when submit listeners are provided', async () => {
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const submitted = vi.fn()
|
||||||
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, titleId:'form-title', onSubmit:(event: Event) => { event.preventDefault(); submitted() } }, {
|
||||||
|
default:() => [h('h2',{id:'form-title'},'form'), h('button',{type:'submit'},'save')],
|
||||||
|
}) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
expect(dialog.tagName).toBe('FORM')
|
||||||
|
dialog.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||||
|
expect(submitted).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps desktop non-modal details inline without inerting the app', async () => {
|
||||||
|
const host = document.createElement('main'); document.body.append(host)
|
||||||
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:false, titleId:'detail-title' }, {
|
||||||
|
default:() => [h('h2',{id:'detail-title'},'detail'), h('button','close')],
|
||||||
|
}) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
const dialog = host.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
expect(dialog).not.toBeNull()
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBeNull()
|
||||||
|
expect(document.querySelector('#overlay-root [role="dialog"]')).toBeNull()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates and deactivates the overlay when modal changes while open', async () => {
|
||||||
|
const host = document.createElement('main'); document.body.append(host)
|
||||||
|
const modal = ref(false)
|
||||||
|
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:modal.value, titleId:'dynamic-title' }, {
|
||||||
|
default:() => [h('h2',{id:'dynamic-title'},'detail'), h('button',{id:'dynamic-close'},'close')],
|
||||||
|
}) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(false)
|
||||||
|
|
||||||
|
modal.value = true; await nextTick(); await nextTick()
|
||||||
|
expect(document.querySelector('#overlay-root [role="dialog"]')).not.toBeNull()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(true)
|
||||||
|
|
||||||
|
modal.value = false; await nextTick(); await nextTick()
|
||||||
|
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps background inert until the last stacked modal closes', async () => {
|
||||||
|
const host = document.createElement('main'); document.body.append(host)
|
||||||
|
const first = ref(true); const second = ref(true)
|
||||||
|
const app = createApp({ setup: () => () => h('div', [
|
||||||
|
h(AppSheet, { open:first.value, titleId:'stack-one', onClose:() => { first.value=false } }, { default:() => h('h2',{id:'stack-one'},'one') }),
|
||||||
|
h(AppSheet, { open:second.value, titleId:'stack-two', onClose:() => { second.value=false } }, { default:() => h('h2',{id:'stack-two'},'two') }),
|
||||||
|
]) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(true)
|
||||||
|
second.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(true)
|
||||||
|
first.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(host.hasAttribute('inert')).toBe(false)
|
||||||
|
expect(host.getAttribute('aria-hidden')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses prompt input and confirm dialog cancel action', async () => {
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
||||||
|
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||||
|
void dialog.value!.show({ title:'prompt', label:'name' }); await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement?.tagName).toBe('INPUT')
|
||||||
|
document.querySelector<HTMLButtonElement>('.app-dialog .secondary')!.click(); await nextTick()
|
||||||
|
void dialog.value!.show({ title:'confirm' }); await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement).toBe(document.querySelector('.app-dialog .secondary'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settles replaced and unmounted dialog promises safely', async () => {
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
||||||
|
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||||
|
const first = dialog.value!.show({ title:'one' })
|
||||||
|
const second = dialog.value!.show({ title:'two', label:'name' })
|
||||||
|
await expect(first).resolves.toBe(false)
|
||||||
|
app.unmount()
|
||||||
|
await expect(second).resolves.toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns focus to the lower overlay when same-tick upper overlay closes', async () => {
|
||||||
|
const host = document.createElement('main')
|
||||||
|
const opener = document.createElement('button')
|
||||||
|
opener.id = 'stack-opener'
|
||||||
|
document.body.append(host, opener)
|
||||||
|
opener.focus()
|
||||||
|
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||||
|
const visibleRef = (element: unknown) => {
|
||||||
|
if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] })
|
||||||
|
}
|
||||||
|
const app = createApp({ setup: () => () => h('div', [
|
||||||
|
h(AppSheet, { open:lowerOpen.value, titleId:'focus-lower' }, { default:() => [h('h2',{id:'focus-lower'},'lower'), h('button',{id:'focus-lower-button', ref:visibleRef},'lower button')] }),
|
||||||
|
h(AppSheet, { open:upperOpen.value, titleId:'focus-upper' }, { default:() => [h('h2',{id:'focus-upper'},'upper'), h('button',{id:'focus-upper-button', ref:visibleRef},'upper button')] }),
|
||||||
|
]) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement?.id).toBe('focus-upper-button')
|
||||||
|
|
||||||
|
upperOpen.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement?.id).toBe('focus-lower-button')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the background opener only after the last stacked overlay closes', async () => {
|
||||||
|
const host = document.createElement('main')
|
||||||
|
const opener = document.createElement('button')
|
||||||
|
opener.id = 'last-stack-opener'
|
||||||
|
document.body.append(host, opener)
|
||||||
|
opener.focus()
|
||||||
|
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||||
|
const app = createApp({ setup: () => () => h('div', [
|
||||||
|
h(AppSheet, { open:lowerOpen.value, titleId:'last-lower' }, { default:() => [h('h2',{id:'last-lower'},'lower'), h('button',{id:'last-lower-button'},'lower button')] }),
|
||||||
|
h(AppSheet, { open:upperOpen.value, titleId:'last-upper' }, { default:() => h('h2',{id:'last-upper'},'upper') }),
|
||||||
|
]) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
|
||||||
|
upperOpen.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement).not.toBe(opener)
|
||||||
|
lowerOpen.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement).toBe(opener)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps focus in the upper overlay when a non-top lower overlay closes', async () => {
|
||||||
|
const host = document.createElement('main')
|
||||||
|
const opener = document.createElement('button')
|
||||||
|
opener.id = 'lower-opener'
|
||||||
|
document.body.append(host, opener)
|
||||||
|
opener.focus()
|
||||||
|
const first = ref(true); const second = ref(false)
|
||||||
|
const app = createApp({ setup: () => () => h('div', [
|
||||||
|
h(AppSheet, { open:first.value, titleId:'lower' }, { default:() => [h('h2',{id:'lower'},'lower'), h('button',{id:'lower-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'lower button')] }),
|
||||||
|
h(AppSheet, { open:second.value, titleId:'upper' }, { default:() => [h('h2',{id:'upper'},'upper'), h('button',{id:'upper-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'upper button')] }),
|
||||||
|
]) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
second.value=true; await nextTick(); await nextTick()
|
||||||
|
const upper = document.querySelector<HTMLButtonElement>('#upper-button')!
|
||||||
|
expect(document.activeElement).toBe(upper)
|
||||||
|
first.value=false; await nextTick(); await nextTick()
|
||||||
|
expect(document.activeElement).toBe(upper)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips focusables hidden by ancestors, aria-hidden, inert, styles, disabled state, or empty client rects', async () => {
|
||||||
|
const { host } = await mountSheet({ initialFocus: '#first' })
|
||||||
|
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||||
|
dialog.querySelector('#first')?.remove()
|
||||||
|
dialog.querySelector('#last')?.remove()
|
||||||
|
const hiddenParent = document.createElement('div')
|
||||||
|
hiddenParent.hidden = true
|
||||||
|
hiddenParent.innerHTML = '<button id="hidden-child">hidden</button>'
|
||||||
|
const ariaParent = document.createElement('div')
|
||||||
|
ariaParent.setAttribute('aria-hidden', 'true')
|
||||||
|
ariaParent.innerHTML = '<button id="aria-child">aria</button>'
|
||||||
|
const inertParent = document.createElement('div')
|
||||||
|
inertParent.setAttribute('inert', '')
|
||||||
|
inertParent.innerHTML = '<button id="inert-child">inert</button>'
|
||||||
|
const displayNone = document.createElement('button')
|
||||||
|
displayNone.id = 'display-none'; displayNone.style.display = 'none'
|
||||||
|
const invisible = document.createElement('button')
|
||||||
|
invisible.id = 'invisible'; invisible.style.visibility = 'hidden'
|
||||||
|
const disabled = document.createElement('button')
|
||||||
|
disabled.id = 'disabled'; disabled.disabled = true
|
||||||
|
const noRect = document.createElement('button')
|
||||||
|
noRect.id = 'no-rect'
|
||||||
|
const visible = document.createElement('button')
|
||||||
|
visible.id = 'visible'
|
||||||
|
Object.defineProperty(visible, 'getClientRects', { value: () => [{ width: 20, height: 20 }] })
|
||||||
|
dialog.append(hiddenParent, ariaParent, inertParent, displayNone, invisible, disabled, noRect, visible)
|
||||||
|
dialog.focus()
|
||||||
|
dialog.dispatchEvent(new KeyboardEvent('keydown', { key:'Tab', bubbles:true, cancelable:true }))
|
||||||
|
expect(document.activeElement).toBe(visible)
|
||||||
|
host.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only closes the top overlay on Escape', async () => {
|
||||||
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
|
const first = ref(true); const second = ref(true); const calls: string[] = []
|
||||||
|
const app = createApp({ setup: () => () => h('div', [
|
||||||
|
h(AppSheet, { open:first.value, titleId:'one', onClose:() => { calls.push('one'); first.value=false } }, { default:() => h('h2',{id:'one'},'one') }),
|
||||||
|
h(AppSheet, { open:second.value, titleId:'two', onClose:() => { calls.push('two'); second.value=false } }, { default:() => h('h2',{id:'two'},'two') }),
|
||||||
|
]) })
|
||||||
|
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', bubbles:true, cancelable:true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(calls).toEqual(['two'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { isTopOverlay, overlayRoot, popOverlay, pushOverlay } from '../composables/useOverlayStack'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
titleId?: string
|
||||||
|
descriptionId?: string
|
||||||
|
label?: string
|
||||||
|
busy?: boolean
|
||||||
|
initialFocus?: string
|
||||||
|
modal?: boolean
|
||||||
|
closeOnScrim?: boolean
|
||||||
|
variant?: 'create' | 'detail' | 'actions'
|
||||||
|
panelClass?: string
|
||||||
|
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '' })
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
const panel = ref<HTMLElement | null>(null)
|
||||||
|
let overlayId: symbol | null = null
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (!props.busy && (!props.modal || isTopOverlay(overlayId))) emit('close')
|
||||||
|
}
|
||||||
|
function scrimClose(event: MouseEvent) {
|
||||||
|
if (props.closeOnScrim && event.target === event.currentTarget) requestClose()
|
||||||
|
}
|
||||||
|
function isVisibleFocusable(element: HTMLElement) {
|
||||||
|
if (element.matches(':disabled') || element.closest('[hidden],[aria-hidden="true"],[inert]')) return false
|
||||||
|
const style = window.getComputedStyle(element)
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden') return false
|
||||||
|
return element.getClientRects().length > 0 || (element.offsetWidth > 0 && element.offsetHeight > 0)
|
||||||
|
}
|
||||||
|
function focusables() {
|
||||||
|
if (!panel.value) return []
|
||||||
|
return Array.from(panel.value.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||||
|
.filter(isVisibleFocusable)
|
||||||
|
}
|
||||||
|
function keydown(event: KeyboardEvent) {
|
||||||
|
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
||||||
|
const controls = focusables()
|
||||||
|
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
||||||
|
const first = controls[0]
|
||||||
|
const last = controls[controls.length - 1]
|
||||||
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.value)) { event.preventDefault(); last.focus() }
|
||||||
|
else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first.focus() }
|
||||||
|
}
|
||||||
|
function focusIntoPanel() {
|
||||||
|
if (!panel.value?.contains(document.activeElement)) {
|
||||||
|
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
|
||||||
|
;(target ?? focusables()[0] ?? panel.value)?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function activate() {
|
||||||
|
if (!props.open || !props.modal || overlayId) return
|
||||||
|
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||||
|
await nextTick()
|
||||||
|
if (!props.open || !props.modal || !overlayId) return
|
||||||
|
focusIntoPanel()
|
||||||
|
}
|
||||||
|
function deactivate() {
|
||||||
|
if (overlayId) popOverlay(overlayId)
|
||||||
|
overlayId = null
|
||||||
|
}
|
||||||
|
watch([() => props.open, () => props.modal], ([open, modal]) => {
|
||||||
|
if (open && modal) void activate()
|
||||||
|
else deactivate()
|
||||||
|
}, { immediate: true })
|
||||||
|
onBeforeUnmount(deactivate)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modal" :to="overlayRoot()">
|
||||||
|
<div v-if="open" class="app-overlay app-sheet-mask" :aria-busy="busy || undefined" @click="scrimClose">
|
||||||
|
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -36,5 +36,6 @@ describe('add-task CalendarPicker integration', () => {
|
|||||||
expect(picker).toContain('data-action="clear"')
|
expect(picker).toContain('data-action="clear"')
|
||||||
expect(picker).toContain('data-action="cancel"')
|
expect(picker).toContain('data-action="cancel"')
|
||||||
expect(picker).toContain('data-action="done"')
|
expect(picker).toContain('data-action="done"')
|
||||||
|
expect(picker).toContain("event.preventDefault(); event.stopPropagation(); close()")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function onGridKey(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onDialogKey(event: KeyboardEvent) {
|
function onDialogKey(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
|
||||||
if (event.key !== 'Tab' || !dialog.value) return
|
if (event.key !== 'Tab' || !dialog.value) return
|
||||||
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
|
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
|
||||||
if (!focusables.length) return
|
if (!focusables.length) return
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ async function mount(overrides: Record<string, unknown> = {}) {
|
|||||||
const events: Record<string, unknown[]> = { saved: [], close: [] }
|
const events: Record<string, unknown[]> = { saved: [], close: [] }
|
||||||
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
|
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
|
||||||
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
|
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
const confirmAction = (overrides.confirmAction ?? vi.fn(async () => true)) as (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean>
|
||||||
|
const app = createApp(() => h(MemoEditor, { memo, request, confirmAction, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||||
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
||||||
}
|
}
|
||||||
@@ -105,19 +106,19 @@ describe('MemoEditor', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
const confirmAction = vi.fn(async () => false)
|
||||||
const request = vi.fn()
|
const request = vi.fn()
|
||||||
const clean = await mount({ memo: draft, request })
|
const clean = await mount({ memo: draft, request, confirmAction })
|
||||||
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
||||||
expect(confirm).not.toHaveBeenCalled()
|
expect(confirmAction).not.toHaveBeenCalled()
|
||||||
expect(request).not.toHaveBeenCalled()
|
expect(request).not.toHaveBeenCalled()
|
||||||
expect(clean.events.close).toEqual([true])
|
expect(clean.events.close).toEqual([true])
|
||||||
|
|
||||||
const edited = await mount({ memo: draft, request })
|
const edited = await mount({ memo: draft, request, confirmAction })
|
||||||
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||||
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
expect(confirm).toHaveBeenCalledOnce()
|
expect(confirmAction).toHaveBeenCalledOnce()
|
||||||
expect(edited.events.close).toEqual([])
|
expect(edited.events.close).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,22 +227,15 @@ describe('MemoEditor', () => {
|
|||||||
expect(events.close).toEqual([])
|
expect(events.close).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => {
|
it('leaves mobile modal focus trapping and Escape close to AppSheet', async () => {
|
||||||
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
|
||||||
const { host, events } = await mount({ mobile: true })
|
const { host, events } = await mount({ mobile: true })
|
||||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
const editor = host.querySelector<HTMLElement>('.memo-editor')!
|
||||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
expect(editor.getAttribute('aria-modal')).toBeNull()
|
||||||
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
|
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||||
last.focus()
|
editor.dispatchEvent(escape)
|
||||||
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
|
||||||
dialog.dispatchEvent(tab)
|
|
||||||
expect(tab.defaultPrevented).toBe(true)
|
|
||||||
expect(document.activeElement).toBe(dialog.querySelector('button'))
|
|
||||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(events.close).toEqual([true])
|
expect(escape.defaultPrevented).toBe(false)
|
||||||
expect(document.activeElement).not.toBe(opener)
|
expect(events.close).toEqual([])
|
||||||
opener.remove()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores a stale reload after its editor selection changes', async () => {
|
it('ignores a stale reload after its editor selection changes', async () => {
|
||||||
@@ -424,15 +418,15 @@ describe('MemoEditor', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('guards dirty close and allows clean close', async () => {
|
it('guards dirty close and allows clean close', async () => {
|
||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
const confirmAction = vi.fn(async () => false)
|
||||||
const { host, events } = await mount()
|
const { host, events } = await mount({ confirmAction })
|
||||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||||
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
|
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
expect(confirm).toHaveBeenCalled()
|
expect(confirmAction).toHaveBeenCalled()
|
||||||
expect(events.close).toEqual([])
|
expect(events.close).toEqual([])
|
||||||
confirm.mockReturnValue(true)
|
confirmAction.mockResolvedValue(true)
|
||||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||||
expect(events.close).toEqual([true])
|
expect(events.close).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type MemoDraft = { id: null; title: string; content: string; version: nul
|
|||||||
export type Memo = MemoRecord
|
export type Memo = MemoRecord
|
||||||
export type MemoEditorValue = MemoRecord | MemoDraft
|
export type MemoEditorValue = MemoRecord | MemoDraft
|
||||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { selectionToken: 0 })
|
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number; confirmAction?: (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean> }>(), { selectionToken: 0 })
|
||||||
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
|
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
@@ -18,7 +18,6 @@ let lifecycleGeneration = 0
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const conflict = ref(false)
|
const conflict = ref(false)
|
||||||
const titleInput = ref<HTMLInputElement | null>(null)
|
const titleInput = ref<HTMLInputElement | null>(null)
|
||||||
const root = ref<HTMLElement | null>(null)
|
|
||||||
const initial = ref({ title: '', content: '' })
|
const initial = ref({ title: '', content: '' })
|
||||||
const memoPreview = ref(false)
|
const memoPreview = ref(false)
|
||||||
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
|
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
|
||||||
@@ -35,8 +34,8 @@ watch(() => props.selectionToken, () => {
|
|||||||
lifecycleGeneration += 1
|
lifecycleGeneration += 1
|
||||||
lifecycleBusy.value = false
|
lifecycleBusy.value = false
|
||||||
})
|
})
|
||||||
function close() {
|
async function close() {
|
||||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
function validate() {
|
function validate() {
|
||||||
@@ -91,7 +90,7 @@ async function reload() {
|
|||||||
async function remove() {
|
async function remove() {
|
||||||
const memoId = props.memo.id
|
const memoId = props.memo.id
|
||||||
if (memoId === null || lifecycleBusy.value) return
|
if (memoId === null || lifecycleBusy.value) return
|
||||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
if (!(await props.confirmAction?.({ title: `把“${props.memo.title}”移到回收站?`, description: '之后可以在回收站恢复。', confirmText: '移到回收站', danger: true }))) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
lifecycleBusy.value = true
|
lifecycleBusy.value = true
|
||||||
const operationToken = ++lifecycleGeneration
|
const operationToken = ++lifecycleGeneration
|
||||||
@@ -130,7 +129,7 @@ async function restore() {
|
|||||||
async function purge() {
|
async function purge() {
|
||||||
const memoId = props.memo.id
|
const memoId = props.memo.id
|
||||||
if (memoId === null || lifecycleBusy.value) return
|
if (memoId === null || lifecycleBusy.value) return
|
||||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
if (!(await props.confirmAction?.({ title: `永久删除“${props.memo.title}”?`, description: '此操作无法撤销。', confirmText: '永久删除', danger: true }))) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
lifecycleBusy.value = true
|
lifecycleBusy.value = true
|
||||||
const operationToken = ++lifecycleGeneration
|
const operationToken = ++lifecycleGeneration
|
||||||
@@ -170,14 +169,7 @@ function handleMemoBodyShortcut(event: KeyboardEvent) {
|
|||||||
formatMemoBody(format)
|
formatMemoBody(format)
|
||||||
}
|
}
|
||||||
function keydown(event: KeyboardEvent) {
|
function keydown(event: KeyboardEvent) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save() }
|
||||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
|
||||||
if (!props.mobile || event.key !== 'Tab' || !root.value) return
|
|
||||||
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
|
|
||||||
if (!controls.length) return
|
|
||||||
const first = controls[0], last = controls.at(-1)!
|
|
||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
||||||
}
|
}
|
||||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||||
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||||
@@ -186,7 +178,7 @@ defineExpose({ dirty, requestClose: close })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
|
<div class="memo-editor" @keydown="keydown">
|
||||||
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
|
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
|
||||||
<div class="memo-editor__fields">
|
<div class="memo-editor__fields">
|
||||||
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
|
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
|
||||||
@@ -213,5 +205,5 @@ defineExpose({ dirty, requestClose: close })
|
|||||||
</div>
|
</div>
|
||||||
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||||
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
|
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
|
||||||
</aside>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
type OverlayEntry = {
|
||||||
|
id: symbol
|
||||||
|
close: () => void
|
||||||
|
busy: () => boolean
|
||||||
|
restoreFocus: HTMLElement | null
|
||||||
|
focusPanel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack: OverlayEntry[] = []
|
||||||
|
const background = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>()
|
||||||
|
let listening = false
|
||||||
|
|
||||||
|
function root() {
|
||||||
|
let element = document.getElementById('overlay-root')
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('div')
|
||||||
|
element.id = 'overlay-root'
|
||||||
|
document.body.appendChild(element)
|
||||||
|
}
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBackground() {
|
||||||
|
const overlayRoot = root()
|
||||||
|
if (stack.length) {
|
||||||
|
for (const child of Array.from(document.body.children)) {
|
||||||
|
if (!(child instanceof HTMLElement) || child === overlayRoot || background.has(child)) continue
|
||||||
|
background.set(child, { inert: child.hasAttribute('inert'), ariaHidden: child.getAttribute('aria-hidden') })
|
||||||
|
child.setAttribute('inert', '')
|
||||||
|
child.setAttribute('aria-hidden', 'true')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const [element, state] of background) {
|
||||||
|
if (!state.inert) element.removeAttribute('inert')
|
||||||
|
if (state.ariaHidden === null) element.removeAttribute('aria-hidden')
|
||||||
|
else element.setAttribute('aria-hidden', state.ariaHidden)
|
||||||
|
}
|
||||||
|
background.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||||
|
const entry = stack.at(-1)
|
||||||
|
if (!entry || entry.busy()) return
|
||||||
|
event.preventDefault()
|
||||||
|
entry.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayRoot() { return root() }
|
||||||
|
|
||||||
|
export function pushOverlay(close: () => void, busy: () => boolean, focusPanel: () => void) {
|
||||||
|
const entry: OverlayEntry = {
|
||||||
|
id: Symbol('overlay'), close, busy, focusPanel,
|
||||||
|
restoreFocus: document.activeElement instanceof HTMLElement ? document.activeElement : null,
|
||||||
|
}
|
||||||
|
stack.push(entry)
|
||||||
|
if (!listening) { document.addEventListener('keydown', onKeydown); listening = true }
|
||||||
|
syncBackground()
|
||||||
|
return entry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popOverlay(id: symbol) {
|
||||||
|
const index = stack.findIndex((entry) => entry.id === id)
|
||||||
|
if (index < 0) return
|
||||||
|
const wasTop = index === stack.length - 1
|
||||||
|
const [entry] = stack.splice(index, 1)
|
||||||
|
if (!stack.length && listening) { document.removeEventListener('keydown', onKeydown); listening = false }
|
||||||
|
syncBackground()
|
||||||
|
if (!wasTop) return
|
||||||
|
const newTop = stack.at(-1)
|
||||||
|
void nextTick(() => {
|
||||||
|
if (newTop) newTop.focusPanel()
|
||||||
|
else if (entry.restoreFocus?.isConnected) entry.restoreFocus.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTopOverlay(id: symbol | null) {
|
||||||
|
return Boolean(id && stack.at(-1)?.id === id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { backupFileSnapshot, isCurrentBackupSnapshot, shouldCommitBackupPreflight } from './backup-preflight-state'
|
||||||
|
|
||||||
|
describe('backup preflight identity', () => {
|
||||||
|
it('rejects a preflight result after the selected file changes even when the old request finishes last', () => {
|
||||||
|
const oldFile = new File(['old'], 'old.zip', { lastModified: 10 })
|
||||||
|
const newFile = new File(['new'], 'new.zip', { lastModified: 20 })
|
||||||
|
const started = backupFileSnapshot(oldFile, 'merge')
|
||||||
|
|
||||||
|
expect(isCurrentBackupSnapshot(started, newFile, 'merge')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a token for one filename authorize another file with matching metadata', () => {
|
||||||
|
const first = new File(['same'], 'first.zip', { lastModified: 10 })
|
||||||
|
const second = new File(['same'], 'second.zip', { lastModified: 10 })
|
||||||
|
|
||||||
|
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), second, 'merge')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates a preflight when restore mode changes', () => {
|
||||||
|
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||||
|
|
||||||
|
expect(isCurrentBackupSnapshot(backupFileSnapshot(file, 'merge'), file, 'replace')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an older generation even when its file snapshot still matches', () => {
|
||||||
|
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||||
|
expect(shouldCommitBackupPreflight(1, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(false)
|
||||||
|
expect(shouldCommitBackupPreflight(2, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('binds identity to the exact File object as well as name size and modified time', () => {
|
||||||
|
const first = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||||
|
const replacement = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||||
|
|
||||||
|
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), replacement, 'merge')).toBe(false)
|
||||||
|
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), first, 'merge')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { BackupMode } from '../api'
|
||||||
|
|
||||||
|
export type BackupFileSnapshot = {
|
||||||
|
file: File
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
lastModified: number
|
||||||
|
mode: BackupMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupFileSnapshot(file: File, mode: BackupMode): BackupFileSnapshot {
|
||||||
|
return { file, name: file.name, size: file.size, lastModified: file.lastModified, mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentBackupSnapshot(snapshot: BackupFileSnapshot, file: File | null, mode: BackupMode) {
|
||||||
|
return Boolean(file)
|
||||||
|
&& snapshot.file === file
|
||||||
|
&& snapshot.name === file!.name
|
||||||
|
&& snapshot.size === file!.size
|
||||||
|
&& snapshot.lastModified === file!.lastModified
|
||||||
|
&& snapshot.mode === mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCommitBackupPreflight(
|
||||||
|
generation: number,
|
||||||
|
currentGeneration: number,
|
||||||
|
snapshot: BackupFileSnapshot,
|
||||||
|
file: File | null,
|
||||||
|
mode: BackupMode,
|
||||||
|
) {
|
||||||
|
return generation === currentGeneration && isCurrentBackupSnapshot(snapshot, file, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyBackup(file: File) {
|
||||||
|
return !file.name.toLowerCase().endsWith('.zip')
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { nextDialogFocusIndex } from './list-purge'
|
|
||||||
|
|
||||||
describe('archived list purge dialog behavior', () => {
|
|
||||||
it('wraps Tab focus between the cancel and destructive actions', () => {
|
|
||||||
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
|
|
||||||
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves focus alone while moving between interior controls', () => {
|
|
||||||
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
|
|
||||||
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
|
|
||||||
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
|
|
||||||
if (controlCount < 2) return null
|
|
||||||
if (shiftKey && currentIndex === 0) return controlCount - 1
|
|
||||||
if (!shiftKey && currentIndex === controlCount - 1) return 0
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none}
|
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||||
|
|||||||
+11
-14
File diff suppressed because one or more lines are too long
+75
-48
@@ -99,10 +99,19 @@ describe('mobile navigation styles', () => {
|
|||||||
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the mobile More sheet visible when it is rendered', () => {
|
it('has no unreachable mobile More state, template, or styles', () => {
|
||||||
expect(css).not.toContain('.more-mask{display:none}')
|
expect(app).not.toContain('mobileMore')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell')
|
expect(app).not.toContain('mobile-more-menu')
|
||||||
expect(css).toContain('.more-mask{position:fixed;z-index:45;')
|
expect(app).not.toContain('more-mask')
|
||||||
|
expect(css).not.toContain('.more-mask')
|
||||||
|
expect(css).not.toContain('.more-sheet')
|
||||||
|
expect(css).not.toContain('.task-compose-mask')
|
||||||
|
expect(css).not.toContain('.habit-detail-mask')
|
||||||
|
expect(css).not.toContain('.countdown-detail-mask')
|
||||||
|
expect(css).not.toContain('.countdown-modal-mask')
|
||||||
|
expect(css).not.toContain('.modal-mask')
|
||||||
|
expect(css).not.toContain('.modal-box')
|
||||||
|
expect(css).not.toContain('.countdown-compose-enter')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets the mobile sidebar scrim cover the outside area and stay below the sidebar', () => {
|
it('lets the mobile sidebar scrim cover the outside area and stay below the sidebar', () => {
|
||||||
@@ -125,7 +134,7 @@ describe('settings sessions and audit activity', () => {
|
|||||||
expect(mvpPanel).toContain('class="session-card-actions"')
|
expect(mvpPanel).toContain('class="session-card-actions"')
|
||||||
expect(mvpPanel).toContain('撤销其他所有会话')
|
expect(mvpPanel).toContain('撤销其他所有会话')
|
||||||
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
|
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
|
||||||
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')")
|
expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
|
||||||
expect(css).toContain('.session-card-actions{')
|
expect(css).toContain('.session-card-actions{')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -186,10 +195,11 @@ describe('solid cream material system', () => {
|
|||||||
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
||||||
expect(css).toContain('main{background:var(--surface-base)}')
|
expect(css).toContain('main{background:var(--surface-base)}')
|
||||||
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
||||||
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
expect(css).toContain('.app-sheet,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||||
expect(css).toContain('.toast{background:#3b342c')
|
expect(css).toContain('.toast{background:#3b342c')
|
||||||
expect(css).toContain('.error-toast{background:var(--danger)')
|
expect(css).toContain('.error-toast{background:var(--danger)')
|
||||||
expect(css).toContain('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
|
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||||
|
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps compact continuous lists without per-row outer shadows', () => {
|
it('keeps compact continuous lists without per-row outer shadows', () => {
|
||||||
@@ -409,14 +419,19 @@ describe('archived task-list disclosure', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('mobile sheet contract', () => {
|
describe('mobile sheet contract', () => {
|
||||||
it('uses shared roles for details, creation and secondary actions', () => {
|
it('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
|
||||||
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
|
expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
|
||||||
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
|
expect(css).toContain('.app-overlay>.detail{transform:none}')
|
||||||
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
|
})
|
||||||
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
|
|
||||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
it('uses shared AppSheet variants for details and creation', () => {
|
||||||
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
expect(app).toContain('panel-class="task-compose-sheet"')
|
||||||
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
|
expect(app).toContain('variant="create"')
|
||||||
|
expect(app).not.toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||||||
|
expect(mvpPanel).toContain('panel-class="task-compose-sheet habit-compose-sheet"')
|
||||||
|
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||||
|
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
|
||||||
|
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||||
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
||||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||||
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
||||||
@@ -447,8 +462,7 @@ describe('mobile list row language', () => {
|
|||||||
|
|
||||||
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
|
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
|
||||||
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
||||||
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
|
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
|
||||||
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
|
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
|
||||||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
||||||
@@ -466,8 +480,8 @@ describe('mobile list row language', () => {
|
|||||||
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
|
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
|
||||||
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||||
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||||
expect(mvpPanel).toContain('ref="habitDetailSheet"')
|
expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
|
||||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
expect(mvpPanel).not.toContain('habitDetailSheet')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('provides an archived-habit viewing path', () => {
|
it('provides an archived-habit viewing path', () => {
|
||||||
@@ -927,7 +941,7 @@ describe('task and habit row decoration', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows a password form with confirmation and calls the protected endpoint', () => {
|
it('shows a password form with confirmation and calls the protected endpoint', () => {
|
||||||
expect(mvpPanel).toContain('class="password-form"')
|
expect(mvpPanel).toContain('class="password-form settings-form"')
|
||||||
expect(mvpPanel).toContain('aria-label="当前密码"')
|
expect(mvpPanel).toContain('aria-label="当前密码"')
|
||||||
expect(mvpPanel).toContain('aria-label="新密码"')
|
expect(mvpPanel).toContain('aria-label="新密码"')
|
||||||
expect(mvpPanel).toContain('aria-label="确认新密码"')
|
expect(mvpPanel).toContain('aria-label="确认新密码"')
|
||||||
@@ -972,20 +986,22 @@ describe('mobile touch targets', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('approved habit safety and U2 title hierarchy', () => {
|
describe('approved habit safety and U2 title hierarchy', () => {
|
||||||
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
|
it('uses one page title and continuous settings section headings', () => {
|
||||||
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
||||||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||||
expect(mvpPanel).toContain('<h2>修改密码</h2>')
|
expect(mvpPanel).toContain('class="settings-sections"')
|
||||||
expect(mvpPanel).toContain('<h2>登录会话</h2>')
|
expect(mvpPanel).not.toContain('class="settings-grid"')
|
||||||
expect(mvpPanel).toContain('<h2>最近活动</h2>')
|
expect(mvpPanel).not.toContain('class="tool-card')
|
||||||
expect(css).toContain('.tool-card>h2{')
|
expect(css).toContain('.settings-sections{width:min(100%,760px);')
|
||||||
|
expect(css).toContain('.settings-row{min-height:56px;')
|
||||||
|
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
||||||
|
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
||||||
expect(app).toContain('const modalError = ref')
|
expect(app).toContain('const appDialog = ref')
|
||||||
expect(app).toContain('role="alert" class="field-error"')
|
expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
|
||||||
expect(app).toContain('normalizeRequiredName')
|
|
||||||
expect(mvpPanel).toContain('habitErrors.name')
|
expect(mvpPanel).toContain('habitErrors.name')
|
||||||
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
|
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
|
||||||
expect(mvpPanel).toContain('const habitFormInvalid = computed')
|
expect(mvpPanel).toContain('const habitFormInvalid = computed')
|
||||||
@@ -1009,17 +1025,26 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('settings data tools', () => {
|
describe('settings data tools', () => {
|
||||||
it('keeps backup export and restore but removes the standalone import tool', () => {
|
it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
|
||||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
expect(mvpPanel).toContain('<h2>数据</h2>')
|
||||||
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
|
expect(mvpPanel).toContain("downloadFullBackup()")
|
||||||
expect(mvpPanel).toContain("'dodo-export.csv'")
|
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
|
||||||
expect(mvpPanel).toContain('导出 CSV')
|
expect(mvpPanel).toContain('导出 ZIP')
|
||||||
expect(mvpPanel).not.toContain('导出 JSON')
|
expect(mvpPanel).toContain('accept=".zip,.csv,.json')
|
||||||
expect(mvpPanel).toContain('@click="restore"')
|
expect(mvpPanel).toContain('v-model="restoreMode"')
|
||||||
expect(mvpPanel).not.toContain('<h3>导入</h3>')
|
expect(mvpPanel).toContain('runPreflight')
|
||||||
|
expect(mvpPanel).toContain('restorePreflight.valid')
|
||||||
|
expect(mvpPanel).toContain('preflight_token')
|
||||||
|
expect(mvpPanel).toContain('await restoreBackup')
|
||||||
|
expect(mvpPanel).toContain("await uploadJson('/restore.csv?mode=merge'")
|
||||||
|
expect(mvpPanel).toContain("await requestJson('/restore?mode=merge'")
|
||||||
|
expect(mvpPanel).toContain('const snapshot = backupFileSnapshot(file, restoreMode.value)')
|
||||||
|
expect(mvpPanel).toContain('shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)')
|
||||||
|
expect(mvpPanel).toContain('preflightController?.abort()')
|
||||||
|
expect(mvpPanel).toContain("旧格式将在恢复时校验,不支持完整预检或 Replace。")
|
||||||
|
expect(mvpPanel).toContain('<option v-if="!legacyRestore" value="replace">')
|
||||||
|
expect(mvpPanel).toContain("emit('changed'); emit('notice', '数据已恢复')")
|
||||||
expect(mvpPanel).not.toContain("request('/import/ticktick")
|
expect(mvpPanel).not.toContain("request('/import/ticktick")
|
||||||
expect(mvpPanel).not.toContain('importFile')
|
|
||||||
expect(mvpPanel).not.toContain('importPreview')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1132,8 +1157,8 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(floatingAdd).toContain("emit('activate',")
|
expect(floatingAdd).toContain("emit('activate',")
|
||||||
expect(css).toContain('.unified-fab.dragging')
|
expect(css).toContain('.unified-fab.dragging')
|
||||||
expect(css).toContain('.unified-fab.snapping')
|
expect(css).toContain('.unified-fab.snapping')
|
||||||
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
|
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
|
||||||
expect(css).toContain('.countdown-compose-enter-active')
|
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||||
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1169,7 +1194,8 @@ describe('unified floating add interaction', () => {
|
|||||||
describe('desktop task detail disclosure', () => {
|
describe('desktop task detail disclosure', () => {
|
||||||
it('gives the task list the full remaining width until a task is selected', () => {
|
it('gives the task list the full remaining width until a task is selected', () => {
|
||||||
expect(app).toContain("'detail-open': Boolean(selectedTask)")
|
expect(app).toContain("'detail-open': Boolean(selectedTask)")
|
||||||
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
|
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
|
||||||
|
expect(app).toContain('panel-class="detail"')
|
||||||
expect(app).toContain('@click="closeTaskDetail"')
|
expect(app).toContain('@click="closeTaskDetail"')
|
||||||
expect(app).toContain('function closeTaskDetail()')
|
expect(app).toContain('function closeTaskDetail()')
|
||||||
expect(app).not.toContain('<div v-else class="paper">')
|
expect(app).not.toContain('<div v-else class="paper">')
|
||||||
@@ -1233,8 +1259,8 @@ describe('sidebar information hierarchy', () => {
|
|||||||
it('groups folder and list editing actions into a clear compact hierarchy', () => {
|
it('groups folder and list editing actions into a clear compact hierarchy', () => {
|
||||||
expect(app).toContain('aria-label="打开文件夹操作"')
|
expect(app).toContain('aria-label="打开文件夹操作"')
|
||||||
expect(app).toContain('aria-label="打开清单操作"')
|
expect(app).toContain('aria-label="打开清单操作"')
|
||||||
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
|
expect(app).toContain('panel-class="sidebar-action-sheet"')
|
||||||
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
|
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
|
||||||
expect(app).toContain('class="sidebar-action-kind"')
|
expect(app).toContain('class="sidebar-action-kind"')
|
||||||
expect(app).toContain('class="sidebar-action-group"')
|
expect(app).toContain('class="sidebar-action-group"')
|
||||||
expect(app).toContain('class="sidebar-action-group-title"')
|
expect(app).toContain('class="sidebar-action-group-title"')
|
||||||
@@ -1382,10 +1408,11 @@ describe('sidebar layout', () => {
|
|||||||
|
|
||||||
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
||||||
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
||||||
expect(app).toContain('ref="purgeCancelButton"')
|
expect(app).toContain('panel-class="purge-list-dialog"')
|
||||||
expect(app).toContain('purgeCancelButton.value?.focus()')
|
expect(app).toContain('initial-focus=".secondary"')
|
||||||
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
|
expect(app).toContain(':busy="purgeListSubmitting"')
|
||||||
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
|
expect(app).toContain('@close="closePurgeList"')
|
||||||
|
expect(app).not.toContain('handlePurgeDialogKeydown')
|
||||||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
||||||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""add durable backup preflights and import ledger
|
||||||
|
|
||||||
|
Revision ID: 0019_backup_imports
|
||||||
|
Revises: 0018_task_completed_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0019_backup_imports"
|
||||||
|
down_revision = "0018_task_completed_at"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"backup_preflights",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("archive_size", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("staging_path", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cleanup_path", sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("staging_path"),
|
||||||
|
sa.UniqueConstraint("token_hash"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_backup_preflights_user_id", "backup_preflights", ["user_id"])
|
||||||
|
op.create_index("ix_backup_preflights_backup_id", "backup_preflights", ["backup_id"])
|
||||||
|
op.create_index("ix_backup_preflights_status", "backup_preflights", ["status"])
|
||||||
|
op.create_index("ix_backup_preflights_expires_at", "backup_preflights", ["expires_at"])
|
||||||
|
op.create_table(
|
||||||
|
"backup_imports",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "backup_id", name="uq_backup_import_user_backup"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_backup_imports_user_id", "backup_imports", ["user_id"])
|
||||||
|
op.create_index("ix_backup_imports_backup_id", "backup_imports", ["backup_id"])
|
||||||
|
op.create_table(
|
||||||
|
"backup_import_entities",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("source_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("content_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"user_id", "backup_id", "entity_type", "source_id",
|
||||||
|
name="uq_backup_import_entity_source",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_backup_import_entities_user_id", "backup_import_entities", ["user_id"])
|
||||||
|
op.create_index("ix_backup_import_entities_backup_id", "backup_import_entities", ["backup_id"])
|
||||||
|
op.create_index("ix_backup_import_entities_source_id", "backup_import_entities", ["source_id"])
|
||||||
|
op.create_index("ix_backup_import_entities_target_id", "backup_import_entities", ["target_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_backup_import_entities_target_id", table_name="backup_import_entities")
|
||||||
|
op.drop_index("ix_backup_import_entities_source_id", table_name="backup_import_entities")
|
||||||
|
op.drop_index("ix_backup_import_entities_backup_id", table_name="backup_import_entities")
|
||||||
|
op.drop_index("ix_backup_import_entities_user_id", table_name="backup_import_entities")
|
||||||
|
op.drop_table("backup_import_entities")
|
||||||
|
op.drop_index("ix_backup_imports_backup_id", table_name="backup_imports")
|
||||||
|
op.drop_index("ix_backup_imports_user_id", table_name="backup_imports")
|
||||||
|
op.drop_table("backup_imports")
|
||||||
|
op.drop_index("ix_backup_preflights_expires_at", table_name="backup_preflights")
|
||||||
|
op.drop_index("ix_backup_preflights_status", table_name="backup_preflights")
|
||||||
|
op.drop_index("ix_backup_preflights_backup_id", table_name="backup_preflights")
|
||||||
|
op.drop_index("ix_backup_preflights_user_id", table_name="backup_preflights")
|
||||||
|
op.drop_table("backup_preflights")
|
||||||
+12
-1
@@ -1,8 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -11,9 +13,18 @@ def client(tmp_path: Path):
|
|||||||
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
|
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
|
||||||
from backend.config import get_settings
|
from backend.config import get_settings
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
from backend.db import reset_engine
|
from backend.db import get_engine, reset_engine
|
||||||
reset_engine()
|
reset_engine()
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def enable_sqlite_foreign_keys(dbapi_connection, _):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
from backend.main import app
|
from backend.main import app
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
yield test_client
|
yield test_client
|
||||||
|
asyncio.run(engine.dispose())
|
||||||
reset_engine()
|
reset_engine()
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
|||||||
assert recurrence["after_completion_days"] == 2
|
assert recurrence["after_completion_days"] == 2
|
||||||
assert recurrence["last_completed_at"] is None
|
assert recurrence["last_completed_at"] is None
|
||||||
|
|
||||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
restored = client.post("/api/v1/restore?mode=merge", json=exported)
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||||
restored_recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
restored_recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||||
@@ -252,7 +252,7 @@ def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
|||||||
csv_export = client.get("/api/v1/export.csv")
|
csv_export = client.get("/api/v1/export.csv")
|
||||||
assert csv_export.status_code == 200
|
assert csv_export.status_code == 200
|
||||||
csv_restore = client.post(
|
csv_restore = client.post(
|
||||||
"/api/v1/restore.csv?mode=replace",
|
"/api/v1/restore.csv?mode=merge",
|
||||||
files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
|
files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
|
||||||
)
|
)
|
||||||
assert csv_restore.status_code == 200
|
assert csv_restore.status_code == 200
|
||||||
|
|||||||
+3
-2
@@ -532,12 +532,13 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
|
|||||||
"/api/v1/habits",
|
"/api/v1/habits",
|
||||||
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
|
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
|
||||||
)
|
)
|
||||||
restored = client.post("/api/v1/restore?mode=replace", json=exported.json())
|
restored = client.post("/api/v1/restore?mode=merge", json=exported.json())
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
|
|
||||||
assert client.get("/api/v1/tags").status_code == 404
|
assert client.get("/api/v1/tags").status_code == 404
|
||||||
habits = client.get("/api/v1/habits").json()
|
habits = client.get("/api/v1/habits").json()
|
||||||
assert [row["name"] for row in habits] == ["俯卧撑"]
|
assert "俯卧撑" in [row["name"] for row in habits]
|
||||||
|
assert "深蹲" in [row["name"] for row in habits]
|
||||||
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
|
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
|
||||||
assert "tags" not in listed[0]
|
assert "tags" not in listed[0]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.models import Attachment, BackupImportEntity
|
||||||
|
from tests.test_backup_v2 import _preflight
|
||||||
|
from tests.test_mvp_backend import boot
|
||||||
|
|
||||||
|
|
||||||
|
async def _attachment_state(attachment_id: str) -> tuple[Attachment, int]:
|
||||||
|
from backend.db import get_engine
|
||||||
|
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
attachment = await db.get(Attachment, UUID(attachment_id))
|
||||||
|
ledger_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(BackupImportEntity).where(
|
||||||
|
BackupImportEntity.entity_type == "attachments",
|
||||||
|
BackupImportEntity.source_id == UUID(attachment_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return attachment, ledger_count or 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("damage", ["missing", "corrupt"])
|
||||||
|
def test_merge_existing_attachment_without_ledger_never_certifies_bad_file(
|
||||||
|
client, tmp_path, damage
|
||||||
|
):
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
inbox = boot(client)
|
||||||
|
root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(root)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
||||||
|
).json()
|
||||||
|
payload = b"trusted attachment bytes"
|
||||||
|
attachment = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", payload, "text/plain")},
|
||||||
|
).json()
|
||||||
|
archive = client.get("/api/v1/backup/export.zip").content
|
||||||
|
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||||
|
assert ledger_count == 0
|
||||||
|
stored = root / row.storage_name
|
||||||
|
if damage == "missing":
|
||||||
|
stored.unlink()
|
||||||
|
else:
|
||||||
|
stored.write_bytes(b"x" * len(payload))
|
||||||
|
|
||||||
|
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": token, "mode": "merge"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"]["code"] in {
|
||||||
|
"backup_entity_missing",
|
||||||
|
"backup_entity_conflict",
|
||||||
|
}
|
||||||
|
_, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||||
|
assert ledger_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_existing_attachment_without_ledger_hashes_equal_bytes_before_ledger(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
inbox = boot(client)
|
||||||
|
root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(root)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
||||||
|
).json()
|
||||||
|
payload = b"trusted attachment bytes"
|
||||||
|
attachment = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.txt", payload, "text/plain")},
|
||||||
|
).json()
|
||||||
|
archive = client.get("/api/v1/backup/export.zip").content
|
||||||
|
|
||||||
|
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": token, "mode": "merge"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||||
|
assert ledger_count == 1
|
||||||
|
assert Path(root / row.storage_name).read_bytes() == payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _business_counts() -> tuple[int, ...]:
|
||||||
|
from backend.backup.service import ENTITY_MODELS
|
||||||
|
from backend.db import get_engine
|
||||||
|
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
counts = []
|
||||||
|
for model in ENTITY_MODELS.values():
|
||||||
|
counts.append((await db.scalar(select(func.count()).select_from(model))) or 0)
|
||||||
|
return tuple(counts)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_graph() -> dict[str, list[dict]]:
|
||||||
|
list_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
task_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
recurrence_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||||
|
return {
|
||||||
|
"lists": [{"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}],
|
||||||
|
"tasks": [{
|
||||||
|
"id": task_id,
|
||||||
|
"list_id": list_id,
|
||||||
|
"parent_id": None,
|
||||||
|
"title": "valid task",
|
||||||
|
"description": "",
|
||||||
|
"priority": 0,
|
||||||
|
"completed": False,
|
||||||
|
"completed_at": None,
|
||||||
|
"due_at": "2026-09-20T08:00:00+00:00",
|
||||||
|
"due_has_time": True,
|
||||||
|
"version": 1,
|
||||||
|
"position": 0,
|
||||||
|
"external_id": None,
|
||||||
|
}],
|
||||||
|
"recurrences": [{
|
||||||
|
"id": recurrence_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
"starts_at": "2026-09-20T08:00:00+00:00",
|
||||||
|
"ends_at": None,
|
||||||
|
"trigger_mode": "scheduled",
|
||||||
|
"after_completion_days": None,
|
||||||
|
"last_completed_at": None,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mutate", "label"),
|
||||||
|
[
|
||||||
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=None), "after completion days required"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule="FREQ=DAILY", after_completion_days=1), "after completion excludes rrule"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=0), "after completion range"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", rrule=None, after_completion_days=None), "scheduled requires rrule"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", after_completion_days=1), "scheduled excludes days"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(rrule="FREQ=NOPE"), "rrule parses"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(ends_at="2026-09-19T08:00:00+00:00"), "ends after starts"),
|
||||||
|
(lambda graph: graph["recurrences"][0].update(last_completed_at="2026-09-21T08:00:00+00:00"), "last completion before start"),
|
||||||
|
(lambda graph: graph["tasks"][0].update(due_at=None, due_has_time=False), "recurring task has due"),
|
||||||
|
(lambda graph: graph["tasks"][0].update(parent_id="dddddddd-dddd-4ddd-8ddd-dddddddddddd"), "recurring task top level"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_recurrence_preflight_rejects_invalid_contract_without_business_writes(client, mutate, label):
|
||||||
|
boot(client)
|
||||||
|
graph = _base_graph()
|
||||||
|
if label == "recurring task top level":
|
||||||
|
graph["tasks"].append({
|
||||||
|
**graph["tasks"][0],
|
||||||
|
"id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
"title": "parent",
|
||||||
|
"parent_id": None,
|
||||||
|
})
|
||||||
|
mutate(graph)
|
||||||
|
before = asyncio.run(_business_counts())
|
||||||
|
|
||||||
|
content = _make_archive_from_graph(graph)
|
||||||
|
response = _preflight(client, content, "replace")
|
||||||
|
|
||||||
|
assert response.status_code == 422, (label, response.text)
|
||||||
|
assert response.json()["detail"]["code"] == "backup_recurrence_invalid"
|
||||||
|
assert asyncio.run(_business_counts()) == before
|
||||||
|
|
||||||
|
|
||||||
|
def _make_archive_from_graph(
|
||||||
|
graph: dict[str, list[dict]],
|
||||||
|
files: dict[str, bytes] | None = None,
|
||||||
|
*,
|
||||||
|
backup_id: str = "11111111-1111-4111-8111-111111111111",
|
||||||
|
) -> bytes:
|
||||||
|
from tests.test_backup_v2 import _make_zip
|
||||||
|
|
||||||
|
entries = {
|
||||||
|
f"data/{entity}.json": __import__("json").dumps(rows).encode()
|
||||||
|
for entity, rows in graph.items()
|
||||||
|
}
|
||||||
|
entries.update(files or {})
|
||||||
|
return _make_zip(entries, backup_id=backup_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_tree_graph(parent_ids: list[str | None]) -> dict[str, list[dict]]:
|
||||||
|
graph = _base_graph()
|
||||||
|
graph["recurrences"] = []
|
||||||
|
template = graph["tasks"][0]
|
||||||
|
task_ids = [
|
||||||
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
]
|
||||||
|
graph["tasks"] = [
|
||||||
|
{
|
||||||
|
**template,
|
||||||
|
"id": task_id,
|
||||||
|
"parent_id": parent_id,
|
||||||
|
"title": f"tree task {index}",
|
||||||
|
"position": index,
|
||||||
|
}
|
||||||
|
for index, (task_id, parent_id) in enumerate(zip(task_ids, parent_ids, strict=True))
|
||||||
|
]
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_three_level_task_tree_without_business_writes(client):
|
||||||
|
boot(client)
|
||||||
|
graph = _task_tree_graph([
|
||||||
|
None,
|
||||||
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
])
|
||||||
|
before = asyncio.run(_business_counts())
|
||||||
|
|
||||||
|
response = _preflight(client, _make_archive_from_graph(graph), "replace")
|
||||||
|
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||||
|
assert asyncio.run(_business_counts()) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_parent_with_multiple_children_round_trips_in_arbitrary_zip_order(client):
|
||||||
|
boot(client)
|
||||||
|
parent_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||||
|
graph = _task_tree_graph([parent_id, None, parent_id])
|
||||||
|
graph["tasks"] = [graph["tasks"][2], graph["tasks"][1], graph["tasks"][0]]
|
||||||
|
|
||||||
|
preflight = _preflight(client, _make_archive_from_graph(graph), "replace")
|
||||||
|
assert preflight.status_code == 200, preflight.text
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
|
||||||
|
from tests.test_backup_v2 import _archive_rows
|
||||||
|
|
||||||
|
rows = _archive_rows(client.get("/api/v1/backup/export.zip").content, "tasks")
|
||||||
|
by_id = {row["id"]: row for row in rows}
|
||||||
|
assert by_id[parent_id]["parent_id"] is None
|
||||||
|
assert {
|
||||||
|
row["id"] for row in rows if row["parent_id"] == parent_id
|
||||||
|
} == {
|
||||||
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
}
|
||||||
|
items = client.get("/api/v1/tasks", params={"q": "tree task"}).json()["items"]
|
||||||
|
restored_parent = next(item for item in items if item["id"] == parent_id)
|
||||||
|
assert {child["id"] for child in restored_parent["subtasks"]} == {
|
||||||
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"recurrence",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1,15;COUNT=8",
|
||||||
|
"trigger_mode": "scheduled",
|
||||||
|
"after_completion_days": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rrule": None,
|
||||||
|
"trigger_mode": "after_completion",
|
||||||
|
"after_completion_days": 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_both_recurrence_modes_round_trip_through_replace(client, recurrence):
|
||||||
|
boot(client)
|
||||||
|
graph = _base_graph()
|
||||||
|
graph["recurrences"][0].update(recurrence)
|
||||||
|
content = _make_archive_from_graph(graph)
|
||||||
|
preflight = _preflight(client, content, "replace")
|
||||||
|
assert preflight.status_code == 200, preflight.text
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
exported = client.get("/api/v1/backup/export.zip").content
|
||||||
|
from tests.test_backup_v2 import _archive_rows
|
||||||
|
|
||||||
|
row = _archive_rows(exported, "recurrences")[0]
|
||||||
|
assert row["rrule"] == recurrence["rrule"]
|
||||||
|
assert row["trigger_mode"] == recurrence["trigger_mode"]
|
||||||
|
assert row["after_completion_days"] == recurrence["after_completion_days"]
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_case(entity: str, row: dict, files: dict[str, bytes] | None = None) -> bytes:
|
||||||
|
graph = _base_graph()
|
||||||
|
graph[entity] = [row]
|
||||||
|
return _make_archive_from_graph(graph, files)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("entity", "row", "files"),
|
||||||
|
[
|
||||||
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": " ", "position": 0}, None),
|
||||||
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x" * 121, "position": 0}, None),
|
||||||
|
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x", "position": -1}, None),
|
||||||
|
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "x" * 121, "is_inbox": True, "position": 0}, None),
|
||||||
|
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "Inbox", "is_inbox": True, "position": -1}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "title": " "}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "priority": 4}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "version": 0}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "position": -1}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "due_at": None, "due_has_time": True}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "completed": False, "completed_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||||
|
("tasks", {**_base_graph()["tasks"][0], "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||||
|
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "bad", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
||||||
|
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "solar", "lunar_month": 1, "lunar_day": 1, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
||||||
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": " ", "content": "", "version": 1}, None),
|
||||||
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 0}, None),
|
||||||
|
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 1, "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||||
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x" * 256, "mime_type": "text/plain", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||||
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "application/x-danger", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||||
|
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "text/plain", "size": -1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_all_entity_contracts_fail_preflight_without_business_writes(client, entity, row, files):
|
||||||
|
boot(client)
|
||||||
|
before = asyncio.run(_business_counts())
|
||||||
|
|
||||||
|
response = _preflight(client, _entity_case(entity, row, files), "replace")
|
||||||
|
|
||||||
|
assert response.status_code == 422, (entity, response.text)
|
||||||
|
assert asyncio.run(_business_counts()) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_replace_is_rejected_without_mutating_data(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "must survive", "list_id": inbox["id"]}
|
||||||
|
).json()
|
||||||
|
payload = client.get("/api/v1/export").json()
|
||||||
|
|
||||||
|
response = client.post("/api/v1/restore?mode=replace", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == {
|
||||||
|
"code": "legacy_replace_unsupported",
|
||||||
|
"message": "旧版备份仅支持合并恢复",
|
||||||
|
}
|
||||||
|
assert client.get(f"/api/v1/tasks/{task['id']}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_csv_upload_has_streaming_hard_limit(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
from backend import mvp
|
||||||
|
|
||||||
|
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_BYTES", 32)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/restore.csv?mode=merge",
|
||||||
|
files={"file": ("backup.csv", b"entity,data\n" + b"x" * 33, "text/csv")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == {
|
||||||
|
"code": "legacy_backup_too_large",
|
||||||
|
"message": "旧版备份文件过大",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_json_rejects_excessive_records(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
from backend import mvp
|
||||||
|
|
||||||
|
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_RECORDS", 1)
|
||||||
|
payload = {"version": 1, "folders": [], "lists": [], "tasks": [
|
||||||
|
{"id": "11111111-1111-4111-8111-111111111111"},
|
||||||
|
{"id": "22222222-2222-4222-8222-222222222222"},
|
||||||
|
]}
|
||||||
|
|
||||||
|
response = client.post("/api/v1/restore?mode=merge", json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"] == "legacy_backup_too_many_records"
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_preflight_rejects_multiple_active_pinned_countdowns(client):
|
||||||
|
boot(client)
|
||||||
|
graph = _base_graph()
|
||||||
|
graph["recurrences"] = []
|
||||||
|
graph["countdowns"] = [
|
||||||
|
{
|
||||||
|
"id": f"{index:08d}-2020-4020-8020-202020202020",
|
||||||
|
"title": f"pinned {index}", "event_date": "2026-09-20",
|
||||||
|
"calendar_mode": "solar", "lunar_month": None, "lunar_day": None,
|
||||||
|
"ignore_year": False, "kind": "countdown", "repeat_rule": "none",
|
||||||
|
"icon": "x", "pinned": True, "archived_at": None,
|
||||||
|
}
|
||||||
|
for index in (1, 2)
|
||||||
|
]
|
||||||
|
|
||||||
|
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaper_removes_failed_and_stranded_staging_and_repairs_cleanup(client, tmp_path):
|
||||||
|
boot(client)
|
||||||
|
from backend.backup.router import _prune
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import BackupPreflight, utcnow
|
||||||
|
|
||||||
|
staging_root = tmp_path / "staging"
|
||||||
|
staging_root.mkdir()
|
||||||
|
get_settings().backup_staging_dir = str(staging_root)
|
||||||
|
failed_file = staging_root / "failed.zip"
|
||||||
|
consuming_file = staging_root / "consuming.zip"
|
||||||
|
failed_file.write_bytes(b"failed")
|
||||||
|
consuming_file.write_bytes(b"consuming")
|
||||||
|
cleanup = tmp_path / "cleanup"
|
||||||
|
cleanup.mkdir()
|
||||||
|
(cleanup / "old").write_bytes(b"old")
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
user_id = await db.scalar(select(__import__("backend.models", fromlist=["User"]).User.id))
|
||||||
|
now = utcnow() - timedelta(hours=1)
|
||||||
|
rows = [
|
||||||
|
BackupPreflight(
|
||||||
|
token_hash=str(index) * 64, user_id=user_id,
|
||||||
|
backup_id=UUID(f"00000000-0000-4000-8000-00000000000{index}"),
|
||||||
|
archive_sha256="0" * 64, archive_size=10,
|
||||||
|
staging_path=str(path), mode="merge", status=status,
|
||||||
|
expires_at=now, consumed_at=now,
|
||||||
|
cleanup_path=str(cleanup) if status == "cleanup_pending" else None,
|
||||||
|
)
|
||||||
|
for index, (status, path) in enumerate(
|
||||||
|
[("failed", failed_file), ("consuming", consuming_file),
|
||||||
|
("cleanup_pending", staging_root / "cleanup.zip")], start=1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
db.add_all(rows)
|
||||||
|
await db.flush()
|
||||||
|
row_ids = [row.id for row in rows]
|
||||||
|
await db.commit()
|
||||||
|
await _prune(db)
|
||||||
|
statuses = {
|
||||||
|
str(row_id): await db.scalar(
|
||||||
|
select(BackupPreflight.status).where(BackupPreflight.id == row_id)
|
||||||
|
)
|
||||||
|
for row_id in row_ids
|
||||||
|
}
|
||||||
|
return row_ids, statuses
|
||||||
|
|
||||||
|
row_ids, statuses = asyncio.run(exercise())
|
||||||
|
assert not failed_file.exists()
|
||||||
|
assert not consuming_file.exists()
|
||||||
|
assert not cleanup.exists()
|
||||||
|
assert statuses[str(row_ids[2])] == "consumed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_repair_pending_is_repaired_not_deleted_with_quarantine(client, tmp_path):
|
||||||
|
boot(client)
|
||||||
|
from backend.backup.router import _prune
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import BackupPreflight, User, utcnow
|
||||||
|
|
||||||
|
staging_root = tmp_path / "staging"
|
||||||
|
attachment_root = tmp_path / "attachments"
|
||||||
|
quarantine = tmp_path / "quarantine"
|
||||||
|
staging_root.mkdir()
|
||||||
|
attachment_root.mkdir()
|
||||||
|
quarantine.mkdir()
|
||||||
|
staging = staging_root / "repair.zip"
|
||||||
|
staging.write_bytes(b"staged")
|
||||||
|
(quarantine / "restored.bin").write_bytes(b"original")
|
||||||
|
get_settings().backup_staging_dir = str(staging_root)
|
||||||
|
get_settings().attachment_dir = str(attachment_root)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
user_id = await db.scalar(select(User.id))
|
||||||
|
row = BackupPreflight(
|
||||||
|
token_hash="9" * 64,
|
||||||
|
user_id=user_id,
|
||||||
|
backup_id=UUID("99999999-9999-4999-8999-999999999999"),
|
||||||
|
archive_sha256="0" * 64,
|
||||||
|
archive_size=7,
|
||||||
|
staging_path=str(staging),
|
||||||
|
mode="replace",
|
||||||
|
status="repair_pending",
|
||||||
|
expires_at=utcnow() - timedelta(hours=1),
|
||||||
|
cleanup_path=str(quarantine),
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
row_id = row.id
|
||||||
|
await db.commit()
|
||||||
|
await _prune(db)
|
||||||
|
repaired = await db.get(BackupPreflight, row_id)
|
||||||
|
return repaired.status, repaired.cleanup_path
|
||||||
|
|
||||||
|
status, cleanup_path = asyncio.run(exercise())
|
||||||
|
assert (attachment_root / "restored.bin").read_bytes() == b"original"
|
||||||
|
assert not quarantine.exists()
|
||||||
|
assert not staging.exists()
|
||||||
|
assert status == "failed"
|
||||||
|
assert cleanup_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_pending_counts_against_preflight_quota(client, tmp_path, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import BackupPreflight, User, utcnow
|
||||||
|
|
||||||
|
router_module = importlib.import_module("backend.backup.router")
|
||||||
|
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||||
|
staging_root = tmp_path / "staging"
|
||||||
|
staging_root.mkdir()
|
||||||
|
get_settings().backup_staging_dir = str(staging_root)
|
||||||
|
staged = staging_root / "pending.zip"
|
||||||
|
staged.write_bytes(b"pending")
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
user_id = await db.scalar(select(User.id))
|
||||||
|
db.add(BackupPreflight(
|
||||||
|
token_hash="8" * 64,
|
||||||
|
user_id=user_id,
|
||||||
|
backup_id=UUID("88888888-8888-4888-8888-888888888888"),
|
||||||
|
archive_sha256="0" * 64,
|
||||||
|
archive_size=7,
|
||||||
|
staging_path=str(staged),
|
||||||
|
mode="replace",
|
||||||
|
status="repair_pending",
|
||||||
|
expires_at=utcnow() + timedelta(hours=1),
|
||||||
|
cleanup_path=str(tmp_path / "quarantine"),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(seed())
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
response = _preflight(client, content, "merge")
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.json()["detail"]["code"] == "backup_preflight_quota"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_pending_exception_survives_router_and_same_token_only_repairs(
|
||||||
|
client, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
inbox = boot(client)
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
backup_router = importlib.import_module("backend.backup.router")
|
||||||
|
from backend.backup import service
|
||||||
|
from backend.config import get_settings
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import Attachment, BackupPreflight
|
||||||
|
|
||||||
|
root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(root)
|
||||||
|
task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json()
|
||||||
|
uploaded = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("old.txt", b"old", "text/plain")},
|
||||||
|
).json()
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||||
|
|
||||||
|
real_restore = service.restore_quarantine
|
||||||
|
monkeypatch.setattr(service, "contained_file", lambda *_: (_ for _ in ()).throw(OSError("write")))
|
||||||
|
monkeypatch.setattr(service, "restore_quarantine", lambda *_: (_ for _ in ()).throw(OSError("repair")))
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json()["detail"]["code"] == "backup_repair_pending"
|
||||||
|
|
||||||
|
async def state():
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.status == "repair_pending"))
|
||||||
|
attachment = await db.get(Attachment, UUID(uploaded["id"]))
|
||||||
|
return row.status, row.cleanup_path, root / attachment.storage_name
|
||||||
|
|
||||||
|
status, cleanup_path, old_path = asyncio.run(state())
|
||||||
|
assert status == "repair_pending"
|
||||||
|
assert cleanup_path
|
||||||
|
assert not old_path.exists()
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "restore_quarantine", real_restore)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backup_router,
|
||||||
|
"restore_v2",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("DB restore repeated")),
|
||||||
|
)
|
||||||
|
retried = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||||
|
)
|
||||||
|
assert retried.status_code == 409
|
||||||
|
assert retried.json()["detail"]["code"] == "backup_restore_retry"
|
||||||
|
assert old_path.read_bytes() == b"old"
|
||||||
|
|
||||||
|
async def repaired_state():
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
|
||||||
|
return row.status, row.cleanup_path
|
||||||
|
|
||||||
|
assert asyncio.run(repaired_state()) == ("failed", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_preflight_rejects_archive_pin_when_user_has_different_active_pin(client):
|
||||||
|
boot(client)
|
||||||
|
existing = client.post(
|
||||||
|
"/api/v1/countdowns",
|
||||||
|
json={"title": "existing pin", "event_date": "2026-09-20", "pinned": True},
|
||||||
|
).json()
|
||||||
|
graph = _base_graph()
|
||||||
|
graph["recurrences"] = []
|
||||||
|
graph["countdowns"] = [{
|
||||||
|
"id": "77777777-7777-4777-8777-777777777777",
|
||||||
|
"title": "incoming pin",
|
||||||
|
"event_date": "2026-09-21",
|
||||||
|
"calendar_mode": "solar",
|
||||||
|
"lunar_month": None,
|
||||||
|
"lunar_day": None,
|
||||||
|
"ignore_year": False,
|
||||||
|
"kind": "countdown",
|
||||||
|
"repeat_rule": "none",
|
||||||
|
"icon": "x",
|
||||||
|
"pinned": True,
|
||||||
|
"archived_at": None,
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||||
|
active = client.get("/api/v1/countdowns").json()
|
||||||
|
assert [row["id"] for row in active if row["pinned"]] == [existing["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_target_ids_are_unique_across_entity_tables(client):
|
||||||
|
boot(client)
|
||||||
|
shared = "66666666-6666-4666-8666-666666666666"
|
||||||
|
|
||||||
|
async def seed_folder():
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import Folder, User
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
user_id = await db.scalar(select(User.id))
|
||||||
|
db.add(Folder(
|
||||||
|
id=UUID(shared), user_id=user_id, name="existing entity id", position=0
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(seed_folder())
|
||||||
|
current = client.get("/api/v1/backup/export.zip").content
|
||||||
|
from tests.test_backup_v2 import _archive_rows, _replace_entities
|
||||||
|
|
||||||
|
lists = _archive_rows(current, "lists")
|
||||||
|
lists.append({
|
||||||
|
"id": shared,
|
||||||
|
"name": "cross-table collision",
|
||||||
|
"is_inbox": False,
|
||||||
|
"position": 1,
|
||||||
|
})
|
||||||
|
second_content = _replace_entities(current, {"lists": lists})
|
||||||
|
entries = __import__("tests.test_backup_v2", fromlist=["_zip_entries"])._zip_entries(second_content)
|
||||||
|
entities = {
|
||||||
|
name.removeprefix("data/").removesuffix(".json"): __import__("json").loads(value)
|
||||||
|
for name, value in entries.items()
|
||||||
|
if name.startswith("data/") and name.endswith(".json")
|
||||||
|
}
|
||||||
|
second = _preflight(
|
||||||
|
client,
|
||||||
|
_make_archive_from_graph(
|
||||||
|
entities,
|
||||||
|
{
|
||||||
|
name: value
|
||||||
|
for name, value in entries.items()
|
||||||
|
if name.startswith("attachments/")
|
||||||
|
},
|
||||||
|
backup_id="55555555-5555-4555-8555-555555555555",
|
||||||
|
),
|
||||||
|
"merge",
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": second.json()["preflight_token"], "mode": "merge"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
|
||||||
|
async def ids():
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import Folder, TaskList
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
folder_id = await db.scalar(select(Folder.id).where(Folder.name == "existing entity id"))
|
||||||
|
list_id = await db.scalar(select(TaskList.id).where(TaskList.name == "cross-table collision"))
|
||||||
|
return folder_id, list_id
|
||||||
|
|
||||||
|
folder_id, list_id = asyncio.run(ids())
|
||||||
|
assert folder_id == UUID(shared)
|
||||||
|
assert list_id != folder_id
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.auth import COOKIE_NAME, hash_token
|
||||||
|
from backend.models import (
|
||||||
|
BackupImport,
|
||||||
|
BackupPreflight,
|
||||||
|
Habit,
|
||||||
|
HabitLog,
|
||||||
|
HabitPause,
|
||||||
|
RecurrenceException,
|
||||||
|
Session,
|
||||||
|
Task,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from tests.test_mvp_backend import boot
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_entries(content: bytes) -> dict[str, bytes]:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||||
|
return {name: archive.read(name) for name in archive.namelist()}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_zip(entries: dict[str, bytes], *, backup_id: str = "11111111-1111-4111-8111-111111111111") -> bytes:
|
||||||
|
entity_names = {
|
||||||
|
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions", "habits",
|
||||||
|
"habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
|
||||||
|
}
|
||||||
|
complete_entries = {f"data/{name}.json": b"[]" for name in entity_names}
|
||||||
|
complete_entries["data/lists.json"] = json.dumps([{
|
||||||
|
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
"name": "Inbox", "is_inbox": True, "position": 0,
|
||||||
|
}]).encode()
|
||||||
|
complete_entries.update(entries)
|
||||||
|
entries = complete_entries
|
||||||
|
checksums = {name: hashlib.sha256(value).hexdigest() for name, value in entries.items()}
|
||||||
|
manifest = {
|
||||||
|
"format": "dodo-backup",
|
||||||
|
"version": 2,
|
||||||
|
"backup_id": backup_id,
|
||||||
|
"entities": {
|
||||||
|
name: len(json.loads(entries[f"data/{name}.json"])) for name in entity_names
|
||||||
|
},
|
||||||
|
"checksums": checksums,
|
||||||
|
}
|
||||||
|
output = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest))
|
||||||
|
for name, value in entries.items():
|
||||||
|
archive.writestr(name, value)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight(client, content: bytes, mode: str = "merge"):
|
||||||
|
return client.post(
|
||||||
|
"/api/v1/backup/preflight",
|
||||||
|
params={"mode": mode},
|
||||||
|
files={"file": ("backup.zip", content, "application/zip")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_entities(content: bytes, replacements: dict[str, list[dict]]) -> bytes:
|
||||||
|
entries = _zip_entries(content)
|
||||||
|
manifest = json.loads(entries.pop("manifest.json"))
|
||||||
|
for entity, rows in replacements.items():
|
||||||
|
name = f"data/{entity}.json"
|
||||||
|
entries[name] = json.dumps(rows, allow_nan=True).encode()
|
||||||
|
manifest["entities"][entity] = len(rows)
|
||||||
|
manifest["checksums"][name] = hashlib.sha256(entries[name]).hexdigest()
|
||||||
|
output = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest))
|
||||||
|
for name, value in entries.items():
|
||||||
|
archive.writestr(name, value)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_rows(content: bytes, entity: str) -> list[dict]:
|
||||||
|
return json.loads(_zip_entries(content)[f"data/{entity}.json"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_v2_round_trip_includes_history_exception_and_attachment_bytes(client, tmp_path):
|
||||||
|
inbox = boot(client)
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "完整备份任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
|
||||||
|
).json()
|
||||||
|
recurrence = client.post(
|
||||||
|
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY"}
|
||||||
|
).json()
|
||||||
|
habit = client.post(
|
||||||
|
"/api/v1/habits", json={"name": "备份习惯", "kind": "numeric", "target": 2, "schedule_type": "daily"}
|
||||||
|
).json()
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
assert client.put(f"/api/v1/habits/{habit['id']}/logs/{today}", json={"value": 2}).status_code == 200
|
||||||
|
assert client.post(
|
||||||
|
f"/api/v1/habits/{habit['id']}/pauses",
|
||||||
|
json={"start_date": "2026-09-18", "end_date": "2026-09-19"},
|
||||||
|
).status_code == 201
|
||||||
|
attachment_bytes = b"\x00real attachment bytes\xff"
|
||||||
|
uploaded = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("proof.bin", attachment_bytes, "application/zip")},
|
||||||
|
)
|
||||||
|
assert uploaded.status_code == 201
|
||||||
|
|
||||||
|
async def seed_exception():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
db.add(RecurrenceException(
|
||||||
|
template_id=UUID(recurrence["id"]), occurrence_at=datetime(2026, 9, 14, 9, tzinfo=UTC),
|
||||||
|
title="例外标题", completed=True,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(seed_exception())
|
||||||
|
exported = client.get("/api/v1/backup/export.zip")
|
||||||
|
assert exported.status_code == 200
|
||||||
|
assert exported.headers["content-type"] == "application/zip"
|
||||||
|
assert "dodo-backup-v2.zip" in exported.headers["content-disposition"]
|
||||||
|
entries = _zip_entries(exported.content)
|
||||||
|
manifest = json.loads(entries["manifest.json"])
|
||||||
|
assert manifest["version"] == 2
|
||||||
|
assert set(manifest["entities"]) >= {
|
||||||
|
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions",
|
||||||
|
"habits", "habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
|
||||||
|
}
|
||||||
|
attachment_row = json.loads(entries["data/attachments.json"])[0]
|
||||||
|
assert entries[attachment_row["archive_path"]] == attachment_bytes
|
||||||
|
assert "password_hash" not in exported.content.decode("utf-8", errors="ignore")
|
||||||
|
assert "token_hash" not in exported.content.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
merge_preflight = _preflight(client, exported.content)
|
||||||
|
merged = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": merge_preflight.json()["preflight_token"], "mode": "merge"},
|
||||||
|
)
|
||||||
|
assert merged.status_code == 200, merged.text
|
||||||
|
|
||||||
|
preflight = _preflight(client, exported.content, "replace")
|
||||||
|
assert preflight.status_code == 200
|
||||||
|
assert preflight.json()["valid"] is True
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
restored_task = client.get("/api/v1/tasks", params={"q": "完整备份任务"}).json()["items"][0]
|
||||||
|
restored_attachment = client.get(f"/api/v1/tasks/{restored_task['id']}/attachments").json()[0]
|
||||||
|
assert client.get(f"/api/v1/attachments/{restored_attachment['id']}").content == attachment_bytes
|
||||||
|
|
||||||
|
async def assert_history():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
assert await db.scalar(select(func.count()).select_from(HabitLog)) == 1
|
||||||
|
assert await db.scalar(select(func.count()).select_from(HabitPause)) == 1
|
||||||
|
assert await db.scalar(select(func.count()).select_from(RecurrenceException)) == 1
|
||||||
|
|
||||||
|
asyncio.run(assert_history())
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
client.post("/api/v1/tasks", json={"title": "只导入一次", "list_id": inbox["id"]})
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
first_token = _preflight(client, content).json()["preflight_token"]
|
||||||
|
second_token = _preflight(client, content).json()["preflight_token"]
|
||||||
|
assert client.post("/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}).status_code == 200
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": second_token, "mode": "merge"},
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["already_imported"] is True
|
||||||
|
|
||||||
|
async def counts():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
return (
|
||||||
|
await db.scalar(select(func.count()).select_from(Task).where(Task.title == "只导入一次")),
|
||||||
|
await db.scalar(select(func.count()).select_from(BackupImport)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(counts()) == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||||
|
valid_data = json.dumps([]).encode()
|
||||||
|
cases = []
|
||||||
|
cases.append(_make_zip({"../escape": b"x", "data/tasks.json": valid_data}))
|
||||||
|
|
||||||
|
duplicate = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(duplicate, "w") as archive:
|
||||||
|
archive.writestr("manifest.json", "{}")
|
||||||
|
archive.writestr("data/tasks.json", "[]")
|
||||||
|
archive.writestr("data/tasks.json", "[]")
|
||||||
|
cases.append(duplicate.getvalue())
|
||||||
|
|
||||||
|
bad_checksum = _make_zip({"data/tasks.json": valid_data})
|
||||||
|
entries = _zip_entries(bad_checksum)
|
||||||
|
manifest = json.loads(entries["manifest.json"])
|
||||||
|
manifest["checksums"]["data/tasks.json"] = "0" * 64
|
||||||
|
cases.append(_make_zip({"data/tasks.json": valid_data}, backup_id=manifest["backup_id"]))
|
||||||
|
# Replace the checksum after helper generation.
|
||||||
|
output = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(output, "w") as archive:
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest))
|
||||||
|
archive.writestr("data/tasks.json", valid_data)
|
||||||
|
cases[-1] = output.getvalue()
|
||||||
|
|
||||||
|
dangling = {"data/tasks.json": json.dumps([{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222", "list_id": "missing", "title": "bad"
|
||||||
|
}]).encode()}
|
||||||
|
cases.append(_make_zip(dangling))
|
||||||
|
|
||||||
|
missing_attachment = {"data/attachments.json": json.dumps([{
|
||||||
|
"id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"task_id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"filename": "x", "mime_type": "text/plain", "size": 1,
|
||||||
|
"archive_path": "attachments/33333333-3333-4333-8333-333333333333/content",
|
||||||
|
}]).encode()}
|
||||||
|
cases.append(_make_zip(missing_attachment))
|
||||||
|
|
||||||
|
for content in cases:
|
||||||
|
response = _preflight(client, content)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"].startswith("backup_")
|
||||||
|
after = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_json_and_csv_restore_remain_supported(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
client.post("/api/v1/tasks", json={"title": "legacy", "list_id": inbox["id"]})
|
||||||
|
exported_json = client.get("/api/v1/export")
|
||||||
|
exported_csv = client.get("/api/v1/export.csv")
|
||||||
|
assert client.post("/api/v1/restore?mode=merge", json=exported_json.json()).status_code == 200
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/restore.csv?mode=merge",
|
||||||
|
files={"file": ("backup.csv", exported_csv.content, "text/csv")},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_routes_require_auth_csrf_bind_tokens_and_consume_once(client):
|
||||||
|
boot(client)
|
||||||
|
exported = client.get("/api/v1/backup/export.zip")
|
||||||
|
token = _preflight(client, exported.content).json()["preflight_token"]
|
||||||
|
|
||||||
|
anonymous = client.__class__(client.app)
|
||||||
|
with anonymous:
|
||||||
|
assert anonymous.get("/api/v1/backup/export.zip").status_code == 401
|
||||||
|
assert _preflight(anonymous, exported.content).status_code == 401
|
||||||
|
assert anonymous.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
).status_code == 401
|
||||||
|
|
||||||
|
csrf = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": token, "mode": "merge"},
|
||||||
|
headers={"origin": "https://dodo.example", "x-csrf-token": "wrong"},
|
||||||
|
)
|
||||||
|
assert csrf.status_code == 403
|
||||||
|
|
||||||
|
async def add_other_user_session():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
other = User(username="other", password_hash="unused")
|
||||||
|
db.add(other)
|
||||||
|
await db.flush()
|
||||||
|
session_token = "other-user-session-token"
|
||||||
|
db.add(Session(
|
||||||
|
token_hash=hash_token(session_token), user_id=other.id,
|
||||||
|
expires_at=datetime(2099, 1, 1, tzinfo=UTC),
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return session_token
|
||||||
|
|
||||||
|
other_session = asyncio.run(add_other_user_session())
|
||||||
|
other = client.__class__(client.app)
|
||||||
|
with other:
|
||||||
|
other.cookies.set(COOKIE_NAME, other_session)
|
||||||
|
wrong_user = other.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert wrong_user.status_code == 409
|
||||||
|
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200
|
||||||
|
reused = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert reused.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_binds_mode_and_persists_only_staged_metadata(client):
|
||||||
|
boot(client)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||||
|
wrong_mode = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert wrong_mode.status_code == 409
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200
|
||||||
|
|
||||||
|
async def assert_persisted_consumption():
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import BackupPreflight
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
|
||||||
|
assert row is not None
|
||||||
|
assert row.consumed_at is not None
|
||||||
|
assert row.archive_sha256 == hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
asyncio.run(assert_persisted_consumption())
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_upload_over_compressed_limit_without_unbounded_read(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
import importlib
|
||||||
|
router_module = importlib.import_module("backend.backup.router")
|
||||||
|
monkeypatch.setattr(router_module, "MAX_ARCHIVE_BYTES", 32)
|
||||||
|
response = _preflight(client, b"x" * 33)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"] == "backup_size_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarantine_compensates_value_error_after_first_move(tmp_path, monkeypatch):
|
||||||
|
from backend.backup import storage
|
||||||
|
root = tmp_path / "attachments"
|
||||||
|
root.mkdir()
|
||||||
|
first = root / "first"
|
||||||
|
first.write_bytes(b"first")
|
||||||
|
original = storage.contained_file
|
||||||
|
|
||||||
|
def fail_second(storage_root, name):
|
||||||
|
if name == "bad":
|
||||||
|
raise ValueError("bad path")
|
||||||
|
return original(storage_root, name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(storage, "contained_file", fail_second)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
storage.quarantine_files(root, ["first", "bad"], tmp_path / "quarantine")
|
||||||
|
assert first.read_bytes() == b"first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_unique_and_invalid_task_graph_constraints(client):
|
||||||
|
boot(client)
|
||||||
|
list_id = "11111111-aaaa-4111-8111-111111111111"
|
||||||
|
parent_id = "22222222-aaaa-4222-8222-222222222222"
|
||||||
|
child_id = "33333333-aaaa-4333-8333-333333333333"
|
||||||
|
base_list = {"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}
|
||||||
|
cases = [
|
||||||
|
{"data/lists.json": json.dumps([base_list, {**base_list, "id": "44444444-aaaa-4444-8444-444444444444"}]).encode()},
|
||||||
|
{"data/lists.json": json.dumps([base_list]).encode(), "data/tasks.json": json.dumps([
|
||||||
|
{"id": parent_id, "list_id": list_id, "parent_id": child_id, "title": "p", "external_id": "same"},
|
||||||
|
{"id": child_id, "list_id": list_id, "parent_id": parent_id, "title": "c", "external_id": "same"},
|
||||||
|
]).encode()},
|
||||||
|
]
|
||||||
|
for entries in cases:
|
||||||
|
response = _preflight(client, _make_zip(entries))
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_per_user_pending_quota(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
import importlib
|
||||||
|
router_module = importlib.import_module("backend.backup.router")
|
||||||
|
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
assert _preflight(client, content).status_code == 200
|
||||||
|
response = _preflight(client, content)
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.json()["detail"]["code"] == "backup_preflight_quota"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_cleanup_failure_is_retryable_and_not_reported_as_success(client, tmp_path, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
from backend.backup import service
|
||||||
|
from backend.config import get_settings
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||||
|
real_remove = service.remove_quarantine
|
||||||
|
monkeypatch.setattr(service, "remove_quarantine", lambda _: (_ for _ in ()).throw(OSError("busy")))
|
||||||
|
response = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json()["detail"]["code"] == "backup_cleanup_pending"
|
||||||
|
monkeypatch.setattr(service, "remove_quarantine", real_remove)
|
||||||
|
retried = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
|
||||||
|
assert retried.status_code == 200
|
||||||
|
assert retried.json()["cleanup_retried"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_malformed_rows_without_writing_or_leaking_details(client):
|
||||||
|
boot(client)
|
||||||
|
malformed = _make_zip({
|
||||||
|
"data/folders.json": json.dumps([{
|
||||||
|
"id": "44444444-4444-4444-8444-444444444444",
|
||||||
|
"name": "bad position",
|
||||||
|
"position": "not-an-integer",
|
||||||
|
}]).encode(),
|
||||||
|
})
|
||||||
|
preflight = _preflight(client, malformed)
|
||||||
|
assert preflight.status_code == 422
|
||||||
|
assert preflight.json()["detail"]["code"] == "backup_entity_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_rejects_invalid_habit_graph_without_any_write(client):
|
||||||
|
boot(client)
|
||||||
|
habit = client.post(
|
||||||
|
"/api/v1/habits",
|
||||||
|
json={"name": "基准", "kind": "numeric", "target": 2, "max_value": 4,
|
||||||
|
"schedule_type": "weekly", "weekdays": [1, 3], "start_date": "2026-09-01"},
|
||||||
|
).json()
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
base_habit = _archive_rows(content, "habits")[0]
|
||||||
|
base_log = {
|
||||||
|
"id": "91919191-9191-4191-8191-919191919191", "habit_id": habit["id"],
|
||||||
|
"day": "2026-09-01", "value": 2, "updated_at": "2026-09-01T08:00:00+00:00",
|
||||||
|
}
|
||||||
|
base_pause = {
|
||||||
|
"id": "92929292-9292-4292-8292-929292929292", "habit_id": habit["id"],
|
||||||
|
"start_date": "2026-09-10", "end_date": "2026-09-12",
|
||||||
|
}
|
||||||
|
invalid_graphs = [
|
||||||
|
({"habits": [{**base_habit, "kind": "counter"}]}, "kind enum"),
|
||||||
|
({"habits": [{**base_habit, "kind": "boolean", "target": 2, "max_value": 1}]}, "boolean target"),
|
||||||
|
({"habits": [{**base_habit, "target": float("inf")}]}, "finite target"),
|
||||||
|
({"habits": [{**base_habit, "target": 5, "max_value": 4}]}, "numeric range"),
|
||||||
|
({"habits": [{**base_habit, "schedule_type": "sometimes"}]}, "schedule enum"),
|
||||||
|
({"habits": [{**base_habit, "weekdays": "99"}]}, "weekday range"),
|
||||||
|
({"habits": [{**base_habit, "weekdays": "1,1"}]}, "weekday uniqueness"),
|
||||||
|
({"habits": [{**base_habit, "weekdays": "1, 3"}]}, "weekday storage"),
|
||||||
|
({"habits": [{**base_habit, "schedule_type": "monthly", "weekdays": None,
|
||||||
|
"month_days": "0", "interval_days": None}]}, "month range"),
|
||||||
|
({"habits": [{**base_habit, "schedule_type": "interval", "weekdays": None,
|
||||||
|
"month_days": None, "interval_days": 0}]}, "interval range"),
|
||||||
|
({"habits": [{**base_habit, "start_date": "2026-02-30"}]}, "start date"),
|
||||||
|
({"habit_logs": [{**base_log, "value": float("inf")}]}, "finite log"),
|
||||||
|
({"habits": [{**base_habit, "kind": "boolean", "target": 1, "max_value": 1}],
|
||||||
|
"habit_logs": [{**base_log, "value": 2}]}, "boolean log"),
|
||||||
|
({"habit_logs": [{**base_log, "value": 5}]}, "numeric log max"),
|
||||||
|
({"habit_logs": [{**base_log, "value": -1}]}, "numeric log minimum"),
|
||||||
|
({"habit_pauses": [{**base_pause, "start_date": "2026-09-13"}]}, "pause order"),
|
||||||
|
({"habit_pauses": [base_pause, {
|
||||||
|
**base_pause, "id": "93939393-9393-4393-8393-939393939393",
|
||||||
|
"start_date": "2026-09-12", "end_date": "2026-09-14",
|
||||||
|
}]}, "pause overlap"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def counts():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
values = []
|
||||||
|
for model in (Habit, HabitLog, HabitPause, BackupPreflight):
|
||||||
|
values.append(await db.scalar(select(func.count()).select_from(model)))
|
||||||
|
return tuple(values)
|
||||||
|
|
||||||
|
before = asyncio.run(counts())
|
||||||
|
for replacements, label in invalid_graphs:
|
||||||
|
response = _preflight(client, _replace_entities(content, replacements), "replace")
|
||||||
|
assert response.status_code == 422, (label, response.text)
|
||||||
|
assert response.json()["detail"]["code"] in {
|
||||||
|
"backup_entity_invalid", "backup_habit_invalid",
|
||||||
|
}, label
|
||||||
|
assert asyncio.run(counts()) == before, label
|
||||||
|
|
||||||
|
|
||||||
|
def test_four_habit_schedules_and_history_round_trip(client):
|
||||||
|
boot(client)
|
||||||
|
definitions = [
|
||||||
|
{"name": "每天", "kind": "boolean", "schedule_type": "daily"},
|
||||||
|
{"name": "每周", "kind": "numeric", "target": 2, "max_value": 4,
|
||||||
|
"schedule_type": "weekly", "weekdays": [1, 3]},
|
||||||
|
{"name": "每月", "kind": "numeric", "target": 3, "max_value": 5,
|
||||||
|
"schedule_type": "monthly", "month_days": [1, 15, 31]},
|
||||||
|
{"name": "间隔", "kind": "numeric", "target": 1.5, "max_value": 2.5,
|
||||||
|
"schedule_type": "interval", "interval_days": 3},
|
||||||
|
]
|
||||||
|
habits = [client.post("/api/v1/habits", json={**item, "start_date": "2026-09-01"}).json()
|
||||||
|
for item in definitions]
|
||||||
|
|
||||||
|
async def add_history():
|
||||||
|
from backend.db import get_engine
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
for index, habit in enumerate(habits):
|
||||||
|
db.add(HabitLog(
|
||||||
|
habit_id=UUID(habit["id"]), day=date(2026, 9, index + 1),
|
||||||
|
value=1 if habit["kind"] == "boolean" else habit["target"],
|
||||||
|
))
|
||||||
|
db.add_all([
|
||||||
|
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 20),
|
||||||
|
end_date=date(2026, 9, 21)),
|
||||||
|
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 23),
|
||||||
|
end_date=date(2026, 9, 24)),
|
||||||
|
])
|
||||||
|
row = await db.get(Habit, UUID(habits[3]["id"]))
|
||||||
|
row.archived_at = datetime(2026, 9, 30, tzinfo=UTC)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
asyncio.run(add_history())
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
expected_habits = _archive_rows(content, "habits")
|
||||||
|
expected_logs = _archive_rows(content, "habit_logs")
|
||||||
|
expected_pauses = _archive_rows(content, "habit_pauses")
|
||||||
|
preflight = _preflight(client, content, "replace")
|
||||||
|
assert preflight.status_code == 200, preflight.text
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
after = client.get("/api/v1/backup/export.zip").content
|
||||||
|
|
||||||
|
def stable(rows):
|
||||||
|
return sorted(
|
||||||
|
[{key: value for key, value in row.items() if key not in {"created_at", "updated_at"}}
|
||||||
|
for row in rows], key=lambda row: row["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stable(_archive_rows(after, "habits")) == stable(expected_habits)
|
||||||
|
assert stable(_archive_rows(after, "habit_logs")) == stable(expected_logs)
|
||||||
|
assert stable(_archive_rows(after, "habit_pauses")) == stable(expected_pauses)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_rejects_backup_id_reuse_with_different_archive(client):
|
||||||
|
boot(client)
|
||||||
|
backup_id = "55555555-5555-4555-8555-555555555555"
|
||||||
|
first = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
|
||||||
|
first_token = _preflight(client, first).json()["preflight_token"]
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
changed = _make_zip({"data/folders.json": json.dumps([{
|
||||||
|
"id": "66666666-6666-4666-8666-666666666666", "name": "different", "position": 0,
|
||||||
|
}]).encode()}, backup_id=backup_id)
|
||||||
|
second_token = _preflight(client, changed).json()["preflight_token"]
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
assert second.status_code == 409
|
||||||
|
assert second.json()["detail"]["code"] == "backup_id_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_rebuilds_backup_identity_ledger_after_prior_merge(client):
|
||||||
|
boot(client)
|
||||||
|
backup_id = "77777777-7777-4777-8777-777777777777"
|
||||||
|
original = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
|
||||||
|
|
||||||
|
merge_token = _preflight(client, original, "merge").json()["preflight_token"]
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": merge_token, "mode": "merge"}
|
||||||
|
).status_code == 200
|
||||||
|
replace_token = _preflight(client, original, "replace").json()["preflight_token"]
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": replace_token, "mode": "replace"}
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
changed = _make_zip({"data/folders.json": json.dumps([{
|
||||||
|
"id": "88888888-8888-4888-8888-888888888888", "name": "new entity", "position": 0,
|
||||||
|
}]).encode()}, backup_id=backup_id)
|
||||||
|
changed_token = _preflight(client, changed, "merge").json()["preflight_token"]
|
||||||
|
conflict = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": changed_token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
assert conflict.json()["detail"]["code"] == "backup_id_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_cleans_parsed_staging_dir_when_preflight_identity_changed(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content, "merge").json()["preflight_token"]
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
router_module = importlib.import_module("backend.backup.router")
|
||||||
|
real_parse = router_module.parse_archive_path
|
||||||
|
parsed_dirs: list[Path] = []
|
||||||
|
|
||||||
|
def parse_with_changed_identity(path, **kwargs):
|
||||||
|
archive = real_parse(path, **kwargs)
|
||||||
|
parsed_dirs.append(archive.staging_dir)
|
||||||
|
return replace(archive, archive_sha256="0" * 64)
|
||||||
|
|
||||||
|
monkeypatch.setattr(router_module, "parse_archive_path", parse_with_changed_identity)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"]["code"] == "backup_preflight_invalid"
|
||||||
|
assert parsed_dirs
|
||||||
|
assert not parsed_dirs[0].exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_handles_child_before_parent_task_order(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
parent = client.post(
|
||||||
|
"/api/v1/tasks", json={"title": "parent", "list_id": inbox["id"]}
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "child", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||||
|
)
|
||||||
|
entries = _zip_entries(client.get("/api/v1/backup/export.zip").content)
|
||||||
|
manifest = json.loads(entries.pop("manifest.json"))
|
||||||
|
tasks = json.loads(entries["data/tasks.json"])
|
||||||
|
entries["data/tasks.json"] = json.dumps(list(reversed(tasks))).encode()
|
||||||
|
manifest["checksums"]["data/tasks.json"] = hashlib.sha256(entries["data/tasks.json"]).hexdigest()
|
||||||
|
rebuilt = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(rebuilt, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest))
|
||||||
|
for name, value in entries.items():
|
||||||
|
archive.writestr(name, value)
|
||||||
|
|
||||||
|
token = _preflight(client, rebuilt.getvalue(), "replace").json()["preflight_token"]
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
items = client.get("/api/v1/tasks", params={"q": "parent"}).json()["items"]
|
||||||
|
assert items[0]["subtasks"][0]["title"] == "child"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_restores_quarantined_files_when_database_write_fails(client, tmp_path, monkeypatch):
|
||||||
|
inbox = boot(client)
|
||||||
|
from backend.backup import service
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
root = tmp_path / "attachments"
|
||||||
|
get_settings().attachment_dir = str(root)
|
||||||
|
old_bytes = b"keep me"
|
||||||
|
task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json()
|
||||||
|
uploaded = client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("old.txt", old_bytes, "text/plain")},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
async def old_storage_path():
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import Attachment
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
row = await db.get(Attachment, UUID(uploaded["id"]))
|
||||||
|
return root / row.storage_name
|
||||||
|
|
||||||
|
old_path = asyncio.run(old_storage_path())
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||||
|
|
||||||
|
def fail_new_attachment_write(storage_root: Path, storage_name: str):
|
||||||
|
raise OSError("simulated write failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "contained_file", fail_new_attachment_write)
|
||||||
|
with pytest.raises(OSError, match="simulated write failure"):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert old_path.read_bytes() == old_bytes
|
||||||
|
assert client.get("/api/v1/tasks", params={"q": "old"}).json()["items"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_blob_is_never_read_whole(client, tmp_path, monkeypatch):
|
||||||
|
inbox = boot(client)
|
||||||
|
from backend.config import get_settings
|
||||||
|
|
||||||
|
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||||
|
task = client.post("/api/v1/tasks", json={"title": "stream", "list_id": inbox["id"]}).json()
|
||||||
|
blob = bytes(range(256)) * 64
|
||||||
|
assert client.post(
|
||||||
|
f"/api/v1/tasks/{task['id']}/attachments",
|
||||||
|
files={"file": ("blob.bin", blob, "application/zip")},
|
||||||
|
).status_code == 201
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
real_read = zipfile.ZipFile.read
|
||||||
|
|
||||||
|
def reject_blob_read(self, name, *args, **kwargs):
|
||||||
|
filename = name.filename if isinstance(name, zipfile.ZipInfo) else name
|
||||||
|
if str(filename).startswith("attachments/"):
|
||||||
|
raise AssertionError("blob entry was loaded with ZipFile.read")
|
||||||
|
return real_read(self, name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(zipfile.ZipFile, "read", reject_blob_read)
|
||||||
|
preflight = _preflight(client, content)
|
||||||
|
assert preflight.status_code == 200, preflight.text
|
||||||
|
restored = client.post(
|
||||||
|
"/api/v1/backup/restore",
|
||||||
|
json={"preflight_token": preflight.json()["preflight_token"], "mode": "merge"},
|
||||||
|
)
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_quota_reservation_is_atomic_across_workers(client, monkeypatch):
|
||||||
|
boot(client)
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
router_module = importlib.import_module("backend.backup.router")
|
||||||
|
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
|
||||||
|
def upload(_):
|
||||||
|
with client.__class__(client.app) as worker:
|
||||||
|
worker.cookies.update(client.cookies)
|
||||||
|
return _preflight(worker, content).status_code
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
statuses = list(executor.map(upload, range(2)))
|
||||||
|
assert sorted(statuses) == [200, 429]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_ledger_survives_partial_retry_and_normalizes_relationships(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
task = client.post("/api/v1/tasks", json={"title": "ledger-parent", "list_id": inbox["id"]}).json()
|
||||||
|
child = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "ledger-child", "list_id": inbox["id"], "parent_id": task["id"]},
|
||||||
|
).json()
|
||||||
|
content = client.get("/api/v1/backup/export.zip").content
|
||||||
|
token = _preflight(client, content).json()["preflight_token"]
|
||||||
|
restored = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"})
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
|
||||||
|
async def verify_ledger():
|
||||||
|
from backend.db import get_engine
|
||||||
|
from backend.models import BackupImportEntity
|
||||||
|
async with AsyncSession(get_engine()) as db:
|
||||||
|
rows = list((await db.scalars(select(BackupImportEntity).where(
|
||||||
|
BackupImportEntity.entity_type == "tasks"
|
||||||
|
))).all())
|
||||||
|
by_source = {str(row.source_id): row for row in rows}
|
||||||
|
assert by_source[task["id"]].target_id
|
||||||
|
assert by_source[child["id"]].target_id
|
||||||
|
assert len(by_source[child["id"]].content_digest) == 64
|
||||||
|
|
||||||
|
asyncio.run(verify_ledger())
|
||||||
@@ -204,7 +204,7 @@ def test_countdowns_backup_replace_and_merge_round_trip(client):
|
|||||||
assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
|
assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
|
||||||
|
|
||||||
create_countdown(client, title="干扰数据")
|
create_countdown(client, title="干扰数据")
|
||||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年")
|
restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年")
|
||||||
assert restored_active["calendar_mode"] == "lunar"
|
assert restored_active["calendar_mode"] == "lunar"
|
||||||
@@ -261,7 +261,7 @@ def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client):
|
|||||||
assert len(client.get("/api/v1/countdowns").json()) == 1
|
assert len(client.get("/api/v1/countdowns").json()) == 1
|
||||||
|
|
||||||
exported_by_other = client.get("/api/v1/export").json()
|
exported_by_other = client.get("/api/v1/export").json()
|
||||||
restored_again = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported_by_other)
|
restored_again = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported_by_other)
|
||||||
assert restored_again.status_code == 200
|
assert restored_again.status_code == 200
|
||||||
replaced = client.get("/api/v1/countdowns").json()
|
replaced = client.get("/api/v1/countdowns").json()
|
||||||
assert len(replaced) == 1
|
assert len(replaced) == 1
|
||||||
@@ -335,20 +335,20 @@ def test_restore_rejects_malformed_countdowns_atomically(client):
|
|||||||
|
|
||||||
malformed = deepcopy(exported)
|
malformed = deepcopy(exported)
|
||||||
malformed["countdowns"][0]["archived_at"] = "not-a-date"
|
malformed["countdowns"][0]["archived_at"] = "not-a-date"
|
||||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
for invalid in (None, 7):
|
for invalid in (None, 7):
|
||||||
malformed = deepcopy(exported)
|
malformed = deepcopy(exported)
|
||||||
malformed["countdowns"] = invalid
|
malformed["countdowns"] = invalid
|
||||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
duplicate = deepcopy(exported)
|
duplicate = deepcopy(exported)
|
||||||
duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0]))
|
duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0]))
|
||||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=duplicate)
|
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=duplicate)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ def test_restore_rejects_malformed_countdowns_atomically(client):
|
|||||||
two_pinned["countdowns"][0]["pinned"] = True
|
two_pinned["countdowns"][0]["pinned"] = True
|
||||||
extra["pinned"] = True
|
extra["pinned"] = True
|
||||||
two_pinned["countdowns"].append(extra)
|
two_pinned["countdowns"].append(extra)
|
||||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=two_pinned)
|
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=two_pinned)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1
|
assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ def test_restore_accepts_lunar_dates_whose_solar_anchor_is_in_next_year(client):
|
|||||||
assert created.json()["event_date"] == "2002-02-01"
|
assert created.json()["event_date"] == "2002-02-01"
|
||||||
exported = client.get("/api/v1/export").json()
|
exported = client.get("/api/v1/export").json()
|
||||||
|
|
||||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
item = client.get("/api/v1/countdowns").json()[0]
|
item = client.get("/api/v1/countdowns").json()[0]
|
||||||
assert item["event_date"] == "2002-02-01"
|
assert item["event_date"] == "2002-02-01"
|
||||||
|
|||||||
+2
-2
@@ -218,7 +218,7 @@ def test_json_and_csv_backup_round_trip_memos_with_all_fields(client):
|
|||||||
csv_backup = client.get("/api/v1/export.csv")
|
csv_backup = client.get("/api/v1/export.csv")
|
||||||
assert b"memos" in csv_backup.content
|
assert b"memos" in csv_backup.content
|
||||||
restored_csv = client.post(
|
restored_csv = client.post(
|
||||||
"/api/v1/restore.csv?mode=replace",
|
"/api/v1/restore.csv?mode=merge",
|
||||||
files={"file": ("dodo-export.csv", csv_backup.content, "text/csv")},
|
files={"file": ("dodo-export.csv", csv_backup.content, "text/csv")},
|
||||||
)
|
)
|
||||||
assert restored_csv.status_code == 200
|
assert restored_csv.status_code == 200
|
||||||
@@ -278,4 +278,4 @@ def test_legacy_backup_without_memos_still_restores(client):
|
|||||||
boot(client)
|
boot(client)
|
||||||
backup = client.get("/api/v1/export").json()
|
backup = client.get("/api/v1/export").json()
|
||||||
backup.pop("memos", None)
|
backup.pop("memos", None)
|
||||||
assert client.post("/api/v1/restore?mode=replace", json=backup).status_code == 200
|
assert client.post("/api/v1/restore?mode=merge", json=backup).status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
|
||||||
|
return subprocess.run(
|
||||||
|
["uv", "run", "alembic", *args], cwd=repo, env=env,
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_migration_upgrade_downgrade_and_reupgrade(tmp_path):
|
||||||
|
repo = Path(__file__).resolve().parents[1]
|
||||||
|
database = tmp_path / "migration.sqlite3"
|
||||||
|
assert run_alembic(repo, database, "upgrade", "0018_task_completed_at").returncode == 0
|
||||||
|
upgraded = run_alembic(repo, database, "upgrade", "0019_backup_imports")
|
||||||
|
assert upgraded.returncode == 0, upgraded.stderr
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||||
|
assert {"backup_imports", "backup_import_entities", "backup_preflights"} <= tables
|
||||||
|
downgraded = run_alembic(repo, database, "downgrade", "0018_task_completed_at")
|
||||||
|
assert downgraded.returncode == 0, downgraded.stderr
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||||
|
assert "backup_imports" not in tables
|
||||||
|
assert "backup_import_entities" not in tables
|
||||||
|
assert "backup_preflights" not in tables
|
||||||
|
reupgraded = run_alembic(repo, database, "upgrade", "head")
|
||||||
|
assert reupgraded.returncode == 0, reupgraded.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_upgrade_has_single_head_and_backup_tables(tmp_path):
|
||||||
|
repo = Path(__file__).resolve().parents[1]
|
||||||
|
heads = run_alembic(repo, tmp_path / "unused.sqlite3", "heads")
|
||||||
|
assert heads.returncode == 0, heads.stderr
|
||||||
|
assert heads.stdout.count("(head)") == 1
|
||||||
|
database = tmp_path / "fresh.sqlite3"
|
||||||
|
upgraded = run_alembic(repo, database, "upgrade", "head")
|
||||||
|
assert upgraded.returncode == 0, upgraded.stderr
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||||
|
assert {"backup_imports", "backup_import_entities", "backup_preflights"} <= tables
|
||||||
@@ -121,7 +121,7 @@ def test_export_and_restore_preserve_task_recurrence(client):
|
|||||||
exported = client.get("/api/v1/export").json()
|
exported = client.get("/api/v1/export").json()
|
||||||
assert exported["recurrences"][0]["task_id"] == task["id"]
|
assert exported["recurrences"][0]["task_id"] == task["id"]
|
||||||
|
|
||||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
restored = client.post("/api/v1/restore?mode=merge", json=exported)
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0]
|
restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0]
|
||||||
recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||||
@@ -599,9 +599,11 @@ def test_ticktick_preview_import_dedupe_and_json_restore(client):
|
|||||||
export = client.get("/api/v1/export").json()
|
export = client.get("/api/v1/export").json()
|
||||||
assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1"
|
assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1"
|
||||||
client.delete(f"/api/v1/tasks/{export['tasks'][0]['id']}")
|
client.delete(f"/api/v1/tasks/{export['tasks'][0]['id']}")
|
||||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=export)
|
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=export)
|
||||||
assert restored.status_code == 200
|
assert restored.status_code == 200
|
||||||
assert len(client.get("/api/v1/tasks").json()["items"]) == 1
|
# Legacy merge is non-destructive and does not resurrect a soft-deleted
|
||||||
|
# task whose external ID already exists.
|
||||||
|
assert len(client.get("/api/v1/tasks").json()["items"]) == 0
|
||||||
assert client.post("/api/v1/restore", json={"version": 999}).status_code == 422
|
assert client.post("/api/v1/restore", json={"version": 999}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user