diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33bba3d..14d1015 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Run core tests run: swift run TaskForgeReminderCoreTests - name: Build release - run: swift build --configuration release + run: swift build --configuration release --product TaskForgeReminderSync - name: Validate property lists run: | plutil -lint Resources/Info.plist diff --git a/.gitignore b/.gitignore index 785e421..f9d3011 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ __pycache__/ .env .env.* Backups/ +PruneCandidates.json +PruneBackups/ +PruneHashSalt +PruneHashSalt.lock +*.prune-test.json +.worktrees/ +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da9e40..9e7ec16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project are documented here. +## Unreleased + +- Added automatic cleanup for unfinished, unimportant reminders that are + confirmed absent from both the TaskForge snapshot and durable Vault source. +- Added protection for completed reminders, EventKit priority and six leading + important-title markers. +- Added an unchanged two-scan confirmation window of at least 60 seconds, + private candidate persistence and fail-closed source resolution. +- Added checksummed `0600` pre-deletion backups and + `--restore-last-prune` with a minimum 24-hour restoration grace period. +- Restore selection skips unresolved, zero-deletion and already restored + backups, choosing the newest remaining real deletion batch. +- Added strict read-only `--prune-dry-run` and one-pass `--prune-once` + commands; `--sync` and `--watch` now advance the same pruning state machine + after reverse and forward synchronization. +- Scoped pruning fetches to exactly one configured reminders list, rejecting + same-name ambiguity and leaving all other lists untouched. +- Added a 30-second EventKit fetch timeout with request cancellation and + anonymous, salted pruning logs. +- Preserved the existing TaskForge boundary: reverse completion only marks + source tasks `done`; pruning never deletes TaskForge task lines or files. + ## 1.1.0 - 2026-07-29 - Added TaskForge-to-Reminders updates for linked title, date, time and status diff --git a/PRIVACY.md b/PRIVACY.md index de304e9..62e11a7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,51 +5,55 @@ TaskForge Reminder Sync is designed to run entirely on the local Mac. ## Data it reads - TaskForge's local `tasks.v6.bin` cache; -- titles, schedules, status and source metadata for TaskForge tasks; -- reminders in the configured Apple Reminders list; -- Markdown or TaskNotes source files only when a linked reminder is completed. +- the preferences plist entry `flutter.ctl_`; +- titles, status, priority, original schedule and source metadata needed for selected custom-list members; +- reminders only when they carry this tool's marker or match the private index; +- the exact Markdown or TaskNotes source file when an approved reverse write is requested; +- the Vault source referenced by the private index when pruning confirms historical presence. ## Data it writes -- linked reminders in the configured Apple Reminders list; -- completion markers in the matching Vault source task; -- timestamped source-file backups under - `~/Library/Application Support/TaskForgeReminderSync/Backups/`; -- operational logs under `~/Library/Logs/`. +- only the tool-managed Apple status lists and their managed reminders; +- the status of a matching Vault source task during reverse sync; +- verified source-file backups and verified reminder deletion backups; +- private configuration, learned symbols, source references, state history and hashes; +- aggregate operational logs. -## Data it does not send - -The application contains no HTTP client, analytics SDK, telemetry, advertising, -crash-reporting service or hosted backend. It does not upload task titles, -Vault paths, reminder contents or source files to this repository or to a -third-party service. +The default private root is: -Apple Reminders may sync through iCloud according to the user's Apple account -and system settings. That synchronization is performed by macOS, not by this -project. +```text +~/Library/Application Support/TaskForgeReminderSync/ +``` -## Metadata stored in reminders +The root and directories are `0700`; configuration, index, ledger, source backups, reminder +backups and salts are `0600`. The list ID, full source references and reminder mappings are +never committed to Git and are not printed in logs. If permissions, ownership, symlinks, +extended ACLs, schema or hashes are unsafe, the operation fails closed. -Linked reminders contain: +## Data it does not send -- a stable, Base64-encoded TaskForge marker; -- a Base64-encoded source reference used for historical reverse completion; -- a human-readable source type, file path and line number. +The project has no HTTP client, hosted backend, analytics SDK, telemetry, advertising or +crash-reporting service. It does not upload task titles, notes, Vault paths, source files, +personal profiles or list IDs. Apple may sync reminders through iCloud according to the user's +Apple account and macOS settings; that is performed by Apple, not this project. -Anyone who can read the reminder can therefore see its title and source path. -Use a dedicated reminder list and an Apple account you trust. +The tool does not use Computer Use and does not request Calendar, Contacts, Photos, microphone, +camera or location access. -## Permissions +## Reminder notes -- **Reminders full access** is required to create, update and observe reminders. -- **Full Disk Access** may be required by macOS to read TaskForge's sandbox - container or a protected Vault location. +Managed reminder notes contain only a stable TaskForge tool marker and a short source marker. +The marker is intended for management, not secrecy. Full source references, previous status, +source hashes and EventKit mappings remain in the private `0600` index rather than being copied +into reminder notes. -The tool does not request Calendar, Contacts, Photos, microphone, camera or -location access. +## Logs and recovery -## Public repository hygiene +Logs contain status counts, timing and anonymous error categories. They do not contain raw task +IDs, EventKit IDs, titles, notes, list IDs, Vault paths or source paths. Backups are intentionally +sensitive local data because they can contain the fields required to restore a source or +reminder; stop the watcher before manual recovery and preserve their permissions. -Generated build directories, TaskForge cache files, logs, backups, `.env` -files and local plist overrides are excluded by `.gitignore`. Contributors -should still inspect staged files before every push. +Reverse completion changes a source task to `done` and never deletes a TaskForge task. Cleanup +deletes only a managed, low-priority, unfinished reminder after the two-scan gate and verified +backup; ordinary Apple reminders and all TaskForge source tasks are protected. diff --git a/Package.swift b/Package.swift index 99f07d4..84fca50 100644 --- a/Package.swift +++ b/Package.swift @@ -21,14 +21,37 @@ let package = Package( .target( name: "TaskForgeReminderCore" ), + .target( + name: "TaskForgeReminderEventKit", + dependencies: ["TaskForgeReminderCore"] + ), .executableTarget( name: "TaskForgeReminderSync", - dependencies: ["TaskForgeReminderCore"] + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ] ), .executableTarget( name: "TaskForgeReminderCoreTests", - dependencies: ["TaskForgeReminderCore"], + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], path: "Tests/TaskForgeReminderCoreTests" + ), + .executableTarget( + name: "TaskForgeReminderCLITests", + dependencies: ["TaskForgeReminderSync"], + path: "Tests/TaskForgeReminderCLITests" + ), + .executableTarget( + name: "TaskForgeReminderEventKitTests", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], + path: "Tests/TaskForgeReminderEventKitTests" ) ] ) diff --git a/README.md b/README.md index 7869c1b..f134136 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,93 @@ -# TaskForge Reminder Sync - -一个完全在本机运行的 macOS 双向同步工具:把 TaskForge 日历视图中的今日任务写入 Apple 提醒事项,并把 Apple 端的完成状态安全回写为 TaskForge `done`。 - -> 非 TaskForge 或 Apple 官方项目。当前版本针对 TaskForge `tasks.v6.bin` 数据格式开发。 - -## 功能 - -- **今日任务正向同步**:只为本地当天、状态不是 `done` / `cancelled` 的 TaskForge 任务创建提醒。 -- **历史完成反向同步**:扫描目标列表中所有已完成的关联提醒,不受提醒日期限制。 -- **近实时响应**: - - Apple 提醒事项变化通知经过约 0.75 秒防抖后触发; - - 每秒检查 TaskForge 任务库修改时间; - - 每分钟主动核对,弥补系统通知漏失; - - 每天 07:00、11:00、15:00 再做定时兜底。 -- **时间保真**:全天任务保持为日期提醒;有时间的任务保留小时和分钟。 -- **TaskForge 变动正向推送**:已关联任务的标题、日期、时间和完成状态发生变化后,会更新原来的提醒事项。 -- **分层稳定去重**:先按 Vault + TaskForge 任务 ID 精确匹配;TaskForge 重新索引导致 ID 改变时,再按活跃任务源位置复用原提醒,并刷新其 ID 标记。 -- **历史实例隔离**:同一笔记行可被不同日期的任务先后复用;已完成提醒只有在源位置和任务日期都相同时才会参与二级匹配。 -- **歧义时不新建**:同一 ID 或源位置对应多个提醒时,记录冲突并停止该任务,不会继续制造重复项。 -- **持久源映射**:提醒中保存经过 Base64 编码的任务源引用。任务离开 TaskForge 当前缓存后,历史提醒仍能定位原笔记。 -- **防止误重开**:任意一端已经完成时,正向同步不会把 Apple 提醒重新打开。 -- **先备份再回写**:每次反向修改前保存源文件副本,并记录修改前后的 SHA-256。 -- **保守拒绝**:重复任务、非 `keep` 完成策略、Vault 外路径、陈旧或歧义源行都不会被自动修改。 -- **不删除源任务**: - - Markdown 内联任务:`- [ ]` 改为 `- [x]`,并追加完成日期; - - TaskNotes 文件:frontmatter 改为 `status: done` 并更新 `completedDate`; - - 工具不会删除任务行或 TaskNotes 文件。 - -## 工作原理 +# TaskForge Kanban ↔ Apple 提醒事项 + +一个完全在本机运行的 macOS 双向同步工具:把固定绑定的 TaskForge 自定义 +Kanban `Today` 列表映射为多个彩色 Apple 提醒事项列表,并把 Apple 的状态变化 +安全回写 TaskForge 源任务。 + +它不是 TaskForge 或 Apple 官方项目。TaskForge 2.6.1 没有可依赖的官方 CLI/API; +本工具只读取本机 `tasks.v6.bin` 和自定义列表配置,不使用 Computer Use,也不向 +网络上传任务内容、路径、列表 ID 或个人资料。 + +## 同步边界 + +默认源是 `custom-list`,不是按日期筛选。成员集合由下面两份本机数据按 TaskForge +真实规则计算: ```text -TaskForge tasks.v6.bin - │ 读取今日未完成任务 +tasks.v6.bin + flutter.ctl_<固定列表 ID> + │ 组内 all/any、组间 all/any、字段和操作符 ▼ -Apple 提醒事项 / TaskForge 今日 - │ 完成状态 + 持久源引用 - ▼ -Vault Markdown / TaskNotes - │ TaskForge 重新索引 - ▼ -TaskForge done +TaskForge Today 当前成员 ``` -正向同步只**新建今天的任务**,避免把整个 Vault 导入提醒事项;已经关联的任务即使改到其他日期,仍会更新原提醒。反向同步则会检查所有已经建立关联的提醒,因此昨天或更早的任务在 Apple 端完成后仍可闭环。 +列表 ID 首次可通过 `--taskforge-list-id` 提供,成功写入时只保存到权限为 `0600` +的私有配置;代码、日志、Git 和 README 不包含真实 ID。列表配置缺失、JSON 损坏、 +任务库损坏、未知字段/操作符或未知逻辑时整轮失败关闭,不猜测成员。 -去重身份按以下顺序选择: +`--source scheduled-day` 和旧的 `--date`、`--list-name` 只保留兼容模式;custom-list +模式使用这些旧参数会直接拒绝。 -1. 精确的 TaskForge 任务 ID; -2. 未完成提醒使用稳定源位置: - - Markdown 内联任务:“标准化文件路径 + 行号”; - - TaskNotes 任务:“标准化文件路径”; -3. 已完成提醒还必须与当前任务的计划日期相同,避免历史任务占用今天的新实例。 +## Apple 状态列表 -第二层命中时不会创建新提醒,而是更新同一个 EventKit 项目,并把提醒中的任务 ID 与源引用替换为 TaskForge 的最新值。如果任一层出现多个候选,工具会保守停止而不是猜测。 +活动状态按需创建,前缀默认为 `TaskForge`,可用 `--list-prefix` 修改。首次创建 +`TaskForge · 待办` 时会把旧的 `TaskForge 今日` 列表原地重命名并复用;不会因重命名 +更换源列表。 -详细设计见 [架构说明](docs/ARCHITECTURE.md),数据与权限边界见 [隐私说明](PRIVACY.md)。 +| TaskForge 状态 | Apple 列表 | 颜色 | 说明 | +|---|---|---|---| +| `todo` | `TaskForge · 待办` | 蓝 | 默认活动状态 | +| `scheduled` | `TaskForge · 已计划` | 紫 | 活动列表 | +| `ready` | `TaskForge · 就绪` | 绿 | 活动列表 | +| `inProgress` | `TaskForge · 进行中` | 青 | 活动列表 | +| `onHold` | `TaskForge · 暂停` | 橙 | 首次出现时创建 | +| `deferred` | `TaskForge · 已推迟` | 灰 | 首次出现时创建 | +| `blocked` | `TaskForge · 已阻塞` | 红 | 首次出现时创建 | +| `someday` | `TaskForge · 将来某天` | 靛蓝 | 首次出现时创建 | +| `done` | 无活动列表 | — | 完成提醒,不删除 TaskForge 任务 | +| `cancelled` | 无活动列表 | — | 完成提醒,标题额外标注“已取消” | -## 系统要求 +未知但合法的 TaskForge 状态会以同前缀的灰色列表显示;未知状态不会被猜测写回。 +列表 ID 被删除或配置丢失时停止同步。 -- macOS 13 或更高版本; -- 已安装 TaskForge; -- TaskForge 使用 `tasks.v6.bin` 数据格式; -- Xcode Command Line Tools / Swift 5.9 或更高版本; -- Apple 提醒事项账户; -- 建议 TaskForge 保持运行,以便反向写入后及时重新索引。 +## 双向语义 + +- TaskForge 标题、原始计划日期/时间和优先级正向更新对应提醒;不会人为添加“今天”。 +- Apple 状态列表移动会回写源状态;移动到普通 Apple 列表的受管提醒会被移回正确状态。 +- TaskForge 开放状态变化与 Apple 开放状态变化冲突时,TaskForge 优先。 +- Apple 新发生的完成操作优先,写回 TaskForge `done`;绝不删除 TaskForge 任务行或文件。 +- 受管提醒只由工具标记或私有索引识别;普通 Apple 列表永不扫描、删除或批量修改。 +- 已不属于 Today、未完成且无优先级的受管提醒,必须经过至少 60 秒间隔的双扫描、 + 备份和回读校验后才可能删除;重要、已完成、无法判断来源的提醒受保护。 +- `TaskForge 今日 · 去重归档` 中确认属于历史重复项的提醒可统一完成,但不会触发 + TaskForge 反向完成;完成后退出 Apple “今天”视图由用户自行操作。 + +### 反向符号学习 + +首期只允许已确认符号:待办 `[ ]`、已计划 `[>]`、进行中 `[/]`、完成 `[x]`。 +工具从真实 TaskForge 记录学习映射并保存到权限为 `0600` 的状态字典。尚未学会、 +同一状态冲突或一个符号被多个状态占用时,拒绝写源文件并把提醒移回 TaskForge +当前状态。Markdown/TaskNotes 写回都先校验源身份和原始内容,再备份、原子写入、 +SHA-256 校验并等待 TaskForge 回读。 + +提醒备注只保留简洁工具标记和来源标记;完整源引用、上次状态、哈希和提醒映射只 +保存在私有索引中。 + +## 近实时监听 + +- `EKEventStoreChanged` 触发约 0.75 秒防抖同步; +- 每秒轮询任务库和自定义列表配置的修改时间; +- 每 60 秒做一次全量校准,恢复漏事件; +- 07:00、11:00、15:00 仅作为额外兜底校准。 -## 安装 +这表示本机事件驱动的近实时语义,不是 TaskForge 官方推送。验收目标是本地变化 +通常 5 秒内完成,漏事件最迟 60 秒修复。 + +## 系统要求与安装 + +- macOS 13 或更高版本; +- 已安装 TaskForge,并使用 `tasks.v6.bin`; +- Swift 5.9 / Xcode Command Line Tools; +- 一个可用的 Apple 提醒事项账户。 ```bash git clone https://github.com/ezbug/taskforge-reminder-sync.git @@ -73,161 +95,122 @@ cd taskforge-reminder-sync ./scripts/build-app.sh ``` -`build-app.sh` 默认使用本机 ad hoc 签名,适合自行构建和首次使用。ad hoc -签名只标识当前这一版二进制;重新构建后,macOS 可能要求重新授予提醒事项和文件 -访问权限。如果钥匙串中已有稳定的代码签名身份,可在构建时指定: +第一次只读配置和成员预演(不会请求提醒事项权限,也不会创建私有状态文件): ```bash -TASKFORGE_SYNC_CODESIGN_IDENTITY="Apple Development: Your Name" \ - ./scripts/build-app.sh +APP=./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync +"$APP" --check-config --taskforge-list-id LIST_ID +"$APP" --dry-run --taskforge-list-id LIST_ID ``` -项目不会自动创建证书、修改钥匙串或重置 TCC 权限。 - -先做完全无写入的配置检查与今日任务预览: +预演输出的状态数量必须与 TaskForge Today 当前界面动态数量一致,才能进入写入门槛。 +确认后执行一次同步,系统会请求提醒事项完整访问权限: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --check-config -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --dry-run +"$APP" --sync --taskforge-list-id LIST_ID +"$APP" --watch ``` -第一次真实同步: - -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --sync -``` +也可以先使用 `--sync --list-prefix NAME`;后续省略前缀时会复用私有配置。macOS +隐私设置可能还需要允许该 App 读取 TaskForge 容器和 Vault。工具只需要“提醒事项” +权限,不需要“日历”权限;不会自动重置 TCC,也不会自动批准权限。 -macOS 会请求“提醒事项”访问权限。工具不需要“日历”权限。根据系统隐私设置,读取 TaskForge 容器或 Vault 时可能还需要为 App 授予“完全磁盘访问权限”。 - -确认一次性同步正常后,安装常驻 LaunchAgent: +建议确认一次性同步和专用验收任务后,再安装监督进程: ```bash ./scripts/install-daily-sync.sh ``` -安装器使用一个随 App 签名的小型监督进程,再通过 macOS LaunchServices 启动 -同步 App,而不是把 App 包内的主二进制当作普通后台命令执行。这样常驻同步进程 -与手动运行使用同一个 bundle 身份,系统授予的“提醒事项”权限才能在后台正确 -生效;若同步进程退出,LaunchAgent 会重新拉起它。 - -安装位置: +安装器不会把真实列表 ID 写入 plist;watcher 从权限为 `0600` 的私有配置读取。卸载: -- App:`~/Applications/TaskForgeReminderSync.app` -- LaunchAgent:`~/Library/LaunchAgents/local.codex.taskforge-reminder-sync.plist` -- 标准日志:`~/Library/Logs/TaskForgeReminderSync.log` -- 错误日志:`~/Library/Logs/TaskForgeReminderSync.error.log` -- 回写备份:`~/Library/Application Support/TaskForgeReminderSync/Backups/` +```bash +./scripts/uninstall-daily-sync.sh +``` ## 命令 -| 命令 | 作用 | 是否写入 | +| 命令 | custom-list 行为 | 写入 | |---|---|---| -| `--check-config` | 检查 TaskForge、数据版本、Vault 和今日任务数量 | 否 | -| `--dry-run` | 列出今天将被同步的任务 | 否 | -| `--audit` | 区分重复活跃任务、重复历史实例、正常历史源复用和缺失映射;不输出任务内容 | 否 | -| `--deduplicate-dry-run` | 预演重复组、保留数和归档数 | 否 | -| `--deduplicate` | 保留权威提醒,把冗余活跃提醒移到可恢复的归档列表 | 是 | -| `--reverse-dry-run` | 预览 Apple → TaskForge 的源文件修改 | 否 | -| `--reverse-once` | 执行一次反向完成并等待 TaskForge 回读 | 是 | -| `--sync` | 先反向扫描,再执行一次今日任务正向同步 | 是 | +| `--check-config` | 检查 v6、固定列表和状态计数 | 否 | +| `--dry-run` | 匿名输出成员及各状态数量 | 否 | +| `--sync` | 反向冲突处理、正向同步、双扫描推进 | 是 | +| `--reverse-dry-run` | 预览 Apple → TaskForge 候选 | 否(需权限) | +| `--reverse-once` | 执行一次反向状态写回 | 是 | +| `--deduplicate-dry-run` | 预览当前受管重复组 | 否(需权限) | +| `--deduplicate` | 将确认的历史重复提醒移到归档并完成 | 是 | +| `--prune-dry-run` | 只读清理分类,不写账本 | 否(需权限) | +| `--prune-once` | 推进双扫描、备份并按门槛清理 | 是 | | `--watch` | 常驻近实时双向同步 | 是 | +custom-list 的 `--deduplicate` 只把确认的重复提醒移到 +`TaskForge 今日 · 去重归档` 并标记完成;归档标记会阻止它触发 TaskForge 反向完成。 +`--restore-last-prune` 仍仅在 `scheduled-day` 兼容模式提供。兼容模式保留旧的 +`--audit`、`--deduplicate-*` 等维护命令,但不会改变 custom-list 的源语义。 + 常用参数: ```text ---list-name NAME 目标提醒事项列表,默认“TaskForge 今日” ---task-store PATH 自定义 tasks.v6.bin 路径 ---date YYYY-MM-DD 指定正向预览/同步日期 ---task-id ID 只处理一个 TaskForge 任务 ---backup-root PATH 自定义反向写入备份目录 +--source custom-list|scheduled-day 同步源,默认 custom-list +--taskforge-list-id LIST_ID 固定绑定的自定义 Kanban 列表 ID +--list-prefix NAME Apple 状态列表前缀 +--task-store PATH 自定义 tasks.v6.bin 路径 +--task-id ID 专用任务验收时只处理一个任务 +--backup-root PATH 源文件反向写入备份目录 +--date YYYY-MM-DD 仅 scheduled-day 兼容模式 +--list-name NAME 仅 scheduled-day 兼容模式 ``` -定向预览或完成一个任务: +## 私有数据、备份与回滚 -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --reverse-dry-run --task-id TASK_ID -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --reverse-once --task-id TASK_ID -``` - -## 同步与安全规则 - -1. 提醒必须带有本工具生成的稳定标记,才会参与反向同步。 -2. 源文件必须位于当前 TaskForge Vault 内。 -3. 任务必须是非重复任务,且 `onCompletion=keep`。 -4. 源行必须与保存的引用一致,或能在文件中唯一找到。 -5. 写入前创建带时间戳的完整文件备份。 -6. 写入后逐字节校验文件,并等待 TaskForge 任务库刷新。 -7. TaskForge 可能从缓存中移除已完成的内联任务;这不等于源任务被删除。 -8. 早期版本创建、没有持久源引用且已经离开 TaskForge 缓存的提醒会被安全跳过,不会猜测写入。 -9. TaskForge 删除任务时,本工具不会自动删除对应提醒;删除属于显式的非自动操作。 -10. 去重维护只移动冗余提醒到独立归档列表,不删除提醒或 TaskForge 源任务。 +默认私有运行根目录: -只读检查整个受管列表是否仍然无重复: - -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --audit +```text +~/Library/Application Support/TaskForgeReminderSync/ +├── KanbanSyncConfig.json 0600:列表 ID、前缀、学到的符号 +├── KanbanSyncIndex.json 0600:提醒映射、状态、源引用和哈希 +├── Backups/ 0700:反向写入前的源文件备份 +└── PruneBackups/ 0700:清理删除前的提醒事项备份 ``` -审计覆盖当前与历史受管提醒,只输出数量,不输出标题、笔记、Vault 路径或原始任务 ID。“历史源位置复用”是信息项:同一行在不同计划日期承载过不同任务,不等同于重复。 +运行根目录和子目录为 `0700`,状态、配置和备份文件为 `0600`。写入前记录涉及 +文件的权限与哈希;写入后逐字节回读。发现私有目录、权限、符号链接、扩展 ACL、 +配置或索引异常时整轮失败关闭。 -修复早期版本已经产生的活跃重复项: +回滚顺序: -```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --deduplicate-dry-run -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --deduplicate -``` - -去重优先保留仍与当前 TaskForge ID 精确匹配的提醒;没有精确匹配时保留最早创建项。冗余项不会被删除,而是移到“TaskForge 今日 · 去重归档”,随后保留项会重新关联当前 TaskForge 记录。需要恢复时可在 Apple 提醒事项中手动移回。 +1. 停止 watcher,避免回滚过程中再次同步; +2. 用 `--restore-last-prune` 恢复最近实际删除的提醒事项批次; +3. 从 `Backups/` 恢复对应 Vault 源文件,保留原权限并重新计算哈希; +4. 检查 TaskForge 回读后,再运行 `--dry-run` 和 `--prune-dry-run`; +5. 必要时移走私有索引后重新用明确的列表 ID做只读预演,不要手动删除普通提醒。 -## 日志与排障 - -```bash -tail -f ~/Library/Logs/TaskForgeReminderSync.log -tail -f ~/Library/Logs/TaskForgeReminderSync.error.log -launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" -``` +清理不会删除 TaskForge 源任务。若源身份、符号、回读或权限无法确认,工具宁可 +把 Apple 提醒移回当前 TaskForge 状态并拒绝写入。 -常见问题和恢复方式见 [排障指南](docs/TROUBLESHOOTING.md)。 +## 验证与生产门槛 -## 卸载 +本项目使用可执行测试入口而不是 XCTest 自动发现: ```bash -./scripts/uninstall-daily-sync.sh +swift build +swift run TaskForgeReminderCoreTests +swift run TaskForgeReminderCLITests +swift run TaskForgeReminderEventKitTests ``` -卸载会停止 LaunchAgent 并移除安装的 App,不会删除: - -- Apple 提醒事项中已经创建的内容; -- Vault 中的任何笔记或任务; -- 反向写入备份。 - -## 开发与测试 +EventKit 隔离测试默认跳过,只有明确设置环境变量才会访问真实提醒事项: ```bash -swift run TaskForgeReminderCoreTests -swift build -./scripts/build-app.sh +TASKFORGE_RUN_EVENTKIT_TESTS=1 swift run TaskForgeReminderEventKitTests ``` -测试覆盖 MessagePack v6 解码、今日任务筛选、稳定标记、ID 变化后的源位置复用、歧义去重、日期语义比较、正反向完成策略、历史源引用、Markdown / TaskNotes 回写和安全拒绝条件。 - -## 限制 - -- TaskForge 改变内部缓存格式后,解码器可能需要更新。 -- 同时改动内联任务的文件位置、行号和标题,且 TaskForge 也更换内部 ID 时,没有足够的稳定信息可安全认定为同一任务;工具宁可拒绝猜测。 -- 当前不会反向处理重复任务或完成后会移动、归档、删除的任务。 -- 当前不会根据 TaskForge 删除操作自动删除 Apple 提醒事项。 -- TaskForge 未运行时,源文件可能不会立即被重新索引;建议让 TaskForge 保持运行。 -- 本项目不提供云服务、遥测或跨设备同步;Apple 提醒事项自身的 iCloud 同步由系统负责。 - -## 参与贡献 - -请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。安全或隐私问题请按 [SECURITY.md](SECURITY.md) 私下报告。 +生产验收至少包括:专用任务“TaskForge同步状态测试”完成 +`待办 → 已计划 → 进行中 → Apple 完成`,再测试 Apple 侧状态移动;watcher 连续 +运行至少 61 秒,确认事件、防抖、双扫描、重启恢复和 07:00/11:00/15:00 校准。 +生产写入前还必须备份提醒事项迁移数据、私有索引及涉及源文件,并记录权限和哈希。 -## License +当前代码不会自动执行权限批准、真实写入或合并 `main`;这些是用户可见的验收门槛。 -[MIT](LICENSE) +更多架构和排障说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)、 +[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)、[PRIVACY.md](PRIVACY.md) +和 [SECURITY.md](SECURITY.md)。 diff --git a/SECURITY.md b/SECURITY.md index ea9e0cf..e26c52a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,39 @@ The application deliberately refuses to: - process tasks whose completion policy is not `keep`; - edit a stale or ambiguous Markdown task; - write directly to TaskForge's binary cache. + +Reminder pruning adds these fixed boundaries: + +- only the configured Apple Reminders list is fetched for pruning; other lists, + completed history and the deduplication archive are never prune targets; +- multiple same-named target lists fail closed instead of selecting one; +- only unfinished, unimportant reminders confirmed absent from both the + current TaskForge snapshot and their durable Vault source may be candidates; +- EventKit priority greater than zero and the six leading title markers `!`, + `!`, `❗`, `‼️`, `⭐`, `📌` protect a reminder; +- deletion requires two unchanged scans at least 60 seconds apart and a + checksummed `0600` backup verified before the EventKit commit; +- snapshot, source, permission, I/O, ledger, backup, EventKit timeout and + ambiguous restore errors preserve data and fail closed; +- `--prune-dry-run` never writes the ledger, backups, salt or reminders; +- normal write paths only migrate an exposed legacy runtime root or known + `Backups/` tree after verifying current-user ownership, real directory / + regular-file types, no extended ACL and no group/world write bits; directories + become `0700`, files become `0600`, and descriptor metadata is rechecked; +- symlinks, a different owner, ACLs and group/world-writable nodes are never + repaired automatically and remain fail-closed; +- TaskForge reverse completion only changes a task to `done`; pruning never + deletes or moves a TaskForge source task; +- restoration skips unresolved, zero-deletion and already restored backups, + selecting the newest remaining verified batch with a non-empty actual + deletion result. + +EventKit target-list reads time out after 30 seconds and cancel the outstanding +request. Pruning logs use aggregate counts, anonymous error categories and +salted truncated identifiers; they exclude reminder content, local paths and +raw TaskForge or EventKit IDs. + +The private candidate ledger, restore backups and hash salt live under +`~/Library/Application Support/TaskForgeReminderSync/` with `0600` file +permissions. They are intentionally preserved by uninstall for recovery and +must never be attached to a public issue or committed to Git. diff --git a/Sources/TaskForgeReminderCore/Core.swift b/Sources/TaskForgeReminderCore/Core.swift index fec0bb2..3cf37c0 100644 --- a/Sources/TaskForgeReminderCore/Core.swift +++ b/Sources/TaskForgeReminderCore/Core.swift @@ -53,6 +53,38 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { public let lineNumber: Int? public let onCompletion: String? public let recurrence: String? + public let tags: [String] + public let contexts: [String] + public let projects: [String] + public let due: TaskForgeScheduledDate? + public let start: TaskForgeScheduledDate? + public let completionDay: TaskForgeDay? + public let cancelledDay: TaskForgeDay? + public let isBlocked: Bool + public let fileName: String? + + private enum CodingKeys: String, CodingKey { + case identifier + case title + case status + case priority + case scheduled + case filePath + case sourceType + case originalLine + case lineNumber + case onCompletion + case recurrence + case tags + case contexts + case projects + case due + case start + case completionDay + case cancelledDay + case isBlocked + case fileName + } public init( identifier: String, @@ -65,7 +97,16 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { originalLine: String?, lineNumber: Int?, onCompletion: String? = nil, - recurrence: String? = nil + recurrence: String? = nil, + tags: [String] = [], + contexts: [String] = [], + projects: [String] = [], + due: TaskForgeScheduledDate? = nil, + start: TaskForgeScheduledDate? = nil, + completionDay: TaskForgeDay? = nil, + cancelledDay: TaskForgeDay? = nil, + isBlocked: Bool = false, + fileName: String? = nil ) { self.identifier = identifier self.title = title @@ -78,10 +119,69 @@ public struct TaskForgeTask: Codable, Equatable, Sendable { self.lineNumber = lineNumber self.onCompletion = onCompletion self.recurrence = recurrence + self.tags = tags + self.contexts = contexts + self.projects = projects + self.due = due + self.start = start + self.completionDay = completionDay + self.cancelledDay = cancelledDay + self.isBlocked = isBlocked + self.fileName = fileName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + identifier: try container.decode(String.self, forKey: .identifier), + title: try container.decode(String.self, forKey: .title), + status: try container.decode(String.self, forKey: .status), + priority: try container.decodeIfPresent(String.self, forKey: .priority), + scheduled: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .scheduled + ), + filePath: try container.decodeIfPresent(String.self, forKey: .filePath), + sourceType: try container.decodeIfPresent(String.self, forKey: .sourceType), + originalLine: try container.decodeIfPresent( + String.self, + forKey: .originalLine + ), + lineNumber: try container.decodeIfPresent(Int.self, forKey: .lineNumber), + onCompletion: try container.decodeIfPresent( + String.self, + forKey: .onCompletion + ), + recurrence: try container.decodeIfPresent(String.self, forKey: .recurrence), + tags: try container.decodeIfPresent([String].self, forKey: .tags) ?? [], + contexts: try container.decodeIfPresent([String].self, forKey: .contexts) ?? [], + projects: try container.decodeIfPresent([String].self, forKey: .projects) ?? [], + due: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .due + ), + start: try container.decodeIfPresent( + TaskForgeScheduledDate.self, + forKey: .start + ), + completionDay: try container.decodeIfPresent( + TaskForgeDay.self, + forKey: .completionDay + ), + cancelledDay: try container.decodeIfPresent( + TaskForgeDay.self, + forKey: .cancelledDay + ), + isBlocked: try container.decodeIfPresent(Bool.self, forKey: .isBlocked) + ?? false, + fileName: try container.decodeIfPresent(String.self, forKey: .fileName) + ) } public var isCompleted: Bool { - status == "done" || status == "cancelled" + let canonical = TaskForgeKanbanStatus.canonical(status) + return canonical == TaskForgeKanbanStatus.done.rawValue + || canonical == TaskForgeKanbanStatus.cancelled.rawValue } } @@ -147,7 +247,34 @@ public enum TaskForgeTaskStore { throw TaskForgeTaskStoreError.unsupportedVersion(version) } - let tasks = records.compactMap(decodeTaskRecord) + var allRecords = records + while decoder.hasRemaining { + let trailing = try decoder.decodeValue() + guard trailing.arrayValue?.count == 33 else { + throw TaskForgeTaskStoreError.malformed( + "任务库尾部包含未知记录" + ) + } + allRecords.append(trailing) + } + + var tasks: [TaskForgeTask] = [] + tasks.reserveCapacity(allRecords.count) + for record in allRecords { + // TaskForge leaves a scalar `1` tombstone in this array after an + // indexed record is removed. It is not a task and is present in + // the live v6 store, so it must not make an otherwise valid store + // fail closed or become a synthetic task. + if record.intValue == 1 { + continue + } + guard let task = decodeTaskRecord(record) else { + throw TaskForgeTaskStoreError.malformed( + "任务记录不完整或字段类型无效" + ) + } + tasks.append(task) + } return TaskForgeSnapshot(version: version, vaultPath: vaultPath, tasks: tasks) } @@ -175,10 +302,39 @@ public enum TaskForgeTaskStore { originalLine: fields[31].stringValue, lineNumber: fields[32].intValue, onCompletion: fields[25].stringValue, - recurrence: fields[30].stringValue + recurrence: fields[30].stringValue, + tags: decodeStrings(fields[5]), + contexts: decodeStrings(fields[6]), + projects: decodeStrings(fields[7]), + due: decodeScheduledDate(fields[14]), + start: decodeScheduledDate(fields[13]), + completionDay: decodeDay(fields[15]), + cancelledDay: decodeDay(fields[16]), + isBlocked: fields[26].boolValue ?? false, + fileName: fields[18].stringValue.map { + URL(fileURLWithPath: $0).lastPathComponent + } ) } + private static func decodeStrings(_ value: MessagePackValue) -> [String] { + value.arrayValue?.compactMap(\.stringValue) ?? [] + } + + private static func decodeDay(_ value: MessagePackValue) -> TaskForgeDay? { + guard + let fields = value.arrayValue, + let date = fields.first?.arrayValue, + date.count >= 3, + let year = date[0].intValue, + let month = date[1].intValue, + let day = date[2].intValue + else { + return nil + } + return TaskForgeDay(year: year, month: month, day: day) + } + private static func decodeScheduledDate( _ value: MessagePackValue ) -> TaskForgeScheduledDate? { @@ -716,6 +872,46 @@ public enum TaskReminderDeduplicationPolicy { } } +public enum TaskSourcePresence: String, Codable, Equatable, Sendable { + case present + case absent + case indeterminate +} + +public enum TaskSourcePresenceInspector { + public static func inspect( + task: TaskForgeTask, + contents: String + ) -> TaskSourcePresence { + switch task.sourceType?.lowercased() { + case "markdowninline": + guard + let originalLine = task.originalLine, + let lineNumber = task.lineNumber, + lineNumber > 0 + else { + return .indeterminate + } + let lines = contents.components(separatedBy: "\n") + if + lines.indices.contains(lineNumber - 1), + lines[lineNumber - 1] == originalLine + { + return .present + } + let matches = lines.filter { $0 == originalLine }.count + if matches == 1 { + return .present + } + return matches == 0 ? .absent : .indeterminate + case "tasknotes": + return .present + default: + return .indeterminate + } + } +} + public enum TaskCompletionSourceInspector { public static func isCompleted( task: TaskForgeTask, @@ -967,6 +1163,13 @@ private indirect enum MessagePackValue { return value } + var boolValue: Bool? { + guard case let .bool(value) = self else { + return nil + } + return value + } + var arrayValue: [MessagePackValue]? { guard case let .array(value) = self else { return nil @@ -979,6 +1182,10 @@ private struct MessagePackDecoder { private let data: Data private var offset = 0 + var hasRemaining: Bool { + offset < data.count + } + init(data: Data) { self.data = data } diff --git a/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift new file mode 100644 index 0000000..f46457a --- /dev/null +++ b/Sources/TaskForgeReminderCore/PrivateRuntimeDirectory.swift @@ -0,0 +1,596 @@ +import Darwin +import Foundation + +public enum PrivateRuntimeNodeKind: Equatable, Sendable { + case directory + case regularFile + case other +} + +public struct PrivateRuntimeNodeSecurity: Equatable, Sendable { + public var ownerUID: UInt32 + public var permissions: Int + public var kind: PrivateRuntimeNodeKind + public var hasExtendedACL: Bool + + public init( + ownerUID: UInt32, + permissions: Int, + kind: PrivateRuntimeNodeKind, + hasExtendedACL: Bool + ) { + self.ownerUID = ownerUID + self.permissions = permissions + self.kind = kind + self.hasExtendedACL = hasExtendedACL + } +} + +public enum PrivateRuntimeDirectoryPolicy { + public static func canMigrate( + _ security: PrivateRuntimeNodeSecurity, + currentUserUID: UInt32, + expectedKind: PrivateRuntimeNodeKind + ) -> Bool { + security.ownerUID == currentUserUID + && security.kind == expectedKind + && security.kind != .other + && !security.hasExtendedACL + && (0...0o777).contains(security.permissions) + && security.permissions & 0o022 == 0 + } +} + +public enum PrivateRuntimeDirectoryError: Error, Equatable { + case unsafeNode +} + +public enum PrivateRuntimeDirectory { + public static func prepareRoot(at url: URL) throws { + if let snapshot = try snapshotIfPresent(at: url) { + try validateMigratable( + snapshot, + expectedKind: .directory + ) + try migrate(snapshot, to: 0o700) + return + } + + try createDirectoryIncludingParents(at: url) + try validatePrivateDirectory(at: url) + } + + public static func prepareRootAndKnownTree( + rootURL: URL, + treeURL: URL + ) throws { + try prepareRootAndKnownTree( + rootURL: rootURL, + treeURL: treeURL, + createTreeIfMissing: true + ) + } + + public static func prepareRootAndExistingKnownTree( + rootURL: URL, + treeURL: URL + ) throws { + try prepareRootAndKnownTree( + rootURL: rootURL, + treeURL: treeURL, + createTreeIfMissing: false + ) + } + + private static func prepareRootAndKnownTree( + rootURL: URL, + treeURL: URL, + createTreeIfMissing: Bool + ) throws { + let root = rootURL.standardizedFileURL + let tree = treeURL.standardizedFileURL + guard tree.deletingLastPathComponent() == root else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + guard let rootSnapshot = try snapshotIfPresent(at: root) else { + try createDirectoryIncludingParents(at: root) + if createTreeIfMissing { + try createPrivateDirectory(at: tree) + } + return + } + try validateMigratable(rootSnapshot, expectedKind: .directory) + + let treeSnapshots: [NodeSnapshot] + if let treeSnapshot = try snapshotIfPresent(at: tree) { + treeSnapshots = try collectKnownTree( + at: tree, + rootSnapshot: treeSnapshot + ) + for snapshot in treeSnapshots { + try validateMigratable( + snapshot, + expectedKind: snapshot.security.kind + ) + } + } else { + treeSnapshots = [] + } + + // Validate the complete known tree before changing any existing mode. + try migrate(rootSnapshot, to: 0o700) + if treeSnapshots.isEmpty && createTreeIfMissing { + try createPrivateDirectory(at: tree) + } else { + for snapshot in treeSnapshots.sorted(by: migrationOrder) { + let permissions = snapshot.security.kind == .directory + ? 0o700 + : 0o600 + try migrate(snapshot, to: permissions) + } + } + } + + public static func validatePrivateRootReadOnly( + at url: URL + ) throws -> Bool { + guard let snapshot = try snapshotIfPresent(at: url) else { + return false + } + try validatePrivate( + snapshot, + expectedKind: .directory, + permissions: 0o700 + ) + return true + } + + public static func validatePrivateDirectory(at url: URL) throws { + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validatePrivate( + snapshot, + expectedKind: .directory, + permissions: 0o700 + ) + } + + public static func validatePrivateFile(at url: URL) throws { + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validatePrivate( + snapshot, + expectedKind: .regularFile, + permissions: 0o600 + ) + } + + public static func createPrivateDirectory(at url: URL) throws { + let result = mkdir(url.path, mode_t(0o700)) + if result != 0 { + guard errno == EEXIST else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + try validatePrivateDirectory(at: url) + } + + public static func writePrivateFile( + _ data: Data, + to url: URL + ) throws { + try validatePrivateDirectory( + at: url.deletingLastPathComponent() + ) + let descriptor = open( + url.path, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + guard descriptor >= 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + var shouldRemove = true + defer { + _ = close(descriptor) + if shouldRemove { + _ = unlink(url.path) + } + } + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + guard let baseAddress = bytes.baseAddress else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let count = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + bytes.count - offset + ) + if count < 0 && errno == EINTR { + continue + } + guard count > 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + offset += count + } + } + guard fsync(descriptor) == 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + + var status = stat() + guard + fstat(descriptor, &status) == 0, + metadata(for: status, hasExtendedACL: false) + == PrivateRuntimeNodeSecurity( + ownerUID: getuid(), + permissions: 0o600, + kind: .regularFile, + hasExtendedACL: false + ), + try !hasExtendedACL(at: url) + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + url, + device: status.st_dev, + inode: status.st_ino, + expectedKind: .regularFile, + permissions: 0o600 + ) + shouldRemove = false + } + + private struct NodeSnapshot { + let url: URL + let security: PrivateRuntimeNodeSecurity + let device: dev_t + let inode: ino_t + } + + private static func createDirectoryIncludingParents(at url: URL) throws { + do { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + guard try snapshotIfPresent(at: url) != nil else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + guard let snapshot = try snapshotIfPresent(at: url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try validateMigratable(snapshot, expectedKind: .directory) + try migrate(snapshot, to: 0o700) + } + + private static func collectKnownTree( + at url: URL, + rootSnapshot: NodeSnapshot + ) throws -> [NodeSnapshot] { + guard rootSnapshot.security.kind == .directory else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory( + atPath: url.path + ) + } catch { + throw PrivateRuntimeDirectoryError.unsafeNode + } + var snapshots = [rootSnapshot] + for name in names { + let childURL = url.appendingPathComponent(name) + guard let child = try snapshotIfPresent(at: childURL) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + switch child.security.kind { + case .directory: + snapshots.append( + contentsOf: try collectKnownTree( + at: childURL, + rootSnapshot: child + ) + ) + case .regularFile: + snapshots.append(child) + case .other: + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + return snapshots + } + + private static func validateMigratable( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind + ) throws { + guard + PrivateRuntimeDirectoryPolicy.canMigrate( + snapshot.security, + currentUserUID: getuid(), + expectedKind: expectedKind + ) + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func validatePrivate( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + guard + PrivateRuntimeDirectoryPolicy.canMigrate( + snapshot.security, + currentUserUID: getuid(), + expectedKind: expectedKind + ), + snapshot.security.permissions == permissions + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let descriptor = try openNode( + snapshot, + expectedKind: expectedKind, + writable: false + ) + defer { _ = close(descriptor) } + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: permissions + ) + guard try !hasExtendedACL(at: snapshot.url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + snapshot.url, + device: snapshot.device, + inode: snapshot.inode, + expectedKind: expectedKind, + permissions: permissions + ) + } + + private static func migrate( + _ snapshot: NodeSnapshot, + to permissions: Int + ) throws { + let expectedKind = snapshot.security.kind + let descriptor = try openNode( + snapshot, + expectedKind: expectedKind, + writable: false + ) + defer { _ = close(descriptor) } + + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: snapshot.security.permissions + ) + guard + try !hasExtendedACL(at: snapshot.url), + fchmod(descriptor, mode_t(permissions)) == 0 + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyOpenedNode( + descriptor, + matches: snapshot, + expectedKind: expectedKind, + permissions: permissions + ) + guard try !hasExtendedACL(at: snapshot.url) else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + try verifyPathStillMatches( + snapshot.url, + device: snapshot.device, + inode: snapshot.inode, + expectedKind: expectedKind, + permissions: permissions + ) + } + + private static func openNode( + _ snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + writable: Bool + ) throws -> Int32 { + var flags = (writable ? O_RDWR : O_RDONLY) | O_NOFOLLOW | O_CLOEXEC + if expectedKind == .directory { + flags |= O_DIRECTORY + } + let descriptor = open(snapshot.url.path, flags) + guard descriptor >= 0 else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return descriptor + } + + private static func verifyOpenedNode( + _ descriptor: Int32, + matches snapshot: NodeSnapshot, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + var status = stat() + guard + fstat(descriptor, &status) == 0, + status.st_dev == snapshot.device, + status.st_ino == snapshot.inode + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let security = metadata( + for: status, + hasExtendedACL: snapshot.security.hasExtendedACL + ) + guard + security.ownerUID == getuid(), + security.kind == expectedKind, + security.permissions == permissions + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func snapshotIfPresent( + at url: URL + ) throws -> NodeSnapshot? { + var status = stat() + guard lstat(url.path, &status) == 0 else { + guard errno == ENOENT else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return nil + } + let acl = try hasExtendedACL(at: url) + return NodeSnapshot( + url: url, + security: metadata(for: status, hasExtendedACL: acl), + device: status.st_dev, + inode: status.st_ino + ) + } + + private static func verifyPathStillMatches( + _ url: URL, + device: dev_t, + inode: ino_t, + expectedKind: PrivateRuntimeNodeKind, + permissions: Int + ) throws { + guard + let current = try snapshotIfPresent(at: url), + current.device == device, + current.inode == inode, + current.security.ownerUID == getuid(), + current.security.kind == expectedKind, + current.security.permissions == permissions, + !current.security.hasExtendedACL + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + } + + private static func metadata( + for status: stat, + hasExtendedACL: Bool + ) -> PrivateRuntimeNodeSecurity { + let kind: PrivateRuntimeNodeKind + switch status.st_mode & S_IFMT { + case S_IFDIR: + kind = .directory + case S_IFREG: + kind = .regularFile + default: + kind = .other + } + return PrivateRuntimeNodeSecurity( + ownerUID: status.st_uid, + permissions: Int(status.st_mode & 0o777), + kind: kind, + hasExtendedACL: hasExtendedACL + ) + } + + private static func hasExtendedACL(at url: URL) throws -> Bool { + errno = 0 + guard let acl = acl_get_file(url.path, ACL_TYPE_EXTENDED) else { + guard errno == ENOENT else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return false + } + acl_free(UnsafeMutableRawPointer(acl)) + return true + } + + private static func migrationOrder( + _ lhs: NodeSnapshot, + _ rhs: NodeSnapshot + ) -> Bool { + if lhs.security.kind != rhs.security.kind { + return lhs.security.kind == .regularFile + } + return lhs.url.pathComponents.count > rhs.url.pathComponents.count + } +} + +public struct TaskSourceBackupStore: Sendable { + public let backupsRootURL: URL + + public init(backupsRootURL: URL) { + self.backupsRootURL = backupsRootURL.standardizedFileURL + } + + public func save( + _ data: Data, + fileName: String, + batchName: String? = nil + ) throws -> URL { + guard + isSinglePathComponent(fileName), + batchName.map(isSinglePathComponent) ?? true + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + let runtimeRoot = backupsRootURL.deletingLastPathComponent() + try PrivateRuntimeDirectory.prepareRootAndKnownTree( + rootURL: runtimeRoot, + treeURL: backupsRootURL + ) + + let resolvedBatchName = batchName ?? defaultBatchName() + let batchURL = backupsRootURL.appendingPathComponent( + resolvedBatchName, + isDirectory: true + ) + try PrivateRuntimeDirectory.createPrivateDirectory(at: batchURL) + let fileURL = batchURL.appendingPathComponent(fileName) + try PrivateRuntimeDirectory.writePrivateFile(data, to: fileURL) + guard + try Data(contentsOf: fileURL) == data + else { + throw PrivateRuntimeDirectoryError.unsafeNode + } + return fileURL + } + + private func defaultBatchName() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .autoupdatingCurrent + formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" + return "\(formatter.string(from: Date()))-\(UUID().uuidString)" + } + + private func isSinglePathComponent(_ value: String) -> Bool { + !value.isEmpty + && value != "." + && value != ".." + && !value.contains("/") + && !value.contains("\0") + } +} diff --git a/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift new file mode 100644 index 0000000..595d832 --- /dev/null +++ b/Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift @@ -0,0 +1,930 @@ +import CryptoKit +import Darwin +import Foundation + +public struct ReminderLocationBackup: Codable, Equatable, Sendable { + public var title: String + public var latitude: Double? + public var longitude: Double? + public var radius: Double + + public init( + title: String, + latitude: Double?, + longitude: Double?, + radius: Double + ) { + self.title = title + self.latitude = latitude + self.longitude = longitude + self.radius = radius + } +} + +public struct ReminderWeekdayBackup: Codable, Equatable, Sendable { + public var dayOfTheWeekRawValue: Int + public var weekNumber: Int + + public init(dayOfTheWeekRawValue: Int, weekNumber: Int) { + self.dayOfTheWeekRawValue = dayOfTheWeekRawValue + self.weekNumber = weekNumber + } +} + +public struct ReminderAlarmBackup: Codable, Equatable, Sendable { + public var absoluteDate: Date? + public var relativeOffset: TimeInterval? + public var structuredLocation: ReminderLocationBackup? + public var proximityRawValue: Int? + + public init( + absoluteDate: Date?, + relativeOffset: TimeInterval?, + structuredLocation: ReminderLocationBackup?, + proximityRawValue: Int? + ) { + self.absoluteDate = absoluteDate + self.relativeOffset = relativeOffset + self.structuredLocation = structuredLocation + self.proximityRawValue = proximityRawValue + } +} + +public struct ReminderRecurrenceBackup: Codable, Equatable, Sendable { + public var frequencyRawValue: Int + public var interval: Int + public var daysOfWeek: [ReminderWeekdayBackup] + public var daysOfMonth: [Int] + public var monthsOfYear: [Int] + public var weeksOfYear: [Int] + public var daysOfYear: [Int] + public var setPositions: [Int] + public var endDate: Date? + public var occurrenceCount: Int? + + public init( + frequencyRawValue: Int, + interval: Int, + daysOfWeek: [ReminderWeekdayBackup], + daysOfMonth: [Int], + monthsOfYear: [Int], + weeksOfYear: [Int], + daysOfYear: [Int], + setPositions: [Int], + endDate: Date?, + occurrenceCount: Int? + ) { + self.frequencyRawValue = frequencyRawValue + self.interval = interval + self.daysOfWeek = daysOfWeek + self.daysOfMonth = daysOfMonth + self.monthsOfYear = monthsOfYear + self.weeksOfYear = weeksOfYear + self.daysOfYear = daysOfYear + self.setPositions = setPositions + self.endDate = endDate + self.occurrenceCount = occurrenceCount + } +} + +public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { + public var originalItemIdentifier: String + public var title: String + public var notes: String? + public var url: URL? + public var priority: Int + public var dueDateComponents: DateComponents? + public var startDateComponents: DateComponents? + public var alarms: [ReminderAlarmBackup] + public var recurrenceRules: [ReminderRecurrenceBackup] + public var taskPresence: TaskForgeReminderPresence + + public init( + originalItemIdentifier: String, + title: String, + notes: String?, + url: URL?, + priority: Int, + dueDateComponents: DateComponents?, + startDateComponents: DateComponents?, + alarms: [ReminderAlarmBackup], + recurrenceRules: [ReminderRecurrenceBackup], + taskPresence: TaskForgeReminderPresence + ) { + self.originalItemIdentifier = originalItemIdentifier + self.title = title + self.notes = notes + self.url = url + self.priority = priority + self.dueDateComponents = dueDateComponents + self.startDateComponents = startDateComponents + self.alarms = alarms + self.recurrenceRules = recurrenceRules + self.taskPresence = taskPresence + } +} + +public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { + public var identifier: UUID + public var createdAt: Date + public var targetCalendarIdentifier: String + public var targetCalendarTitle: String + public var targetSourceIdentifier: String + public var backupSchemaVersion: Int + public var rulesVersion: Int + public var items: [ReminderPruneBackupItem] + public var actuallyDeletedIdentifiers: [String]? + public var restoreAttemptIdentifier: UUID? + public var restoredItemIdentifiers: [String: String] + public var restoredAt: Date? + + public var actuallyDeletedItems: [ReminderPruneBackupItem]? { + guard let identifiers = actuallyDeletedIdentifiers else { + return nil + } + let deleted = Set(identifiers) + let result = items.filter { + deleted.contains($0.originalItemIdentifier) + } + return result.count == deleted.count ? result : nil + } + + public init( + identifier: UUID, + createdAt: Date, + targetCalendarIdentifier: String, + targetCalendarTitle: String, + targetSourceIdentifier: String, + backupSchemaVersion: Int, + rulesVersion: Int, + items: [ReminderPruneBackupItem], + actuallyDeletedIdentifiers: [String]?, + restoreAttemptIdentifier: UUID?, + restoredItemIdentifiers: [String: String], + restoredAt: Date? + ) { + self.identifier = identifier + self.createdAt = createdAt + self.targetCalendarIdentifier = targetCalendarIdentifier + self.targetCalendarTitle = targetCalendarTitle + self.targetSourceIdentifier = targetSourceIdentifier + self.backupSchemaVersion = backupSchemaVersion + self.rulesVersion = rulesVersion + self.items = items + self.actuallyDeletedIdentifiers = actuallyDeletedIdentifiers + self.restoreAttemptIdentifier = restoreAttemptIdentifier + self.restoredItemIdentifiers = restoredItemIdentifiers + self.restoredAt = restoredAt + } +} + +public enum ReminderPruneStoreError: Error, Equatable { + case invalidLedger + case invalidBackup + case checksumMismatch + case permissions + case noUnrestoredBackup +} + +public final class ReminderPruneLocalStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: + "\(NSHomeDirectory())/Library/Application Support/" + + "TaskForgeReminderSync", + isDirectory: true + ) + + public let rootURL: URL + + public var ledgerURL: URL { + rootURL.appendingPathComponent("PruneCandidates.json") + } + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL + } + + public func loadLedger() throws -> ReminderPruneLedger { + try ensureDirectory(rootURL) + guard try itemExists(at: ledgerURL) else { + return ReminderPruneLedger() + } + + try ensurePrivateFile(ledgerURL) + let data = try readData(at: ledgerURL, error: .invalidLedger) + do { + return try decoder.decode(ReminderPruneLedger.self, from: data) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + } + + public func loadLedgerReadOnly() throws -> ReminderPruneLedger { + do { + guard + try PrivateRuntimeDirectory.validatePrivateRootReadOnly( + at: rootURL + ) + else { + return ReminderPruneLedger() + } + } catch { + throw ReminderPruneStoreError.permissions + } + guard try itemExists(at: ledgerURL) else { + return ReminderPruneLedger() + } + try ensurePrivateFile(ledgerURL) + let data = try readData(at: ledgerURL, error: .invalidLedger) + do { + return try decoder.decode(ReminderPruneLedger.self, from: data) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + } + + public func saveLedger(_ ledger: ReminderPruneLedger) throws { + try ensureDirectory(rootURL) + let data: Data + do { + data = try encoder.encode(ledger) + } catch { + throw ReminderPruneStoreError.invalidLedger + } + try writeAtomically(data, to: ledgerURL) + } + + public func saveBackup(_ batch: ReminderPruneBackupBatch) throws -> URL { + let url = try backupURL(for: batch.identifier) + try saveBackup(batch, to: url) + return url + } + + public func loadBackup(at url: URL) throws -> ReminderPruneBackupBatch { + try ensureDirectory(rootURL) + let backups = try backupsURL() + guard url.standardizedFileURL.deletingLastPathComponent() == backups else { + throw ReminderPruneStoreError.invalidBackup + } + + let data = try readData(at: url, error: .invalidBackup) + let rawRoot: [String: Any] + let rawPayload: [String: Any] + let hasSchemaVersion: Bool + do { + guard + let root = try JSONSerialization.jsonObject(with: data) + as? [String: Any], + let payload = root["payload"] as? [String: Any] + else { + throw ReminderPruneStoreError.checksumMismatch + } + rawRoot = root + rawPayload = payload + hasSchemaVersion = payload["backupSchemaVersion"] != nil + } catch let error as ReminderPruneStoreError { + throw error + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + + let batch: ReminderPruneBackupBatch + if hasSchemaVersion { + let envelope: BackupEnvelope + do { + envelope = try decoder.decode(BackupEnvelope.self, from: data) + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + let payload: Data + do { + payload = try encoder.encode(envelope.payload) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + guard envelope.checksum == checksum(for: payload) else { + throw ReminderPruneStoreError.checksumMismatch + } + batch = envelope.payload + } else { + guard + Set(rawRoot.keys) == LegacyBackupEnvelopeV1.envelopeKeys, + LegacyReminderPruneBackupBatchV1.hasExactKeys( + Set(rawPayload.keys) + ) + else { + throw ReminderPruneStoreError.checksumMismatch + } + let legacy: LegacyBackupEnvelopeV1 + do { + legacy = try decoder.decode( + LegacyBackupEnvelopeV1.self, + from: data + ) + } catch { + throw ReminderPruneStoreError.checksumMismatch + } + let legacyPayload: Data + do { + legacyPayload = try encoder.encode(legacy.payload) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + guard legacy.checksum == checksum(for: legacyPayload) else { + throw ReminderPruneStoreError.checksumMismatch + } + batch = legacy.payload.migrated() + } + guard isValidBackup(batch) else { + throw ReminderPruneStoreError.invalidBackup + } + try ensurePrivateFile(url) + return batch + } + + public func latestUnresolvedDeletionBackup() throws + -> (URL, ReminderPruneBackupBatch)? + { + try latestBackup { + $0.restoredAt == nil + && $0.actuallyDeletedIdentifiers == nil + } + } + + public func latestRestorableBackup() throws + -> (URL, ReminderPruneBackupBatch)? + { + try latestBackup { + guard + $0.restoredAt == nil, + let deletedItems = $0.actuallyDeletedItems + else { + return false + } + return !deletedItems.isEmpty + } + } + + private func latestBackup( + where isEligible: (ReminderPruneBackupBatch) -> Bool + ) throws -> (URL, ReminderPruneBackupBatch)? { + try ensureDirectory(rootURL) + let backups = try backupsURL() + let urls: [URL] + do { + urls = try fileManager.contentsOfDirectory( + at: backups, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "json" } + } catch { + throw ReminderPruneStoreError.permissions + } + + let batches = try urls.map { url in + (url, try loadBackup(at: url)) + } + return batches + .filter { isEligible($0.1) } + .sorted { + if $0.1.createdAt == $1.1.createdAt { + return $0.0.lastPathComponent > $1.0.lastPathComponent + } + return $0.1.createdAt > $1.1.createdAt + } + .first + } + + public func markRestored(at url: URL, date: Date) throws { + var batch = try loadBackup(at: url) + guard + batch.restoredAt == nil, + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + !deleted.isEmpty, + Set(batch.restoredItemIdentifiers.keys) == Set(deleted) + else { + throw ReminderPruneStoreError.invalidBackup + } + batch.restoredAt = date + try saveBackup(batch, to: url) + } + + public func recordActuallyDeletedIdentifiers( + _ identifiers: [String], + at url: URL + ) throws { + var batch = try loadBackup(at: url) + guard + batch.restoredAt == nil, + batch.restoreAttemptIdentifier == nil, + batch.restoredItemIdentifiers.isEmpty + else { + throw ReminderPruneStoreError.invalidBackup + } + let original = Set(batch.items.map(\.originalItemIdentifier)) + let deleted = Set(identifiers) + guard deleted.isSubset(of: original) else { + throw ReminderPruneStoreError.invalidBackup + } + if let existing = batch.actuallyDeletedIdentifiers { + guard Set(existing) == deleted else { + throw ReminderPruneStoreError.invalidBackup + } + return + } + batch.actuallyDeletedIdentifiers = deleted.sorted() + try saveBackup(batch, to: url) + } + + public func beginRestoreAttempt( + at url: URL + ) throws -> ReminderPruneBackupBatch { + var batch = try loadBackup(at: url) + guard + batch.restoredAt == nil, + let deletedItems = batch.actuallyDeletedItems, + !deletedItems.isEmpty + else { + throw ReminderPruneStoreError.invalidBackup + } + if batch.restoreAttemptIdentifier == nil { + batch.restoreAttemptIdentifier = UUID() + try saveBackup(batch, to: url) + } + return batch + } + + public func recordRestoreReadback( + _ identifiers: [String: String], + at url: URL + ) throws { + var batch = try loadBackup(at: url) + guard + batch.restoredAt == nil, + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + !deleted.isEmpty, + Set(identifiers.keys) == Set(deleted), + identifiers.values.allSatisfy({ !$0.isEmpty }), + Set(identifiers.values).count == identifiers.count + else { + throw ReminderPruneStoreError.invalidBackup + } + if !batch.restoredItemIdentifiers.isEmpty { + guard batch.restoredItemIdentifiers == identifiers else { + throw ReminderPruneStoreError.invalidBackup + } + return + } + batch.restoredItemIdentifiers = identifiers + try saveBackup(batch, to: url) + } + + public func loadOrCreateHashSalt() throws -> Data { + Self.saltSetupLock.lock() + defer { Self.saltSetupLock.unlock() } + + try ensureDirectory(rootURL) + let saltURL = rootURL.appendingPathComponent("PruneHashSalt") + let lockURL = rootURL.appendingPathComponent("PruneHashSalt.lock") + let lockDescriptor = try openSaltLock(at: lockURL) + defer { _ = close(lockDescriptor) } + guard flock(lockDescriptor, LOCK_EX) == 0 else { + throw ReminderPruneStoreError.permissions + } + defer { _ = flock(lockDescriptor, LOCK_UN) } + + try ensurePrivateFile(lockURL) + if try itemExists(at: saltURL) { + try ensurePrivateFile(saltURL) + let salt = try readData(at: saltURL, error: .invalidBackup) + guard salt.count == 32 else { + throw ReminderPruneStoreError.invalidBackup + } + return salt + } + + let salt = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } + try writeAtomically(salt, to: saltURL) + return salt + } + + private let fileManager = FileManager.default + private static let saltSetupLock = NSLock() + private let runtimePreparationLock = NSLock() + private var runtimePrepared = false + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + private var decoder: JSONDecoder { + JSONDecoder() + } + + private func backupsURL() throws -> URL { + try ensureDirectory(rootURL) + let url = rootURL.appendingPathComponent("PruneBackups", isDirectory: true) + try ensureDirectory(url) + return url.standardizedFileURL + } + + private func backupURL(for identifier: UUID) throws -> URL { + try backupsURL().appendingPathComponent("\(identifier.uuidString).json") + } + + private func saveBackup( + _ batch: ReminderPruneBackupBatch, + to url: URL + ) throws { + let backups = try backupsURL() + guard url.standardizedFileURL.deletingLastPathComponent() == backups else { + throw ReminderPruneStoreError.invalidBackup + } + + guard isValidBackup(batch) else { + throw ReminderPruneStoreError.invalidBackup + } + let payload: Data + let data: Data + do { + payload = try encoder.encode(batch) + data = try encoder.encode( + BackupEnvelope( + checksum: checksum(for: payload), + payload: batch + ) + ) + } catch { + throw ReminderPruneStoreError.invalidBackup + } + try writeAtomically(data, to: url) + } + + private func isValidBackup(_ batch: ReminderPruneBackupBatch) -> Bool { + let originalIdentifiers = batch.items.map(\.originalItemIdentifier) + let original = Set(originalIdentifiers) + guard + !batch.targetCalendarIdentifier.isEmpty, + !batch.targetSourceIdentifier.isEmpty, + batch.backupSchemaVersion > 0, + batch.rulesVersion >= 0, + original.count == originalIdentifiers.count, + originalIdentifiers.allSatisfy({ !$0.isEmpty }) + else { + return false + } + if let deleted = batch.actuallyDeletedIdentifiers { + guard + Set(deleted).count == deleted.count, + Set(deleted).isSubset(of: original) + else { + return false + } + } else if batch.restoreAttemptIdentifier != nil + || !batch.restoredItemIdentifiers.isEmpty + || batch.restoredAt != nil + { + return false + } + if !batch.restoredItemIdentifiers.isEmpty { + guard + batch.restoreAttemptIdentifier != nil, + let deleted = batch.actuallyDeletedIdentifiers, + Set(batch.restoredItemIdentifiers.keys) == Set(deleted), + batch.restoredItemIdentifiers.values.allSatisfy({ + !$0.isEmpty + }), + Set(batch.restoredItemIdentifiers.values).count + == batch.restoredItemIdentifiers.count + else { + return false + } + } + return batch.restoredAt == nil + || batch.actuallyDeletedIdentifiers?.isEmpty == true + || !batch.restoredItemIdentifiers.isEmpty + } + + private func ensureDirectory(_ url: URL) throws { + do { + if url.standardizedFileURL == rootURL.standardizedFileURL { + runtimePreparationLock.lock() + defer { runtimePreparationLock.unlock() } + if runtimePrepared { + try PrivateRuntimeDirectory.validatePrivateDirectory( + at: rootURL + ) + } else { + try PrivateRuntimeDirectory + .prepareRootAndExistingKnownTree( + rootURL: rootURL, + treeURL: rootURL.appendingPathComponent( + "Backups", + isDirectory: true + ) + ) + runtimePrepared = true + } + } else if try attributesIfItemExists(at: url) == nil { + try PrivateRuntimeDirectory.createPrivateDirectory(at: url) + } else { + try PrivateRuntimeDirectory.validatePrivateDirectory(at: url) + } + } catch { + throw ReminderPruneStoreError.permissions + } + } + + private func ensurePrivateFile(_ url: URL) throws { + do { + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + } catch { + throw ReminderPruneStoreError.permissions + } + } + + private func openSaltLock(at url: URL) throws -> Int32 { + let descriptor = open( + url.path, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(0o600) + ) + if descriptor >= 0 { + guard fchmod(descriptor, mode_t(0o600)) == 0 else { + _ = close(descriptor) + throw ReminderPruneStoreError.permissions + } + return descriptor + } + guard errno == EEXIST else { + throw ReminderPruneStoreError.permissions + } + + let existingDescriptor = open( + url.path, + O_RDWR | O_NOFOLLOW | O_CLOEXEC + ) + guard existingDescriptor >= 0 else { + throw ReminderPruneStoreError.permissions + } + return existingDescriptor + } + + private func itemExists(at url: URL) throws -> Bool { + try attributesIfItemExists(at: url) != nil + } + + private func attributesIfItemExists( + at url: URL + ) throws -> [FileAttributeKey: Any]? { + do { + return try fileManager.attributesOfItem(atPath: url.path) + } catch { + guard isNoSuchFileError(error) else { + throw ReminderPruneStoreError.permissions + } + return nil + } + } + + private func isNoSuchFileError(_ error: Error) -> Bool { + let error = error as NSError + return (error.domain == NSCocoaErrorDomain + && (error.code == NSFileNoSuchFileError + || error.code == NSFileReadNoSuchFileError)) + || (error.domain == NSPOSIXErrorDomain && error.code == ENOENT) + } + + private func readData( + at url: URL, + error storeError: ReminderPruneStoreError + ) throws -> Data { + do { + return try Data(contentsOf: url) + } catch { + throw storeError + } + } + + private func writeAtomically(_ data: Data, to url: URL) throws { + let parent = url.deletingLastPathComponent() + try ensureDirectory(parent) + let temporaryURL = parent.appendingPathComponent( + ".\(UUID().uuidString).tmp" + ) + defer { + try? fileManager.removeItem(at: temporaryURL) + } + + guard fileManager.createFile(atPath: temporaryURL.path, contents: nil) else { + throw ReminderPruneStoreError.permissions + } + do { + let handle = try FileHandle(forWritingTo: temporaryURL) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: temporaryURL.path + ) + try ensurePrivateFile(temporaryURL) + + if try itemExists(at: url) { + try ensurePrivateFile(url) + _ = try fileManager.replaceItemAt( + url, + withItemAt: temporaryURL, + backupItemName: nil, + options: [.usingNewMetadataOnly] + ) + } else { + try fileManager.moveItem(at: temporaryURL, to: url) + } + try ensurePrivateFile(url) + } catch let error as ReminderPruneStoreError { + throw error + } catch { + throw ReminderPruneStoreError.permissions + } + } + + private func checksum(for data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public final class ReminderPruneOperationFileLock: @unchecked Sendable { + public static func defaultAnchorURL() throws -> URL { + guard + let account = getpwuid(getuid()), + let homePath = String( + validatingUTF8: account.pointee.pw_dir + ) + else { + throw ReminderPruneStoreError.permissions + } + let home = URL(fileURLWithPath: homePath, isDirectory: true) + let candidates = [ + home.appendingPathComponent( + "Library/Caches", + isDirectory: true + ), + home.appendingPathComponent("Library", isDirectory: true), + home + ] + for candidate in candidates { + if let validated = validatedAnchor(candidate) { + return validated + } + } + throw ReminderPruneStoreError.permissions + } + + public init( + exclusive: Bool, + anchorURL: URL? = nil + ) throws { + let anchor: URL + if let anchorURL { + guard let validated = Self.validatedAnchor(anchorURL) else { + throw ReminderPruneStoreError.permissions + } + anchor = validated + } else { + anchor = try Self.defaultAnchorURL() + } + let opened = open( + anchor.path, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY + ) + guard opened >= 0 else { + throw ReminderPruneStoreError.permissions + } + var status = stat() + guard + fstat(opened, &status) == 0, + status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 + else { + _ = close(opened) + throw ReminderPruneStoreError.permissions + } + let operation = exclusive ? LOCK_EX : LOCK_SH + var result: Int32 + repeat { + result = flock(opened, operation) + } while result != 0 && errno == EINTR + guard result == 0 else { + _ = close(opened) + throw ReminderPruneStoreError.permissions + } + descriptor = opened + } + + deinit { + unlock() + } + + public func unlock() { + guard let descriptor else { + return + } + _ = flock(descriptor, LOCK_UN) + _ = close(descriptor) + self.descriptor = nil + } + + private var descriptor: Int32? + + private static func validatedAnchor(_ url: URL) -> URL? { + let resolved = url.standardizedFileURL.resolvingSymlinksInPath() + guard resolved.path != "/tmp", resolved.path != "/private/tmp" else { + return nil + } + var status = stat() + guard + lstat(resolved.path, &status) == 0, + status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 + else { + return nil + } + return resolved + } +} + +private struct BackupEnvelope: Codable { + let checksum: String + let payload: ReminderPruneBackupBatch +} + +private struct LegacyBackupEnvelopeV1: Codable { + let checksum: String + let payload: LegacyReminderPruneBackupBatchV1 + + static let envelopeKeys: Set = [ + "checksum", + "payload" + ] +} + +private struct LegacyReminderPruneBackupBatchV1: Codable { + let identifier: UUID + let createdAt: Date + let targetCalendarIdentifier: String + let targetCalendarTitle: String + let targetSourceIdentifier: String + let rulesVersion: Int + let items: [ReminderPruneBackupItem] + let actuallyDeletedIdentifiers: [String]? + let restoreAttemptIdentifier: UUID? + let restoredItemIdentifiers: [String: String] + let restoredAt: Date? + + static func hasExactKeys(_ keys: Set) -> Bool { + requiredKeys.isSubset(of: keys) + && keys.isSubset(of: requiredKeys.union(optionalKeys)) + } + + func migrated() -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: identifier, + createdAt: createdAt, + targetCalendarIdentifier: targetCalendarIdentifier, + targetCalendarTitle: targetCalendarTitle, + targetSourceIdentifier: targetSourceIdentifier, + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, + rulesVersion: rulesVersion, + items: items, + actuallyDeletedIdentifiers: actuallyDeletedIdentifiers, + restoreAttemptIdentifier: restoreAttemptIdentifier, + restoredItemIdentifiers: restoredItemIdentifiers, + restoredAt: restoredAt + ) + } + + private static let requiredKeys: Set = [ + "identifier", + "createdAt", + "targetCalendarIdentifier", + "targetCalendarTitle", + "targetSourceIdentifier", + "rulesVersion", + "items", + "restoredItemIdentifiers" + ] + + private static let optionalKeys: Set = [ + "actuallyDeletedIdentifiers", + "restoreAttemptIdentifier", + "restoredAt" + ] +} diff --git a/Sources/TaskForgeReminderCore/ReminderPruning.swift b/Sources/TaskForgeReminderCore/ReminderPruning.swift new file mode 100644 index 0000000..5fa97c3 --- /dev/null +++ b/Sources/TaskForgeReminderCore/ReminderPruning.swift @@ -0,0 +1,210 @@ +import Foundation + +public enum TaskForgeReminderPresence: String, Codable, Equatable, Sendable { + case currentSnapshot + case sourceConfirmed + case absent + case indeterminate +} + +public struct ReminderPruneObservation: Equatable, Sendable { + public let itemIdentifier: String + public let calendarIdentifier: String + public let isCompleted: Bool + public let priority: Int + public let title: String + public let fingerprint: String + public let taskPresence: TaskForgeReminderPresence + + public init( + itemIdentifier: String, + calendarIdentifier: String, + isCompleted: Bool, + priority: Int, + title: String, + fingerprint: String, + taskPresence: TaskForgeReminderPresence + ) { + self.itemIdentifier = itemIdentifier + self.calendarIdentifier = calendarIdentifier + self.isCompleted = isCompleted + self.priority = priority + self.title = title + self.fingerprint = fingerprint + self.taskPresence = taskPresence + } + + public func withTaskPresence( + _ value: TaskForgeReminderPresence + ) -> ReminderPruneObservation { + ReminderPruneObservation( + itemIdentifier: itemIdentifier, + calendarIdentifier: calendarIdentifier, + isCompleted: isCompleted, + priority: priority, + title: title, + fingerprint: fingerprint, + taskPresence: value + ) + } +} + +public enum ReminderPruneCandidatePolicy { + public static let importantPrefixes = [ + "!", "!", "❗", "‼️", "⭐", "📌" + ] + + public static func isCandidate( + _ observation: ReminderPruneObservation, + targetCalendarIdentifier: String + ) -> Bool { + guard observation.calendarIdentifier == targetCalendarIdentifier else { + return false + } + guard !observation.isCompleted, observation.priority == 0 else { + return false + } + let title = observation.title.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !importantPrefixes.contains(where: title.hasPrefix) else { + return false + } + return observation.taskPresence == .absent + } +} + +public struct ReminderPruneLedgerEntry: Codable, Equatable, Sendable { + public var firstSeen: Date + public var fingerprint: String + public var calendarIdentifier: String + public var rulesVersion: Int + public var graceUntil: Date? + + public init( + firstSeen: Date, + fingerprint: String, + calendarIdentifier: String, + rulesVersion: Int, + graceUntil: Date? + ) { + self.firstSeen = firstSeen + self.fingerprint = fingerprint + self.calendarIdentifier = calendarIdentifier + self.rulesVersion = rulesVersion + self.graceUntil = graceUntil + } +} + +public struct ReminderPruneLedger: Codable, Equatable, Sendable { + public var entries: [String: ReminderPruneLedgerEntry] + + public init(entries: [String: ReminderPruneLedgerEntry] = [:]) { + self.entries = entries + } +} + +public struct ReminderPrunePlan: Equatable, Sendable { + public let firstSeenIdentifiers: [String] + public let waitingIdentifiers: [String] + public let readyIdentifiers: [String] + public let revokedIdentifiers: [String] + public let nextLedger: ReminderPruneLedger +} + +public enum ReminderPruneStateMachine { + public static let rulesVersion = 1 + + public static func plan( + observations: [ReminderPruneObservation], + prior: ReminderPruneLedger, + targetCalendarIdentifier: String, + now: Date, + confirmationInterval: TimeInterval = 60 + ) -> ReminderPrunePlan { + let effectiveConfirmationInterval = max(confirmationInterval, 60) + var next = ReminderPruneLedger() + var firstSeen: [String] = [] + var waiting: [String] = [] + var ready: [String] = [] + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + } + + for observation in candidates { + let old = prior.entries[observation.itemIdentifier] + let isSame = old?.fingerprint == observation.fingerprint + && old?.calendarIdentifier == observation.calendarIdentifier + && old?.rulesVersion == rulesVersion + if !isSame { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } else if let old, let graceUntil = old.graceUntil { + if now < graceUntil { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } else { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } + } else if let old, + now.timeIntervalSince(old.firstSeen) >= effectiveConfirmationInterval { + next.entries[observation.itemIdentifier] = old + ready.append(observation.itemIdentifier) + } else if let old { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } + } + + let active = Set(candidates.map(\.itemIdentifier)) + let revoked = prior.entries.keys.filter { !active.contains($0) }.sorted() + return ReminderPrunePlan( + firstSeenIdentifiers: firstSeen.sorted(), + waitingIdentifiers: waiting.sorted(), + readyIdentifiers: ready.sorted(), + revokedIdentifiers: revoked, + nextLedger: next + ) + } +} + +public enum ReminderPruneRestorePolicy { + public static let currentBackupSchemaVersion = 1 + + public static func supportsBackupSchema(_ version: Int) -> Bool { + version == currentBackupSchemaVersion + } + + public static func graceLedgerEntry( + fingerprint: String, + calendarIdentifier: String, + now: Date, + restoreGraceInterval: TimeInterval = 86_400 + ) -> ReminderPruneLedgerEntry { + ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: fingerprint, + calendarIdentifier: calendarIdentifier, + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval( + max(restoreGraceInterval, 86_400) + ) + ) + } +} diff --git a/Sources/TaskForgeReminderCore/TaskForgeKanban.swift b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift new file mode 100644 index 0000000..f32627c --- /dev/null +++ b/Sources/TaskForgeReminderCore/TaskForgeKanban.swift @@ -0,0 +1,961 @@ +import Foundation + +public enum TaskForgeKanbanStatus: String, Codable, CaseIterable, Sendable { + case todo + case scheduled + case ready + case inProgress + case onHold + case deferred + case blocked + case someday + case done + case cancelled + + public static func canonical(_ value: String) -> String { + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.hasPrefix("TaskStatus.") { + normalized = String(normalized.dropFirst("TaskStatus.".count)) + } + normalized = normalized.replacingOccurrences(of: "-", with: "_") + switch normalized.lowercased() { + case "todo", "open": + return Self.todo.rawValue + case "scheduled", "planned": + return Self.scheduled.rawValue + case "ready": + return Self.ready.rawValue + case "inprogress", "in_progress": + return Self.inProgress.rawValue + case "onhold", "on_hold", "hold": + return Self.onHold.rawValue + case "deferred", "postponed": + return Self.deferred.rawValue + case "blocked": + return Self.blocked.rawValue + case "someday", "someday_maybe": + return Self.someday.rawValue + case "done", "completed", "complete": + return Self.done.rawValue + case "cancelled", "canceled": + return Self.cancelled.rawValue + default: + return normalized + } + } + + public static func fromTask(_ task: TaskForgeTask) -> Self? { + Self(rawValue: canonical(task.status)) + } + + public var isTerminal: Bool { + self == .done || self == .cancelled + } +} + +public enum TaskForgeFilterConfigurationError: Error, LocalizedError, Equatable { + case missingList(String) + case malformedJSON + case unsupportedField(String) + case unsupportedOperator(String) + case invalidMatchMode(String) + case invalidCondition(String) + case invalidPrivateState + + public var errorDescription: String? { + switch self { + case let .missingList(id): + _ = id + return "找不到 TaskForge 自定义列表配置,已停止同步。" + case .malformedJSON: + return "TaskForge 自定义列表配置不是有效 JSON。" + case let .unsupportedField(field): + return "TaskForge 自定义列表包含未知过滤字段:\(field)" + case let .unsupportedOperator(value): + return "TaskForge 自定义列表包含未知过滤操作符:\(value)" + case let .invalidMatchMode(value): + return "TaskForge 自定义列表包含无效组逻辑:\(value)" + case let .invalidCondition(value): + return "TaskForge 自定义列表过滤条件无效:\(value)" + case .invalidPrivateState: + return "TaskForge 同步私有状态损坏,已拒绝写入。" + } + } +} + +public enum TaskForgeFilterField: String, CaseIterable, Sendable { + case dueDate = "due_date" + case scheduledDate = "scheduled_date" + case startDate = "start_date" + case completionDate = "completion_date" + case cancelledDate = "cancelled_date" + case status + case priority + case tag + case context + case project + case filePath = "file_path" + case fileName = "file_name" + case taskSourceType = "task_source_type" + case title + case isBlocked = "is_blocked" +} + +public enum TaskForgeFilterOperator: String, CaseIterable, Sendable { + case equals + case notEquals = "not_equals" + case contains + case notContains = "not_contains" + case isNull = "is_null" + case isNotNull = "is_not_null" + case today + case beforeToday = "before_today" + case afterToday = "after_today" + case inNextDays = "in_next_days" + case inLastDays = "in_last_days" + case onOrBefore = "on_or_before" + case onOrAfter = "on_or_after" + case anyOf = "any_of" + case notAnyOf = "not_any_of" +} + +public struct TaskForgeFilterCondition: Codable, Equatable, Sendable { + public let type: String + public let `operator`: String + public let value: String? + public let days: Int? + public let propertyName: String? + public let useInCalendarView: Bool + public let useInDistributionView: Bool + + public init( + type: String, + operator: String, + value: String? = nil, + days: Int? = nil, + propertyName: String? = nil, + useInCalendarView: Bool = true, + useInDistributionView: Bool = true + ) throws { + guard TaskForgeFilterField(rawValue: type) != nil else { + throw TaskForgeFilterConfigurationError.unsupportedField(type) + } + guard TaskForgeFilterOperator(rawValue: `operator`) != nil else { + throw TaskForgeFilterConfigurationError.unsupportedOperator(`operator`) + } + self.type = type + self.operator = `operator` + self.value = value + self.days = days + self.propertyName = propertyName + self.useInCalendarView = useInCalendarView + self.useInDistributionView = useInDistributionView + } + + private enum CodingKeys: String, CodingKey { + case type + case `operator` + case value + case days + case propertyName + case useInCalendarView + case useInDistributionView + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + let op = try container.decode(String.self, forKey: .operator) + try self.init( + type: type, + operator: op, + value: try container.decodeIfPresent(String.self, forKey: .value), + days: try container.decodeIfPresent(Int.self, forKey: .days), + propertyName: try container.decodeIfPresent( + String.self, + forKey: .propertyName + ), + useInCalendarView: try container.decodeIfPresent( + Bool.self, + forKey: .useInCalendarView + ) ?? true, + useInDistributionView: try container.decodeIfPresent( + Bool.self, + forKey: .useInDistributionView + ) ?? true + ) + } +} + +public struct TaskForgeFilterGroup: Codable, Equatable, Sendable { + public let conditions: [TaskForgeFilterCondition] + public let matchMode: String + + public init( + conditions: [TaskForgeFilterCondition], + matchMode: String + ) throws { + guard matchMode == "all" || matchMode == "any" else { + throw TaskForgeFilterConfigurationError.invalidMatchMode(matchMode) + } + guard !conditions.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("empty group") + } + self.conditions = conditions + self.matchMode = matchMode + } + + private enum CodingKeys: String, CodingKey { + case conditions + case matchMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + conditions: container.decode([TaskForgeFilterCondition].self, forKey: .conditions), + matchMode: container.decode(String.self, forKey: .matchMode) + ) + } +} + +public struct TaskForgeCustomList: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let filterGroups: [TaskForgeFilterGroup] + public let filterGroupsMatchMode: String + public let kanbanMode: Bool + + public init( + id: String, + name: String, + filterGroups: [TaskForgeFilterGroup], + filterGroupsMatchMode: String, + kanbanMode: Bool + ) throws { + guard !id.isEmpty, !name.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("list identity") + } + guard filterGroupsMatchMode == "all" || filterGroupsMatchMode == "any" else { + throw TaskForgeFilterConfigurationError.invalidMatchMode( + filterGroupsMatchMode + ) + } + guard !filterGroups.isEmpty else { + throw TaskForgeFilterConfigurationError.invalidCondition("empty list") + } + self.id = id + self.name = name + self.filterGroups = filterGroups + self.filterGroupsMatchMode = filterGroupsMatchMode + self.kanbanMode = kanbanMode + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case filterGroups + case filterGroupsMatchMode + case kanbanMode + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + id: container.decode(String.self, forKey: .id), + name: container.decode(String.self, forKey: .name), + filterGroups: container.decode( + [TaskForgeFilterGroup].self, + forKey: .filterGroups + ), + filterGroupsMatchMode: container.decode( + String.self, + forKey: .filterGroupsMatchMode + ), + kanbanMode: container.decodeIfPresent(Bool.self, forKey: .kanbanMode) + ?? false + ) + } +} + +public enum TaskForgeListConfigurationStore { + public static let defaultPreferencesPath = + "\(NSHomeDirectory())/Library/Containers/com.azhard.taskforge/Data/Library/Preferences/com.azhard.taskforge.plist" + + public static func load( + listID: String, + preferencesPath: String = defaultPreferencesPath + ) throws -> TaskForgeCustomList { + let url = URL(fileURLWithPath: preferencesPath) + guard + let data = try? Data(contentsOf: url), + let propertyList = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let plist = propertyList as? [String: Any], + let rawJSON = plist["flutter.ctl_\(listID)"] as? String + else { + throw TaskForgeFilterConfigurationError.missingList(listID) + } + guard let jsonData = rawJSON.data(using: .utf8) else { + throw TaskForgeFilterConfigurationError.malformedJSON + } + do { + return try JSONDecoder().decode(TaskForgeCustomList.self, from: jsonData) + } catch let error as TaskForgeFilterConfigurationError { + throw error + } catch { + throw TaskForgeFilterConfigurationError.malformedJSON + } + } + + public static func decode(jsonData: Data) throws -> TaskForgeCustomList { + do { + return try JSONDecoder().decode(TaskForgeCustomList.self, from: jsonData) + } catch let error as TaskForgeFilterConfigurationError { + throw error + } catch { + throw TaskForgeFilterConfigurationError.malformedJSON + } + } +} + +public enum TaskForgeFilterEvaluator { + public static func select( + tasks: [TaskForgeTask], + list: TaskForgeCustomList, + calendar: Calendar, + now: Date = Date() + ) throws -> [TaskForgeTask] { + try tasks.filter { task in + try evaluate(task: task, list: list, calendar: calendar, now: now) + } + } + + public static func evaluate( + task: TaskForgeTask, + list: TaskForgeCustomList, + calendar: Calendar, + now: Date = Date() + ) throws -> Bool { + let groupResults = try list.filterGroups.map { group in + let conditionResults = try group.conditions.map { + try evaluate( + condition: $0, + task: task, + calendar: calendar, + now: now + ) + } + return group.matchMode == "all" + ? conditionResults.allSatisfy { $0 } + : conditionResults.contains { $0 } + } + return list.filterGroupsMatchMode == "all" + ? groupResults.allSatisfy { $0 } + : groupResults.contains { $0 } + } + + private static func evaluate( + condition: TaskForgeFilterCondition, + task: TaskForgeTask, + calendar: Calendar, + now: Date + ) throws -> Bool { + guard let field = TaskForgeFilterField(rawValue: condition.type) else { + throw TaskForgeFilterConfigurationError.unsupportedField(condition.type) + } + guard let op = TaskForgeFilterOperator(rawValue: condition.operator) else { + throw TaskForgeFilterConfigurationError.unsupportedOperator( + condition.operator + ) + } + switch field { + case .dueDate, .scheduledDate, .startDate, .completionDate, .cancelledDate: + let day: TaskForgeDay? + switch field { + case .dueDate: day = task.due?.day + case .scheduledDate: day = task.scheduled?.day + case .startDate: day = task.start?.day + case .completionDate: day = task.completionDay + case .cancelledDate: day = task.cancelledDay + default: day = nil + } + return evaluateDate( + day: day, + operator: op, + value: condition.value, + days: condition.days, + calendar: calendar, + now: now + ) + case .isBlocked: + return evaluateScalar( + task.isBlocked ? "true" : "false", + isPresent: true, + operator: op, + value: condition.value + ) + case .status: + return evaluateScalar( + TaskForgeKanbanStatus.canonical(task.status), + isPresent: true, + operator: op, + value: condition.value.map(TaskForgeKanbanStatus.canonical) + ) + case .priority: + return evaluateScalar( + canonicalEnum(task.priority), + isPresent: task.priority != nil, + operator: op, + value: condition.value.map(canonicalEnum) + ) + case .tag: + return evaluateCollection( + task.tags, + operator: op, + value: condition.value + ) + case .context: + return evaluateCollection( + task.contexts, + operator: op, + value: condition.value + ) + case .project: + return evaluateCollection( + task.projects, + operator: op, + value: condition.value + ) + case .filePath: + return evaluateScalar( + task.filePath ?? "", + isPresent: task.filePath != nil, + operator: op, + value: condition.value + ) + case .fileName: + return evaluateScalar( + task.fileName ?? task.filePath.map { + URL(fileURLWithPath: $0).lastPathComponent + } ?? "", + isPresent: task.fileName != nil || task.filePath != nil, + operator: op, + value: condition.value + ) + case .taskSourceType: + return evaluateScalar( + canonicalEnum(task.sourceType), + isPresent: task.sourceType != nil, + operator: op, + value: condition.value.map(canonicalEnum) + ) + case .title: + return evaluateScalar( + task.title, + isPresent: !task.title.isEmpty, + operator: op, + value: condition.value + ) + } + } + + private static func evaluateScalar( + _ actual: String, + isPresent: Bool, + operator op: TaskForgeFilterOperator, + value: String? + ) -> Bool { + let expected = value ?? "" + switch op { + case .equals: + return isPresent && actual.caseInsensitiveCompare(expected) == .orderedSame + case .notEquals: + return !isPresent || actual.caseInsensitiveCompare(expected) != .orderedSame + case .contains: + return isPresent && actual.range(of: expected, options: .caseInsensitive) != nil + case .notContains: + return !isPresent || actual.range(of: expected, options: .caseInsensitive) == nil + case .isNull: + return !isPresent + case .isNotNull: + return isPresent + case .anyOf: + return valueList(value).contains { + actual.caseInsensitiveCompare($0) == .orderedSame + } + case .notAnyOf: + return !valueList(value).contains { + actual.caseInsensitiveCompare($0) == .orderedSame + } + default: + return false + } + } + + private static func evaluateCollection( + _ actual: [String], + operator op: TaskForgeFilterOperator, + value: String? + ) -> Bool { + let expected = value ?? "" + let matches: (String) -> Bool = { + $0.range(of: expected, options: .caseInsensitive) != nil + } + switch op { + case .equals, .contains: + return actual.contains(where: matches) + case .notEquals, .notContains: + return !actual.contains(where: matches) + case .isNull: + return actual.isEmpty + case .isNotNull: + return !actual.isEmpty + case .anyOf: + return valueList(value).contains { candidate in + actual.contains { $0.caseInsensitiveCompare(candidate) == .orderedSame } + } + case .notAnyOf: + return !valueList(value).contains { candidate in + actual.contains { $0.caseInsensitiveCompare(candidate) == .orderedSame } + } + default: + return false + } + } + + private static func evaluateDate( + day: TaskForgeDay?, + operator op: TaskForgeFilterOperator, + value: String?, + days: Int?, + calendar: Calendar, + now: Date + ) -> Bool { + guard let day else { + return op == .isNull || op == .notEquals + } + if op == .isNotNull { return true } + if op == .isNull { return false } + let today = TaskForgeDay(containing: now, calendar: calendar) + let target = parseDay(value) ?? today + guard let dayDate = date(day, calendar: calendar), + let targetDate = date(target, calendar: calendar), + let todayDate = date(today, calendar: calendar) + else { return false } + switch op { + case .equals, .today: + return dayDate == targetDate + case .notEquals: + return dayDate != targetDate + case .beforeToday: + return dayDate < todayDate + case .afterToday: + return dayDate > todayDate + case .inNextDays: + let upper = calendar.date(byAdding: .day, value: max(days ?? 0, 0), to: todayDate) ?? todayDate + return dayDate >= todayDate && dayDate <= upper + case .inLastDays: + let lower = calendar.date(byAdding: .day, value: -max(days ?? 0, 0), to: todayDate) ?? todayDate + return dayDate >= lower && dayDate <= todayDate + case .onOrBefore: + return dayDate <= targetDate + case .onOrAfter: + return dayDate >= targetDate + case .anyOf: + return valueList(value).contains { parseDay($0) == day } + case .notAnyOf: + return !valueList(value).contains { parseDay($0) == day } + default: + return false + } + } + + private static func canonicalEnum(_ value: String?) -> String { + guard let value else { return "" } + if let dot = value.lastIndex(of: ".") { + return String(value[value.index(after: dot)...]).lowercased() + } + return value.lowercased().replacingOccurrences(of: "-", with: "_") + } + + private static func valueList(_ value: String?) -> [String] { + value?.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? [] + } + + private static func parseDay(_ value: String?) -> TaskForgeDay? { + guard let value else { return nil } + let parts = value.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + return TaskForgeDay(year: year, month: month, day: day) + } + + private static func date(_ day: TaskForgeDay, calendar: Calendar) -> Date? { + calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: day.year, + month: day.month, + day: day.day + )) + } +} + +public enum TaskForgeStatusSymbolError: Error, LocalizedError, Equatable { + case conflict(String) + case unknownStatus(String) + case sourceLineNotUnique + case sourceLineMismatch + case unsupportedSourceType + case statusFieldNotFound + + public var errorDescription: String? { + switch self { + case let .conflict(status): + return "TaskForge 状态符号存在冲突:\(status)" + case let .unknownStatus(status): + return "尚未学习 TaskForge 状态的写回符号:\(status)" + case .sourceLineNotUnique: + return "TaskForge 源任务行不唯一,已拒绝写回。" + case .sourceLineMismatch: + return "TaskForge 源任务原始内容已变化,已拒绝写回。" + case .unsupportedSourceType: + return "暂不支持该 TaskForge 源类型的状态写回。" + case .statusFieldNotFound: + return "TaskNotes frontmatter 中找不到 status 字段。" + } + } +} + +public enum TaskForgeStatusSymbolLearner { + public static let supportedSymbols = Set(["[ ]", "[>]", "[/]", "[x]"]) + + public static func learn( + tasks: [TaskForgeTask], + existing: [String: String] = [:] + ) throws -> [String: String] { + var result: [String: String] = [:] + for (rawStatus, symbol) in existing { + let status = TaskForgeKanbanStatus.canonical(rawStatus) + if let previous = result[status], previous != symbol { + throw TaskForgeStatusSymbolError.conflict(status) + } + if let otherStatus = result.first(where: { + $0.value == symbol && $0.key != status + })?.key { + throw TaskForgeStatusSymbolError.conflict(otherStatus) + } + result[status] = symbol + } + for task in tasks { + guard task.sourceType?.lowercased() == "markdowninline", + let line = task.originalLine, + let symbol = symbol(in: line), + supportedSymbols.contains(symbol) + else { continue } + let status = TaskForgeKanbanStatus.canonical(task.status) + if let previous = result[status], previous != symbol { + throw TaskForgeStatusSymbolError.conflict(status) + } + if let otherStatus = result.first(where: { + $0.value == symbol && $0.key != status + })?.key { + throw TaskForgeStatusSymbolError.conflict(otherStatus) + } + result[status] = symbol + } + return result + } + + public static func symbol(in line: String) -> String? { + guard let range = line.range(of: #"\[[^\]]\]"#, options: .regularExpression) else { + return nil + } + return String(line[range]) + } +} + +public struct TaskForgeStatusEdit: Equatable, Sendable { + public let updatedContents: String + public let lineNumber: Int + public let originalLine: String + public let updatedLine: String + + public init( + updatedContents: String, + lineNumber: Int, + originalLine: String, + updatedLine: String + ) { + self.updatedContents = updatedContents + self.lineNumber = lineNumber + self.originalLine = originalLine + self.updatedLine = updatedLine + } +} + +public enum TaskForgeStatusSourceEditor { + public static func update( + task: TaskForgeTask, + contents: String, + targetStatus: String, + symbols: [String: String] + ) throws -> TaskForgeStatusEdit { + let canonicalStatus = TaskForgeKanbanStatus.canonical(targetStatus) + switch task.sourceType?.lowercased() { + case "markdowninline": + guard let expectedLine = task.originalLine else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + var lines = contents.components(separatedBy: "\n") + let expectedIndex = task.lineNumber.map { $0 - 1 } + let lineIndex: Int + if let expectedIndex, + lines.indices.contains(expectedIndex), + lines[expectedIndex] == expectedLine + { + lineIndex = expectedIndex + } else { + let matches = lines.indices.filter { lines[$0] == expectedLine } + guard matches.count == 1, let match = matches.first else { + throw matches.isEmpty + ? TaskForgeStatusSymbolError.sourceLineMismatch + : TaskForgeStatusSymbolError.sourceLineNotUnique + } + lineIndex = match + } + guard let symbol = symbols[canonicalStatus], supported(symbol) else { + throw TaskForgeStatusSymbolError.unknownStatus(canonicalStatus) + } + let pattern = #"^(\s*(?:[-*+]|\d+\.)\s+)\[[^\]]\]"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(lines[lineIndex].startIndex..., in: lines[lineIndex]) + guard regex.firstMatch(in: lines[lineIndex], range: range) != nil else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + let updatedLine = regex.stringByReplacingMatches( + in: lines[lineIndex], + range: range, + withTemplate: "$1\(symbol)" + ) + lines[lineIndex] = updatedLine + return TaskForgeStatusEdit( + updatedContents: lines.joined(separator: "\n"), + lineNumber: lineIndex + 1, + originalLine: expectedLine, + updatedLine: updatedLine + ) + case "tasknotes": + var lines = contents.components(separatedBy: "\n") + guard lines.first?.trimmingCharacters(in: .whitespaces) == "---", + let closing = lines.dropFirst().firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "---" + }) + else { + throw TaskForgeStatusSymbolError.statusFieldNotFound + } + guard let statusIndex = (1.. Bool { + TaskForgeStatusSymbolLearner.supportedSymbols.contains(symbol) + } +} + +public struct TaskForgeSyncIndexEntry: Codable, Equatable, Sendable { + public var reminderIdentifier: String + public var calendarIdentifier: String + public var status: String + public var sourceHash: String? + public var sourceReference: TaskForgeTask? + public var lastSyncAt: Date + + public init( + reminderIdentifier: String, + calendarIdentifier: String, + status: String, + sourceHash: String?, + sourceReference: TaskForgeTask? = nil, + lastSyncAt: Date + ) { + self.reminderIdentifier = reminderIdentifier + self.calendarIdentifier = calendarIdentifier + self.status = status + self.sourceHash = sourceHash + self.sourceReference = sourceReference + self.lastSyncAt = lastSyncAt + } +} + +public struct TaskForgeSyncIndex: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var listID: String + public var entries: [String: TaskForgeSyncIndexEntry] + + public init( + schemaVersion: Int = 1, + listID: String, + entries: [String: TaskForgeSyncIndexEntry] = [:] + ) { + self.schemaVersion = schemaVersion + self.listID = listID + self.entries = entries + } +} + +public struct TaskForgeSyncPrivateConfiguration: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var taskForgeListID: String? + public var listPrefix: String + public var learnedSymbols: [String: String] + + public init( + schemaVersion: Int = 1, + taskForgeListID: String? = nil, + listPrefix: String = "TaskForge", + learnedSymbols: [String: String] = [:] + ) { + self.schemaVersion = schemaVersion + self.taskForgeListID = taskForgeListID + self.listPrefix = listPrefix + self.learnedSymbols = learnedSymbols + } +} + +public final class TaskForgeSyncPrivateStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: "\(NSHomeDirectory())/Library/Application Support/TaskForgeReminderSync", + isDirectory: true + ) + + public let rootURL: URL + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL.standardizedFileURL + } + + public var configurationURL: URL { + rootURL.appendingPathComponent("KanbanSyncConfig.json") + } + + public var indexURL: URL { + rootURL.appendingPathComponent("KanbanSyncIndex.json") + } + + public func loadConfigurationReadOnly() throws -> TaskForgeSyncPrivateConfiguration? { + guard try PrivateRuntimeDirectory.validatePrivateRootReadOnly(at: rootURL) else { + return nil + } + guard FileManager.default.fileExists(atPath: configurationURL.path) else { + return nil + } + try PrivateRuntimeDirectory.validatePrivateFile(at: configurationURL) + let configuration = try decode( + TaskForgeSyncPrivateConfiguration.self, + at: configurationURL + ) + guard configuration.schemaVersion == 1, + !configuration.listPrefix.isEmpty, + !configuration.listPrefix.contains("\n"), + !configuration.listPrefix.contains("\r"), + configuration.learnedSymbols.allSatisfy({ key, value in + !key.isEmpty + && TaskForgeStatusSymbolLearner.supportedSymbols.contains(value) + }) + else { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + return configuration + } + + public func loadIndexReadOnly() throws -> TaskForgeSyncIndex? { + guard try PrivateRuntimeDirectory.validatePrivateRootReadOnly(at: rootURL) else { + return nil + } + guard FileManager.default.fileExists(atPath: indexURL.path) else { + return nil + } + try PrivateRuntimeDirectory.validatePrivateFile(at: indexURL) + let index = try decode(TaskForgeSyncIndex.self, at: indexURL) + guard index.schemaVersion == 1, !index.listID.isEmpty, + index.entries.allSatisfy({ key, entry in + !key.isEmpty && !entry.reminderIdentifier.isEmpty + && !entry.calendarIdentifier.isEmpty + && !entry.status.isEmpty + }) + else { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + return index + } + + public func saveConfiguration( + _ configuration: TaskForgeSyncPrivateConfiguration + ) throws { + try save(configuration, to: configurationURL) + } + + public func saveIndex(_ index: TaskForgeSyncIndex) throws { + try save(index, to: indexURL) + } + + private func save(_ value: Value, to url: URL) throws { + try PrivateRuntimeDirectory.prepareRoot(at: rootURL) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + let temporary = rootURL.appendingPathComponent( + ".\(url.lastPathComponent).\(UUID().uuidString).tmp" + ) + try PrivateRuntimeDirectory.writePrivateFile(data, to: temporary) + if FileManager.default.fileExists(atPath: url.path) { + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + _ = try FileManager.default.replaceItemAt(url, withItemAt: temporary) + } else { + try FileManager.default.moveItem(at: temporary, to: url) + } + try PrivateRuntimeDirectory.validatePrivateFile(at: url) + } + + private func decode(_ type: Value.Type, at url: URL) throws -> Value { + do { + return try JSONDecoder().decode(Value.self, from: Data(contentsOf: url)) + } catch { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + } +} diff --git a/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift new file mode 100644 index 0000000..26e5f4c --- /dev/null +++ b/Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift @@ -0,0 +1,271 @@ +import CoreLocation +import EventKit +import Foundation +import TaskForgeReminderCore + +enum ReminderBackupAdapter { + static func capture(_ reminder: EKReminder) -> ReminderPruneBackupItem { + capture(reminder, taskPresence: .indeterminate) + } + + static func capture( + _ reminder: EKReminder, + taskPresence: TaskForgeReminderPresence + ) -> ReminderPruneBackupItem { + ReminderPruneBackupItem( + originalItemIdentifier: reminder.calendarItemIdentifier, + title: reminder.title ?? "", + notes: reminder.notes, + url: reminder.url, + priority: reminder.priority, + dueDateComponents: reminder.dueDateComponents, + startDateComponents: reminder.startDateComponents, + alarms: (reminder.alarms ?? []).map(captureAlarm), + recurrenceRules: (reminder.recurrenceRules ?? []).map( + captureRecurrence + ), + taskPresence: taskPresence + ) + } + + static func restore( + _ backup: ReminderPruneBackupItem, + into reminder: EKReminder + ) { + reminder.title = backup.title + reminder.notes = backup.notes + reminder.url = backup.url + reminder.priority = backup.priority + reminder.dueDateComponents = backup.dueDateComponents + reminder.startDateComponents = backup.startDateComponents + + (reminder.alarms ?? []).forEach(reminder.removeAlarm) + (reminder.recurrenceRules ?? []).forEach( + reminder.removeRecurrenceRule + ) + + for backupAlarm in backup.alarms { + let alarm: EKAlarm + if let absoluteDate = backupAlarm.absoluteDate { + alarm = EKAlarm(absoluteDate: absoluteDate) + } else { + alarm = EKAlarm( + relativeOffset: backupAlarm.relativeOffset ?? 0 + ) + } + if let backupLocation = backupAlarm.structuredLocation { + let location = EKStructuredLocation( + title: backupLocation.title + ) + if + let latitude = backupLocation.latitude, + let longitude = backupLocation.longitude + { + location.geoLocation = CLLocation( + latitude: latitude, + longitude: longitude + ) + } + location.radius = backupLocation.radius + alarm.structuredLocation = location + } + if let proximity = alarmProximity( + rawValue: backupAlarm.proximityRawValue + ) { + alarm.proximity = proximity + } + reminder.addAlarm(alarm) + } + + for backupRule in backup.recurrenceRules { + guard + let frequency = recurrenceFrequency( + rawValue: backupRule.frequencyRawValue + ), + backupRule.interval > 0, + validRecurrenceValues(backupRule), + let daysOfWeek = recurrenceDays( + from: backupRule.daysOfWeek + ) + else { + continue + } + let rule = EKRecurrenceRule( + recurrenceWith: frequency, + interval: backupRule.interval, + daysOfTheWeek: daysOfWeek.nilIfEmpty, + daysOfTheMonth: backupRule.daysOfMonth.numbers.nilIfEmpty, + monthsOfTheYear: backupRule.monthsOfYear.numbers.nilIfEmpty, + weeksOfTheYear: backupRule.weeksOfYear.numbers.nilIfEmpty, + daysOfTheYear: backupRule.daysOfYear.numbers.nilIfEmpty, + setPositions: backupRule.setPositions.numbers.nilIfEmpty, + end: recurrenceEnd( + date: backupRule.endDate, + count: backupRule.occurrenceCount + ) + ) + reminder.addRecurrenceRule(rule) + } + } + + private static func captureAlarm(_ alarm: EKAlarm) -> ReminderAlarmBackup { + let location = alarm.structuredLocation.map { + ReminderLocationBackup( + title: $0.title ?? "", + latitude: $0.geoLocation?.coordinate.latitude, + longitude: $0.geoLocation?.coordinate.longitude, + radius: $0.radius + ) + } + return ReminderAlarmBackup( + absoluteDate: alarm.absoluteDate, + relativeOffset: alarm.absoluteDate == nil + ? alarm.relativeOffset + : nil, + structuredLocation: location, + proximityRawValue: alarm.proximity.rawValue + ) + } + + private static func captureRecurrence( + _ rule: EKRecurrenceRule + ) -> ReminderRecurrenceBackup { + let end = rule.recurrenceEnd + return ReminderRecurrenceBackup( + frequencyRawValue: rule.frequency.rawValue, + interval: rule.interval, + daysOfWeek: (rule.daysOfTheWeek ?? []).map { + ReminderWeekdayBackup( + dayOfTheWeekRawValue: $0.dayOfTheWeek.rawValue, + weekNumber: $0.weekNumber + ) + }, + daysOfMonth: (rule.daysOfTheMonth ?? []).map(\.intValue), + monthsOfYear: (rule.monthsOfTheYear ?? []).map(\.intValue), + weeksOfYear: (rule.weeksOfTheYear ?? []).map(\.intValue), + daysOfYear: (rule.daysOfTheYear ?? []).map(\.intValue), + setPositions: (rule.setPositions ?? []).map(\.intValue), + endDate: end?.endDate, + occurrenceCount: end.flatMap { + $0.occurrenceCount > 0 ? Int($0.occurrenceCount) : nil + } + ) + } + + private static func recurrenceFrequency( + rawValue: Int + ) -> EKRecurrenceFrequency? { + switch rawValue { + case EKRecurrenceFrequency.daily.rawValue: + return .daily + case EKRecurrenceFrequency.weekly.rawValue: + return .weekly + case EKRecurrenceFrequency.monthly.rawValue: + return .monthly + case EKRecurrenceFrequency.yearly.rawValue: + return .yearly + default: + return nil + } + } + + private static func recurrenceDays( + from backups: [ReminderWeekdayBackup] + ) -> [EKRecurrenceDayOfWeek]? { + var result: [EKRecurrenceDayOfWeek] = [] + for backup in backups { + guard + let weekday = weekday(rawValue: backup.dayOfTheWeekRawValue), + (-53...53).contains(backup.weekNumber) + else { + return nil + } + result.append( + EKRecurrenceDayOfWeek( + dayOfTheWeek: weekday, + weekNumber: backup.weekNumber + ) + ) + } + return result + } + + private static func validRecurrenceValues( + _ backup: ReminderRecurrenceBackup + ) -> Bool { + backup.daysOfMonth.allSatisfy { + $0 != 0 && (-31...31).contains($0) + } + && backup.monthsOfYear.allSatisfy { (1...12).contains($0) } + && backup.weeksOfYear.allSatisfy { + $0 != 0 && (-53...53).contains($0) + } + && backup.daysOfYear.allSatisfy { + $0 != 0 && (-366...366).contains($0) + } + && backup.setPositions.allSatisfy { + $0 != 0 && (-366...366).contains($0) + } + } + + private static func weekday(rawValue: Int) -> EKWeekday? { + switch rawValue { + case EKWeekday.sunday.rawValue: + return .sunday + case EKWeekday.monday.rawValue: + return .monday + case EKWeekday.tuesday.rawValue: + return .tuesday + case EKWeekday.wednesday.rawValue: + return .wednesday + case EKWeekday.thursday.rawValue: + return .thursday + case EKWeekday.friday.rawValue: + return .friday + case EKWeekday.saturday.rawValue: + return .saturday + default: + return nil + } + } + + private static func alarmProximity( + rawValue: Int? + ) -> EKAlarmProximity? { + switch rawValue { + case EKAlarmProximity.none.rawValue: + return EKAlarmProximity.none + case EKAlarmProximity.enter.rawValue: + return .enter + case EKAlarmProximity.leave.rawValue: + return .leave + default: + return nil + } + } + + private static func recurrenceEnd( + date: Date?, + count: Int? + ) -> EKRecurrenceEnd? { + if let date { + return EKRecurrenceEnd(end: date) + } + if let count, count > 0 { + return EKRecurrenceEnd(occurrenceCount: count) + } + return nil + } +} + +private extension Array { + var nilIfEmpty: Self? { + isEmpty ? nil : self + } +} + +private extension Array where Element == Int { + var numbers: [NSNumber] { + map(NSNumber.init(value:)) + } +} diff --git a/Sources/TaskForgeReminderEventKit/ReminderPruner.swift b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift new file mode 100644 index 0000000..8eef154 --- /dev/null +++ b/Sources/TaskForgeReminderEventKit/ReminderPruner.swift @@ -0,0 +1,1011 @@ +import CryptoKit +import Darwin +import Dispatch +import EventKit +import Foundation +import TaskForgeReminderCore + +public struct ReminderPruneConfiguration: Sendable { + public var listName: String + public var localRoot: URL + public var confirmationInterval: TimeInterval + public var restoreGraceInterval: TimeInterval + public var managedIndex: TaskForgeSyncIndex? + + public init( + listName: String, + localRoot: URL, + confirmationInterval: TimeInterval = 60, + restoreGraceInterval: TimeInterval = 86_400, + managedIndex: TaskForgeSyncIndex? = nil + ) { + self.listName = listName + self.localRoot = localRoot + self.confirmationInterval = confirmationInterval + self.restoreGraceInterval = restoreGraceInterval + self.managedIndex = managedIndex + } +} + +@MainActor +private final class ReminderPrunerOperationGate { + static let shared = ReminderPrunerOperationGate() + + private var held: Set = [] + private var waiters: [ + String: [CheckedContinuation] + ] = [:] + + func acquire(_ key: String) async { + if held.insert(key).inserted { + return + } + await withCheckedContinuation { continuation in + waiters[key, default: []].append(continuation) + } + } + + func release(_ key: String) { + if var pending = waiters[key], !pending.isEmpty { + let next = pending.removeFirst() + if pending.isEmpty { + waiters.removeValue(forKey: key) + } else { + waiters[key] = pending + } + next.resume() + } else { + held.remove(key) + } + } +} + +public struct ReminderPruneCounts: Equatable, Sendable { + public init() {} + + public var scanned = 0 + public var firstSeen = 0 + public var waiting = 0 + public var ready = 0 + public var deleted = 0 + public var restored = 0 + public var protected = 0 + public var failed = 0 +} + +public enum ReminderPrunerError: Error, LocalizedError { + case backupReminderSourceUnavailable + case backupVerificationFailed + case backupSchemaVersionUnsupported + case deletionOutcomeUnresolved + case ambiguousTargetCalendar + case operationLockFailed + case reminderFetchFailed + case restoreReadbackAmbiguous + case restoredReminderReadbackFailed + + public var errorDescription: String? { + switch self { + case .backupReminderSourceUnavailable: + return "找不到备份指定的提醒事项账户,备份仍未消费。" + case .backupVerificationFailed: + return "清理备份写入后的回读校验失败。" + case .backupSchemaVersionUnsupported: + return "备份格式版本不兼容,备份仍未消费。" + case .deletionOutcomeUnresolved: + return "备份尚未记录可核实的实际删除结果,已拒绝恢复。" + case .ambiguousTargetCalendar: + return "原账户中存在多个同名提醒列表,已拒绝选择。" + case .operationLockFailed: + return "无法取得提醒清理操作锁。" + case .reminderFetchFailed: + return "提醒事项读取失败,已按失败关闭。" + case .restoreReadbackAmbiguous: + return "恢复尝试的提醒回读不唯一,备份仍未消费。" + case .restoredReminderReadbackFailed: + return "恢复提交后未能回读全部新提醒,备份仍未消费。" + } + } +} + +private final class ReminderFetchResolution< + Value, + Handle: Sendable +>: @unchecked Sendable { + init( + _ continuation: CheckedContinuation, + cancel: @escaping @Sendable (Handle) -> Void + ) { + self.continuation = continuation + self.cancel = cancel + } + + func resolve(_ result: Result) { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + func timeout() { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + timeoutWon = true + self.continuation = nil + let handleToCancel = cancellationHandleLocked() + lock.unlock() + + if let handleToCancel { + cancel(handleToCancel) + } + continuation.resume( + throwing: ReminderPrunerError.reminderFetchFailed + ) + } + + func register(_ handle: Handle) { + lock.lock() + self.handle = handle + let handleToCancel = cancellationHandleLocked() + lock.unlock() + + if let handleToCancel { + cancel(handleToCancel) + } + } + + private func cancellationHandleLocked() -> Handle? { + guard timeoutWon, !cancelIssued, let handle else { + return nil + } + cancelIssued = true + return handle + } + + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var handle: Handle? + private var settled = false + private var timeoutWon = false + private var cancelIssued = false + private let cancel: @Sendable (Handle) -> Void +} + +enum ReminderFetchWaiter { + typealias TimeoutScheduler = @Sendable ( + TimeInterval, + @escaping @Sendable () -> Void + ) -> Void + + static func wait( + timeout: TimeInterval = 30, + scheduleTimeout: @escaping TimeoutScheduler = { + interval, timeout in + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + max(interval, 0), + execute: timeout + ) + }, + cancel: @escaping @Sendable (Handle) -> Void, + start: ( + @escaping @Sendable (Result) -> Void + ) -> Handle + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + let resolution = ReminderFetchResolution( + continuation, + cancel: cancel + ) + scheduleTimeout(timeout) { + resolution.timeout() + } + let handle = start { result in + resolution.resolve(result) + } + resolution.register(handle) + } + } +} + +private final class ReminderFetchRequestIdentifier: @unchecked Sendable { + init(_ rawValue: Any) { + self.rawValue = rawValue + } + + let rawValue: Any +} + +@MainActor +private final class ReminderFetchCanceller { + init(eventStore: EKEventStore) { + self.eventStore = eventStore + } + + func cancel(_ identifier: ReminderFetchRequestIdentifier) { + eventStore.cancelFetchRequest(identifier.rawValue) + } + + private let eventStore: EKEventStore +} + +@MainActor +public final class ReminderPruner { + public init( + eventStore: EKEventStore, + configuration: ReminderPruneConfiguration, + log: @escaping @Sendable (String) -> Void, + logError: @escaping @Sendable (String) -> Void + ) { + self.eventStore = eventStore + self.configuration = configuration + self.localStore = ReminderPruneLocalStore( + rootURL: configuration.localRoot + ) + self.log = log + self.logError = logError + } + + public func dryRun( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts { + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try operationFileLock(exclusive: false) + defer { operationLock.unlock() } + + guard let targetCalendar = try targetCalendar() else { + return ReminderPruneCounts() + } + let reminders = try await fetchReminders(in: targetCalendar) + let observations = reminders.compactMap { + observation( + for: $0, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + } + let prior = try localStore.loadLedgerReadOnly() + let plan = ReminderPruneStateMachine.plan( + observations: observations, + prior: prior, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + let counts = counts( + scanned: reminders.count, + observations: observations, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + plan: plan + ) + log(summary(prefix: "清理预演", counts: counts)) + return counts + } + + public func advance( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts { + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try operationFileLock(exclusive: true) + defer { operationLock.unlock() } + + guard let targetCalendar = try targetCalendar() else { + return ReminderPruneCounts() + } + let initialReminders = try await fetchReminders(in: targetCalendar) + try reconcileUnresolvedBackup( + targetCalendar: targetCalendar, + reminders: initialReminders + ) + let observations = initialReminders.compactMap { + observation( + for: $0, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + } + let prior = try localStore.loadLedger() + let initialPlan = ReminderPruneStateMachine.plan( + observations: observations, + prior: prior, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + try localStore.saveLedger(initialPlan.nextLedger) + + var counts = counts( + scanned: initialReminders.count, + observations: observations, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + plan: initialPlan + ) + let freshReminders = try await fetchReminders(in: targetCalendar) + var freshByIdentifier: [String: EKReminder] = [:] + for reminder in freshReminders { + freshByIdentifier[reminder.calendarItemIdentifier] = reminder + } + var confirmed: [ + (reminder: EKReminder, observation: ReminderPruneObservation) + ] = [] + for identifier in initialPlan.readyIdentifiers { + guard + let current = freshByIdentifier[identifier], + let currentObservation = observation( + for: current, + targetCalendar: targetCalendar, + snapshot: snapshot + ) + else { + continue + } + let recheck = ReminderPruneStateMachine.plan( + observations: [currentObservation], + prior: initialPlan.nextLedger, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + now: now, + confirmationInterval: configuration.confirmationInterval + ) + if recheck.readyIdentifiers == [identifier] { + confirmed.append((current, currentObservation)) + } + } + counts.ready = confirmed.count + counts.protected += initialPlan.readyIdentifiers.count - confirmed.count + guard !confirmed.isEmpty else { + log(summary(prefix: "清理推进", counts: counts)) + return counts + } + + let backup = ReminderPruneBackupBatch( + identifier: UUID(), + createdAt: now, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + targetCalendarTitle: targetCalendar.title, + targetSourceIdentifier: + targetCalendar.source.sourceIdentifier, + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, + rulesVersion: ReminderPruneStateMachine.rulesVersion, + items: confirmed.map { + ReminderBackupAdapter.capture( + $0.reminder, + taskPresence: $0.observation.taskPresence + ) + }, + actuallyDeletedIdentifiers: nil, + restoreAttemptIdentifier: nil, + restoredItemIdentifiers: [:], + restoredAt: nil + ) + let backupURL = try localStore.saveBackup(backup) + guard try localStore.loadBackup(at: backupURL) == backup else { + throw ReminderPrunerError.backupVerificationFailed + } + + let confirmedIdentifiers = Set( + confirmed.map(\.reminder.calendarItemIdentifier) + ) + let targetCalendarIdentifier = targetCalendar.calendarIdentifier + let targetSourceIdentifier = targetCalendar.source.sourceIdentifier + for candidate in confirmed { + let reminder = candidate.reminder + do { + try eventStore.remove(reminder, commit: false) + } catch { + let identifier = loggedIdentifier( + reminder.calendarItemIdentifier + ) + logError( + "清理暂存失败 [\(identifier)]。" + ) + } + } + + do { + try eventStore.commit() + } catch { + logError("清理提交失败,将按回读实际状态结算。") + } + eventStore.reset() + + var ledger = initialPlan.nextLedger + guard + let refreshedCalendar = eventStore.calendar( + withIdentifier: targetCalendarIdentifier + ), + refreshedCalendar.source.sourceIdentifier == targetSourceIdentifier + else { + throw ReminderPrunerError.reminderFetchFailed + } + let remaining = Set( + try await fetchReminders(in: refreshedCalendar).map( + \.calendarItemIdentifier + ) + ) + let actuallyDeleted = confirmedIdentifiers.subtracting(remaining) + try localStore.recordActuallyDeletedIdentifiers( + actuallyDeleted.sorted(), + at: backupURL + ) + for identifier in confirmedIdentifiers { + if remaining.contains(identifier) { + counts.failed += 1 + } else { + ledger.entries.removeValue(forKey: identifier) + counts.deleted += 1 + } + } + try localStore.saveLedger(ledger) + log(summary(prefix: "清理推进", counts: counts)) + return counts + } + + public func restoreLast( + now: Date = Date() + ) async throws -> ReminderPruneCounts { + await ReminderPrunerOperationGate.shared.acquire(operationKey) + defer { + ReminderPrunerOperationGate.shared.release(operationKey) + } + let operationLock = try operationFileLock(exclusive: true) + defer { operationLock.unlock() } + + guard let (backupURL, originalBackup) = + try localStore.latestRestorableBackup() + else { + return ReminderPruneCounts() + } + guard + ReminderPruneRestorePolicy.supportsBackupSchema( + originalBackup.backupSchemaVersion + ) + else { + throw ReminderPrunerError.backupSchemaVersionUnsupported + } + guard + let items = originalBackup.actuallyDeletedItems, + !items.isEmpty + else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + + let backup = try localStore.beginRestoreAttempt(at: backupURL) + guard let attemptIdentifier = backup.restoreAttemptIdentifier else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + var calendar = try restoreTargetCalendar(for: backup) + var reminders = try await fetchReminders(in: calendar) + var restoredIdentifiers = backup.restoredItemIdentifiers + + if restoredIdentifiers.isEmpty { + var stagedCreation = false + for item in items { + let marker = restoreMarker( + backupIdentifier: backup.identifier, + attemptIdentifier: attemptIdentifier, + originalItemIdentifier: item.originalItemIdentifier + ) + let matches = reminders.filter { + containsRestoreMarker($0.notes, marker: marker) + } + guard matches.count <= 1 else { + throw ReminderPrunerError.restoreReadbackAmbiguous + } + guard matches.isEmpty else { + continue + } + let reminder = EKReminder(eventStore: eventStore) + reminder.calendar = calendar + ReminderBackupAdapter.restore(item, into: reminder) + reminder.notes = addingRestoreMarker( + to: item.notes, + marker: marker + ) + try eventStore.save(reminder, commit: false) + stagedCreation = true + } + if stagedCreation { + let calendarIdentifier = calendar.calendarIdentifier + let sourceIdentifier = calendar.source.sourceIdentifier + do { + try eventStore.commit() + } catch { + eventStore.reset() + throw error + } + eventStore.reset() + calendar = try exactRestoreCalendar( + identifier: calendarIdentifier, + sourceIdentifier: sourceIdentifier + ) + } + reminders = try await fetchReminders(in: calendar) + for item in items { + let marker = restoreMarker( + backupIdentifier: backup.identifier, + attemptIdentifier: attemptIdentifier, + originalItemIdentifier: item.originalItemIdentifier + ) + let matches = reminders.filter { + containsRestoreMarker($0.notes, marker: marker) + } + guard matches.count == 1, let match = matches.first else { + throw matches.isEmpty + ? ReminderPrunerError.restoredReminderReadbackFailed + : ReminderPrunerError.restoreReadbackAmbiguous + } + restoredIdentifiers[item.originalItemIdentifier] = + match.calendarItemIdentifier + } + try localStore.recordRestoreReadback( + restoredIdentifiers, + at: backupURL + ) + } + + let restoredPairs = try restoreReadbackPairs( + items: items, + identifiers: restoredIdentifiers, + reminders: reminders + ) + do { + for pair in restoredPairs { + ReminderBackupAdapter.restore(pair.item, into: pair.reminder) + try eventStore.save(pair.reminder, commit: false) + } + try eventStore.commit() + } catch { + eventStore.reset() + throw error + } + + let restoredCalendarIdentifier = calendar.calendarIdentifier + let restoredSourceIdentifier = calendar.source.sourceIdentifier + eventStore.reset() + let refreshedCalendar = try exactRestoreCalendar( + identifier: restoredCalendarIdentifier, + sourceIdentifier: restoredSourceIdentifier + ) + let refreshed = try await fetchReminders(in: refreshedCalendar) + let finalPairs = try restoreReadbackPairs( + items: items, + identifiers: restoredIdentifiers, + reminders: refreshed + ) + var ledger = try localStore.loadLedger() + for pair in finalPairs { + let reminder = pair.reminder + let identifier = reminder.calendarItemIdentifier + ledger.entries[identifier] = + ReminderPruneRestorePolicy.graceLedgerEntry( + fingerprint: fingerprint( + reminder: reminder, + targetCalendarIdentifier: + refreshedCalendar.calendarIdentifier, + presence: pair.item.taskPresence + ), + calendarIdentifier: refreshedCalendar.calendarIdentifier, + now: now, + restoreGraceInterval: configuration.restoreGraceInterval + ) + } + try localStore.saveLedger(ledger) + try localStore.markRestored(at: backupURL, date: now) + + var counts = ReminderPruneCounts() + counts.scanned = refreshed.count + counts.restored = finalPairs.count + log(summary(prefix: "清理恢复", counts: counts)) + return counts + } + + private let eventStore: EKEventStore + private let configuration: ReminderPruneConfiguration + private let localStore: ReminderPruneLocalStore + private let log: @Sendable (String) -> Void + private let logError: @Sendable (String) -> Void + + private var operationKey: String { + configuration.localRoot.standardizedFileURL + .resolvingSymlinksInPath().path + } + + private func operationFileLock( + exclusive: Bool + ) throws -> ReminderPruneOperationFileLock { + do { + return try ReminderPruneOperationFileLock( + exclusive: exclusive + ) + } catch { + throw ReminderPrunerError.operationLockFailed + } + } + + nonisolated static func uniqueTargetCalendarMatch( + _ matches: [Element] + ) throws -> Element? { + switch matches.count { + case 0: + return nil + case 1: + return matches[0] + default: + throw ReminderPrunerError.ambiguousTargetCalendar + } + } + + private func targetCalendar() throws -> EKCalendar? { + let matches = eventStore.calendars(for: .reminder).filter { + $0.title == configuration.listName + } + return try Self.uniqueTargetCalendarMatch(matches) + } + + private func reconcileUnresolvedBackup( + targetCalendar: EKCalendar, + reminders: [EKReminder] + ) throws { + guard + let (url, backup) = + try localStore.latestUnresolvedDeletionBackup() + else { + return + } + guard + backup.targetCalendarIdentifier + == targetCalendar.calendarIdentifier, + backup.targetSourceIdentifier + == targetCalendar.source.sourceIdentifier + else { + throw ReminderPrunerError.deletionOutcomeUnresolved + } + let remaining = Set( + reminders.map(\.calendarItemIdentifier) + ) + let attempted = Set( + backup.items.map(\.originalItemIdentifier) + ) + try localStore.recordActuallyDeletedIdentifiers( + attempted.subtracting(remaining).sorted(), + at: url + ) + } + + private func restoreTargetCalendar( + for backup: ReminderPruneBackupBatch + ) throws -> EKCalendar { + let calendars = eventStore.calendars(for: .reminder) + if let exact = calendars.first(where: { + $0.calendarIdentifier == backup.targetCalendarIdentifier + && $0.source.sourceIdentifier + == backup.targetSourceIdentifier + }) { + return exact + } + let named = calendars.filter { + $0.title == backup.targetCalendarTitle + && $0.source.sourceIdentifier + == backup.targetSourceIdentifier + } + guard named.count <= 1 else { + throw ReminderPrunerError.ambiguousTargetCalendar + } + if let existing = named.first { + return existing + } + guard let source = eventStore.sources.first(where: { + $0.sourceIdentifier == backup.targetSourceIdentifier + }) else { + throw ReminderPrunerError.backupReminderSourceUnavailable + } + let calendar = EKCalendar(for: .reminder, eventStore: eventStore) + calendar.title = backup.targetCalendarTitle + calendar.source = source + try eventStore.saveCalendar(calendar, commit: true) + return calendar + } + + private func exactRestoreCalendar( + identifier: String, + sourceIdentifier: String + ) throws -> EKCalendar { + guard let calendar = eventStore.calendars(for: .reminder).first(where: { + $0.calendarIdentifier == identifier + && $0.source.sourceIdentifier == sourceIdentifier + }) else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + return calendar + } + + private func fetchReminders( + in calendar: EKCalendar + ) async throws -> [EKReminder] { + let predicate = eventStore.predicateForReminders(in: [calendar]) + let canceller = ReminderFetchCanceller(eventStore: eventStore) + return try await ReminderFetchWaiter.wait( + timeout: 30, + cancel: { identifier in + Task { @MainActor in + canceller.cancel(identifier) + } + }, + start: { completion in + let identifier = eventStore.fetchReminders( + matching: predicate + ) { reminders in + guard let reminders else { + completion( + .failure( + ReminderPrunerError.reminderFetchFailed + ) + ) + return + } + completion(.success(reminders)) + } + return ReminderFetchRequestIdentifier(identifier) + } + ) + } + + private func restoreReadbackPairs( + items: [ReminderPruneBackupItem], + identifiers: [String: String], + reminders: [EKReminder] + ) throws -> [ + (item: ReminderPruneBackupItem, reminder: EKReminder) + ] { + guard + Set(identifiers.keys) + == Set(items.map(\.originalItemIdentifier)) + else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + var remindersByIdentifier: [String: EKReminder] = [:] + for reminder in reminders { + let identifier = reminder.calendarItemIdentifier + guard remindersByIdentifier[identifier] == nil else { + throw ReminderPrunerError.restoreReadbackAmbiguous + } + remindersByIdentifier[identifier] = reminder + } + return try items.map { item in + guard + let restoredIdentifier = + identifiers[item.originalItemIdentifier], + let reminder = + remindersByIdentifier[restoredIdentifier] + else { + throw ReminderPrunerError.restoredReminderReadbackFailed + } + return (item, reminder) + } + } + + private func restoreMarker( + backupIdentifier: UUID, + attemptIdentifier: UUID, + originalItemIdentifier: String + ) -> String { + let itemHash = SHA256.hash( + data: Data(originalItemIdentifier.utf8) + ).map { String(format: "%02x", $0) }.joined() + return "TaskForge-Prune-Restore-ID: " + + "\(backupIdentifier.uuidString):" + + "\(attemptIdentifier.uuidString):\(itemHash)" + } + + private func containsRestoreMarker( + _ notes: String?, + marker: String + ) -> Bool { + notes?.components(separatedBy: "\n").contains(marker) == true + } + + private func addingRestoreMarker( + to notes: String?, + marker: String + ) -> String { + guard let notes, !notes.isEmpty else { + return marker + } + return notes + "\n" + marker + } + + private func observation( + for reminder: EKReminder, + targetCalendar: EKCalendar, + snapshot: TaskForgeSnapshot + ) -> ReminderPruneObservation? { + let identifier = reminder.calendarItemIdentifier + guard !identifier.isEmpty else { + return nil + } + let presence = sourcePresence( + notes: reminder.notes, + snapshot: snapshot + ) + return ReminderPruneObservation( + itemIdentifier: identifier, + calendarIdentifier: targetCalendar.calendarIdentifier, + isCompleted: reminder.isCompleted, + priority: reminder.priority, + title: reminder.title ?? "", + fingerprint: fingerprint( + reminder: reminder, + targetCalendarIdentifier: targetCalendar.calendarIdentifier, + presence: presence + ), + taskPresence: presence + ) + } + + private func sourcePresence( + notes: String?, + snapshot: TaskForgeSnapshot + ) -> TaskForgeReminderPresence { + if + let marker = TaskSyncMarker.extract(from: notes), + snapshot.tasks.contains(where: { + marker == TaskSyncMarker.make( + vaultPath: snapshot.vaultPath, + taskIdentifier: $0.identifier + ) + }) + { + return .currentSnapshot + } + let markerTaskIdentifier = TaskSyncMarker.extract(from: notes) + .flatMap(TaskSyncMarker.decode)?.taskIdentifier + let reference = TaskSourceReference.decode(from: notes) + ?? markerTaskIdentifier.flatMap { + configuration.managedIndex?.entries[$0]?.sourceReference + .map(TaskSourceReference.init(task:)) + } + guard let reference, let path = reference.task.filePath + else { + return .absent + } + + let vaultURL = URL( + fileURLWithPath: snapshot.vaultPath, + isDirectory: true + ).standardizedFileURL.resolvingSymlinksInPath() + let sourceURL = URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + guard isDescendant(sourceURL, of: vaultURL) else { + return .indeterminate + } + + do { + _ = try FileManager.default.attributesOfItem( + atPath: sourceURL.path + ) + } catch { + return isNoSuchFileError(error) ? .absent : .indeterminate + } + guard + let data = try? Data(contentsOf: sourceURL), + let contents = String(data: data, encoding: .utf8) + else { + return .indeterminate + } + switch TaskSourcePresenceInspector.inspect( + task: reference.task, + contents: contents + ) { + case .present: + return .sourceConfirmed + case .absent: + return .absent + case .indeterminate: + return .indeterminate + } + } + + private func fingerprint( + reminder: EKReminder, + targetCalendarIdentifier: String, + presence: TaskForgeReminderPresence + ) -> String { + let fields: [String] = [ + targetCalendarIdentifier, + reminder.calendarItemIdentifier, + reminder.isCompleted ? "1" : "0", + String(reminder.priority), + reminder.title ?? "", + reminder.notes ?? "", + presence.rawValue + ] + var data = Data() + for field in fields { + let bytes = Data(field.utf8) + var length = UInt64(bytes.count).bigEndian + withUnsafeBytes(of: &length) { + data.append(contentsOf: $0) + } + data.append(bytes) + } + return SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } + + private func counts( + scanned: Int, + observations: [ReminderPruneObservation], + targetCalendarIdentifier: String, + plan: ReminderPrunePlan + ) -> ReminderPruneCounts { + var counts = ReminderPruneCounts() + counts.scanned = scanned + counts.firstSeen = plan.firstSeenIdentifiers.count + counts.waiting = plan.waitingIdentifiers.count + counts.ready = plan.readyIdentifiers.count + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + }.count + counts.protected = max(scanned - candidates, 0) + return counts + } + + private func isDescendant(_ child: URL, of parent: URL) -> Bool { + let parentComponents = parent.pathComponents + let childComponents = child.pathComponents + return childComponents.count > parentComponents.count + && Array(childComponents.prefix(parentComponents.count)) + == parentComponents + } + + private func isNoSuchFileError(_ error: Error) -> Bool { + let error = error as NSError + return (error.domain == NSCocoaErrorDomain + && (error.code == NSFileNoSuchFileError + || error.code == NSFileReadNoSuchFileError)) + || (error.domain == NSPOSIXErrorDomain && error.code == ENOENT) + } + + private func loggedIdentifier(_ identifier: String) -> String { + guard var data = try? localStore.loadOrCreateHashSalt() else { + return "hash-unavailable" + } + data.append(Data(identifier.utf8)) + return SHA256.hash(data: data) + .prefix(6) + .map { String(format: "%02x", $0) } + .joined() + } + + private func summary( + prefix: String, + counts: ReminderPruneCounts + ) -> String { + "\(prefix):扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),就绪 \(counts.ready)," + + "删除 \(counts.deleted),恢复 \(counts.restored)," + + "保护 \(counts.protected),失败 \(counts.failed)" + } +} diff --git a/Sources/TaskForgeReminderSync/Command.swift b/Sources/TaskForgeReminderSync/Command.swift index f3cb598..183f487 100644 --- a/Sources/TaskForgeReminderSync/Command.swift +++ b/Sources/TaskForgeReminderSync/Command.swift @@ -11,13 +11,63 @@ private enum RunMode { case sync case reverseDryRun case reverseOnce + case pruneDryRun + case pruneOnce + case restoreLastPrune case watch case help + + var parseOnlyLabel: String { + switch self { + case .checkConfig: + return "check-config" + case .dryRun: + return "dry-run" + case .audit: + return "audit" + case .deduplicateDryRun: + return "deduplicate-dry-run" + case .deduplicate: + return "deduplicate" + case .sync: + return "sync" + case .reverseDryRun: + return "reverse-dry-run" + case .reverseOnce: + return "reverse-once" + case .pruneDryRun: + return "prune-dry-run" + case .pruneOnce: + return "prune-once" + case .restoreLastPrune: + return "restore-last-prune" + case .watch: + return "watch" + case .help: + return "help" + } + } +} + +private enum SyncSource: String { + case customList = "custom-list" + case scheduledDay = "scheduled-day" +} + +private enum CommandParsingError: Error, LocalizedError { + case conflictingRunModes + + var errorDescription: String? { + "不能同时指定多个运行模式。" + } } private struct Options { var mode: RunMode = .dryRun + var source: SyncSource = .customList var listName = "TaskForge 今日" + var taskForgeListID: String? + var listPrefix: String? var taskStorePath = TaskForgeTaskStore.defaultPath var requestedDay: TaskForgeDay? var taskIdentifier: String? @@ -25,30 +75,58 @@ private struct Options { static func parse(_ arguments: [String], calendar: Calendar) throws -> Options { var options = Options() + var selectedMode: RunMode? var index = 0 while index < arguments.count { let argument = arguments[index] switch argument { case "--check-config": - options.mode = .checkConfig + try select(.checkConfig, into: &selectedMode) case "--dry-run": - options.mode = .dryRun + try select(.dryRun, into: &selectedMode) case "--audit": - options.mode = .audit + try select(.audit, into: &selectedMode) case "--deduplicate-dry-run": - options.mode = .deduplicateDryRun + try select(.deduplicateDryRun, into: &selectedMode) case "--deduplicate": - options.mode = .deduplicate + try select(.deduplicate, into: &selectedMode) case "--sync": - options.mode = .sync + try select(.sync, into: &selectedMode) case "--reverse-dry-run": - options.mode = .reverseDryRun + try select(.reverseDryRun, into: &selectedMode) case "--reverse-once": - options.mode = .reverseOnce + try select(.reverseOnce, into: &selectedMode) + case "--prune-dry-run": + try select(.pruneDryRun, into: &selectedMode) + case "--prune-once": + try select(.pruneOnce, into: &selectedMode) + case "--restore-last-prune": + try select(.restoreLastPrune, into: &selectedMode) case "--watch": - options.mode = .watch + try select(.watch, into: &selectedMode) case "--help", "-h": - options.mode = .help + try select(.help, into: &selectedMode) + case "--source": + index += 1 + let value = try value(after: argument, at: index, in: arguments) + guard let source = SyncSource(rawValue: value) else { + throw SyncError.invalidSource(value) + } + options.source = source + case "--taskforge-list-id": + index += 1 + options.taskForgeListID = try value( + after: argument, + at: index, + in: arguments + ) + case "--list-prefix": + index += 1 + options.listPrefix = try value( + after: argument, + at: index, + in: arguments + ) case "--list-name": index += 1 options.listName = try value(after: argument, at: index, in: arguments) @@ -80,9 +158,28 @@ private struct Options { } index += 1 } + if options.source == .customList, + options.requestedDay != nil + || options.listName != "TaskForge 今日" + { + throw SyncError.legacyOptionRequiresScheduledDay( + "--date/--list-name" + ) + } + options.mode = selectedMode ?? .dryRun return options } + private static func select( + _ mode: RunMode, + into selectedMode: inout RunMode? + ) throws { + guard selectedMode == nil else { + throw CommandParsingError.conflictingRunModes + } + selectedMode = mode + } + private static func value( after argument: String, at index: Int, @@ -143,6 +240,17 @@ private struct TaskForgeReminderSyncCommand { calendar: calendar ) + #if DEBUG + if + ProcessInfo.processInfo.environment[ + "TASKFORGE_REMINDER_SYNC_TEST_PARSE_ONLY" + ] == "1" + { + print("parse-mode=\(options.mode.parseOnlyLabel)") + return + } + #endif + switch options.mode { case .help: printHelp() @@ -151,7 +259,8 @@ private struct TaskForgeReminderSyncCommand { case .dryRun: try printPreview(options: options, calendar: calendar) case .audit, .deduplicateDryRun, .deduplicate, - .sync, .reverseDryRun, .reverseOnce, .watch: + .sync, .reverseDryRun, .reverseOnce, .pruneDryRun, + .pruneOnce, .restoreLastPrune, .watch: try await run(options: options, calendar: calendar) } } catch { @@ -160,7 +269,33 @@ private struct TaskForgeReminderSyncCommand { } } + @MainActor private static func checkConfig(options: Options, calendar: Calendar) throws { + if options.source == .customList { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + let preview = try engine.preview() + print( + "TaskForge:" + + (FileManager.default.fileExists( + atPath: "/Applications/TaskForge.app" + ) ? "已安装" : "未在 /Applications 找到") + ) + print("任务库:\(options.taskStorePath)") + print("自定义列表:\(preview.listName)") + print("列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + print( + "配置 ID:" + + (preview.taskForgeListIDWasConfigured + ? "已配置到私有状态" + : "仅使用本次参数") + ) + print("配置检查不会请求提醒事项权限,也不会写入任何内容。") + return + } let snapshot = try loadSnapshotWithRetry(at: options.taskStorePath) let requestedDay = options.requestedDay ?? TaskForgeDay(containing: Date(), calendar: calendar) @@ -182,6 +317,10 @@ private struct TaskForgeReminderSyncCommand { @MainActor private static func run(options: Options, calendar: Calendar) async throws { + if options.source == .customList { + try await runCustomList(options: options, calendar: calendar) + return + } let configuration = SyncConfiguration( listName: options.listName, taskStorePath: options.taskStorePath, @@ -228,11 +367,14 @@ private struct TaskForgeReminderSyncCommand { requireCandidate: options.taskIdentifier != nil ) let forward = try await engine.forward() + let prune = try await engine.prune(dryRun: false) print( "双向同步完成:反向写入 \(reverse.written)," + "正向新建 \(forward.created),更新 \(forward.updated)," + "重新关联 \(forward.relinked),无需变化 \(forward.unchanged)," - + "冲突 \(forward.conflicts)。" + + "冲突 \(forward.conflicts);" + + "清理首次 \(prune.firstSeen),等待 \(prune.waiting)," + + "删除 \(prune.deleted),失败 \(prune.failed)。" ) case .reverseDryRun: let counts = try await engine.reverse( @@ -248,6 +390,25 @@ private struct TaskForgeReminderSyncCommand { requireCandidate: true ) print("反向同步完成:写入 \(counts.written),失败 \(counts.failed)。") + case .pruneDryRun: + let counts = try await engine.prune(dryRun: true) + print( + "清理预演:扫描 \(counts.scanned),首次候选 \(counts.firstSeen)," + + "已满足二次确认 \(counts.ready)。" + ) + print("预演模式:没有写候选账本,没有删除提醒。") + case .pruneOnce: + let counts = try await engine.prune(dryRun: false) + print( + "清理推进:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),删除 \(counts.deleted)," + + "失败 \(counts.failed)。" + ) + case .restoreLastPrune: + let counts = try await engine.restoreLastPrune() + print( + "清理恢复:恢复 \(counts.restored),失败 \(counts.failed)。" + ) case .watch: try await engine.watch() case .checkConfig, .dryRun, .help: @@ -255,7 +416,86 @@ private struct TaskForgeReminderSyncCommand { } } + @MainActor + private static func runCustomList( + options: Options, + calendar: Calendar + ) async throws { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + switch options.mode { + case .audit: + let preview = try engine.preview() + print("自定义列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + case .sync: + let counts = try await engine.sync() + print( + "Kanban 双向同步:新建 \(counts.created),更新 \(counts.updated)," + + "移动 \(counts.moved),完成 \(counts.completed)," + + "反向候选 \(counts.reverseCandidates)," + + "反向写入 \(counts.reverseWritten)," + + "拒绝 \(counts.reverseSkipped),冲突 \(counts.conflicts)。" + ) + case .reverseDryRun: + let counts = try await engine.reverse(dryRun: true) + print( + "Kanban 反向预演:候选 \(counts.reverseCandidates)," + + "没有写入源文件或私有状态。" + ) + case .reverseOnce: + let counts = try await engine.reverse(dryRun: false) + print( + "Kanban 反向同步:候选 \(counts.reverseCandidates)," + + "写入 \(counts.reverseWritten),拒绝 \(counts.reverseSkipped)。" + ) + case .pruneDryRun, .pruneOnce: + let dryRun = options.mode == .pruneDryRun + let counts = try await engine.prune(dryRun: dryRun) + print( + dryRun + ? "清理预演:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),可删除 \(counts.ready)。" + : "清理推进:扫描 \(counts.scanned),首次 \(counts.firstSeen)," + + "等待 \(counts.waiting),删除 \(counts.deleted)," + + "失败 \(counts.failed)。" + ) + case .watch: + try await engine.watch() + case .deduplicateDryRun, .deduplicate: + let counts = try await engine.deduplicate( + dryRun: options.mode == .deduplicateDryRun + ) + print( + "Kanban 去重\(options.mode == .deduplicateDryRun ? "预演" : "完成"):" + + "重复组 \(counts.duplicateGroups),保留 \(counts.preserved)," + + "归档并完成 \(counts.archived)。" + ) + case .restoreLastPrune: + throw SyncError.legacyOptionRequiresScheduledDay("--restore-last-prune") + case .checkConfig, .dryRun, .help: + break + } + } + + @MainActor private static func printPreview(options: Options, calendar: Calendar) throws { + if options.source == .customList { + let engine = KanbanSyncEngine( + configuration: kanbanConfiguration(options: options), + calendar: calendar + ) + let preview = try engine.preview() + print("自定义列表:\(preview.listName)") + print("列表成员:\(preview.totalMembers)") + printStatusCounts(preview.statusCounts) + print("计划操作:按状态同步到对应彩色列表;done/cancelled 只完成提醒,不删除 TaskForge 源任务。") + print("计划操作:Apple 事件防抖、1 秒轮询和 60 秒全量校准;不读取提醒事项。") + print("\n匿名只读预演:没有读取提醒事项,也没有写入任何内容。") + return + } let snapshot = try loadSnapshotWithRetry(at: options.taskStorePath) let requestedDay = options.requestedDay ?? TaskForgeDay(containing: Date(), calendar: calendar) @@ -313,12 +553,18 @@ private struct TaskForgeReminderSyncCommand { TaskForgeReminderSync --sync [--task-id ID] TaskForgeReminderSync --reverse-dry-run [--task-id ID] TaskForgeReminderSync --reverse-once --task-id ID + TaskForgeReminderSync --prune-dry-run + TaskForgeReminderSync --prune-once + TaskForgeReminderSync --restore-last-prune TaskForgeReminderSync --watch 选项: - --list-name NAME 目标提醒事项列表(默认:TaskForge 今日) + --source MODE custom-list(默认)或 scheduled-day 兼容模式 + --taskforge-list-id ID 固定绑定的 TaskForge Kanban 列表 ID(写入私有配置) + --list-prefix NAME Apple 状态列表前缀(默认:TaskForge) + --list-name NAME 兼容模式目标提醒事项列表(默认:TaskForge 今日) --task-store PATH TaskForge tasks.v6.bin 路径 - --date DATE 指定要同步的本地日期 + --date DATE scheduled-day 兼容模式日期 --task-id ID 只处理一个 TaskForge 任务 --backup-root PATH 反向写入前的备份目录 --dry-run 只列出今日任务,不请求权限(默认) @@ -327,6 +573,9 @@ private struct TaskForgeReminderSyncCommand { --deduplicate 将冗余活跃提醒移到可恢复的归档列表 --reverse-dry-run 预览 Apple 完成状态的反向写入 --reverse-once 执行一次反向写入并等待 TaskForge 回读 + --prune-dry-run 只读预演提醒清理,不写候选账本 + --prune-once 推进一轮双扫描确认和清理 + --restore-last-prune 恢复最近一批已删除提醒 --sync 反向写入后再正向同步一次 --watch 常驻近实时双向同步 """ @@ -348,4 +597,28 @@ private struct TaskForgeReminderSyncCommand { print("缺少可审计源身份:\(report.missingSourceIdentityCount)") print("去重状态:\(report.isDuplicateFree ? "通过" : "发现冲突")") } + + private static func kanbanConfiguration( + options: Options + ) -> KanbanSyncConfiguration { + KanbanSyncConfiguration( + taskStorePath: options.taskStorePath, + taskForgeListID: options.taskForgeListID, + listPrefix: options.listPrefix, + taskIdentifier: options.taskIdentifier, + backupRoot: options.backupRoot + ) + } + + private static func printStatusCounts(_ counts: [String: Int]) { + for status in TaskForgeKanbanStatus.allCases { + if let count = counts[status.rawValue] { + print("- \(status.rawValue):\(count)") + } + } + let known = Set(TaskForgeKanbanStatus.allCases.map(\.rawValue)) + for key in counts.keys.sorted() where !known.contains(key) { + print("- \(key):\(counts[key] ?? 0)") + } + } } diff --git a/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift new file mode 100644 index 0000000..45fc62b --- /dev/null +++ b/Sources/TaskForgeReminderSync/KanbanSyncEngine.swift @@ -0,0 +1,1279 @@ +import CoreGraphics +import CryptoKit +import Darwin +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +private struct KanbanFetchHandle: @unchecked Sendable { + let rawValue: Any +} + +private final class KanbanFetchResolution: @unchecked Sendable { + init( + _ continuation: CheckedContinuation<[EKReminder], Error>, + cancel: @escaping @Sendable (KanbanFetchHandle) -> Void + ) { + self.continuation = continuation + self.cancel = cancel + } + + func resolve(_ result: Result<[EKReminder], Error>) { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + func register(_ handle: KanbanFetchHandle) { + lock.lock() + self.handle = handle + let shouldCancel = timedOut && !cancelIssued + if shouldCancel { cancelIssued = true } + lock.unlock() + if shouldCancel { cancel(handle) } + } + + func timeout() { + lock.lock() + guard !settled, let continuation else { + lock.unlock() + return + } + settled = true + timedOut = true + self.continuation = nil + let handleToCancel: KanbanFetchHandle? + if let handle, !cancelIssued { + cancelIssued = true + handleToCancel = handle + } else { + handleToCancel = nil + } + lock.unlock() + if let handleToCancel { cancel(handleToCancel) } + continuation.resume(throwing: SyncError.reminderFetchFailed) + } + + private let lock = NSLock() + private var continuation: CheckedContinuation<[EKReminder], Error>? + private var handle: KanbanFetchHandle? + private var settled = false + private var timedOut = false + private var cancelIssued = false + private let cancel: @Sendable (KanbanFetchHandle) -> Void +} + +struct KanbanSyncConfiguration { + var taskStorePath: String + var taskForgeListID: String? + var listPrefix: String? + var taskIdentifier: String? + var backupRoot: String + var preferencesPath: String + var privateRoot: URL + + init( + taskStorePath: String, + taskForgeListID: String?, + listPrefix: String? = nil, + taskIdentifier: String?, + backupRoot: String, + preferencesPath: String = TaskForgeListConfigurationStore.defaultPreferencesPath, + privateRoot: URL = TaskForgeSyncPrivateStore.defaultRoot + ) { + self.taskStorePath = taskStorePath + self.taskForgeListID = taskForgeListID + self.listPrefix = listPrefix + self.taskIdentifier = taskIdentifier + self.backupRoot = backupRoot + self.preferencesPath = preferencesPath + self.privateRoot = privateRoot + } +} + +struct KanbanPreview { + var listName: String + var totalMembers: Int + var statusCounts: [String: Int] + var taskForgeListIDWasConfigured: Bool +} + +struct KanbanSyncCounts { + var created = 0 + var updated = 0 + var moved = 0 + var completed = 0 + var reverseCandidates = 0 + var reverseWritten = 0 + var reverseSkipped = 0 + var conflicts = 0 + var failed = 0 +} + +@MainActor +final class KanbanSyncEngine { + private let configuration: KanbanSyncConfiguration + private let store: EKEventStore + private let privateStore: TaskForgeSyncPrivateStore + private var calendar: Calendar + private var reminderObserver: NSObjectProtocol? + private var debounceTask: Task? + private var isReconciling = false + private var needsAnotherPass = false + private var resolvedListPrefix = "TaskForge" + + init(configuration: KanbanSyncConfiguration, calendar: Calendar) { + self.configuration = configuration + self.calendar = calendar + self.store = EKEventStore() + self.privateStore = TaskForgeSyncPrivateStore( + rootURL: configuration.privateRoot + ) + } + + deinit { + if let reminderObserver { + NotificationCenter.default.removeObserver(reminderObserver) + } + debounceTask?.cancel() + } + + func requestReminderAccess() async throws { + let status = EKEventStore.authorizationStatus(for: .reminder) + if #available(macOS 14.0, *), status == .fullAccess { + return + } + if #unavailable(macOS 14.0), status == .authorized { + return + } + let granted: Bool + if #available(macOS 14.0, *) { + granted = try await withCheckedThrowingContinuation { continuation in + store.requestFullAccessToReminders { granted, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + } else { + granted = try await withCheckedThrowingContinuation { continuation in + store.requestAccess(to: .reminder) { granted, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + } + guard granted else { + throw SyncError.accessDenied + } + } + + func preview() throws -> KanbanPreview { + let snapshot = try loadSnapshotWithRetry() + let (list, _) = try loadList(readOnly: true) + let privateConfiguration = try privateStore.loadConfigurationReadOnly() + let configured = privateConfiguration?.taskForgeListID != nil + let tasks = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + var counts: [String: Int] = [:] + for task in tasks { + let status = TaskForgeKanbanStatus.canonical(task.status) + counts[status, default: 0] += 1 + } + return KanbanPreview( + listName: list.name, + totalMembers: tasks.count, + statusCounts: counts, + taskForgeListIDWasConfigured: configured + ) + } + + func sync() async throws -> KanbanSyncCounts { + try await requestReminderAccess() + return try await reconcileOnce(reason: "手动同步") + } + + func reverse(dryRun: Bool) async throws -> KanbanSyncCounts { + try await requestReminderAccess() + let snapshot = try loadSnapshotWithRetry() + let (list, listID) = try loadList(readOnly: dryRun) + let members = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + let symbols = try learnedSymbols( + snapshot: snapshot, + listID: listID, + persist: !dryRun + ) + return try await reverse( + snapshot: snapshot, + members: members, + listID: listID, + symbols: symbols, + dryRun: dryRun + ) + } + + func deduplicate(dryRun: Bool) async throws -> DeduplicationCounts { + try await requestReminderAccess() + let snapshot = try loadSnapshotWithRetry() + let (_, listID) = try loadList(readOnly: dryRun) + let index = try privateStore.loadIndexReadOnly() + let reminders = try await fetchReminders(in: nil) + let records = reminders.enumerated().compactMap { + key, reminder -> TaskReminderDeduplicationRecord? in + guard isManaged(reminder, snapshot: snapshot, listID: listID), + !reminder.isCompleted, + let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath + else { return nil } + let sourceReference = index?.entries[decoded.taskIdentifier]?.sourceReference + ?? TaskSourceReference.decode(from: reminder.notes)?.task + ?? snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) + return TaskReminderDeduplicationRecord( + key: key, + taskIdentifier: decoded.taskIdentifier, + sourceIdentity: sourceReference.flatMap(TaskSourceIdentity.init), + creationTimestamp: reminder.creationDate?.timeIntervalSince1970 + ?? .greatestFiniteMagnitude + ) + } + let plan = TaskReminderDeduplicationPolicy.plan( + records: records, + currentTaskIdentifiers: Set(snapshot.tasks.map(\.identifier)) + ) + let counts = DeduplicationCounts( + duplicateGroups: plan.duplicateGroups, + preserved: plan.preservedKeys.count, + archived: plan.archiveKeys.count + ) + guard !dryRun, !plan.archiveKeys.isEmpty else { return counts } + guard let source = store.defaultCalendarForNewReminders()?.source + ?? store.calendars(for: .reminder).first?.source + else { throw SyncError.noReminderSource } + let archiveTitle = "TaskForge 今日 · 去重归档" + let matching = store.calendars(for: .reminder).filter { + $0.title == archiveTitle + } + guard matching.count <= 1 else { throw SyncError.noReminderSource } + let archive: EKCalendar + if let existing = matching.first { + archive = existing + } else { + archive = EKCalendar(for: .reminder, eventStore: store) + archive.title = archiveTitle + archive.source = source + try store.saveCalendar(archive, commit: true) + } + for key in plan.archiveKeys { + let reminder = reminders[key] + reminder.isCompleted = true + reminder.calendar = archive + reminder.notes = (reminder.notes ?? "") + + "\nTaskForge-Dedup-Archived: 1" + try store.save(reminder, commit: false) + } + try store.commit() + return counts + } + + func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + try await requestReminderAccess() + let (_, listID) = try loadList(readOnly: dryRun) + let snapshot = try loadSnapshotWithRetry() + return try await prune( + snapshot: snapshot, + listID: listID, + dryRun: dryRun + ) + } + + private func prune( + snapshot: TaskForgeSnapshot, + listID: String, + dryRun: Bool + ) async throws -> ReminderPruneCounts { + let baseCalendar = try stateCalendar( + status: .todo, + create: !dryRun + ) + guard let baseCalendar else { + return ReminderPruneCounts() + } + let pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: baseCalendar.title, + localRoot: privateStore.rootURL, + confirmationInterval: 60, + restoreGraceInterval: 86_400, + managedIndex: try privateStore.loadIndexReadOnly() + ), + log: { _ in }, + logError: { _ in } + ) + _ = listID + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) + } + + func watch() async throws { + try await requestReminderAccess() + _ = try loadList(readOnly: true) + var lastTaskStoreModification = modificationDate( + at: configuration.taskStorePath + ) + var lastPreferencesModification = modificationDate( + at: configuration.preferencesPath + ) + var lastFullReconciliation = Date.distantPast + var lastScheduleKey = "" + + reminderObserver = NotificationCenter.default.addObserver( + forName: .EKEventStoreChanged, + object: store, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.debounceTask?.cancel() + self.debounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 750_000_000) + guard !Task.isCancelled else { return } + await self?.reconcile(reason: "Apple 提醒事项变化") + } + } + } + + await reconcile(reason: "启动") + while true { + try await Task.sleep(nanoseconds: 1_000_000_000) + let currentTaskStoreModification = modificationDate( + at: configuration.taskStorePath + ) + let currentPreferencesModification = modificationDate( + at: configuration.preferencesPath + ) + if currentTaskStoreModification != lastTaskStoreModification + || currentPreferencesModification != lastPreferencesModification + { + lastTaskStoreModification = currentTaskStoreModification + lastPreferencesModification = currentPreferencesModification + await reconcile(reason: "TaskForge 任务库或列表规则变化") + } + + let now = Date() + if now.timeIntervalSince(lastFullReconciliation) >= 60 { + lastFullReconciliation = now + await reconcile(reason: "60 秒漏事件兜底") + } + let components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute], + from: now + ) + if let hour = components.hour, + components.minute == 0, + [7, 11, 15].contains(hour) + { + let key = String( + format: "%04d-%02d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0, + hour + ) + if key != lastScheduleKey { + lastScheduleKey = key + await reconcile(reason: "\(hour):00 全量校准") + } + } + } + } + + private func reconcile(reason: String) async { + if isReconciling { + needsAnotherPass = true + return + } + isReconciling = true + defer { isReconciling = false } + repeat { + needsAnotherPass = false + do { + _ = try await reconcileOnce(reason: reason) + } catch { + fputs("TaskForge 同步失败:\(error.localizedDescription)\n", stderr) + } + } while needsAnotherPass + } + + private func reconcileOnce(reason: String) async throws -> KanbanSyncCounts { + let snapshot = try loadSnapshotWithRetry() + let (list, listID) = try loadList(readOnly: false) + let members = try TaskForgeFilterEvaluator.select( + tasks: snapshot.tasks, + list: list, + calendar: calendar + ) + let symbols = try learnedSymbols( + snapshot: snapshot, + listID: listID, + persist: true + ) + var counts = try await reverse( + snapshot: snapshot, + members: members, + listID: listID, + symbols: symbols, + dryRun: false + ) + let refreshedSnapshot = (try? loadSnapshotWithRetry()) ?? snapshot + let refreshedMembers = (try? TaskForgeFilterEvaluator.select( + tasks: refreshedSnapshot.tasks, + list: list, + calendar: calendar + )) ?? members + let forwardCounts = try await forward( + snapshot: refreshedSnapshot, + members: refreshedMembers, + listID: listID, + dryRun: false + ) + counts.created += forwardCounts.created + counts.updated += forwardCounts.updated + counts.moved += forwardCounts.moved + counts.completed += forwardCounts.completed + counts.conflicts += forwardCounts.conflicts + counts.failed += forwardCounts.failed + _ = try await prune( + snapshot: refreshedSnapshot, + listID: listID, + dryRun: false + ) + _ = reason + return counts + } + + private func loadList(readOnly: Bool) throws -> (TaskForgeCustomList, String) { + let existing = try privateStore.loadConfigurationReadOnly() + if let configured = existing?.taskForgeListID, + let requested = configuration.taskForgeListID, + configured != requested + { + throw TaskForgeFilterConfigurationError.invalidPrivateState + } + let listID = configuration.taskForgeListID ?? existing?.taskForgeListID + guard let listID, + !listID.isEmpty, + listID == listID.trimmingCharacters(in: .whitespacesAndNewlines), + !listID.contains("\n"), + !listID.contains("\r") + else { + throw TaskForgeFilterConfigurationError.missingList("未配置") + } + resolvedListPrefix = configuration.listPrefix + ?? existing?.listPrefix + ?? "TaskForge" + guard !resolvedListPrefix.isEmpty, + !resolvedListPrefix.contains("\n"), + !resolvedListPrefix.contains("\r"), + resolvedListPrefix.count <= 80 + else { + throw TaskForgeFilterConfigurationError.invalidCondition( + "列表前缀" + ) + } + let list = try TaskForgeListConfigurationStore.load( + listID: listID, + preferencesPath: configuration.preferencesPath + ) + guard list.kanbanMode else { + throw TaskForgeFilterConfigurationError.invalidCondition("目标列表不是 Kanban") + } + if !readOnly { + var state = existing ?? TaskForgeSyncPrivateConfiguration() + state.taskForgeListID = listID + state.listPrefix = resolvedListPrefix + try privateStore.saveConfiguration(state) + } + return (list, listID) + } + + private func learnedSymbols( + snapshot: TaskForgeSnapshot, + listID: String, + persist: Bool + ) throws -> [String: String] { + let existing = try privateStore.loadConfigurationReadOnly() + let learned = try TaskForgeStatusSymbolLearner.learn( + tasks: snapshot.tasks, + existing: existing?.learnedSymbols ?? [:] + ) + guard persist else { return learned } + var state = existing ?? TaskForgeSyncPrivateConfiguration() + state.taskForgeListID = listID + state.listPrefix = resolvedListPrefix + state.learnedSymbols = learned + try privateStore.saveConfiguration(state) + return learned + } + + private func forward( + snapshot: TaskForgeSnapshot, + members: [TaskForgeTask], + listID: String, + dryRun: Bool + ) async throws -> KanbanSyncCounts { + let calendars = try stateCalendars( + statusKeys: Set(members.map { + TaskForgeKanbanStatus.canonical($0.status) + }), + create: !dryRun + ) + let reminders = try await fetchReminders(in: nil) + let managed = reminders.filter { + isManaged($0, snapshot: snapshot, listID: listID) + } + var counts = KanbanSyncCounts() + var claimed = Set() + var index = try privateStore.loadIndexReadOnly() + ?? TaskForgeSyncIndex(listID: listID) + var seenTaskIDs = Set() + for task in members { + if let taskIdentifier = configuration.taskIdentifier, + taskIdentifier != task.identifier + { + continue + } + guard seenTaskIDs.insert(task.identifier).inserted else { + counts.conflicts += 1 + continue + } + let statusKey = TaskForgeKanbanStatus.canonical(task.status) + let candidates = managed.filter { reminder in + TaskSyncMarker.decode( + TaskSyncMarker.extract(from: reminder.notes) ?? "" + )?.taskIdentifier == task.identifier + } + guard candidates.count <= 1 else { + counts.conflicts += 1 + continue + } + let reminder: EKReminder + let isNew: Bool + if let current = candidates.first { + reminder = current + isNew = false + claimed.insert(current.calendarItemIdentifier) + } else if let entry = index.entries[task.identifier], + let current = managed.first(where: { + $0.calendarItemIdentifier == entry.reminderIdentifier + }), + !claimed.contains(current.calendarItemIdentifier) + { + reminder = current + isNew = false + claimed.insert(current.calendarItemIdentifier) + } else { + reminder = EKReminder(eventStore: store) + isNew = true + } + if (statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue) + && isNew + { + continue + } + let desiredCalendar: EKCalendar? + if statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue + { + desiredCalendar = nil + } else { + desiredCalendar = calendars[statusKey] + } + if let desiredCalendar, reminder.calendar?.calendarIdentifier + != desiredCalendar.calendarIdentifier + { + if !dryRun { + reminder.calendar = desiredCalendar + } + counts.moved += 1 + } + if isNew, let desiredCalendar, !dryRun { + reminder.calendar = desiredCalendar + } + let changed = apply( + task: task, + vaultPath: snapshot.vaultPath, + to: reminder + ) + if !dryRun && (isNew || changed) { + try store.save(reminder, commit: false) + if isNew { counts.created += 1 } else { counts.updated += 1 } + if statusKey == TaskForgeKanbanStatus.done.rawValue + || statusKey == TaskForgeKanbanStatus.cancelled.rawValue + { + counts.completed += 1 + } + } + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: desiredCalendar?.calendarIdentifier + ?? reminder.calendar?.calendarIdentifier ?? "completed", + status: statusKey, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + let baseCalendar = try stateCalendar( + status: .todo, + create: !dryRun + ) + for reminder in managed where !claimed.contains( + reminder.calendarItemIdentifier + ) { + guard let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath, + configuration.taskIdentifier == nil + || configuration.taskIdentifier == decoded.taskIdentifier, + let task = snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) + else { continue } + let status = TaskForgeKanbanStatus.canonical(task.status) + var unclaimedChanged = false + if status == TaskForgeKanbanStatus.done.rawValue + || status == TaskForgeKanbanStatus.cancelled.rawValue + { + unclaimedChanged = apply( + task: task, + vaultPath: snapshot.vaultPath, + to: reminder + ) + if !reminder.isCompleted { + reminder.isCompleted = true + unclaimedChanged = true + } + } + if let baseCalendar, + reminder.calendar?.calendarIdentifier != baseCalendar.calendarIdentifier + { + if !dryRun { + reminder.calendar = baseCalendar + try store.save(reminder, commit: false) + unclaimedChanged = false + } + counts.moved += 1 + } + if !dryRun && unclaimedChanged { + try store.save(reminder, commit: false) + } + if !dryRun { + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: status, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + } + if !dryRun { + try store.commit() + index.listID = listID + try privateStore.saveIndex(index) + } + return counts + } + + private func reverse( + snapshot: TaskForgeSnapshot, + members: [TaskForgeTask], + listID: String, + symbols: [String: String], + dryRun: Bool + ) async throws -> KanbanSyncCounts { + let reminders = try await fetchReminders(in: nil) + let memberIDs = Set(members.map(\.identifier)) + var counts = KanbanSyncCounts() + var index = try privateStore.loadIndexReadOnly() + ?? TaskForgeSyncIndex(listID: listID) + let stateCalendarNames = try stateCalendarNameMap() + for reminder in reminders where isManaged( + reminder, + snapshot: snapshot, + listID: listID + ) { + guard let decoded = TaskSyncMarker.decode( + TaskSyncMarker.extract(from: reminder.notes) ?? "" + ) else { continue } + guard let task = snapshot.tasks.first(where: { + $0.identifier == decoded.taskIdentifier + }) else { continue } + if configuration.taskIdentifier != nil, + configuration.taskIdentifier != task.identifier + { + continue + } + let currentStatus = TaskForgeKanbanStatus.canonical(task.status) + let calendarStatus: String? + if let title = reminder.calendar?.title { + calendarStatus = stateCalendarNames[title] + } else { + calendarStatus = nil + } + var targetStatus = calendarStatus ?? currentStatus + let lastStatus = index.entries[task.identifier]?.status + let taskStatusChanged = lastStatus.map { + $0 != currentStatus + } ?? false + let appleStatusChanged: Bool + if let calendarStatus { + appleStatusChanged = lastStatus.map { + calendarStatus != $0 + } ?? (calendarStatus != currentStatus) + } else { + appleStatusChanged = false + } + var sourceHash: String? + if !reminder.isCompleted, + memberIDs.contains(task.identifier), + calendarStatus == nil + { + if !dryRun { + try moveBack(reminder: reminder, status: currentStatus) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + continue + } + if reminder.isCompleted && !task.isCompleted { + counts.reverseCandidates += 1 + if dryRun { + continue + } + do { + sourceHash = try writeSourceStatus( + task: task, + snapshot: snapshot, + targetStatus: TaskForgeKanbanStatus.done.rawValue, + symbols: symbols + ) + try await verifyTaskForgeStatus( + task: task, + targetStatus: TaskForgeKanbanStatus.done.rawValue + ) + counts.reverseWritten += 1 + targetStatus = TaskForgeKanbanStatus.done.rawValue + } catch { + counts.reverseSkipped += 1 + logError( + "反向写回拒绝:\(reverseFailureCategory(error));" + + "提醒已恢复到当前 TaskForge 状态。" + ) + try moveBack( + reminder: reminder, + status: currentStatus + ) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + continue + } + } else if !reminder.isCompleted, + !task.isCompleted, + appleStatusChanged, + !taskStatusChanged, + memberIDs.contains(task.identifier) + { + counts.reverseCandidates += 1 + if dryRun { + continue + } + do { + sourceHash = try writeSourceStatus( + task: task, + snapshot: snapshot, + targetStatus: targetStatus, + symbols: symbols + ) + try await verifyTaskForgeStatus( + task: task, + targetStatus: targetStatus + ) + counts.reverseWritten += 1 + } catch { + counts.reverseSkipped += 1 + logError( + "反向写回拒绝:\(reverseFailureCategory(error));" + + "提醒已恢复到当前 TaskForge 状态。" + ) + try moveBack( + reminder: reminder, + status: currentStatus + ) + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier + ?? "unknown", + status: currentStatus, + sourceHash: index.entries[task.identifier]?.sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + continue + } + } + if !dryRun { + index.entries[task.identifier] = TaskForgeSyncIndexEntry( + reminderIdentifier: reminder.calendarItemIdentifier, + calendarIdentifier: reminder.calendar?.calendarIdentifier ?? "completed", + status: targetStatus, + sourceHash: sourceHash, + sourceReference: task, + lastSyncAt: Date() + ) + } + } + if !dryRun { + index.listID = listID + try privateStore.saveIndex(index) + } + return counts + } + + private func apply( + task: TaskForgeTask, + vaultPath: String, + to reminder: EKReminder + ) -> Bool { + let status = TaskForgeKanbanStatus.canonical(task.status) + let title = task.title.trimmingCharacters(in: .whitespacesAndNewlines) + let desiredTitle = status == TaskForgeKanbanStatus.cancelled.rawValue + ? "已取消 · \(title.isEmpty ? "(无标题 TaskForge 任务)" : title)" + : (title.isEmpty ? "(无标题 TaskForge 任务)" : title) + let desiredNotes = [ + TaskSyncMarker.make( + vaultPath: vaultPath, + taskIdentifier: task.identifier + ), + "来源:TaskForge" + ].joined(separator: "\n") + let desiredDueDate = task.scheduled.map { + TaskReminderTiming.dueDateComponents(for: $0, calendar: calendar) + } + var changed = false + if reminder.title != desiredTitle { + reminder.title = desiredTitle + changed = true + } + if !ReminderDueDatePolicy.isEquivalent( + reminder.dueDateComponents, + desiredDueDate + ) { + reminder.dueDateComponents = desiredDueDate + changed = true + } + if reminder.priority != applePriority(task.priority) { + reminder.priority = applePriority(task.priority) + changed = true + } + if reminder.notes != desiredNotes { + reminder.notes = desiredNotes + changed = true + } + let shouldBeCompleted = task.isCompleted || reminder.isCompleted + if reminder.isCompleted != shouldBeCompleted { + reminder.isCompleted = shouldBeCompleted + changed = true + } + if reminder.url != nil { + reminder.url = nil + changed = true + } + return changed + } + + private func writeSourceStatus( + task: TaskForgeTask, + snapshot: TaskForgeSnapshot, + targetStatus: String, + symbols: [String: String] + ) throws -> String { + guard let sourcePath = task.filePath else { + throw TaskForgeStatusSymbolError.sourceLineMismatch + } + let sourceURL = URL(fileURLWithPath: sourcePath) + .standardizedFileURL + .resolvingSymlinksInPath() + let vaultURL = URL(fileURLWithPath: snapshot.vaultPath) + .standardizedFileURL + .resolvingSymlinksInPath() + let prefix = vaultURL.path.hasSuffix("/") + ? vaultURL.path + : vaultURL.path + "/" + guard sourceURL.path.hasPrefix(prefix) else { + throw SyncError.sourceOutsideVault(sourceURL.path) + } + let originalData = try Data(contentsOf: sourceURL) + guard let contents = String(data: originalData, encoding: .utf8) else { + throw SyncError.sourceEncodingInvalid(sourceURL.path) + } + let edit = try TaskForgeStatusSourceEditor.update( + task: task, + contents: contents, + targetStatus: targetStatus, + symbols: symbols + ) + guard let updatedData = edit.updatedContents.data(using: .utf8) else { + throw SyncError.sourceEncodingInvalid(sourceURL.path) + } + guard updatedData != originalData else { + return sha256(originalData) + } + let safeIdentifier = task.identifier.map { character in + character.isLetter || character.isNumber + || character == "-" || character == "_" + ? character + : "_" + } + _ = try TaskSourceBackupStore( + backupsRootURL: URL( + fileURLWithPath: configuration.backupRoot, + isDirectory: true + ) + ).save( + originalData, + fileName: "\(String(safeIdentifier))-\(sourceURL.lastPathComponent).bak" + ) + try updatedData.write(to: sourceURL, options: [.atomic]) + guard try Data(contentsOf: sourceURL) == updatedData else { + throw SyncError.writeVerificationFailed(sourceURL.path) + } + return sha256(updatedData) + } + + private func verifyTaskForgeStatus( + task: TaskForgeTask, + targetStatus: String, + timeout: TimeInterval = 15 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let refreshed = try? loadSnapshotWithRetry(), + let current = refreshed.tasks.first(where: { + $0.identifier == task.identifier + }) + { + if TaskForgeKanbanStatus.canonical(current.status) + == TaskForgeKanbanStatus.canonical(targetStatus) + { + return + } + } else if targetStatus == TaskForgeKanbanStatus.done.rawValue, + let sourcePath = task.filePath, + let data = try? Data(contentsOf: URL(fileURLWithPath: sourcePath)), + let contents = String(data: data, encoding: .utf8), + TaskCompletionSourceInspector.isCompleted( + task: task, + contents: contents + ) + { + return + } + try await Task.sleep(nanoseconds: 250_000_000) + } + throw SyncError.taskForgeVerificationTimedOut("受管任务") + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } + + private func moveBack(reminder: EKReminder, status: String) throws { + guard let calendar = try stateCalendar(rawStatus: status, create: true) + else { return } + reminder.isCompleted = false + reminder.calendar = calendar + try store.save(reminder, commit: true) + } + + private func stateCalendars( + statuses: [TaskForgeKanbanStatus], + create: Bool + ) throws -> [String: EKCalendar] { + var result: [String: EKCalendar] = [:] + for status in Set(statuses) where !status.isTerminal { + if let calendar = try stateCalendar(status: status, create: create) { + result[status.rawValue] = calendar + } + } + return result + } + + private func stateCalendars( + statusKeys: Set, + create: Bool + ) throws -> [String: EKCalendar] { + var result: [String: EKCalendar] = [:] + for statusKey in statusKeys + where statusKey != TaskForgeKanbanStatus.done.rawValue + && statusKey != TaskForgeKanbanStatus.cancelled.rawValue + { + if let calendar = try stateCalendar( + rawStatus: statusKey, + create: create + ) { + result[statusKey] = calendar + } + } + return result + } + + private func stateCalendarNameMap() throws -> [String: String] { + var result: [String: String] = [:] + for status in TaskForgeKanbanStatus.allCases where !status.isTerminal { + result[stateTitle(status)] = status.rawValue + } + return result + } + + private func stateCalendar( + status: TaskForgeKanbanStatus?, + create: Bool + ) throws -> EKCalendar? { + guard let status, !status.isTerminal else { return nil } + return try stateCalendar(rawStatus: status.rawValue, create: create) + } + + private func stateCalendar( + rawStatus: String, + create: Bool + ) throws -> EKCalendar? { + let canonicalStatus = TaskForgeKanbanStatus.canonical(rawStatus) + guard canonicalStatus != TaskForgeKanbanStatus.done.rawValue, + canonicalStatus != TaskForgeKanbanStatus.cancelled.rawValue + else { return nil } + let status = TaskForgeKanbanStatus(rawValue: canonicalStatus) + let title = stateTitle(rawStatus: canonicalStatus) + let matching = store.calendars(for: .reminder).filter { + $0.title == title + } + guard matching.count <= 1 else { + throw SyncError.noReminderSource + } + if let existing = matching.first { + try setColor(existing, status: status, save: true) + return existing + } + guard create else { return nil } + if canonicalStatus == TaskForgeKanbanStatus.todo.rawValue, + let legacy = store.calendars(for: .reminder).first(where: { + $0.title == "TaskForge 今日" + }) + { + legacy.title = title + try setColor(legacy, status: status, save: true) + return legacy + } + guard let source = store.defaultCalendarForNewReminders()?.source + ?? store.calendars(for: .reminder).first?.source + else { + throw SyncError.noReminderSource + } + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = title + calendar.source = source + try setColor(calendar, status: status, save: false) + try store.saveCalendar(calendar, commit: true) + return calendar + } + + private func setColor( + _ calendar: EKCalendar, + status: TaskForgeKanbanStatus?, + save: Bool + ) throws { + let color: (CGFloat, CGFloat, CGFloat) + switch status { + case nil: color = (0.56, 0.56, 0.58) + case .todo: color = (0.20, 0.47, 0.96) + case .scheduled: color = (0.69, 0.32, 0.86) + case .ready: color = (0.20, 0.78, 0.35) + case .inProgress: color = (0.00, 0.78, 0.75) + case .onHold: color = (1.00, 0.58, 0.00) + case .deferred: color = (0.56, 0.56, 0.58) + case .blocked: color = (1.00, 0.23, 0.19) + case .someday: color = (0.35, 0.34, 0.84) + case .done, .cancelled: return + } + calendar.cgColor = CGColor( + srgbRed: color.0, + green: color.1, + blue: color.2, + alpha: 1 + ) + if save { + try store.saveCalendar(calendar, commit: true) + } + } + + private func stateTitle(_ status: TaskForgeKanbanStatus) -> String { + stateTitle(rawStatus: status.rawValue) + } + + private func stateTitle(rawStatus: String) -> String { + let status = TaskForgeKanbanStatus(rawValue: rawStatus) + let name: String + switch status { + case .todo: name = "待办" + case .scheduled: name = "已计划" + case .ready: name = "就绪" + case .inProgress: name = "进行中" + case .onHold: name = "暂停" + case .deferred: name = "已推迟" + case .blocked: name = "已阻塞" + case .someday: name = "将来某天" + case .done: name = "完成" + case .cancelled: name = "取消" + case nil: name = rawStatus + } + return "\(resolvedListPrefix) · \(name)" + } + + private func isManaged( + _ reminder: EKReminder, + snapshot: TaskForgeSnapshot, + listID: String + ) -> Bool { + guard !(reminder.notes ?? "").contains("TaskForge-Dedup-Archived: 1") + else { return false } + if let marker = TaskSyncMarker.extract(from: reminder.notes), + let decoded = TaskSyncMarker.decode(marker), + decoded.vaultPath == snapshot.vaultPath + { + return true + } + guard let index = try? privateStore.loadIndexReadOnly(), + index.listID == listID + else { return false } + return index.entries.values.contains { + $0.reminderIdentifier == reminder.calendarItemIdentifier + } + } + + private func fetchReminders(in calendars: [EKCalendar]?) async throws -> [EKReminder] { + let predicate = store.predicateForReminders(in: calendars) + return try await withCheckedThrowingContinuation { continuation in + let resolution = KanbanFetchResolution( + continuation, + cancel: { [store] handle in + Task { @MainActor in + store.cancelFetchRequest(handle.rawValue) + } + } + ) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + 30 + ) { + resolution.timeout() + } + let request = store.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + resolution.resolve(.failure(SyncError.reminderFetchFailed)) + return + } + resolution.resolve(.success(reminders)) + } + resolution.register(KanbanFetchHandle(rawValue: request)) + } + } + + private func loadSnapshotWithRetry() throws -> TaskForgeSnapshot { + var lastError: Error? + for attempt in 1...3 { + do { + return try TaskForgeTaskStore.load(at: configuration.taskStorePath) + } catch { + lastError = error + if attempt < 3 { usleep(200_000) } + } + } + throw lastError ?? TaskForgeTaskStoreError.truncated + } + + private func applePriority(_ value: String?) -> Int { + switch value?.lowercased() { + case "highest", "high", "taskpriority.highest", "taskpriority.high": + return 1 + case "medium", "taskpriority.medium": + return 5 + case "low", "lowest", "taskpriority.low", "taskpriority.lowest": + return 9 + default: + return 0 + } + } + + private func modificationDate(at path: String) -> Date? { + (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] + as? Date + } + + private func reverseFailureCategory(_ error: Error) -> String { + switch error { + case is TaskForgeStatusSymbolError: + return "source-status-validation" + case is SyncError: + return "source-write-or-readback" + default: + return "unknown-write-failure" + } + } + + private func logError(_ message: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + fputs("[\(timestamp)] \(message)\n", stderr) + } +} diff --git a/Sources/TaskForgeReminderSync/SyncEngine.swift b/Sources/TaskForgeReminderSync/SyncEngine.swift index 9f1b346..5e78e9a 100644 --- a/Sources/TaskForgeReminderSync/SyncEngine.swift +++ b/Sources/TaskForgeReminderSync/SyncEngine.swift @@ -3,6 +3,7 @@ import Darwin import EventKit import Foundation import TaskForgeReminderCore +import TaskForgeReminderEventKit struct SyncConfiguration { var listName: String @@ -25,6 +26,9 @@ enum SyncError: Error, LocalizedError { case backupFailed(String) case writeVerificationFailed(String) case taskForgeVerificationTimedOut(String) + case reminderFetchFailed + case invalidSource(String) + case legacyOptionRequiresScheduledDay(String) var errorDescription: String? { switch self { @@ -52,6 +56,12 @@ enum SyncError: Error, LocalizedError { return "写入后内容校验失败:\(path)" case let .taskForgeVerificationTimedOut(title): return "源文件已写入,但等待 TaskForge 确认完成超时:\(title)" + case .reminderFetchFailed: + return "提醒事项读取超时或失败,已按失败关闭。" + case let .invalidSource(value): + return "同步源必须是 custom-list 或 scheduled-day:\(value)" + case let .legacyOptionRequiresScheduledDay(value): + return "参数 \(value) 仅能在 --source scheduled-day 兼容模式使用。" } } } @@ -122,15 +132,26 @@ enum TaskSourceWriter { throw SyncError.sourceEncodingInvalid(sourceURL.path) } - let backupDirectory = try makeBackupDirectory(root: backupRoot) let safeName = sourceURL.lastPathComponent.replacingOccurrences(of: "/", with: "_") - let backupURL = backupDirectory.appendingPathComponent( - "\(task.identifier)-\(safeName).bak" - ) + let safeIdentifier = task.identifier.map { character in + character.isLetter || character.isNumber + || character == "-" || character == "_" + ? character + : "_" + } + let backupURL: URL do { - try originalData.write(to: backupURL, options: [.atomic]) + backupURL = try TaskSourceBackupStore( + backupsRootURL: URL( + fileURLWithPath: backupRoot, + isDirectory: true + ) + ).save( + originalData, + fileName: "\(String(safeIdentifier))-\(safeName).bak" + ) } catch { - throw SyncError.backupFailed(backupURL.path) + throw SyncError.backupFailed(backupRoot) } try updatedData.write(to: sourceURL, options: [.atomic]) @@ -149,20 +170,6 @@ enum TaskSourceWriter { ) } - private static func makeBackupDirectory(root: String) throws -> URL { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = .autoupdatingCurrent - formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" - let directory = URL(fileURLWithPath: root, isDirectory: true) - .appendingPathComponent(formatter.string(from: Date()), isDirectory: true) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - return directory - } - private static func sha256(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } @@ -172,15 +179,36 @@ enum TaskSourceWriter { final class SyncEngine { private let configuration: SyncConfiguration private var calendar: Calendar - private let store = EKEventStore() + private let store: EKEventStore + private let pruner: ReminderPruner private var reminderObserver: NSObjectProtocol? private var reminderDebounceTask: Task? private var isReconciling = false private var needsAnotherPass = false init(configuration: SyncConfiguration, calendar: Calendar) { + let store = EKEventStore() self.configuration = configuration self.calendar = calendar + self.store = store + self.pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: configuration.listName, + localRoot: ReminderPruneLocalStore.defaultRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: { message in + print("[\(ISO8601DateFormatter().string(from: Date()))] \(message)") + }, + logError: { message in + fputs( + "[\(ISO8601DateFormatter().string(from: Date()))] \(message)\n", + stderr + ) + } + ) } deinit { @@ -556,6 +584,17 @@ final class SyncEngine { return counts } + func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + let snapshot = try loadSnapshotWithRetry() + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) + } + + func restoreLastPrune() async throws -> ReminderPruneCounts { + try await pruner.restoreLast() + } + func reconcile(reason: String) async { if isReconciling { needsAnotherPass = true @@ -578,6 +617,12 @@ final class SyncEngine { + "跳过 \(reverseCounts.skipped),失败 \(reverseCounts.failed)" ) _ = try await forward() + let pruneCounts = try await prune(dryRun: false) + log( + "清理同步:首次 \(pruneCounts.firstSeen)," + + "等待 \(pruneCounts.waiting),删除 \(pruneCounts.deleted)," + + "失败 \(pruneCounts.failed)" + ) } catch { logError("双向同步失败:\(error.localizedDescription)") } diff --git a/Tests/TaskForgeReminderCLITests/main.swift b/Tests/TaskForgeReminderCLITests/main.swift new file mode 100644 index 0000000..b65f7d7 --- /dev/null +++ b/Tests/TaskForgeReminderCLITests/main.swift @@ -0,0 +1,179 @@ +import Foundation + +private struct TestFailure: Error, CustomStringConvertible { + let description: String +} + +private func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw TestFailure(description: message) + } +} + +private struct ParserResult { + let status: Int32 + let output: String + let error: String +} + +private func runParser(_ arguments: [String]) throws -> ParserResult { + let executable = URL(fileURLWithPath: CommandLine.arguments[0]) + .deletingLastPathComponent() + .appendingPathComponent("TaskForgeReminderSync") + let process = Process() + process.executableURL = executable + process.arguments = arguments + var environment = ProcessInfo.processInfo.environment + environment["TASKFORGE_REMINDER_SYNC_TEST_PARSE_ONLY"] = "1" + process.environment = environment + + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + try process.run() + process.waitUntilExit() + + let output = String( + data: standardOutput.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let error = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + return ParserResult( + status: process.terminationStatus, + output: output, + error: error + ) +} + +private func runConflictingModes( + _ arguments: [String], + label: String +) throws { + let result = try runParser(arguments) + try require( + result.status == 1, + "\(label): conflicting modes must fail before execution" + ) + try require( + result.error.contains("不能同时指定多个运行模式"), + "\(label): failure must use the anonymous conflict error" + ) + for argument in arguments { + try require( + !result.error.contains(argument), + "\(label): error must not echo a raw argument" + ) + } + try require( + !result.output.contains("提醒事项权限") + && !result.error.contains("提醒事项权限") + && !result.output.contains("EventKit") + && !result.error.contains("EventKit"), + "\(label): conflict must fail before EventKit access" + ) +} + +private func requireMode( + _ arguments: [String], + expected: String, + label: String +) throws { + let result = try runParser(arguments) + try require( + result.status == 0, + "\(label): one mode should parse successfully" + ) + try require( + result.output == "parse-mode=\(expected)\n", + "\(label): unexpected anonymous mode label \(result.output)" + ) + try require( + result.error.isEmpty, + "\(label): parse-only mode wrote stderr" + ) + try require( + !result.output.contains("提醒事项权限") + && !result.output.contains("EventKit"), + "\(label): parse-only mode reached EventKit" + ) +} + +private let conflictPairs = [ + ["--prune-dry-run", "--prune-once"], + ["--restore-last-prune", "--sync"], + ["--dry-run", "--sync"], + ["--help", "--prune-once"], + ["--prune-once", "--prune-once"] +] + +private let singleModes: [([String], String)] = [ + ([], "dry-run"), + (["--check-config"], "check-config"), + (["--dry-run"], "dry-run"), + (["--audit"], "audit"), + (["--deduplicate-dry-run"], "deduplicate-dry-run"), + (["--deduplicate"], "deduplicate"), + (["--sync"], "sync"), + (["--reverse-dry-run"], "reverse-dry-run"), + (["--reverse-once"], "reverse-once"), + (["--prune-dry-run"], "prune-dry-run"), + (["--prune-once"], "prune-once"), + (["--restore-last-prune"], "restore-last-prune"), + (["--watch"], "watch"), + (["--help"], "help"), + (["--source", "custom-list"], "dry-run"), + (["--source", "scheduled-day", "--date", "2026-08-04"], "dry-run") +] + +private func requireSourceCompatibilityRules() throws { + let result = try runParser([ + "--source", "custom-list", "--date", "2026-08-04" + ]) + try require( + result.status == 1 + && result.error.contains("scheduled-day"), + "custom-list must reject legacy date selection" + ) + try require( + !result.error.contains("2026-08-04"), + "parser error must not echo a date value" + ) + let unknown = try runParser(["--source", "not-a-source"]) + try require( + unknown.status == 1 + && unknown.error.contains("custom-list") + && unknown.error.contains("scheduled-day"), + "unknown source must fail closed" + ) +} + +do { + for (index, pair) in conflictPairs.enumerated() { + try runConflictingModes(pair, label: "pair \(index + 1) forward") + try runConflictingModes( + Array(pair.reversed()), + label: "pair \(index + 1) reverse" + ) + } + for (arguments, expected) in singleModes { + try requireMode( + arguments, + expected: expected, + label: arguments.first ?? "no mode" + ) + } + try requireSourceCompatibilityRules() + let testCount = conflictPairs.count * 2 + singleModes.count + print("\(testCount)/\(testCount) CLI parser tests passed") +} catch { + fputs("FAIL \(error)\n", stderr) + exit(1) +} diff --git a/Tests/TaskForgeReminderCoreTests/main.swift b/Tests/TaskForgeReminderCoreTests/main.swift index dde007f..c13234c 100644 --- a/Tests/TaskForgeReminderCoreTests/main.swift +++ b/Tests/TaskForgeReminderCoreTests/main.swift @@ -1,5 +1,11 @@ +import CoreLocation +import CryptoKit +import Dispatch +import Darwin +import EventKit import Foundation import TaskForgeReminderCore +@testable import TaskForgeReminderEventKit private struct TestFailure: Error, CustomStringConvertible { let description: String @@ -24,6 +30,236 @@ private func requireValue( return value } +private final class AsyncTestResultBox: @unchecked Sendable { + func store(_ result: Result) { + lock.lock() + self.result = result + lock.unlock() + } + + func take() -> Result? { + lock.lock() + defer { lock.unlock() } + return result + } + + private let lock = NSLock() + private var result: Result? +} + +private final class LockedCounter: @unchecked Sendable { + func increment() { + lock.lock() + count += 1 + lock.unlock() + } + + func read() -> Int { + lock.lock() + defer { lock.unlock() } + return count + } + + private let lock = NSLock() + private var count = 0 +} + +private final class LockedValues: @unchecked Sendable { + func append(_ value: Value) { + lock.lock() + values.append(value) + lock.unlock() + } + + func read() -> [Value] { + lock.lock() + defer { lock.unlock() } + return values + } + + private let lock = NSLock() + private var values: [Value] = [] +} + +private final class ScheduledCallbackBox: @unchecked Sendable { + func store(_ callback: @escaping @Sendable () -> Void) { + lock.lock() + self.callback = callback + lock.unlock() + } + + func call() throws { + lock.lock() + let callback = self.callback + lock.unlock() + try require( + callback != nil, + "scheduled callback was not registered" + ) + callback?() + } + + private let lock = NSLock() + private var callback: (@Sendable () -> Void)? +} + +private func waitForAsync( + _ operation: @escaping @Sendable () async throws -> Value +) throws -> Value { + let resultBox = AsyncTestResultBox() + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + do { + resultBox.store(.success(try await operation())) + } catch { + resultBox.store(.failure(error)) + } + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + 5) == .success else { + throw TestFailure(description: "async unit test timed out") + } + return try requireValue( + resultBox.take(), + "async unit test produced no result" + ).get() +} + +private func addReadOnlyExtendedACL(to url: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/chmod") + process.arguments = ["+a", "everyone allow read", url.path] + let standardError = Pipe() + process.standardError = standardError + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + throw TestFailure( + description: "could not create ACL fixture: \(message)" + ) + } +} + +private func permissions(at url: URL) throws -> Int { + let value = try requireValue( + FileManager.default.attributesOfItem( + atPath: url.path + )[.posixPermissions] as? NSNumber, + "permissions missing for \(url.lastPathComponent)" + ) + return value.intValue & 0o777 +} + +private struct LegacySourceBackupFixture { + let backupsRoot: URL + let directories: [URL] + let files: [URL] +} + +private struct RuntimeTreeEvidence: Equatable { + var directoryCount = 0 + var fileCount = 0 + var fileHashes: [String: String] = [:] +} + +private func makeLegacySourceBackupFixture( + root: URL, + rootPermissions: Int +) throws -> LegacySourceBackupFixture { + let backups = root.appendingPathComponent("Backups", isDirectory: true) + let batchA = backups.appendingPathComponent("batch-a", isDirectory: true) + let nested = batchA.appendingPathComponent("nested", isDirectory: true) + let batchB = backups.appendingPathComponent("batch-b", isDirectory: true) + let batchC = backups.appendingPathComponent("batch-c", isDirectory: true) + let directories = [root, backups, batchA, nested, batchB, batchC] + try FileManager.default.createDirectory( + at: nested, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: batchB, + withIntermediateDirectories: false + ) + try FileManager.default.createDirectory( + at: batchC, + withIntermediateDirectories: false + ) + for directory in directories { + try FileManager.default.setAttributes( + [ + .posixPermissions: + directory == root ? rootPermissions : 0o755 + ], + ofItemAtPath: directory.path + ) + } + + let files = [ + backups.appendingPathComponent("manifest.bak"), + batchA.appendingPathComponent("one.bak"), + nested.appendingPathComponent("two.bak"), + batchB.appendingPathComponent("three.bak"), + batchC.appendingPathComponent("four.bak") + ] + for (index, file) in files.enumerated() { + try Data("legacy-\(index)".utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: file.path + ) + } + return LegacySourceBackupFixture( + backupsRoot: backups, + directories: directories, + files: files + ) +} + +private func runtimeTreeEvidence(at root: URL) throws + -> RuntimeTreeEvidence +{ + var evidence = RuntimeTreeEvidence() + + func visit(_ url: URL) throws { + var status = stat() + guard lstat(url.path, &status) == 0 else { + throw TestFailure( + description: "could not inspect \(url.lastPathComponent)" + ) + } + switch status.st_mode & S_IFMT { + case S_IFDIR: + evidence.directoryCount += 1 + let children = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil + ) + for child in children.sorted(by: { $0.path < $1.path }) { + try visit(child) + } + case S_IFREG: + evidence.fileCount += 1 + let relative = String( + url.path.dropFirst(root.path.count) + ) + evidence.fileHashes[relative] = SHA256.hash( + data: try Data(contentsOf: url) + ).map { String(format: "%02x", $0) }.joined() + default: + throw TestFailure( + description: "unexpected node in evidence tree" + ) + } + } + + try visit(root) + return evidence +} + private var shanghaiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! @@ -148,8 +384,11 @@ private func taskRecord( return .array(fields) } -private func taskStoreFixture() -> Data { - encodeFixture( +private func taskStoreFixture( + extraRecord: FixtureValue? = nil, + trailingRecord: FixtureValue? = nil +) -> Data { + let root = encodeFixture( .array([ .int(6), .map([]), @@ -179,13 +418,653 @@ private func taskStoreFixture() -> Data { title: "明天任务", status: "todo", scheduled: taskDate(2026, 7, 27) - ) - ]) + ), + // A live v6 store can retain scalar `1` tombstones. + .int(1) + ] + (extraRecord.map { [$0] } ?? [])) ]) ) + guard let trailingRecord else { return root } + return root + encodeFixture(trailingRecord) +} + +private func pruneBackupFixture( + identifier: UUID = UUID( + uuidString: "00000000-0000-0000-0000-000000000001" + )!, + createdAt: Date = Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [String]? = nil +) -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: identifier, + createdAt: createdAt, + targetCalendarIdentifier: "calendar", + targetCalendarTitle: "TaskForge 今日", + targetSourceIdentifier: "source", + backupSchemaVersion: + ReminderPruneRestorePolicy.currentBackupSchemaVersion, + rulesVersion: ReminderPruneStateMachine.rulesVersion, + items: [ + ReminderPruneBackupItem( + originalItemIdentifier: "item", + title: "普通提醒", + notes: "本地测试", + url: URL(string: "taskforge-test://item"), + priority: 0, + dueDateComponents: DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 7, + day: 30 + ), + startDateComponents: nil, + alarms: [], + recurrenceRules: [], + taskPresence: .absent + ) + ], + actuallyDeletedIdentifiers: actuallyDeletedIdentifiers, + restoreAttemptIdentifier: nil, + restoredItemIdentifiers: [:], + restoredAt: nil + ) +} + +private let legacyPruneBackupEnvelopeFixture = Data( + """ + {"checksum":"386b6ec8c52714e00c16f65f69c8dbace2eff0f899613a43f6cbb467796235c1","payload":{"createdAt":-978307180,"identifier":"00000000-0000-0000-0000-0000000000A1","items":[],"restoredItemIdentifiers":{},"rulesVersion":0,"targetCalendarIdentifier":"legacy-calendar","targetCalendarTitle":"Legacy list","targetSourceIdentifier":"legacy-source"}} + """.utf8 +) + +private let legacyPruneBackupUnknownPayloadFieldFixture = Data( + """ + {"checksum":"386b6ec8c52714e00c16f65f69c8dbace2eff0f899613a43f6cbb467796235c1","payload":{"createdAt":-978307180,"identifier":"00000000-0000-0000-0000-0000000000A1","items":[],"restoredItemIdentifiers":{},"rulesVersion":0,"targetCalendarIdentifier":"legacy-calendar","targetCalendarTitle":"Legacy list","targetSourceIdentifier":"legacy-source","unknownPayloadField":"must-not-be-ignored"}} + """.utf8 +) + +private func currentAccountHomeURL() throws -> URL { + guard + let account = getpwuid(getuid()), + let homePath = String( + validatingUTF8: account.pointee.pw_dir + ) + else { + throw TestFailure(description: "current account home is unavailable") + } + return URL(fileURLWithPath: homePath, isDirectory: true) + .standardizedFileURL + .resolvingSymlinksInPath() +} + +private func expectedOperationLockAnchorURL() throws -> URL { + let home = try currentAccountHomeURL() + let candidates = [ + home.appendingPathComponent("Library/Caches", isDirectory: true), + home.appendingPathComponent("Library", isDirectory: true), + home + ] + for candidate in candidates { + let resolved = candidate.standardizedFileURL + .resolvingSymlinksInPath() + var status = stat() + guard lstat(resolved.path, &status) == 0 else { + continue + } + if status.st_uid == getuid(), + status.st_mode & S_IFMT == S_IFDIR, + status.st_mode & 0o077 == 0 + { + return resolved + } + } + throw TestFailure( + description: "no deterministic private account anchor is available" + ) +} + +private func taskForgeEntries(at url: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: url.path) + .filter { $0.localizedCaseInsensitiveContains("taskforge") } + .sorted() +} + +private func kanbanTask( + identifier: String, + status: String, + line: String, + fileName: String = "Today.md", + tags: [String] = [] +) -> TaskForgeTask { + TaskForgeTask( + identifier: identifier, + title: identifier, + status: status, + priority: "medium", + scheduled: nil, + filePath: "/vault/\(fileName)", + sourceType: "markdownInline", + originalLine: line, + lineNumber: 1, + tags: tags, + fileName: fileName + ) } private let tests: [TestCase] = [ + ("custom Kanban filters preserve group and condition logic", { + let statusCondition = try TaskForgeFilterCondition( + type: "status", + operator: "not_equals", + value: "TaskStatus.done" + ) + let fileCondition = try TaskForgeFilterCondition( + type: "file_name", + operator: "contains", + value: "Today" + ) + let tagCondition = try TaskForgeFilterCondition( + type: "tag", + operator: "contains", + value: "keep" + ) + let list = try TaskForgeCustomList( + id: "list", + name: "Today", + filterGroups: [ + try TaskForgeFilterGroup( + conditions: [statusCondition, fileCondition], + matchMode: "all" + ), + try TaskForgeFilterGroup( + conditions: [tagCondition], + matchMode: "all" + ) + ], + filterGroupsMatchMode: "any", + kanbanMode: true + ) + let tasks = [ + kanbanTask( + identifier: "first", + status: "todo", + line: "- [ ] first" + ), + kanbanTask( + identifier: "second", + status: "done", + line: "- [x] second" + ), + kanbanTask( + identifier: "third", + status: "todo", + line: "- [ ] third", + fileName: "Other.md", + tags: ["keep"] + ) + ] + let selected = try TaskForgeFilterEvaluator.select( + tasks: tasks, + list: list, + calendar: Calendar(identifier: .gregorian) + ) + try require( + selected.map(\.identifier) == ["first", "third"], + "custom list group logic selected the wrong tasks" + ) + }), + ("custom Kanban rejects unknown fields and operators", { + let unknownField = Data( + """ + {"id":"list","name":"Today","filterGroups":[{"conditions":[{"type":"unknown","operator":"equals"}],"matchMode":"all"}],"filterGroupsMatchMode":"all","kanbanMode":true} + """.utf8 + ) + do { + _ = try TaskForgeListConfigurationStore.decode(jsonData: unknownField) + throw TestFailure(description: "unknown field was accepted") + } catch let error as TaskForgeFilterConfigurationError { + try require( + error == .unsupportedField("unknown"), + "unexpected unknown field error" + ) + } + + let unknownOperator = Data( + """ + {"id":"list","name":"Today","filterGroups":[{"conditions":[{"type":"status","operator":"guess"}],"matchMode":"all"}],"filterGroupsMatchMode":"all","kanbanMode":true} + """.utf8 + ) + do { + _ = try TaskForgeListConfigurationStore.decode(jsonData: unknownOperator) + throw TestFailure(description: "unknown operator was accepted") + } catch let error as TaskForgeFilterConfigurationError { + try require( + error == .unsupportedOperator("guess"), + "unexpected unknown operator error" + ) + } + }), + ("TaskForge status symbol learning rejects conflicts", { + let tasks = [ + kanbanTask( + identifier: "one", + status: "todo", + line: "- [ ] one" + ), + kanbanTask( + identifier: "two", + status: "todo", + line: "- [>] two" + ) + ] + do { + _ = try TaskForgeStatusSymbolLearner.learn(tasks: tasks) + throw TestFailure(description: "conflicting symbols were learned") + } catch let error as TaskForgeStatusSymbolError { + try require( + error == .conflict("todo"), + "unexpected symbol conflict error" + ) + } + }), + ("TaskForge status editor changes only the checkbox symbol", { + let task = kanbanTask( + identifier: "one", + status: "todo", + line: "- [ ] one" + ) + let edit = try TaskForgeStatusSourceEditor.update( + task: task, + contents: "- [ ] one\n", + targetStatus: "inProgress", + symbols: ["inProgress": "[/]"] + ) + try require( + edit.updatedContents == "- [/ ] one\n" + || edit.updatedContents == "- [/] one\n", + "status editor changed the source unexpectedly" + ) + try require( + !edit.updatedContents.contains("✅"), + "status editor added an artificial completion date" + ) + }), + ("TaskForge status symbol learning migrates planned aliases", { + let task = kanbanTask( + identifier: "planned-task", + status: "planned", + line: "- [>] planned-task" + ) + let learned = try TaskForgeStatusSymbolLearner.learn( + tasks: [task], + existing: ["planned": "[>]"] + ) + try require( + learned["scheduled"] == "[>]" && learned["planned"] == nil, + "planned symbol alias was not migrated to scheduled" + ) + }), + ("TaskForge status aliases cover every Kanban state", { + let aliases = [ + "todo", "scheduled", "planned", "ready", "inProgress", "on-hold", + "deferred", "blocked", "someday", "done", "cancelled" + ] + let expected = Set(TaskForgeKanbanStatus.allCases.map(\.rawValue)) + try require( + Set(aliases.map(TaskForgeKanbanStatus.canonical)) == expected, + "status aliases do not cover the locked status matrix" + ) + }), + ("Kanban private state is read-only until an explicit save", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = TaskForgeSyncPrivateStore(rootURL: root) + try require( + try store.loadConfigurationReadOnly() == nil, + "read-only private load should not create a root" + ) + try require( + !FileManager.default.fileExists(atPath: root.path), + "read-only private load created a root" + ) + try store.saveConfiguration( + TaskForgeSyncPrivateConfiguration(taskForgeListID: "list") + ) + try store.saveIndex(TaskForgeSyncIndex(listID: "list")) + let rootMode = try requireValue( + FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] + as? NSNumber, + "private root mode missing" + ) + let configMode = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.configurationURL.path + )[.posixPermissions] as? NSNumber, + "private config mode missing" + ) + try require(rootMode.intValue & 0o777 == 0o700, "private root is not 0700") + try require(configMode.intValue & 0o777 == 0o600, "private config is not 0600") + try require( + try store.loadConfigurationReadOnly()?.taskForgeListID == "list", + "private config did not round-trip" + ) + }), + ("reminder fetch timeout cancels a registered request exactly once", { + let cancellations = LockedValues() + do { + let _: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + timeout: 0.05, + scheduleTimeout: { _, timeout in + DispatchQueue.global().asyncAfter( + deadline: .now() + 0.05 + ) { + timeout() + timeout() + } + }, + cancel: { identifier in + cancellations.append(identifier) + }, + start: { completion in + _ = completion + return 11 + } + ) + } + throw TestFailure( + description: "fetch timeout unexpectedly succeeded" + ) + } catch ReminderPrunerError.reminderFetchFailed { + // Expected anonymous timeout. + } + try require( + cancellations.read() == [11], + "timeout must cancel the registered request identifier once" + ) + }), + ("reminder fetch success ignores a late timeout without cancelling", { + let scheduledTimeout = ScheduledCallbackBox() + let cancellations = LockedCounter() + let value: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + scheduleTimeout: { _, timeout in + scheduledTimeout.store(timeout) + }, + cancel: { _ in + cancellations.increment() + }, + start: { completion in + completion(.success(7)) + return 12 + } + ) + } + try scheduledTimeout.call() + try scheduledTimeout.call() + try require(value == 7, "fetch success returned an unexpected value") + try require( + cancellations.read() == 0, + "a late timeout must not cancel a successful request" + ) + }), + ("reminder fetch timeout before handle registration cancels once", { + let cancellations = LockedValues() + do { + let _: Int = try waitForAsync { + try await ReminderFetchWaiter.wait( + timeout: 0, + scheduleTimeout: { _, timeout in + timeout() + timeout() + }, + cancel: { identifier in + cancellations.append(identifier) + }, + start: { completion in + completion(.success(9)) + return 13 + } + ) + } + throw TestFailure( + description: "pre-registration timeout unexpectedly succeeded" + ) + } catch ReminderPrunerError.reminderFetchFailed { + // Expected; the callback arrives after timeout has already won. + } + try require( + cancellations.read() == [13], + "a late request identifier must be cancelled exactly once" + ) + }), + ("reminder backup adapter round-trips every expressible field", { + let eventStore = EKEventStore() + let original = EKReminder(eventStore: eventStore) + original.title = "adapter fixture" + original.notes = "adapter notes" + original.url = URL(string: "taskforge-adapter-test://fixture") + original.priority = 5 + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 9 * 3_600)! + original.dueDateComponents = DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + era: 1, + year: 2031, + month: 8, + day: 9, + hour: 10, + minute: 11, + second: 12 + ) + original.startDateComponents = DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + era: 1, + year: 2031, + month: 8, + day: 9, + hour: 9, + minute: 10, + second: 11 + ) + + let absoluteAlarm = EKAlarm( + absoluteDate: Date(timeIntervalSince1970: 2_000_000_000) + ) + let location = EKStructuredLocation(title: "adapter location") + location.geoLocation = CLLocation( + latitude: 31.2304, + longitude: 121.4737 + ) + location.radius = 125 + absoluteAlarm.structuredLocation = location + absoluteAlarm.proximity = .enter + original.addAlarm(absoluteAlarm) + original.addAlarm(EKAlarm(relativeOffset: -1_800)) + + let recurrence = EKRecurrenceRule( + recurrenceWith: .yearly, + interval: 2, + daysOfTheWeek: [ + EKRecurrenceDayOfWeek( + dayOfTheWeek: .monday, + weekNumber: 2 + ) + ], + daysOfTheMonth: [1, -1].map(NSNumber.init(value:)), + monthsOfTheYear: [1, 12].map(NSNumber.init(value:)), + weeksOfTheYear: [1, -1].map(NSNumber.init(value:)), + daysOfTheYear: [100, -1].map(NSNumber.init(value:)), + setPositions: [1, -1].map(NSNumber.init(value:)), + end: EKRecurrenceEnd(occurrenceCount: 7) + ) + original.addRecurrenceRule(recurrence) + + let captured = ReminderBackupAdapter.capture( + original, + taskPresence: .sourceConfirmed + ) + let restored = EKReminder(eventStore: eventStore) + ReminderBackupAdapter.restore(captured, into: restored) + let roundTrip = ReminderBackupAdapter.capture( + restored, + taskPresence: .sourceConfirmed + ) + + try require( + roundTrip.title == captured.title + && roundTrip.notes == captured.notes + && roundTrip.url == captured.url + && roundTrip.priority == captured.priority, + "adapter changed scalar reminder fields" + ) + try require( + roundTrip.dueDateComponents == captured.dueDateComponents + && roundTrip.startDateComponents + == captured.startDateComponents, + "adapter changed date component fields" + ) + try require( + roundTrip.alarms.count == captured.alarms.count, + "adapter changed alarm count" + ) + let capturedAbsolute = try requireValue( + captured.alarms.first { $0.absoluteDate != nil }, + "absolute alarm fixture was not captured" + ) + let roundTripAbsolute = try requireValue( + roundTrip.alarms.first { $0.absoluteDate != nil }, + "absolute alarm was not restored" + ) + try require( + roundTripAbsolute.absoluteDate + == capturedAbsolute.absoluteDate, + "adapter changed absolute alarm date" + ) + try require( + roundTripAbsolute.structuredLocation + == capturedAbsolute.structuredLocation, + "adapter changed structured alarm location" + ) + try require( + roundTripAbsolute.proximityRawValue + == capturedAbsolute.proximityRawValue, + "adapter changed alarm proximity" + ) + let capturedRelative = try requireValue( + captured.alarms.first { $0.absoluteDate == nil }, + "relative alarm fixture was not captured" + ) + let roundTripRelative = try requireValue( + roundTrip.alarms.first { $0.absoluteDate == nil }, + "relative alarm was not restored" + ) + try require( + roundTripRelative == capturedRelative, + "adapter changed relative alarm fields" + ) + try require( + roundTrip.recurrenceRules == captured.recurrenceRules, + "adapter changed recurrence fields" + ) + try require( + roundTrip.taskPresence == .sourceConfirmed + && captured.priority == 5 + && captured.alarms.count == 2 + && captured.alarms.contains { + $0.absoluteDate != nil + && $0.structuredLocation != nil + && $0.proximityRawValue + == EKAlarmProximity.enter.rawValue + } + && captured.recurrenceRules.first?.daysOfMonth + == [1, -1] + && captured.recurrenceRules.first?.monthsOfYear + == [1, 12] + && captured.recurrenceRules.first?.weeksOfYear + == [1, -1] + && captured.recurrenceRules.first?.daysOfYear + == [100, -1] + && captured.recurrenceRules.first?.setPositions + == [1, -1], + "adapter fixture did not cover extended fields" + ) + }), + ("reminder backup adapter round-trips recurrence end date", { + let eventStore = EKEventStore() + let original = EKReminder(eventStore: eventStore) + original.title = "adapter end-date fixture" + original.addRecurrenceRule( + EKRecurrenceRule( + recurrenceWith: .daily, + interval: 3, + end: EKRecurrenceEnd( + end: Date(timeIntervalSince1970: 2_100_000_000) + ) + ) + ) + + let captured = ReminderBackupAdapter.capture( + original, + taskPresence: .absent + ) + let stableEndDate = try requireValue( + original.recurrenceRules?.first?.recurrenceEnd?.endDate, + "EventKit did not expose the recurrence end date" + ) + let capturedRule = try requireValue( + captured.recurrenceRules.first, + "end-date recurrence fixture was not captured" + ) + try require( + capturedRule.endDate == stableEndDate, + "adapter did not preserve EventKit's stable recurrence end date" + ) + let restored = EKReminder(eventStore: eventStore) + ReminderBackupAdapter.restore(captured, into: restored) + let restoredEndDate = try requireValue( + restored.recurrenceRules?.first?.recurrenceEnd?.endDate, + "adapter did not restore the recurrence end date" + ) + let roundTrip = ReminderBackupAdapter.capture( + restored, + taskPresence: .absent + ) + let roundTripRule = try requireValue( + roundTrip.recurrenceRules.first, + "end-date recurrence was not restored" + ) + + try require( + restoredEndDate == stableEndDate + && roundTripRule.endDate == stableEndDate, + "adapter changed EventKit's stable recurrence end date" + ) + try require( + capturedRule.occurrenceCount == nil + && roundTripRule.occurrenceCount == nil, + "date-bounded recurrence must not become count-bounded" + ) + }), + ("prune target calendar selection fails closed on ambiguity", { + let missing: Int? = try ReminderPruner.uniqueTargetCalendarMatch([]) + try require(missing == nil, "zero matching calendars should be absent") + let unique = try ReminderPruner.uniqueTargetCalendarMatch([7]) + try require(unique == 7, "one matching calendar should be selected") + + do { + let _: Int? = try ReminderPruner.uniqueTargetCalendarMatch([7, 8]) + throw TestFailure( + description: "multiple matching calendars must fail closed" + ) + } catch ReminderPrunerError.ambiguousTargetCalendar { + // Expected anonymous fail-closed error. + } + }), ("TaskForge v6 MessagePack store decodes task records", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) try require(snapshot.version == 6, "unexpected task store version") @@ -194,6 +1073,31 @@ private let tests: [TestCase] = [ try require(snapshot.tasks[0].title == "示例任务", "unexpected first task title") try require(snapshot.tasks[0].sourceType == "markdownInline", "unexpected source type") }), + ("TaskForge v6 store rejects unknown scalar records", { + do { + _ = try TaskForgeTaskStore.decode( + taskStoreFixture(extraRecord: .int(2)) + ) + throw TestFailure(description: "unknown scalar record was accepted") + } catch TaskForgeTaskStoreError.malformed { + // Expected fail-closed behavior. + } + }), + ("TaskForge v6 store includes a complete trailing task record", { + let trailing = taskRecord( + id: "trailing-task", + title: "追加任务", + status: "todo", + scheduled: .null + ) + let snapshot = try TaskForgeTaskStore.decode( + taskStoreFixture(trailingRecord: trailing) + ) + try require( + snapshot.tasks.contains { $0.identifier == "trailing-task" }, + "complete trailing task record was dropped" + ) + }), ("today selection matches TaskForge calendar open tasks", { let snapshot = try TaskForgeTaskStore.decode(taskStoreFixture()) let today = TaskForgeDay(year: 2026, month: 7, day: 26) @@ -779,6 +1683,1915 @@ private let tests: [TestCase] = [ ), "completed historical source should be recognized" ) + }), + ("source presence confirms exact and uniquely moved inline tasks", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 2 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\n- [ ] 保留任务\n" + ) == .present, + "exact source line should be present" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\nheading\n" + ) == .present, + "uniquely moved source line should be present" + ) + }), + ("source presence distinguishes absent from ambiguous", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 3 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\nother\n" + ) == .absent, + "missing source line should be absent" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n- [ ] 保留任务\n" + ) == .indeterminate, + "ambiguous source lines must fail closed" + ) + }), + ("source presence protects an existing TaskNotes file", { + let task = TaskForgeTask( + identifier: "note", + title: "任务笔记", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/TaskNotes/任务.md", + sourceType: "taskNotes", + originalLine: "tasknotes:{}", + lineNumber: 1 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "---\nstatus: open\n---\n" + ) == .present, + "readable TaskNotes source should be present" + ) + }), + ("source presence fails closed for missing or unknown source metadata", { + let cases = [ + TaskForgeTask( + identifier: "missing-line", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: nil, + lineNumber: 1 + ), + TaskForgeTask( + identifier: "missing-type", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: nil, + originalLine: "- [ ] 保留任务", + lineNumber: 1 + ), + TaskForgeTask( + identifier: "unknown-type", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "unsupported", + originalLine: "- [ ] 保留任务", + lineNumber: 1 + ) + ] + for task in cases { + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n" + ) == .indeterminate, + "\(task.identifier) must fail closed" + ) + } + }), + ("source presence rejects non-positive and overflowing line numbers", { + for lineNumber in [Int.min, 0, -1] { + let task = TaskForgeTask( + identifier: "invalid-line-\(lineNumber)", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: lineNumber + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n" + ) == .indeterminate, + "\(lineNumber) must fail closed without index arithmetic" + ) + } + }), + ("prune policy protects non-target, completed and important reminders", { + let target = "calendar-target" + let protected = [ + ReminderPruneObservation( + itemIdentifier: "other-list", + calendarIdentifier: "calendar-other", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "a", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "completed", + calendarIdentifier: target, + isCompleted: true, + priority: 0, + title: "普通提醒", + fingerprint: "b", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "priority", + calendarIdentifier: target, + isCompleted: false, + priority: 1, + title: "普通提醒", + fingerprint: "c", + taskPresence: .absent + ) + ] + for observation in protected { + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: target + ), + "\(observation.itemIdentifier) must be protected" + ) + } + }), + ("prune policy recognizes every approved title prefix", { + for (index, prefix) in ["!", "!", "❗", "‼️", "⭐", "📌"].enumerated() { + let observation = ReminderPruneObservation( + itemIdentifier: "important-\(index)", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: " \(prefix) 保留", + fingerprint: "\(index)", + taskPresence: .absent + ) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: "calendar-target" + ), + "\(prefix) must protect the reminder" + ) + } + }), + ("prune policy only selects an unimportant absent TaskForge task", { + let base = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + try require( + ReminderPruneCandidatePolicy.isCandidate( + base, + targetCalendarIdentifier: "calendar-target" + ), + "external reminder should become a candidate" + ) + for presence in [ + TaskForgeReminderPresence.currentSnapshot, + .sourceConfirmed, + .indeterminate + ] { + let protected = base.withTaskPresence(presence) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + protected, + targetCalendarIdentifier: "calendar-target" + ), + "\(presence) must fail closed" + ) + } + }), + ("prune state requires two unchanged scans at least sixty seconds apart", { + let firstDate = Date(timeIntervalSince1970: 1_000) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: firstDate + ) + try require(first.readyIdentifiers.isEmpty, "first scan must not delete") + try require(first.firstSeenIdentifiers == ["external"], "candidate not recorded") + + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(59) + ) + try require(early.readyIdentifiers.isEmpty, "59 seconds is too early") + + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: early.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(60) + ) + try require(ready.readyIdentifiers == ["external"], "candidate should be ready") + }), + ("prune state revokes or restarts changed candidates", { + let now = Date(timeIntervalSince1970: 2_000) + let original = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "v1", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [original], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + let changed = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "改过的提醒", + fingerprint: "v2", + taskPresence: .absent + ) + let restarted = ReminderPruneStateMachine.plan( + observations: [changed], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(120) + ) + try require(restarted.readyIdentifiers.isEmpty, "changed item must restart") + try require( + restarted.nextLedger.entries["external"]?.firstSeen + == now.addingTimeInterval(120), + "changed item should receive a new firstSeen" + ) + }), + ("prune state restarts when the calendar identifier changes", { + let now = Date(timeIntervalSince1970: 2_250) + let prior = ReminderPruneLedger(entries: [ + "external": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-before", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: nil + ) + ]) + let moved = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-after", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let plan = ReminderPruneStateMachine.plan( + observations: [moved], + prior: prior, + targetCalendarIdentifier: "calendar-after", + now: now + ) + + try require( + plan.firstSeenIdentifiers == ["external"], + "calendar move must start a fresh confirmation window" + ) + try require( + plan.readyIdentifiers.isEmpty, + "calendar move must not reuse the old ready state" + ) + try require( + plan.nextLedger.entries["external"]?.calendarIdentifier + == "calendar-after", + "fresh state must record the current calendar" + ) + }), + ("prune state clamps custom confirmation intervals to sixty seconds", { + let now = Date(timeIntervalSince1970: 2_500) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + for confirmationInterval: TimeInterval in [0, 59] { + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(59), + confirmationInterval: confirmationInterval + ) + try require( + early.readyIdentifiers.isEmpty, + "\(confirmationInterval) seconds must not bypass confirmation" + ) + } + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(60), + confirmationInterval: 0 + ) + try require( + ready.readyIdentifiers == ["external"], + "clamped interval should allow readiness at sixty seconds" + ) + }), + ("prune state restarts after restore grace and rule changes", { + let now = Date(timeIntervalSince1970: 3_000) + let observation = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "恢复提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let prior = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval(60) + ) + ]) + let duringGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require(duringGrace.readyIdentifiers.isEmpty, "grace must protect") + + let afterGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: duringGrace.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(61) + ) + try require( + afterGrace.firstSeenIdentifiers == ["restored"], + "expired grace must start a fresh first scan" + ) + try require(afterGrace.readyIdentifiers.isEmpty, "grace expiry must not delete") + + let oldRules = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion - 1, + graceUntil: nil + ) + ]) + let versionReset = ReminderPruneStateMachine.plan( + observations: [observation], + prior: oldRules, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + versionReset.firstSeenIdentifiers == ["restored"], + "rules change must restart confirmation" + ) + + let completed = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: true, + priority: 0, + title: "恢复提醒", + fingerprint: "completed", + taskPresence: .absent + ) + let revoked = ReminderPruneStateMachine.plan( + observations: [completed], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + revoked.revokedIdentifiers == ["restored"], + "completed reminder must revoke its candidate" + ) + try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") + }), + ("legacy prune backup verifies old checksum before migration", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try legacyPruneBackupEnvelopeFixture.write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + let migrated = try store.loadBackup(at: url) + try require( + migrated.backupSchemaVersion + == ReminderPruneRestorePolicy.currentBackupSchemaVersion, + "legacy backup did not migrate to schema 1" + ) + try require( + migrated.rulesVersion == 0 + && migrated.targetCalendarTitle == "Legacy list", + "legacy payload fields changed during migration" + ) + + let tampered = String( + data: legacyPruneBackupEnvelopeFixture, + encoding: .utf8 + )!.replacingOccurrences( + of: "Legacy list", + with: "Tampered list" + ) + try Data(tampered.utf8).write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + do { + _ = try store.loadBackup(at: url) + throw TestFailure( + description: "tampered legacy backup was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .checksumMismatch, + "unexpected legacy checksum error" + ) + } + }), + ("legacy prune backup rejects an unknown payload field", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try legacyPruneBackupUnknownPayloadFieldFixture.write( + to: url, + options: .atomic + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + + do { + _ = try store.loadBackup(at: url) + throw TestFailure( + description: "unknown legacy payload field was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .checksumMismatch, + "unexpected unknown legacy field error" + ) + } + }), + ("restore policy separates backup schema from candidate rules", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + var legacyRulesBackup = pruneBackupFixture() + legacyRulesBackup.rulesVersion = 0 + let url = try store.saveBackup(legacyRulesBackup) + let loaded = try store.loadBackup(at: url) + try require( + ReminderPruneRestorePolicy.supportsBackupSchema( + loaded.backupSchemaVersion + ), + "supported schema must remain restorable" + ) + try require( + !ReminderPruneRestorePolicy.supportsBackupSchema( + loaded.backupSchemaVersion + 1 + ), + "unknown backup schema must be rejected" + ) + try require( + loaded.rulesVersion == 0, + "candidate rules version must remain audit data" + ) + let now = Date(timeIntervalSince1970: 100) + let entry = ReminderPruneRestorePolicy.graceLedgerEntry( + fingerprint: "restored", + calendarIdentifier: "calendar", + now: now, + restoreGraceInterval: 1 + ) + try require( + entry.rulesVersion == ReminderPruneStateMachine.rulesVersion, + "restored grace must use current candidate rules" + ) + try require( + entry.graceUntil == now.addingTimeInterval(86_400), + "restored grace must remain at least 24 hours" + ) + }), + ("prune operation flock does not change the filesystem tree", { + let defaultAnchor = + try ReminderPruneOperationFileLock.defaultAnchorURL() + let expectedDefaultAnchor = try expectedOperationLockAnchorURL() + try require( + defaultAnchor == expectedDefaultAnchor, + "operation lock anchor must be deterministic from account home" + ) + var defaultBeforeStatus = stat() + try require( + lstat(defaultAnchor.path, &defaultBeforeStatus) == 0, + "default operation lock anchor is unavailable" + ) + try require( + defaultBeforeStatus.st_uid == getuid() + && defaultBeforeStatus.st_mode & S_IFMT == S_IFDIR + && defaultBeforeStatus.st_mode & 0o077 == 0, + "default operation lock anchor must be a private owned directory" + ) + let defaultTaskForgeEntriesBefore = + try taskForgeEntries(at: defaultAnchor) + let defaultShared = try ReminderPruneOperationFileLock( + exclusive: false + ) + defaultShared.unlock() + let defaultExclusive = try ReminderPruneOperationFileLock( + exclusive: true + ) + defaultExclusive.unlock() + var defaultAfterStatus = stat() + try require( + lstat(defaultAnchor.path, &defaultAfterStatus) == 0 + && defaultAfterStatus.st_dev == defaultBeforeStatus.st_dev + && defaultAfterStatus.st_ino == defaultBeforeStatus.st_ino + && defaultAfterStatus.st_uid == defaultBeforeStatus.st_uid + && defaultAfterStatus.st_mode & 0o777 + == defaultBeforeStatus.st_mode & 0o777, + "default flock must retain its private anchor inode" + ) + try require( + try taskForgeEntries(at: defaultAnchor) + == defaultTaskForgeEntriesBefore, + "default flock must not create a TaskForge path" + ) + do { + _ = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: URL( + fileURLWithPath: "/tmp", + isDirectory: true + ) + ) + throw TestFailure( + description: "system temp anchor should be rejected" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected system temp anchor error" + ) + } + + let anchor = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: anchor) } + try FileManager.default.createDirectory( + at: anchor, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let before = try FileManager.default.contentsOfDirectory( + atPath: anchor.path + ).sorted() + let shared = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: anchor + ) + shared.unlock() + let exclusive = try ReminderPruneOperationFileLock( + exclusive: true, + anchorURL: anchor + ) + exclusive.unlock() + let after = try FileManager.default.contentsOfDirectory( + atPath: anchor.path + ).sorted() + try require(before == after, "flock must not create a lock path") + }), + ("prune operation flock rejects a non-private owned anchor", { + let anchor = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: anchor) } + try FileManager.default.createDirectory( + at: anchor, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o750] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o750], + ofItemAtPath: anchor.path + ) + + do { + _ = try ReminderPruneOperationFileLock( + exclusive: false, + anchorURL: anchor + ) + throw TestFailure( + description: "non-private operation anchor was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected non-private anchor error" + ) + } + }), + ("prune read-only ledger load never creates its root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + try require( + try store.loadLedgerReadOnly() == ReminderPruneLedger(), + "missing read-only ledger should be empty" + ) + try require( + !FileManager.default.fileExists(atPath: root.path), + "read-only ledger load must not create its root" + ) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + try require( + try store.loadLedgerReadOnly() == ReminderPruneLedger(), + "missing read-only ledger should remain empty" + ) + try require( + !FileManager.default.fileExists(atPath: store.ledgerURL.path), + "read-only ledger load must not create the ledger" + ) + }), + ("prune dry-run rejects an exposed root without changing it", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: root) + .loadLedgerReadOnly() + throw TestFailure( + description: "read-only load accepted an unmigrated root" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected read-only exposed-root error" + ) + } + try require( + try permissions(at: root) == 0o755, + "dry-run changed the root permissions" + ) + }), + ("prune mutating load safely migrates an exposed root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedger() + == ReminderPruneLedger(), + "normal load should preserve an absent ledger" + ) + try require( + try permissions(at: root) == 0o700, + "normal load did not migrate the root to 0700" + ) + try require( + !FileManager.default.fileExists( + atPath: root.appendingPathComponent("Backups").path + ), + "prune initialization created an unused source backup tree" + ) + }), + ("unresolved selection chooses the newest unresolved outcome", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000201" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + ) + let newerUnresolved = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000202" + )!, + createdAt: Date(timeIntervalSince1970: 20) + ) + _ = try store.saveBackup(newerUnresolved) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000203" + )!, + createdAt: Date(timeIntervalSince1970: 30), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000204" + )!, + createdAt: Date(timeIntervalSince1970: 40), + actuallyDeletedIdentifiers: [] + ) + ) + let restoredURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000205" + )!, + createdAt: Date(timeIntervalSince1970: 50), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: restoredURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: restoredURL + ) + try store.markRestored( + at: restoredURL, + date: Date(timeIntervalSince1970: 60) + ) + + let selected = try requireValue( + try store.latestUnresolvedDeletionBackup(), + "unresolved deletion backup should be selected" + ) + try require( + selected.1.identifier == newerUnresolved.identifier, + "selection did not choose the newest unresolved outcome" + ) + }), + ("unresolved selection ignores empty resolved and restorable outcomes", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000211" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: [] + ) + ) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000212" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + ) + + try require( + try store.latestUnresolvedDeletionBackup() == nil, + "resolved outcomes must not be selected as unresolved" + ) + }), + ("unresolved outcome settles into a preserved restorable backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let batch = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000221" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + let url = try store.saveBackup(batch) + let unresolved = try requireValue( + try store.latestUnresolvedDeletionBackup(), + "unresolved deletion outcome should be discoverable" + ) + try require( + unresolved.1.identifier == batch.identifier, + "unresolved selection returned another backup" + ) + + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + + try require( + try store.latestUnresolvedDeletionBackup() == nil, + "settled outcome remained unresolved" + ) + let restorable = try requireValue( + try store.latestRestorableBackup(), + "settled non-empty deletion should become restorable" + ) + try require( + restorable.1.identifier == batch.identifier, + "settlement replaced or lost the original backup" + ) + try require( + FileManager.default.fileExists(atPath: url.path), + "settlement must preserve the backup file" + ) + }), + ("restore selection skips a newer unresolved backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000101" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000102" + )!, + createdAt: Date(timeIntervalSince1970: 20) + ) + ) + + let selected = try requireValue( + try store.latestRestorableBackup(), + "older real deletion backup should remain restorable" + ) + try require( + selected.1.identifier == older.identifier, + "newer unresolved backup blocked the real deletion backup" + ) + }), + ("restore selection skips a newer empty deletion result", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000111" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000112" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [] + ) + ) + + let selected = try requireValue( + try store.latestRestorableBackup(), + "older real deletion backup should remain restorable" + ) + try require( + selected.1.identifier == older.identifier, + "newer empty deletion result blocked the real deletion backup" + ) + }), + ("unresolved and empty backups are not restorable or markable", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let unresolvedURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000121" + )!, + createdAt: Date(timeIntervalSince1970: 10) + ) + ) + let emptyURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000122" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: [] + ) + ) + + try require( + try store.latestRestorableBackup() == nil, + "unresolved or empty deletion results are not restorable" + ) + for url in [unresolvedURL, emptyURL] { + do { + try store.markRestored( + at: url, + date: Date(timeIntervalSince1970: 30) + ) + throw TestFailure( + description: "non-restorable backup was marked restored" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected non-restorable mark error" + ) + } + try require( + try store.loadBackup(at: url).restoredAt == nil, + "failed mark must not modify the backup" + ) + } + }), + ("restore selection chooses the newest real deletion backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + _ = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000131" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + ) + let newer = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000132" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(newer) + + let selected = try requireValue( + try store.latestRestorableBackup(), + "real deletion backup should be restorable" + ) + try require( + selected.1.identifier == newer.identifier, + "selection did not choose the newest real deletion backup" + ) + }), + ("restore selection skips a restored real deletion backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let older = pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000141" + )!, + createdAt: Date(timeIntervalSince1970: 10), + actuallyDeletedIdentifiers: ["item"] + ) + _ = try store.saveBackup(older) + let newerURL = try store.saveBackup( + pruneBackupFixture( + identifier: UUID( + uuidString: "00000000-0000-0000-0000-000000000142" + )!, + createdAt: Date(timeIntervalSince1970: 20), + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: newerURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: newerURL + ) + try store.markRestored( + at: newerURL, + date: Date(timeIntervalSince1970: 30) + ) + + let selected = try requireValue( + try store.latestRestorableBackup(), + "older unrestored deletion backup should remain available" + ) + try require( + selected.1.identifier == older.identifier, + "restored deletion backup was selected again" + ) + }), + ("prune backup persists deletion and idempotent restore readback", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + var batch = pruneBackupFixture() + var notDeleted = batch.items[0] + notDeleted.originalItemIdentifier = "not-deleted" + batch.items.append(notDeleted) + let url = try store.saveBackup(batch) + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + let firstAttempt = try store.beginRestoreAttempt(at: url) + let secondAttempt = try store.beginRestoreAttempt(at: url) + try require( + firstAttempt.restoreAttemptIdentifier + == secondAttempt.restoreAttemptIdentifier, + "restore attempt must be stable across retries" + ) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + let loaded = try store.loadBackup(at: url) + try require( + loaded.actuallyDeletedIdentifiers == ["item"], + "actual deletion result did not persist" + ) + try require( + loaded.actuallyDeletedItems?.map(\.originalItemIdentifier) + == ["item"], + "restore selection must exclude attempted but retained items" + ) + try require( + loaded.restoredItemIdentifiers == ["item": "restored-item"], + "restore readback did not persist" + ) + do { + try store.recordActuallyDeletedIdentifiers([], at: url) + throw TestFailure( + description: "deletion result overwrite should fail" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected deletion overwrite error" + ) + } + do { + try store.recordRestoreReadback( + ["item": "different-restored-item"], + at: url + ) + throw TestFailure( + description: "restore readback overwrite should fail" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restore overwrite error" + ) + } + }), + ("restored backup rejects every further state mutation", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup( + pruneBackupFixture( + actuallyDeletedIdentifiers: ["item"] + ) + ) + _ = try store.beginRestoreAttempt(at: url) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + let restoredAt = Date(timeIntervalSince1970: 30) + try store.markRestored(at: url, date: restoredAt) + + do { + try store.recordActuallyDeletedIdentifiers(["item"], at: url) + throw TestFailure( + description: "restored deletion outcome was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored outcome mutation error" + ) + } + do { + _ = try store.beginRestoreAttempt(at: url) + throw TestFailure( + description: "restored backup began another attempt" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored begin mutation error" + ) + } + do { + try store.recordRestoreReadback( + ["item": "restored-item"], + at: url + ) + throw TestFailure( + description: "restored backup accepted another readback" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected restored readback mutation error" + ) + } + do { + try store.markRestored( + at: url, + date: Date(timeIntervalSince1970: 40) + ) + throw TestFailure( + description: "restored backup was marked twice" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .invalidBackup, + "unexpected repeated restored mutation error" + ) + } + try require( + try store.loadBackup(at: url).restoredAt == restoredAt, + "rejected mutations changed the restored backup" + ) + }), + ("prune local store writes private ledger and verified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let ledger = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 10), + fingerprint: "fingerprint", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(ledger) + try require(try store.loadLedger() == ledger, "ledger round-trip failed") + + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "permissions missing" + ) + try require(permissions.intValue & 0o777 == 0o600, "ledger must be 0600") + + let rootPermissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: root.path + )[.posixPermissions] as? NSNumber, + "root permissions missing" + ) + try require(rootPermissions.intValue & 0o777 == 0o700, "root must be 0700") + + let batch = pruneBackupFixture() + let url = try store.saveBackup(batch) + let loadedBatch = try store.loadBackup(at: url) + try require(loadedBatch == batch, "backup verification failed") + try require( + loadedBatch.targetSourceIdentifier == "source", + "backup source identifier round-trip failed" + ) + try require( + loadedBatch.backupSchemaVersion + == ReminderPruneRestorePolicy.currentBackupSchemaVersion, + "backup schema version round-trip failed" + ) + try require( + loadedBatch.rulesVersion + == ReminderPruneStateMachine.rulesVersion, + "backup rules version round-trip failed" + ) + try require( + loadedBatch.items.first?.taskPresence == .absent, + "backup task presence round-trip failed" + ) + }), + ("runtime backup migration preserves content and normalizes permissions", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let backups = root.appendingPathComponent( + "Backups", + isDirectory: true + ) + let legacyBatch = backups.appendingPathComponent( + "legacy-batch", + isDirectory: true + ) + let sentinel = legacyBatch.appendingPathComponent("sentinel.bak") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: legacyBatch, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + for directory in [root, backups, legacyBatch] { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: directory.path + ) + } + let sentinelData = Data("legacy sentinel".utf8) + try sentinelData.write(to: sentinel) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: sentinel.path + ) + + let backupStore = TaskSourceBackupStore(backupsRootURL: backups) + let freshData = Data("fresh backup".utf8) + let fresh = try backupStore.save( + freshData, + fileName: "fresh.bak", + batchName: "fresh-batch" + ) + let ledger = try ReminderPruneLocalStore(rootURL: root).loadLedger() + + try require( + ledger == ReminderPruneLedger(), + "prune store could not load after source backup initialization" + ) + try require( + try Data(contentsOf: sentinel) == sentinelData, + "legacy backup content changed during migration" + ) + try require( + try Data(contentsOf: fresh) == freshData, + "new source backup content changed" + ) + for directory in [ + root, + backups, + legacyBatch, + fresh.deletingLastPathComponent() + ] { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) must be 0700" + ) + } + for file in [sentinel, fresh] { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) must be 0600" + ) + } + }), + ("prune store migrates an existing backup tree under a private root", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o700 + ) + let before = try runtimeTreeEvidence(at: root) + try require( + before.directoryCount == 6 && before.fileCount == 5, + "legacy fixture does not match production tree shape" + ) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedger() + == ReminderPruneLedger(), + "normal prune load should preserve an absent ledger" + ) + let after = try runtimeTreeEvidence(at: root) + + try require( + after == before, + "prune initialization changed backup count or content hashes" + ) + for directory in fixture.directories { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) was not migrated to 0700" + ) + } + for file in fixture.files { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) was not migrated to 0600" + ) + } + }), + ("prune store migrates an exposed root and its complete backup tree", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o755 + ) + let before = try runtimeTreeEvidence(at: fixture.backupsRoot) + let store = ReminderPruneLocalStore(rootURL: root) + + try store.saveLedger(ReminderPruneLedger()) + try require( + try store.loadLedger() == ReminderPruneLedger(), + "normal prune save did not preserve the ledger" + ) + let after = try runtimeTreeEvidence(at: fixture.backupsRoot) + + try require( + after == before, + "full migration changed backup count or content hashes" + ) + for directory in fixture.directories { + try require( + try permissions(at: directory) == 0o700, + "\(directory.lastPathComponent) was not private" + ) + } + for file in fixture.files { + try require( + try permissions(at: file) == 0o600, + "\(file.lastPathComponent) was not private" + ) + } + try require( + try permissions(at: store.ledgerURL) == 0o600, + "prune save did not create a private ledger" + ) + }), + ("prune store fails closed for an unsafe existing backup tree", { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: container) } + + let writableRoot = container.appendingPathComponent( + "writable", + isDirectory: true + ) + let writableFixture = try makeLegacySourceBackupFixture( + root: writableRoot, + rootPermissions: 0o700 + ) + let writableBefore = try runtimeTreeEvidence(at: writableRoot) + let writableFile = writableFixture.files[0] + try FileManager.default.setAttributes( + [.posixPermissions: 0o660], + ofItemAtPath: writableFile.path + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: writableRoot) + .loadLedger() + throw TestFailure( + description: "group-writable backup tree was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected writable-tree error" + ) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: writableFile.path + ) + try require( + try runtimeTreeEvidence(at: writableRoot) == writableBefore, + "failed migration changed writable-tree content" + ) + try require( + try permissions(at: writableFixture.backupsRoot) == 0o755, + "failed migration partially changed directory modes" + ) + + let symlinkRoot = container.appendingPathComponent( + "symlink", + isDirectory: true + ) + let symlinkFixture = try makeLegacySourceBackupFixture( + root: symlinkRoot, + rootPermissions: 0o700 + ) + let external = container.appendingPathComponent("external.bak") + let linked = symlinkFixture.backupsRoot + .appendingPathComponent("linked.bak") + try Data("external".utf8).write(to: external) + try FileManager.default.createSymbolicLink( + at: linked, + withDestinationURL: external + ) + + do { + _ = try ReminderPruneLocalStore(rootURL: symlinkRoot).loadLedger() + throw TestFailure( + description: "symlinked backup tree was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected symlink-tree error" + ) + } + try require( + try Data(contentsOf: external) == Data("external".utf8), + "failed migration changed the symlink target" + ) + try require( + try permissions(at: symlinkFixture.backupsRoot) == 0o755, + "symlink rejection partially changed directory modes" + ) + }), + ("prune dry-run leaves an exposed backup tree unchanged", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try makeLegacySourceBackupFixture( + root: root, + rootPermissions: 0o700 + ) + let before = try runtimeTreeEvidence(at: root) + + try require( + try ReminderPruneLocalStore(rootURL: root).loadLedgerReadOnly() + == ReminderPruneLedger(), + "dry-run should ignore an unrelated source backup tree" + ) + + try require( + try runtimeTreeEvidence(at: root) == before, + "dry-run changed backup count or content hashes" + ) + try require( + try permissions(at: fixture.backupsRoot) == 0o755, + "dry-run changed source backup directory permissions" + ) + try require( + try permissions(at: fixture.files[0]) == 0o644, + "dry-run changed source backup file permissions" + ) + }), + ("runtime migration policy rejects a different owner", { + let metadata = PrivateRuntimeNodeSecurity( + ownerUID: 502, + permissions: 0o755, + kind: .directory, + hasExtendedACL: false + ) + try require( + !PrivateRuntimeDirectoryPolicy.canMigrate( + metadata, + currentUserUID: 501, + expectedKind: .directory + ), + "a different owner must fail closed" + ) + }), + ("runtime root migration rejects symlinks and writable modes", { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let target = container.appendingPathComponent( + "target", + isDirectory: true + ) + let symlink = container.appendingPathComponent( + "runtime-link", + isDirectory: true + ) + let writable = container.appendingPathComponent( + "runtime-writable", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: container) } + try FileManager.default.createDirectory( + at: target, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.createSymbolicLink( + at: symlink, + withDestinationURL: target + ) + try FileManager.default.createDirectory( + at: writable, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o770] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o770], + ofItemAtPath: writable.path + ) + + for root in [symlink, writable] { + do { + _ = try ReminderPruneLocalStore(rootURL: root).loadLedger() + throw TestFailure( + description: "\(root.lastPathComponent) was migrated" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected unsafe-root error" + ) + } + } + try require( + try permissions(at: target) == 0o755, + "symlink target permissions changed" + ) + try require( + try permissions(at: writable) == 0o770, + "writable root permissions changed" + ) + }), + ("runtime backup migration rejects a symlink entry", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let backups = root.appendingPathComponent( + "Backups", + isDirectory: true + ) + let target = root.appendingPathComponent("outside.bak") + let linked = backups.appendingPathComponent("linked.bak") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: backups, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + try Data("outside".utf8).write(to: target) + try FileManager.default.createSymbolicLink( + at: linked, + withDestinationURL: target + ) + + do { + _ = try TaskSourceBackupStore(backupsRootURL: backups).save( + Data("new".utf8), + fileName: "new.bak", + batchName: "new-batch" + ) + throw TestFailure( + description: "symlinked legacy backup entry was accepted" + ) + } catch let error as PrivateRuntimeDirectoryError { + try require( + error == .unsafeNode, + "unexpected backup-tree symlink error" + ) + } + try require( + try Data(contentsOf: target) == Data("outside".utf8), + "symlink target content changed" + ) + }), + ("runtime root migration rejects an extended ACL", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o755] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.path + ) + try addReadOnlyExtendedACL(to: root) + + do { + _ = try ReminderPruneLocalStore(rootURL: root).loadLedger() + throw TestFailure( + description: "extended ACL runtime root was accepted" + ) + } catch let error as ReminderPruneStoreError { + try require( + error == .permissions, + "unexpected ACL-root error" + ) + } + try require( + try permissions(at: root) == 0o755, + "ACL rejection changed root permissions" + ) + }), + ("prune local store rejects a modified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + let object = try requireValue( + try JSONSerialization.jsonObject(with: Data(contentsOf: url)) + as? [String: Any], + "backup envelope should be JSON" + ) + var modified = object + var payload = try requireValue( + object["payload"] as? [String: Any], + "backup payload should be an object" + ) + payload["targetCalendarTitle"] = "tampered" + modified["payload"] = payload + try JSONSerialization.data( + withJSONObject: modified, + options: [.sortedKeys] + ).write(to: url, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + do { + _ = try store.loadBackup(at: url) + throw TestFailure(description: "tampered backup was accepted") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected store error") + } + }), + ("prune local store refuses insecure overwrite before replacing the ledger", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let original = ReminderPruneLedger(entries: [:]) + let replacement = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 100), + fingerprint: "replacement", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(original) + let originalData = try Data(contentsOf: store.ledgerURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: store.ledgerURL.path + ) + + do { + try store.saveLedger(replacement) + throw TestFailure(description: "insecure ledger was overwritten") + } catch let error as ReminderPruneStoreError { + try require(error == .permissions, "unexpected overwrite error") + } + try require( + try Data(contentsOf: store.ledgerURL) == originalData, + "insecure ledger contents changed before rejection" + ) + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "ledger permissions missing" + ) + try require( + permissions.intValue & 0o777 == 0o644, + "insecure ledger permissions changed before rejection" + ) + }), + ("prune local store fails closed for corrupt ledger and backup inventory", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + try store.saveLedger(ReminderPruneLedger()) + try Data("not ledger json".utf8).write(to: store.ledgerURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: store.ledgerURL.path + ) + do { + _ = try store.loadLedger() + throw TestFailure(description: "corrupt ledger returned an empty ledger") + } catch let error as ReminderPruneStoreError { + try require(error == .invalidLedger, "unexpected corrupt ledger error") + } + + let backupURL = try store.saveBackup(pruneBackupFixture()) + try Data("not backup json".utf8).write(to: backupURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: backupURL.path + ) + do { + _ = try store.latestRestorableBackup() + throw TestFailure(description: "corrupt backup was ignored") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected corrupt backup error") + } + }), + ("prune local store preserves restored backups and private runtime permissions", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let backupURL = try store.saveBackup(pruneBackupFixture()) + let restoredAt = Date(timeIntervalSince1970: 99) + try store.recordActuallyDeletedIdentifiers(["item"], at: backupURL) + _ = try store.beginRestoreAttempt(at: backupURL) + try store.recordRestoreReadback( + ["item": "restored-item"], + at: backupURL + ) + try store.markRestored(at: backupURL, date: restoredAt) + try require( + try store.loadBackup(at: backupURL).restoredAt == restoredAt, + "restored backup must still verify" + ) + try require( + try store.latestRestorableBackup() == nil, + "restored backup must not remain latest unrestored" + ) + + let salt = try store.loadOrCreateHashSalt() + try require(salt.count == 32, "salt must be 32 bytes") + try require(salt == store.loadOrCreateHashSalt(), "salt should be reused") + for (url, expected) in [ + (root, 0o700), + (backupURL.deletingLastPathComponent(), 0o700), + (backupURL, 0o600), + (root.appendingPathComponent("PruneHashSalt"), 0o600) + ] { + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: url.path + )[.posixPermissions] as? NSNumber, + "permissions missing for \(url.lastPathComponent)" + ) + try require( + permissions.intValue & 0o777 == expected, + "unexpected permissions for \(url.lastPathComponent)" + ) + } + }), + ("prune local stores create one shared hash salt across instances", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let stores = (0..<32).map { _ in + ReminderPruneLocalStore(rootURL: root) + } + let queue = DispatchQueue(label: "prune-salt", attributes: .concurrent) + let group = DispatchGroup() + let start = DispatchSemaphore(value: 0) + let lock = NSLock() + var salts: [Data] = [] + var failures: [String] = [] + + for index in stores.indices { + group.enter() + queue.async { + start.wait() + do { + let salt = try stores[index].loadOrCreateHashSalt() + lock.lock() + salts.append(salt) + lock.unlock() + } catch { + lock.lock() + failures.append(String(describing: error)) + lock.unlock() + } + group.leave() + } + } + for _ in 0..<32 { + start.signal() + } + group.wait() + + try require( + failures.isEmpty, + "concurrent salt creation failed: \(failures)" + ) + try require(salts.count == 32, "missing concurrent salt result") + try require( + salts.dropFirst().allSatisfy { $0 == salts[0] }, + "concurrent callers received different salts" + ) + try require( + try ReminderPruneLocalStore(rootURL: root).loadOrCreateHashSalt() + == salts[0], + "persisted salt differs from concurrent callers" + ) }) ] diff --git a/Tests/TaskForgeReminderEventKitTests/main.swift b/Tests/TaskForgeReminderEventKitTests/main.swift new file mode 100644 index 0000000..f0cc03c --- /dev/null +++ b/Tests/TaskForgeReminderEventKitTests/main.swift @@ -0,0 +1,1047 @@ +import Darwin +import Dispatch +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +struct IntegrationFailure: Error, CustomStringConvertible { + let description: String +} + +func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw IntegrationFailure(description: message) + } +} + +guard ProcessInfo.processInfo.environment[ + "TASKFORGE_RUN_EVENTKIT_TESTS" +] == "1" else { + print("SKIP EventKit integration tests require explicit opt-in") + exit(0) +} + +private final class AsyncResultGate: @unchecked Sendable { + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resolve(_ result: Result) { + lock.lock() + guard let continuation else { + lock.unlock() + return + } + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } + + private let lock = NSLock() + private var continuation: CheckedContinuation? +} + +private struct FixtureLabels { + let plain: String + let priority: String + let important: [String] + let completed: String + let current: String + let historical: String + let other: String +} + +private struct RestoreExpectation { + let title: String + let notes: String + let url: URL + let dueDateComponents: DateComponents + let startDateComponents: DateComponents + let alarmOffset: TimeInterval + let recurrenceInterval: Int + let recurrenceCount: Int +} + +private struct TemporaryCalendarIdentifiers { + var target: String? + var other: String? + + var all: [String] { + [target, other].compactMap { $0 } + } +} + +private struct DateComponentsBaseline: Equatable { + init(_ components: DateComponents) { + calendarIdentifier = components.calendar?.identifier + timeZoneIdentifier = components.timeZone?.identifier + timeZoneSecondsFromGMT = + components.timeZone?.secondsFromGMT() + era = components.era + year = components.year + month = components.month + day = components.day + hour = components.hour + minute = components.minute + second = components.second + nanosecond = components.nanosecond + weekday = components.weekday + weekdayOrdinal = components.weekdayOrdinal + quarter = components.quarter + weekOfMonth = components.weekOfMonth + weekOfYear = components.weekOfYear + yearForWeekOfYear = components.yearForWeekOfYear + isLeapMonth = components.isLeapMonth + } + + let calendarIdentifier: Calendar.Identifier? + let timeZoneIdentifier: String? + let timeZoneSecondsFromGMT: Int? + let era: Int? + let year: Int? + let month: Int? + let day: Int? + let hour: Int? + let minute: Int? + let second: Int? + let nanosecond: Int? + let weekday: Int? + let weekdayOrdinal: Int? + let quarter: Int? + let weekOfMonth: Int? + let weekOfYear: Int? + let yearForWeekOfYear: Int? + let isLeapMonth: Bool? +} + +private struct ReminderFieldBaseline: Equatable { + init(_ reminder: EKReminder) { + title = reminder.title + notes = reminder.notes + url = reminder.url + priority = reminder.priority + isCompleted = reminder.isCompleted + dueDateComponents = reminder.dueDateComponents.map( + DateComponentsBaseline.init + ) + startDateComponents = reminder.startDateComponents.map( + DateComponentsBaseline.init + ) + alarms = (reminder.alarms ?? []).map { alarm in + let location = alarm.structuredLocation.map { + ReminderLocationBackup( + title: $0.title ?? "", + latitude: $0.geoLocation?.coordinate.latitude, + longitude: $0.geoLocation?.coordinate.longitude, + radius: $0.radius + ) + } + return ReminderAlarmBackup( + absoluteDate: alarm.absoluteDate, + relativeOffset: alarm.absoluteDate == nil + ? alarm.relativeOffset + : nil, + structuredLocation: location, + proximityRawValue: alarm.proximity.rawValue + ) + } + recurrenceRules = (reminder.recurrenceRules ?? []).map { rule in + let end = rule.recurrenceEnd + return ReminderRecurrenceBackup( + frequencyRawValue: rule.frequency.rawValue, + interval: rule.interval, + daysOfWeek: (rule.daysOfTheWeek ?? []).map { + ReminderWeekdayBackup( + dayOfTheWeekRawValue: + $0.dayOfTheWeek.rawValue, + weekNumber: $0.weekNumber + ) + }, + daysOfMonth: + (rule.daysOfTheMonth ?? []).map(\.intValue), + monthsOfYear: + (rule.monthsOfTheYear ?? []).map(\.intValue), + weeksOfYear: + (rule.weeksOfTheYear ?? []).map(\.intValue), + daysOfYear: + (rule.daysOfTheYear ?? []).map(\.intValue), + setPositions: + (rule.setPositions ?? []).map(\.intValue), + endDate: end?.endDate, + occurrenceCount: end.flatMap { + $0.occurrenceCount > 0 + ? Int($0.occurrenceCount) + : nil + } + ) + } + } + + let title: String? + let notes: String? + let url: URL? + let priority: Int + let isCompleted: Bool + let dueDateComponents: DateComponentsBaseline? + let startDateComponents: DateComponentsBaseline? + let alarms: [ReminderAlarmBackup] + let recurrenceRules: [ReminderRecurrenceBackup] +} + +private let operationTimeout: TimeInterval = 30 + +private func checked( + _ count: inout Int, + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + try require(condition(), message) + count += 1 +} + +private func requestReminderAccess( + eventStore: EKEventStore +) async throws { + let granted: Bool = try await withCheckedThrowingContinuation { + continuation in + let gate = AsyncResultGate(continuation) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + operationTimeout + ) { + gate.resolve( + .failure( + IntegrationFailure( + description: "reminder access request timed out" + ) + ) + ) + } + + let completion: @Sendable (Bool, Error?) -> Void = { + granted, error in + if error != nil { + gate.resolve( + .failure( + IntegrationFailure( + description: "reminder access request failed" + ) + ) + ) + } else { + gate.resolve(.success(granted)) + } + } + if #available(macOS 14.0, *) { + eventStore.requestFullAccessToReminders(completion: completion) + } else { + eventStore.requestAccess(to: .reminder, completion: completion) + } + } + guard granted else { + throw IntegrationFailure(description: "reminder access was denied") + } +} + +private func fetchReminders( + eventStore: EKEventStore, + calendars: [EKCalendar] +) async throws -> [EKReminder] { + guard !calendars.isEmpty else { + throw IntegrationFailure( + description: "isolated reminder fetch requires a temporary calendar" + ) + } + let predicate = eventStore.predicateForReminders(in: calendars) + return try await withCheckedThrowingContinuation { continuation in + let gate = AsyncResultGate<[EKReminder]>(continuation) + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + operationTimeout + ) { + gate.resolve( + .failure( + IntegrationFailure( + description: "isolated reminder fetch timed out" + ) + ) + ) + } + _ = eventStore.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + gate.resolve( + .failure( + IntegrationFailure( + description: "isolated reminder fetch failed" + ) + ) + ) + return + } + gate.resolve(.success(reminders)) + } + } +} + +private func exactTemporaryCalendar( + eventStore: EKEventStore, + identifier: String, + expectedName: String +) throws -> EKCalendar { + guard + let calendar = eventStore.calendar(withIdentifier: identifier), + calendar.title == expectedName + else { + throw IntegrationFailure( + description: "temporary calendar lookup failed" + ) + } + return calendar +} + +private func createTemporaryCalendars( + eventStore: EKEventStore, + targetName: String, + otherName: String, + identifiers: inout TemporaryCalendarIdentifiers +) throws { + let reminderSources = eventStore.sources.filter { + $0.sourceType == .local || $0.sourceType == .calDAV + } + guard let source = reminderSources.first else { + throw IntegrationFailure( + description: "no writable reminder source is available" + ) + } + + let target = EKCalendar(for: .reminder, eventStore: eventStore) + target.title = targetName + target.source = source + try eventStore.saveCalendar(target, commit: true) + guard !target.calendarIdentifier.isEmpty else { + try? eventStore.removeCalendar(target, commit: true) + throw IntegrationFailure( + description: "target calendar identifier was unavailable" + ) + } + let targetIdentifier = target.calendarIdentifier + identifiers.target = targetIdentifier + + let other = EKCalendar(for: .reminder, eventStore: eventStore) + other.title = otherName + other.source = source + try eventStore.saveCalendar(other, commit: true) + guard !other.calendarIdentifier.isEmpty else { + try? eventStore.removeCalendar(other, commit: true) + throw IntegrationFailure( + description: "other calendar identifier was unavailable" + ) + } + let otherIdentifier = other.calendarIdentifier + identifiers.other = otherIdentifier + + eventStore.reset() + _ = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetIdentifier, + expectedName: targetName + ) + _ = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherIdentifier, + expectedName: otherName + ) +} + +private func cleanupTemporaryCalendars( + eventStore: EKEventStore, + identifiers: [String] +) -> IntegrationFailure? { + for identifier in identifiers { + for _ in 0..<3 { + eventStore.reset() + guard + let calendar = eventStore.calendar( + withIdentifier: identifier + ) + else { + break + } + try? eventStore.removeCalendar(calendar, commit: true) + } + } + eventStore.reset() + guard identifiers.allSatisfy({ + eventStore.calendar(withIdentifier: $0) == nil + }) else { + return IntegrationFailure( + description: "temporary calendar cleanup failed" + ) + } + return nil +} + +private func createPrivateDirectory(_ url: URL) throws { + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) +} + +private func stageReminder( + eventStore: EKEventStore, + calendar: EKCalendar, + title: String, + notes: String? = nil, + priority: Int = 0, + isCompleted: Bool = false, + configure: ((EKReminder) -> Void)? = nil +) throws { + let reminder = EKReminder(eventStore: eventStore) + reminder.calendar = calendar + reminder.title = title + reminder.notes = notes + reminder.priority = priority + reminder.isCompleted = isCompleted + configure?(reminder) + try eventStore.save(reminder, commit: false) +} + +private func stageFixtures( + eventStore: EKEventStore, + targetCalendar: EKCalendar, + otherCalendar: EKCalendar, + labels: FixtureLabels, + restore: RestoreExpectation, + currentMarker: String, + historicalReference: String +) throws { + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.plain, + notes: restore.notes + ) { reminder in + reminder.url = restore.url + reminder.dueDateComponents = restore.dueDateComponents + reminder.startDateComponents = restore.startDateComponents + reminder.addAlarm( + EKAlarm(relativeOffset: restore.alarmOffset) + ) + reminder.addRecurrenceRule( + EKRecurrenceRule( + recurrenceWith: .daily, + interval: restore.recurrenceInterval, + end: EKRecurrenceEnd( + occurrenceCount: restore.recurrenceCount + ) + ) + ) + } + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.priority, + priority: 1 + ) + for title in labels.important { + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: title + ) + } + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.completed, + isCompleted: true + ) + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.current, + notes: currentMarker + ) + try stageReminder( + eventStore: eventStore, + calendar: targetCalendar, + title: labels.historical, + notes: historicalReference + ) + try stageReminder( + eventStore: eventStore, + calendar: otherCalendar, + title: labels.other + ) + try eventStore.commit() + eventStore.reset() +} + +private func onlyReminder( + _ reminders: [EKReminder], + title: String +) throws -> EKReminder { + let matches = reminders.filter { $0.title == title } + guard matches.count == 1, let reminder = matches.first else { + throw IntegrationFailure( + description: "fixture reminder lookup was not unique" + ) + } + return reminder +} + +private func latestBackupURL(localRoot: URL) throws -> URL { + let backupDirectory = localRoot.appendingPathComponent( + "PruneBackups", + isDirectory: true + ) + let backups = try FileManager.default.contentsOfDirectory( + at: backupDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "json" } + guard backups.count == 1, let backup = backups.first else { + throw IntegrationFailure( + description: "exactly one prune backup was expected" + ) + } + return backup +} + +private func privateFilePermissions(at url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem( + atPath: url.path + ) + if let permissions = attributes[.posixPermissions] as? NSNumber { + return permissions.intValue & 0o777 + } + if let permissions = attributes[.posixPermissions] as? Int { + return permissions & 0o777 + } + throw IntegrationFailure( + description: "backup permissions were unavailable" + ) +} + +@MainActor +private func runIntegrationTests() async throws -> Int { + let eventStore = EKEventStore() + let targetName = "TaskForgeReminderSync Test \(UUID().uuidString)" + let otherName = "TaskForgeReminderSync Other \(UUID().uuidString)" + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent( + "TaskForgeReminderEventKitTests-\(UUID().uuidString)", + isDirectory: true + ) + let vaultURL = testRoot.appendingPathComponent( + "Vault", + isDirectory: true + ) + let localRoot = testRoot.appendingPathComponent( + "PruneState", + isDirectory: true + ) + + var bodyError: Error? + var cleanupError: Error? + var passCount = 0 + var calendarIdentifiers = TemporaryCalendarIdentifiers() + + do { + defer { + let calendarError = cleanupTemporaryCalendars( + eventStore: eventStore, + identifiers: calendarIdentifiers.all + ) + var directoryError: IntegrationFailure? + if FileManager.default.fileExists(atPath: testRoot.path) { + do { + try FileManager.default.removeItem(at: testRoot) + } catch { + directoryError = IntegrationFailure( + description: "temporary test directory cleanup failed" + ) + } + } + cleanupError = calendarError ?? directoryError + } + + do { + try await requestReminderAccess(eventStore: eventStore) + try createPrivateDirectory(testRoot) + try createPrivateDirectory(vaultURL) + try createTemporaryCalendars( + eventStore: eventStore, + targetName: targetName, + otherName: otherName, + identifiers: &calendarIdentifiers + ) + guard + let targetCalendarIdentifier = + calendarIdentifiers.target, + let otherCalendarIdentifier = + calendarIdentifiers.other + else { + throw IntegrationFailure( + description: "temporary calendar identifiers missing" + ) + } + print("TEMP \(targetName)") + print("TEMP \(otherName)") + + let labelToken = UUID().uuidString + let labels = FixtureLabels( + plain: "plain-\(labelToken)", + priority: "priority-\(labelToken)", + important: ["!", "!", "❗", "‼️", "⭐", "📌"].map { + " \($0) protected-\(labelToken)" + }, + completed: "completed-\(labelToken)", + current: "current-\(labelToken)", + historical: "historical-\(labelToken)", + other: "other-\(labelToken)" + ) + + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = TimeZone(secondsFromGMT: 0)! + let dueDate = DateComponents( + calendar: gregorian, + timeZone: gregorian.timeZone, + year: 2030, + month: 7, + day: 30, + hour: 10, + minute: 45 + ) + let startDate = DateComponents( + calendar: gregorian, + timeZone: gregorian.timeZone, + year: 2030, + month: 7, + day: 30, + hour: 9, + minute: 30 + ) + let restore = RestoreExpectation( + title: labels.plain, + notes: "integration fixture notes", + url: URL( + string: + "taskforge-eventkit-test://restore/\(UUID().uuidString)" + )!, + dueDateComponents: dueDate, + startDateComponents: startDate, + alarmOffset: -1_800, + recurrenceInterval: 2, + recurrenceCount: 4 + ) + + let sourceURL = vaultURL.appendingPathComponent("history.md") + try "- [ ] 历史任务\n".write( + to: sourceURL, + atomically: true, + encoding: .utf8 + ) + let historicalTask = TaskForgeTask( + identifier: "historical-\(UUID().uuidString)", + title: "历史任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: sourceURL.path, + sourceType: "markdownInline", + originalLine: "- [ ] 历史任务", + lineNumber: 1 + ) + let currentTask = TaskForgeTask( + identifier: "current-\(UUID().uuidString)", + title: "当前任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: nil, + sourceType: nil, + originalLine: nil, + lineNumber: nil + ) + let snapshot = TaskForgeSnapshot( + version: 6, + vaultPath: vaultURL.path, + tasks: [currentTask] + ) + let currentMarker = TaskSyncMarker.make( + vaultPath: snapshot.vaultPath, + taskIdentifier: currentTask.identifier + ) + let historicalReference = TaskSourceReference( + task: historicalTask + ).encodedLine + + let targetCalendar = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + let otherCalendar = try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName + ) + try stageFixtures( + eventStore: eventStore, + targetCalendar: targetCalendar, + otherCalendar: otherCalendar, + labels: labels, + restore: restore, + currentMarker: currentMarker, + historicalReference: historicalReference + ) + + let initialTarget = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let initialOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName + ) + ] + ) + try checked( + &passCount, + initialTarget.count == 11, + "target fixture count was incorrect" + ) + try checked( + &passCount, + initialOther.count == 1, + "other-list fixture count was incorrect" + ) + + let initialTargetIdentifiers = Set( + initialTarget.map(\.calendarItemIdentifier) + ) + let initialOtherIdentifiers = Set( + initialOther.map(\.calendarItemIdentifier) + ) + let plainReminder = try onlyReminder( + initialTarget, + title: labels.plain + ) + let plainIdentifier = + plainReminder.calendarItemIdentifier + let plainFieldBaseline = ReminderFieldBaseline( + plainReminder + ) + let priorityIdentifier = try onlyReminder( + initialTarget, + title: labels.priority + ).calendarItemIdentifier + let importantIdentifiers = try labels.important.map { + try onlyReminder(initialTarget, title: $0) + .calendarItemIdentifier + } + let completedIdentifier = try onlyReminder( + initialTarget, + title: labels.completed + ).calendarItemIdentifier + let currentIdentifier = try onlyReminder( + initialTarget, + title: labels.current + ).calendarItemIdentifier + let historicalIdentifier = try onlyReminder( + initialTarget, + title: labels.historical + ).calendarItemIdentifier + let pruner = ReminderPruner( + eventStore: eventStore, + configuration: ReminderPruneConfiguration( + listName: targetName, + localRoot: localRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: { _ in }, + logError: { _ in } + ) + let firstNow = Date(timeIntervalSince1970: 10_000) + let secondNow = Date(timeIntervalSince1970: 10_061) + let first = try await pruner.advance( + snapshot: snapshot, + now: firstNow + ) + try checked( + &passCount, + first.deleted == 0, + "first scan deleted immediately" + ) + try checked( + &passCount, + first.firstSeen == 1, + "first scan did not register exactly one candidate" + ) + + let afterFirst = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let afterFirstOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName + ) + ] + ) + try checked( + &passCount, + Set(afterFirst.map(\.calendarItemIdentifier)) + == initialTargetIdentifiers, + "first scan changed the target identifier set" + ) + try checked( + &passCount, + Set(afterFirstOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, + "first scan changed the other-list identifier set" + ) + + let second = try await pruner.advance( + snapshot: snapshot, + now: secondNow + ) + try checked( + &passCount, + second.deleted == 1, + "second scan did not delete exactly one reminder" + ) + + let afterSecond = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let afterSecondIdentifiers = Set( + afterSecond.map(\.calendarItemIdentifier) + ) + try checked( + &passCount, + !afterSecondIdentifiers.contains(plainIdentifier), + "plain external fixture survived the second scan" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(priorityIdentifier), + "priority fixture was not protected" + ) + for identifier in importantIdentifiers { + try checked( + &passCount, + afterSecondIdentifiers.contains(identifier), + "important-prefix fixture was not protected" + ) + } + try checked( + &passCount, + afterSecondIdentifiers.contains(completedIdentifier), + "completed fixture was not protected" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(currentIdentifier), + "current snapshot fixture was not protected" + ) + try checked( + &passCount, + afterSecondIdentifiers.contains(historicalIdentifier), + "historical source fixture was not protected" + ) + + let afterSecondOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName + ) + ] + ) + try checked( + &passCount, + Set(afterSecondOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, + "other-list fixture was touched" + ) + + let backupURL = try latestBackupURL(localRoot: localRoot) + let backupPermissions = try privateFilePermissions( + at: backupURL + ) + try checked( + &passCount, + backupPermissions == 0o600, + "prune backup permissions were not 0600" + ) + + let restoreNow = Date(timeIntervalSince1970: 10_062) + let restoredCounts = try await pruner.restoreLast(now: restoreNow) + try checked( + &passCount, + restoredCounts.restored == 1, + "restore did not recreate exactly one reminder" + ) + + let afterRestore = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let restored = try onlyReminder( + afterRestore, + title: restore.title + ) + let restoredIdentifier = + restored.calendarItemIdentifier + try checked( + &passCount, + ReminderFieldBaseline(restored) + == plainFieldBaseline, + "restored fields differ from the saved EventKit baseline" + ) + + let withinGrace = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(86_399) + ) + try checked( + &passCount, + withinGrace.deleted == 0 && withinGrace.waiting == 1, + "24-hour restore grace did not block deletion" + ) + + let afterGraceFirst = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(86_401) + ) + try checked( + &passCount, + afterGraceFirst.deleted == 0 + && afterGraceFirst.firstSeen == 1, + "post-grace first scan did not re-register the candidate" + ) + + let afterGraceSecond = try await pruner.advance( + snapshot: snapshot, + now: restoreNow.addingTimeInterval(86_462) + ) + try checked( + &passCount, + afterGraceSecond.deleted == 1, + "post-grace second scan did not delete the candidate" + ) + + let finalTarget = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: targetCalendarIdentifier, + expectedName: targetName + ) + ] + ) + let finalOther = try await fetchReminders( + eventStore: eventStore, + calendars: [ + try exactTemporaryCalendar( + eventStore: eventStore, + identifier: otherCalendarIdentifier, + expectedName: otherName + ) + ] + ) + try checked( + &passCount, + !Set(finalTarget.map(\.calendarItemIdentifier)) + .contains(restoredIdentifier) + && !finalTarget.contains { + $0.title == labels.plain + }, + "post-grace second scan retained the restored fixture" + ) + try checked( + &passCount, + Set(finalTarget.map(\.calendarItemIdentifier)) + == afterSecondIdentifiers, + "post-grace scans changed a protected fixture" + ) + try checked( + &passCount, + Set(finalOther.map(\.calendarItemIdentifier)) + == initialOtherIdentifiers, + "other-list fixture was touched after restore" + ) + } catch { + bodyError = error + } + } + + if let cleanupError { + throw cleanupError + } + if let bodyError { + throw bodyError + } + return passCount +} + +do { + let passCount = try await runIntegrationTests() + print("PASS \(passCount) isolated EventKit checks") + print("EventKit integration tests passed") +} catch let failure as IntegrationFailure { + fputs( + "FAIL EventKit integration tests: \(failure.description)\n", + stderr + ) + exit(1) +} catch { + fputs( + "FAIL EventKit integration tests: unexpected-stage-failure\n", + stderr + ) + exit(1) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2211642..a8d2d84 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,104 +4,121 @@ ### TaskForgeReminderCore -A Foundation-only library responsible for: +Core is pure Foundation policy and private persistence. It is responsible for: -- decoding TaskForge MessagePack v6 records; -- selecting scheduled open tasks; -- creating and decoding stable markers; -- deriving stable source identities and selecting deduplicated reminder matches; -- serializing durable source references; -- comparing reminder dates semantically; -- validating and editing Markdown / TaskNotes completion state. +- strict decoding of TaskForge v6 records, including the live scalar tombstone; +- decoding `flutter.ctl_` from the TaskForge preferences plist; +- validating known fields/operators and evaluating nested `all`/`any` filter groups; +- canonicalizing TaskForge statuses and learning the four approved Markdown symbols; +- editing Markdown/TaskNotes source content only after identity and original-line checks; +- storing the private Kanban configuration/index with `0700`/`0600` verification; +- the existing pruning, backup, restore and source-presence policies. -Keeping these rules outside EventKit makes them deterministic and testable. +Unknown filter schema, malformed private state and unknown status symbols are errors, not empty +matches. This keeps an incomplete TaskForge reverse-engineering result from becoming a broad +destructive query. ### TaskForgeReminderSync -The macOS executable coordinates: +The executable owns `EKEventStore`, calendar/list creation, forward and reverse reconciliation, +source backups, TaskForge readback verification, and the near-real-time watcher. Its custom +source path never writes `tasks.v6.bin`; it writes only the Vault source file and lets TaskForge +re-index it. -- `EKEventStore` access; -- forward and reverse reconciliation; -- source backups and SHA-256 receipts; -- TaskForge cache refresh verification; -- EventKit notifications, polling and scheduled fallbacks. - -The `--audit` path reads every managed reminder in the target list and -separately reports duplicate task identifiers, duplicate active source -identities, duplicate completed occurrences, normal historical source reuse and -missing source identities. It emits aggregate counts only, including for -historical reminders no longer present in the current TaskForge cache. - -The explicit deduplication path builds connected duplicate groups from active -reminders that share either a TaskForge ID or a source identity. It preserves a -single canonical reminder, preferring an ID still present in the current cache -and otherwise the oldest item. Every redundant reminder is moved—not -deleted—to a separate archive calendar before forward reconciliation refreshes -the preserved item. +The old scheduled-day engine remains behind `--source scheduled-day` for compatibility. It is +not used by the default custom-list path. ### LaunchAgent -The installed LaunchAgent keeps a small signed supervisor alive. The supervisor -starts the app through LaunchServices, monitors the exact `--watch` process and -terminates it when the LaunchAgent is unloaded. Launching the bundle, rather -than its inner Mach-O directly, preserves the EventKit/TCC app identity in the -background. The watcher: - -1. reconciles at startup; -2. debounces `EKEventStoreChanged` notifications for 750 ms; -3. checks the TaskForge cache mtime every second; -4. runs a full reconciliation every minute; -5. runs additional checks at 07:00, 11:00 and 15:00. - -## Forward flow - -1. Decode `tasks.v6.bin`. -2. Select tasks scheduled today and not completed/cancelled. -3. Find or create the configured reminder list. -4. Match reminders by a stable marker derived from Vault path and task ID. -5. If the ID changed, fall back to a durable source identity: - - inline Markdown: standardized file path and line number; - - TaskNotes: standardized file path. -6. Prefer an uncompleted reminder at that source. A completed reminder also - needs the same scheduled day, so an older task that reused the line cannot - capture a new occurrence. -7. Reject ambiguous ID/source/occurrence matches instead of creating another - reminder. -8. Create/update title, due date, notes, marker and durable source reference. -9. Preserve completion if either side is already completed. - -Only today's open tasks create new reminders. Existing linked reminders may -still be refreshed so title, date, time, completion and durable source mapping -stay current. A source-identity fallback rewrites the old marker on the same -EventKit item, so a TaskForge reindex does not create a second reminder. - -## Reverse flow - -1. Fetch all reminders from the configured list, including completed history. -2. Keep only completed reminders carrying this tool's marker. -3. Resolve the task from the current TaskForge cache or the durable reminder - source reference. -4. Reject recurring, non-`keep`, out-of-Vault or ambiguous tasks. -5. Detect and skip a source that is already completed. -6. Back up the complete source file. -7. Apply the completion edit atomically. -8. Verify exact file bytes and wait for TaskForge's cache to refresh. +The supervisor starts the signed App bundle so the background process has the same Reminders TCC +identity as a manually launched App. It restarts the watcher if it exits. The watcher: + +1. reconciles once at startup; +2. debounces `EKEventStoreChanged` for 750 ms; +3. checks the task store and preferences plist every second; +4. performs a full pass every 60 seconds; +5. performs extra passes at 07:00, 11:00 and 15:00. + +Each pass is serialized. Its order is reverse conflict resolution, forward status reconciliation, +then the protected two-scan cleanup. A TaskForge source change is re-read before the forward +phase when reverse writing occurred in the same pass. + +## Custom-list forward flow + +1. Read `tasks.v6.bin`; a truncated or unknown record fails the pass closed. +2. Read the fixed private list ID and the matching `flutter.ctl_` JSON. +3. Validate and evaluate TaskForge's group and condition logic against all v6 tasks. +4. Learn only non-conflicting symbols from real source records and persist them privately. +5. Find or create only the state lists needed by current statuses. Reuse/rename the legacy + `TaskForge 今日` list as `TaskForge · 待办`. +6. Match managed reminders by the stable marker or private index. Duplicate candidates are a + conflict and never cause a new reminder. +7. Move the reminder to the canonical status list, update title, original scheduled date/time, + priority and concise marker notes, and preserve completion. +8. Completed/cancelled tasks are completed in EventKit and are not put in an active state list. +9. Managed reminders whose source task left the fixed list are moved to the base `待办` list + so the protected prune state machine can classify low-priority absent items. + +No date filter is added by this path. An existing task's original schedule is the only date +information sent to EventKit. + +## Reverse flow and conflict precedence + +All state lists are read for managed reminders, including completed reminders. For an open task, +the previous private index status distinguishes a TaskForge change from an Apple list move: + +| Situation | Decision | +|---|---| +| TaskForge open status changed | TaskForge wins; forward phase moves Apple reminder | +| Apple open list changed only | Write that approved status to source, then verify readback | +| Both open sides changed | TaskForge wins; Apple reminder is moved back | +| Apple completion is newly observed | Apple completion wins; source becomes `done` | +| Apple reminder was moved to an ordinary list | Move it back to current TaskForge state | +| Symbol unknown/conflicting or source stale | Refuse source write and move reminder back | + +Source write checks the resolved source path is inside the Vault, reads the exact original bytes, +backs them up, applies only the status edit, atomically replaces the file, hashes and reads it +back, then waits up to 15 seconds for TaskForge to expose the target state. A completion never +deletes a TaskForge line or file. + +## State list policy + +Known active states map to the names and colors documented in the README. Unknown active states +use the configured prefix and a gray list; terminal states have no active list. Existing exact +state-list duplicates are ambiguous and stop the pass. A normal Apple list is never treated as a +status merely because its title resembles one. + +## Pruning and recovery + +The custom path scopes pruning to `TaskForge · 待办`. It protects completed, important, +priority-bearing, indeterminate and ordinary reminders. An absent low-priority managed item is +registered in a private ledger, re-fetched after at least 60 seconds, backed up, removed, and +verified. Fetches time out after 30 seconds; timeout, permission, source and I/O errors preserve +the reminder. Other Apple lists are never fetched as prune inputs. + +Source references used for historical presence checks are stored in the private index, not in +reminder notes. The private index is passed into the pruning policy so historical managed items +remain protected without exposing full paths in EventKit notes. + +All deletion and source-write backups are private, checksum-verified and recoverable. Restore +never consumes a backup when account, calendar, schema, checksum or readback is ambiguous. + +The custom deduplication command builds duplicate groups from all active marked reminders using +the private source references, moves confirmed extras to `TaskForge 今日 · 去重归档`, marks those +extras complete, and adds an archive marker that excludes them from reverse completion. It never +deletes the reminder or its TaskForge source. ## Why the binary cache is read-only -`tasks.v6.bin` is treated as an implementation detail and cache, not as a -public database. Writing it could race with TaskForge, corrupt the cache or -bypass TaskForge's own source-of-truth rules. Reverse sync therefore changes -the Vault source and lets TaskForge re-index it. - -## Loop prevention - -- Apple completion is monotonic: forward sync never reopens an already - completed reminder. -- Date components are compared by year/month/day/hour/minute, ignoring - EventKit's calendar/time-zone metadata. -- EventKit changes are debounced. -- A second reconciliation is queued instead of running concurrently. -- A reminder is claimed by at most one TaskForge task in each reconciliation. -- Duplicate IDs, duplicate source identities and ambiguous existing reminders - are reported as conflicts and never cause a new reminder to be created. +`tasks.v6.bin` is a TaskForge cache and implementation detail. Writing it could race with +TaskForge or bypass the source-of-truth rules. Reverse sync changes the Markdown/TaskNotes +source, then waits for TaskForge to re-index it. + +## Loop prevention and safety gates + +- EventKit notifications are debounced and passes cannot run concurrently. +- A reminder is claimed by at most one source task per pass. +- Completion is monotonic; forward sync never reopens an Apple-completed reminder. +- Date comparisons use only year/month/day/hour/minute, not EventKit metadata. +- Private configuration/index permission or schema failures stop writes. +- No production permission request, write, cleanup or watcher acceptance is automated by tests. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a2ed165..5936f69 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,88 +1,117 @@ # Troubleshooting -## Permission denied +## Start with a read-only custom-list preview + +```bash +APP=./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync +"$APP" --check-config --taskforge-list-id LIST_ID +"$APP" --dry-run --taskforge-list-id LIST_ID +``` -Open: +The preview reads TaskForge only. It must report the same dynamic member count as the current +TaskForge Today list before any command that requests Reminders permission. A missing list ID, +missing `flutter.ctl_`, malformed JSON, unknown filter field/operator, or malformed v6 store +is a fail-closed configuration error. -`System Settings → Privacy & Security → Reminders` +Once accepted, configure once with `--sync --taskforge-list-id LIST_ID`; later runs can omit the +ID and read the private `0600` configuration. Never place a real ID in shell history shared with +others, logs, tests or repository files. -Enable **TaskForge Reminder Sync**. If the TaskForge cache or Vault cannot be -read, also check **Full Disk Access**. +## Permission denied -If a manual `--sync` works but the LaunchAgent does not write a startup log, -re-run `./scripts/install-daily-sync.sh`. Current releases start the bundle -through LaunchServices so the background process retains the same Reminders -permission identity as the app. +Open `System Settings → Privacy & Security → Reminders` and grant full access to the built App. +The TaskForge container or Vault may additionally require Full Disk Access. Rebuilt ad hoc-signed +Apps can receive a new TCC identity and may need re-authorization. The project never resets TCC +or approves permissions automatically. -Locally rebuilt ad hoc-signed apps receive a version-specific code identity. -After rebuilding, re-authorize the newly installed app. To preserve identity -across builds, use a stable code-signing certificate via -`TASKFORGE_SYNC_CODESIGN_IDENTITY`; the project never creates one automatically. +## A list or reminder is missing -## LaunchAgent is not running +Check the source first: ```bash -launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" -tail -n 100 ~/Library/Logs/TaskForgeReminderSync.error.log +"$APP" --dry-run ``` -Reinstall: +Confirm the task is a member of the fixed custom Kanban list, not terminal, and that its status +is a known or displayable active status. A status list is created only when needed. If the old +`TaskForge 今日` list exists, it is renamed in place to `TaskForge · 待办`. -```bash -./scripts/uninstall-daily-sync.sh -./scripts/install-daily-sync.sh -``` +If a managed reminder was moved to a normal Apple list, the next reverse pass moves it back. A +normal reminder without the marker/index is never imported or modified. + +## Apple completion is not reflected in TaskForge + +1. Keep TaskForge running so it can re-index the source. +2. Run `--reverse-dry-run --task-id TEST_TASK_ID`. +3. Check that the reminder still has the tool marker and is a managed task. +4. Check the source is inside the current Vault and has not changed at the expected line. +5. Confirm the source type is Markdown inline or TaskNotes and that symbol learning is not conflicting. + +An unknown or conflicting symbol intentionally refuses the source write and moves the reminder +back. Completion writes `done`; it never deletes the source task. -## A reminder is not created +## Open-state move was rejected -Run: +The source status must be learned from real records. Current Markdown writeback symbols are +`[ ]`, `[>]`, `[/]` and `[x]`. A symbol not learned for the target status, a symbol conflict, +stale original line, ambiguous match, non-UTF-8 source, or Vault escape is a deliberate stop. +TaskForge wins an open/open conflict; Apple completion wins an open/completed conflict. + +## TaskForge list was deleted or changed + +Stop the watcher. Restore or recreate the original fixed list without silently choosing a new +ID, then run the anonymous preview again. A renamed list keeps its ID; a deleted or missing ID +stops synchronization rather than falling back to a date query. + +## Prune candidate is not deleted + +Use the strict read-only classification: ```bash -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --check-config -./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ - --dry-run +"$APP" --prune-dry-run ``` -Confirm the task is scheduled for today and is not already done or cancelled. +Retention is expected when the reminder is completed, important, priority-bearing, in an +ordinary list, still present in the current snapshot, source-confirmed by the private index, +indeterminate due to I/O/permissions/ambiguity, or has not passed two unchanged scans separated +by at least 60 seconds. A move, edit, completion or source reappearance restarts the gate. -## A TaskForge edit reports a deduplication conflict +`--prune-dry-run` never creates or changes the ledger, backup directory, salt or reminders. A +normal mutating pass may tighten a safe user-owned private root to `0700`; an exposed symlink, +wrong owner, ACL or damaged state remains fail-closed. -The forward log includes `冲突 N`. A conflict means more than one TaskForge -record or managed reminder carries the same ID/source identity. The tool does -not select one arbitrarily and does not create another reminder. +## EventKit reading times out -1. Stop editing the affected task briefly and let TaskForge finish re-indexing. -2. Run `--sync` again and confirm the conflict settles to `0`. -3. If it remains, inspect only redacted reminder metadata and source locations. -4. Do not delete reminders automatically; decide which existing item is - authoritative before manual cleanup. +Kanban fetches and prune fetches have a 30-second limit. The request is cancelled once and the +pass fails closed; a late callback is ignored. Check Reminders permission and retry only after +Reminders is responsive. A timeout must never be treated as an empty list. -## Apple completion is not reflected in TaskForge - -1. Keep TaskForge running. -2. Check the standard and error logs. -3. Confirm the reminder was created by this tool and still contains its marker. -4. Confirm the source remains inside the current Vault. -5. Confirm the task is non-recurring and uses `onCompletion=keep`. +## Roll back a cleanup or source write -Use `--reverse-dry-run --task-id TASK_ID` before any manual retry. +Stop the watcher first. Use `--restore-last-prune` for the newest verified reminder deletion +batch. Source-file backups are below: -## Restore a source file +```text +~/Library/Application Support/TaskForgeReminderSync/Backups/ +``` -Backups are stored below: +Compare hashes before restoring. Keep the private index and backups until TaskForge re-reads the +source and a fresh `--dry-run`/`--prune-dry-run` passes. -`~/Library/Application Support/TaskForgeReminderSync/Backups/` +## Watcher and LaunchAgent -Each run uses a timestamped directory. Compare the backup and current source -before restoring. Stop the LaunchAgent first if manual restoration is needed. +```bash +launchctl print "gui/$(id -u)/local.codex.taskforge-reminder-sync" +tail -n 100 ~/Library/Logs/TaskForgeReminderSync.error.log +``` -## Repeated sync loop +Verify the private list ID exists before reinstalling. The watcher should be left running for at +least 61 seconds during acceptance so both the event path and the full-scan fallback are observed. +To reinstall: -Current versions compare EventKit date components semantically and should -settle at `更新 0`. If logs show continuous updates: +```bash +./scripts/uninstall-daily-sync.sh +./scripts/install-daily-sync.sh +``` -1. stop the LaunchAgent; -2. save the relevant redacted logs; -3. report the macOS and TaskForge versions; -4. do not publish real reminder notes or Vault paths. +The installer does not delete reminders, TaskForge sources, private indexes or backups. diff --git a/docs/superpowers/plans/2026-07-30-reminder-pruning.md b/docs/superpowers/plans/2026-07-30-reminder-pruning.md new file mode 100644 index 0000000..6d10e82 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-reminder-pruning.md @@ -0,0 +1,1645 @@ +# TaskForge 今日提醒自动清理实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 +> superpowers:subagent-driven-development(推荐)或 +> superpowers:executing-plans 逐任务实现此计划。步骤使用复选框 +>(`- [ ]`)语法来跟踪进度。 + +**目标:** 在 `TaskForge 今日` 列表内自动删除连续两次确认均不属于 +TaskForge、未完成且不重要的提醒,同时提供本机备份和恢复能力。 + +**架构:** 把候选判定、源任务存在性和双扫描状态机放入可独立测试的 +`TaskForgeReminderCore`;把 EventKit 查询、备份适配和删除协调放入新的 +`TaskForgeReminderEventKit` 库。命令行程序在现有“反向 → 正向”之后调用 +清理器,所有不可判定错误都失败关闭。 + +**技术栈:** Swift 5.9、Foundation、CryptoKit、EventKit、CoreLocation、 +Swift Package Manager、LaunchAgent、macOS 13+ + +--- + +## 文件结构 + +### 新建 + +- `Sources/TaskForgeReminderCore/ReminderPruning.swift` + - 重要标记判断、TaskForge 存在性枚举、候选策略、双扫描状态机。 +- `Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift` + - 候选账本、备份 DTO、SHA-256 包装、权限 `0600` 的原子文件存储。 +- `Sources/TaskForgeReminderEventKit/ReminderPruner.swift` + - 只查询目标列表、将 EventKit 项目转成候选输入、执行二次确认删除。 +- `Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift` + - EventKit 提醒与可恢复备份 DTO 之间的双向转换。 +- `Tests/TaskForgeReminderEventKitTests/main.swift` + - 使用随机命名临时列表的隔离 EventKit 端到端测试。 + +### 修改 + +- `Package.swift` + - 增加 EventKit 库和隔离测试可执行目标。 +- `Sources/TaskForgeReminderCore/Core.swift` + - 增加源任务存在性检查,复用现有 TaskForge 源元数据。 +- `Tests/TaskForgeReminderCoreTests/main.swift` + - 增加候选、安全边界、状态机、源检查和持久化测试。 +- `Sources/TaskForgeReminderSync/SyncEngine.swift` + - 配置并调用清理器;保持“反向 → 正向 → 清理”顺序。 +- `Sources/TaskForgeReminderSync/Command.swift` + - 增加 `--prune-dry-run`、`--prune-once`、`--restore-last-prune`。 +- `.gitignore` + - 明确忽略清理候选、备份、盐值和测试运行数据的同名导出。 +- `README.md` + - 增加功能、判定表、命令、恢复方法和运行时目录。 +- `docs/ARCHITECTURE.md` + - 增加清理状态机、EventKit 隔离和同步顺序。 +- `docs/TROUBLESHOOTING.md` + - 增加候选未删除、恢复和权限故障排查。 +- `PRIVACY.md` + - 记录本机候选账本、备份字段、匿名日志和不上传承诺。 +- `SECURITY.md` + - 记录失败关闭、目标列表边界和删除前备份。 +- `CHANGELOG.md` + - 记录自动清理功能。 + +## Task 1:建立重要标记与候选判定策略 + +**文件:** + +- 创建:`Sources/TaskForgeReminderCore/ReminderPruning.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的候选判定测试** + +在测试数组结尾、`completed source inspector...` 测试之后加入表驱动测试: + +```swift +("prune policy protects non-target, completed and important reminders", { + let target = "calendar-target" + let protected = [ + ReminderPruneObservation( + itemIdentifier: "other-list", + calendarIdentifier: "calendar-other", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "a", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "completed", + calendarIdentifier: target, + isCompleted: true, + priority: 0, + title: "普通提醒", + fingerprint: "b", + taskPresence: .absent + ), + ReminderPruneObservation( + itemIdentifier: "priority", + calendarIdentifier: target, + isCompleted: false, + priority: 1, + title: "普通提醒", + fingerprint: "c", + taskPresence: .absent + ) + ] + for observation in protected { + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: target + ), + "\(observation.itemIdentifier) must be protected" + ) + } +}), +("prune policy recognizes every approved title prefix", { + for (index, prefix) in ["!", "!", "❗", "‼️", "⭐", "📌"].enumerated() { + let observation = ReminderPruneObservation( + itemIdentifier: "important-\(index)", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: " \(prefix) 保留", + fingerprint: "\(index)", + taskPresence: .absent + ) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + observation, + targetCalendarIdentifier: "calendar-target" + ), + "\(prefix) must protect the reminder" + ) + } +}), +("prune policy only selects an unimportant absent TaskForge task", { + let base = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + try require( + ReminderPruneCandidatePolicy.isCandidate( + base, + targetCalendarIdentifier: "calendar-target" + ), + "external reminder should become a candidate" + ) + for presence in [ + TaskForgeReminderPresence.currentSnapshot, + .sourceConfirmed, + .indeterminate + ] { + let protected = base.withTaskPresence(presence) + try require( + !ReminderPruneCandidatePolicy.isCandidate( + protected, + targetCalendarIdentifier: "calendar-target" + ), + "\(presence) must fail closed" + ) + } +}) +``` + +- [ ] **步骤 2:运行测试并确认因类型未定义而失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneObservation' in scope`。 + +- [ ] **步骤 3:实现最小候选策略** + +创建 `Sources/TaskForgeReminderCore/ReminderPruning.swift`: + +```swift +import Foundation + +public enum TaskForgeReminderPresence: String, Codable, Equatable, Sendable { + case currentSnapshot + case sourceConfirmed + case absent + case indeterminate +} + +public struct ReminderPruneObservation: Equatable, Sendable { + public let itemIdentifier: String + public let calendarIdentifier: String + public let isCompleted: Bool + public let priority: Int + public let title: String + public let fingerprint: String + public let taskPresence: TaskForgeReminderPresence + + public init( + itemIdentifier: String, + calendarIdentifier: String, + isCompleted: Bool, + priority: Int, + title: String, + fingerprint: String, + taskPresence: TaskForgeReminderPresence + ) { + self.itemIdentifier = itemIdentifier + self.calendarIdentifier = calendarIdentifier + self.isCompleted = isCompleted + self.priority = priority + self.title = title + self.fingerprint = fingerprint + self.taskPresence = taskPresence + } + + public func withTaskPresence( + _ value: TaskForgeReminderPresence + ) -> ReminderPruneObservation { + ReminderPruneObservation( + itemIdentifier: itemIdentifier, + calendarIdentifier: calendarIdentifier, + isCompleted: isCompleted, + priority: priority, + title: title, + fingerprint: fingerprint, + taskPresence: value + ) + } +} + +public enum ReminderPruneCandidatePolicy { + public static let importantPrefixes = [ + "!", "!", "❗", "‼️", "⭐", "📌" + ] + + public static func isCandidate( + _ observation: ReminderPruneObservation, + targetCalendarIdentifier: String + ) -> Bool { + guard observation.calendarIdentifier == targetCalendarIdentifier else { + return false + } + guard !observation.isCompleted, observation.priority == 0 else { + return false + } + let title = observation.title.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !importantPrefixes.contains(where: title.hasPrefix) else { + return false + } + return observation.taskPresence == .absent + } +} +``` + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:现有 24 项和新增 3 项全部通过,末行是 +`27/27 tests passed`。 + +- [ ] **步骤 5:提交候选策略** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加提醒候选安全策略" +``` + +## Task 2:实现真实源任务存在性检查 + +**文件:** + +- 修改:`Sources/TaskForgeReminderCore/Core.swift:719` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的源存在性测试** + +```swift +("source presence confirms exact and uniquely moved inline tasks", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 2 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\n- [ ] 保留任务\n" + ) == .present, + "exact source line should be present" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\nheading\n" + ) == .present, + "uniquely moved source line should be present" + ) +}), +("source presence distinguishes absent from ambiguous", { + let task = TaskForgeTask( + identifier: "inline", + title: "保留任务", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/note.md", + sourceType: "markdownInline", + originalLine: "- [ ] 保留任务", + lineNumber: 3 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "heading\nother\n" + ) == .absent, + "missing source line should be absent" + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "- [ ] 保留任务\n- [ ] 保留任务\n" + ) == .indeterminate, + "ambiguous source lines must fail closed" + ) +}), +("source presence protects an existing TaskNotes file", { + let task = TaskForgeTask( + identifier: "note", + title: "任务笔记", + status: "todo", + priority: nil, + scheduled: nil, + filePath: "/vault/TaskNotes/任务.md", + sourceType: "taskNotes", + originalLine: "tasknotes:{}", + lineNumber: 1 + ) + try require( + TaskSourcePresenceInspector.inspect( + task: task, + contents: "---\nstatus: open\n---\n" + ) == .present, + "readable TaskNotes source should be present" + ) +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'TaskSourcePresenceInspector' in scope`。 + +- [ ] **步骤 3:在完成检查器之前加入源存在性检查器** + +在 `Core.swift` 的 `TaskCompletionSourceInspector` 前增加: + +```swift +public enum TaskSourcePresence: String, Codable, Equatable, Sendable { + case present + case absent + case indeterminate +} + +public enum TaskSourcePresenceInspector { + public static func inspect( + task: TaskForgeTask, + contents: String + ) -> TaskSourcePresence { + switch task.sourceType?.lowercased() { + case "markdowninline": + guard let originalLine = task.originalLine else { + return .indeterminate + } + let lines = contents.components(separatedBy: "\n") + if + let lineNumber = task.lineNumber, + lines.indices.contains(lineNumber - 1), + lines[lineNumber - 1] == originalLine + { + return .present + } + let matches = lines.filter { $0 == originalLine }.count + if matches == 1 { + return .present + } + return matches == 0 ? .absent : .indeterminate + case "tasknotes": + return .present + default: + return .indeterminate + } + } +} +``` + +文件不存在、Vault 越界、读取失败和编码失败不传入此函数,由 EventKit +协调层分别映射为 `.absent` 或 `.indeterminate`。 + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:`30/30 tests passed`。 + +- [ ] **步骤 5:提交源检查** + +```bash +git add Sources/TaskForgeReminderCore/Core.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 验证历史任务真实源" +``` + +## Task 3:实现双扫描确认状态机 + +**文件:** + +- 修改:`Sources/TaskForgeReminderCore/ReminderPruning.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的首次发现和二次确认测试** + +```swift +("prune state requires two unchanged scans at least sixty seconds apart", { + let firstDate = Date(timeIntervalSince1970: 1_000) + let observation = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [observation], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: firstDate + ) + try require(first.readyIdentifiers.isEmpty, "first scan must not delete") + try require(first.firstSeenIdentifiers == ["external"], "candidate not recorded") + + let early = ReminderPruneStateMachine.plan( + observations: [observation], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(59) + ) + try require(early.readyIdentifiers.isEmpty, "59 seconds is too early") + + let ready = ReminderPruneStateMachine.plan( + observations: [observation], + prior: early.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: firstDate.addingTimeInterval(60) + ) + try require(ready.readyIdentifiers == ["external"], "candidate should be ready") +}), +("prune state revokes or restarts changed candidates", { + let now = Date(timeIntervalSince1970: 2_000) + let original = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "普通提醒", + fingerprint: "v1", + taskPresence: .absent + ) + let first = ReminderPruneStateMachine.plan( + observations: [original], + prior: ReminderPruneLedger(), + targetCalendarIdentifier: "calendar-target", + now: now + ) + let changed = ReminderPruneObservation( + itemIdentifier: "external", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "改过的提醒", + fingerprint: "v2", + taskPresence: .absent + ) + let restarted = ReminderPruneStateMachine.plan( + observations: [changed], + prior: first.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(120) + ) + try require(restarted.readyIdentifiers.isEmpty, "changed item must restart") + try require( + restarted.nextLedger.entries["external"]?.firstSeen + == now.addingTimeInterval(120), + "changed item should receive a new firstSeen" + ) +}), +("prune state restarts after restore grace and rule changes", { + let now = Date(timeIntervalSince1970: 3_000) + let observation = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: false, + priority: 0, + title: "恢复提醒", + fingerprint: "stable", + taskPresence: .absent + ) + let prior = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion, + graceUntil: now.addingTimeInterval(60) + ) + ]) + let duringGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require(duringGrace.readyIdentifiers.isEmpty, "grace must protect") + + let afterGrace = ReminderPruneStateMachine.plan( + observations: [observation], + prior: duringGrace.nextLedger, + targetCalendarIdentifier: "calendar-target", + now: now.addingTimeInterval(61) + ) + try require( + afterGrace.firstSeenIdentifiers == ["restored"], + "expired grace must start a fresh first scan" + ) + try require(afterGrace.readyIdentifiers.isEmpty, "grace expiry must not delete") + + let oldRules = ReminderPruneLedger(entries: [ + "restored": ReminderPruneLedgerEntry( + firstSeen: now.addingTimeInterval(-120), + fingerprint: "stable", + calendarIdentifier: "calendar-target", + rulesVersion: ReminderPruneStateMachine.rulesVersion - 1, + graceUntil: nil + ) + ]) + let versionReset = ReminderPruneStateMachine.plan( + observations: [observation], + prior: oldRules, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + versionReset.firstSeenIdentifiers == ["restored"], + "rules change must restart confirmation" + ) + + let completed = ReminderPruneObservation( + itemIdentifier: "restored", + calendarIdentifier: "calendar-target", + isCompleted: true, + priority: 0, + title: "恢复提醒", + fingerprint: "completed", + taskPresence: .absent + ) + let revoked = ReminderPruneStateMachine.plan( + observations: [completed], + prior: prior, + targetCalendarIdentifier: "calendar-target", + now: now + ) + try require( + revoked.revokedIdentifiers == ["restored"], + "completed reminder must revoke its candidate" + ) + try require(revoked.readyIdentifiers.isEmpty, "revoked item must not delete") +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneStateMachine' in scope`。 + +- [ ] **步骤 3:实现 Codable 账本和状态机** + +在 `ReminderPruning.swift` 增加: + +```swift +public struct ReminderPruneLedgerEntry: Codable, Equatable, Sendable { + public var firstSeen: Date + public var fingerprint: String + public var calendarIdentifier: String + public var rulesVersion: Int + public var graceUntil: Date? + + public init( + firstSeen: Date, + fingerprint: String, + calendarIdentifier: String, + rulesVersion: Int, + graceUntil: Date? + ) { + self.firstSeen = firstSeen + self.fingerprint = fingerprint + self.calendarIdentifier = calendarIdentifier + self.rulesVersion = rulesVersion + self.graceUntil = graceUntil + } +} + +public struct ReminderPruneLedger: Codable, Equatable, Sendable { + public var entries: [String: ReminderPruneLedgerEntry] + + public init(entries: [String: ReminderPruneLedgerEntry] = [:]) { + self.entries = entries + } +} + +public struct ReminderPrunePlan: Equatable, Sendable { + public let firstSeenIdentifiers: [String] + public let waitingIdentifiers: [String] + public let readyIdentifiers: [String] + public let revokedIdentifiers: [String] + public let nextLedger: ReminderPruneLedger +} + +public enum ReminderPruneStateMachine { + public static let rulesVersion = 1 + + public static func plan( + observations: [ReminderPruneObservation], + prior: ReminderPruneLedger, + targetCalendarIdentifier: String, + now: Date, + confirmationInterval: TimeInterval = 60 + ) -> ReminderPrunePlan { + var next = ReminderPruneLedger() + var firstSeen: [String] = [] + var waiting: [String] = [] + var ready: [String] = [] + let candidates = observations.filter { + ReminderPruneCandidatePolicy.isCandidate( + $0, + targetCalendarIdentifier: targetCalendarIdentifier + ) + } + + for observation in candidates { + let old = prior.entries[observation.itemIdentifier] + let isSame = old?.fingerprint == observation.fingerprint + && old?.calendarIdentifier == observation.calendarIdentifier + && old?.rulesVersion == rulesVersion + if !isSame { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } else if let old, let graceUntil = old.graceUntil { + if now < graceUntil { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } else { + next.entries[observation.itemIdentifier] = ReminderPruneLedgerEntry( + firstSeen: now, + fingerprint: observation.fingerprint, + calendarIdentifier: observation.calendarIdentifier, + rulesVersion: rulesVersion, + graceUntil: nil + ) + firstSeen.append(observation.itemIdentifier) + } + } else if let old, now.timeIntervalSince(old.firstSeen) >= confirmationInterval { + next.entries[observation.itemIdentifier] = old + ready.append(observation.itemIdentifier) + } else if let old { + next.entries[observation.itemIdentifier] = old + waiting.append(observation.itemIdentifier) + } + } + + let active = Set(candidates.map(\.itemIdentifier)) + let revoked = prior.entries.keys.filter { !active.contains($0) }.sorted() + return ReminderPrunePlan( + firstSeenIdentifiers: firstSeen.sorted(), + waitingIdentifiers: waiting.sorted(), + readyIdentifiers: ready.sorted(), + revokedIdentifiers: revoked, + nextLedger: next + ) + } +} +``` + +上述第三个测试同时固定 `rulesVersion` 变化、`graceUntil` 到期后的重新 +计时,以及提醒完成后的候选撤销。 + +- [ ] **步骤 4:运行测试确认通过** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:至少 `33/33 tests passed`。 + +- [ ] **步骤 5:提交状态机** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPruning.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加双扫描确认状态机" +``` + +## Task 4:实现本机候选账本和可校验备份 + +**文件:** + +- 创建:`Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift` +- 修改:`Tests/TaskForgeReminderCoreTests/main.swift:782` + +- [ ] **步骤 1:编写失败的权限、原子读写和损坏拒绝测试** + +使用 `FileManager.default.temporaryDirectory` 下的 UUID 目录,并在 `defer` +中删除。先在测试辅助函数区增加: + +```swift +func pruneBackupFixture() -> ReminderPruneBackupBatch { + ReminderPruneBackupBatch( + identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + createdAt: Date(timeIntervalSince1970: 20), + targetCalendarIdentifier: "calendar", + targetCalendarTitle: "TaskForge 今日", + items: [ + ReminderPruneBackupItem( + originalItemIdentifier: "item", + title: "普通提醒", + notes: "本地测试", + url: URL(string: "taskforge-test://item"), + priority: 0, + dueDateComponents: DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 7, + day: 30 + ), + startDateComponents: nil, + alarms: [], + recurrenceRules: [] + ) + ], + restoredAt: nil + ) +} +``` + +再加入测试: + +```swift +("prune local store writes private ledger and verified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let ledger = ReminderPruneLedger(entries: [ + "item": ReminderPruneLedgerEntry( + firstSeen: Date(timeIntervalSince1970: 10), + fingerprint: "fingerprint", + calendarIdentifier: "calendar", + rulesVersion: 1, + graceUntil: nil + ) + ]) + try store.saveLedger(ledger) + try require(try store.loadLedger() == ledger, "ledger round-trip failed") + + let permissions = try requireValue( + FileManager.default.attributesOfItem( + atPath: store.ledgerURL.path + )[.posixPermissions] as? NSNumber, + "permissions missing" + ) + try require(permissions.intValue & 0o777 == 0o600, "ledger must be 0600") + + let batch = pruneBackupFixture() + let url = try store.saveBackup(batch) + try require(try store.loadBackup(at: url) == batch, "backup verification failed") +}), +("prune local store rejects a modified backup", { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = ReminderPruneLocalStore(rootURL: root) + let url = try store.saveBackup(pruneBackupFixture()) + try Data("tampered".utf8).write(to: url, options: .atomic) + do { + _ = try store.loadBackup(at: url) + throw TestFailure(description: "tampered backup was accepted") + } catch let error as ReminderPruneStoreError { + try require(error == .checksumMismatch, "unexpected store error") + } +}) +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:编译失败,包含 +`cannot find 'ReminderPruneLocalStore' in scope`。 + +- [ ] **步骤 3:定义备份 DTO 和本地存储** + +创建 `ReminderPrunePersistence.swift`,包含以下公开接口: + +```swift +import CryptoKit +import Foundation + +public struct ReminderLocationBackup: Codable, Equatable, Sendable { + public var title: String + public var latitude: Double? + public var longitude: Double? + public var radius: Double +} + +public struct ReminderWeekdayBackup: Codable, Equatable, Sendable { + public var dayOfTheWeekRawValue: Int + public var weekNumber: Int +} + +public struct ReminderAlarmBackup: Codable, Equatable, Sendable { + public var absoluteDate: Date? + public var relativeOffset: TimeInterval? + public var structuredLocation: ReminderLocationBackup? + public var proximityRawValue: Int? +} + +public struct ReminderRecurrenceBackup: Codable, Equatable, Sendable { + public var frequencyRawValue: Int + public var interval: Int + public var daysOfWeek: [ReminderWeekdayBackup] + public var daysOfMonth: [Int] + public var monthsOfYear: [Int] + public var weeksOfYear: [Int] + public var daysOfYear: [Int] + public var setPositions: [Int] + public var endDate: Date? + public var occurrenceCount: Int? +} + +public struct ReminderPruneBackupItem: Codable, Equatable, Sendable { + public var originalItemIdentifier: String + public var title: String + public var notes: String? + public var url: URL? + public var priority: Int + public var dueDateComponents: DateComponents? + public var startDateComponents: DateComponents? + public var alarms: [ReminderAlarmBackup] + public var recurrenceRules: [ReminderRecurrenceBackup] +} + +public struct ReminderPruneBackupBatch: Codable, Equatable, Sendable { + public var identifier: UUID + public var createdAt: Date + public var targetCalendarIdentifier: String + public var targetCalendarTitle: String + public var items: [ReminderPruneBackupItem] + public var restoredAt: Date? +} + +public enum ReminderPruneStoreError: Error, Equatable { + case invalidLedger + case invalidBackup + case checksumMismatch + case permissions + case noUnrestoredBackup +} + +public final class ReminderPruneLocalStore: @unchecked Sendable { + public static let defaultRoot = URL( + fileURLWithPath: + "\(NSHomeDirectory())/Library/Application Support/" + + "TaskForgeReminderSync", + isDirectory: true + ) + public let rootURL: URL + public var ledgerURL: URL { + rootURL.appendingPathComponent("PruneCandidates.json") + } + + public init(rootURL: URL = defaultRoot) { + self.rootURL = rootURL + } + + public func loadLedger() throws -> ReminderPruneLedger + public func saveLedger(_ ledger: ReminderPruneLedger) throws + public func saveBackup(_ batch: ReminderPruneBackupBatch) throws -> URL + public func loadBackup(at url: URL) throws -> ReminderPruneBackupBatch + public func latestUnresolvedDeletionBackup() throws + -> (URL, ReminderPruneBackupBatch)? + public func latestRestorableBackup() throws + -> (URL, ReminderPruneBackupBatch)? + public func markRestored(at url: URL, date: Date) throws + public func loadOrCreateHashSalt() throws -> Data +} +``` + +所有 DTO 都实现显式 `public init(...)`,参数顺序与上方属性顺序一致;不能依赖 +Swift 默认的 internal memberwise initializer,否则测试目标无法构造 fixture。 + +实现细节: + +1. 目录权限设为 `0700`,文件权限设为 `0600`。 +2. 编码器使用 `.sortedKeys`。 +3. 备份写成 `{checksum, payload}` 包装;checksum 是 payload JSON 的 + SHA-256。 +4. 所有写入先写同目录 UUID 临时文件、`synchronize()`、设置权限,再 + `replaceItemAt` 或 `moveItem`。 +5. JSON 解码、权限或校验错误不得返回空账本,必须抛出 + `ReminderPruneStoreError`,由上层失败关闭。 + +- [ ] **步骤 4:运行测试确认通过并检查无残留临时目录** + +运行: + +```bash +swift run TaskForgeReminderCoreTests +``` + +预期:新增持久化测试通过;测试内的 `defer` 删除 UUID 临时目录。 + +- [ ] **步骤 5:提交本机存储** + +```bash +git add Sources/TaskForgeReminderCore/ReminderPrunePersistence.swift \ + Tests/TaskForgeReminderCoreTests/main.swift +git commit -m "feat(清理): 添加私有候选账本和备份" +``` + +## Task 5:建立 EventKit 清理库和备份适配 + +**文件:** + +- 修改:`Package.swift` +- 创建:`Sources/TaskForgeReminderEventKit/ReminderBackupAdapter.swift` +- 创建:`Sources/TaskForgeReminderEventKit/ReminderPruner.swift` + +- [ ] **步骤 1:先增加 EventKit 目标并确认最小骨架可编译** + +把 `Package.swift` 的 executable dependencies 改为: + +```swift +.target( + name: "TaskForgeReminderEventKit", + dependencies: ["TaskForgeReminderCore"] +), +.executableTarget( + name: "TaskForgeReminderSync", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ] +), +.executableTarget( + name: "TaskForgeReminderCoreTests", + dependencies: ["TaskForgeReminderCore"], + path: "Tests/TaskForgeReminderCoreTests" +) +``` + +先创建两个只含 import 的源文件,然后运行: + +```bash +swift build +``` + +预期:`Build complete!`,证明新目标边界可编译。 + +- [ ] **步骤 2:实现 EventKit 备份双向适配器** + +`ReminderBackupAdapter.swift` 的固定接口: + +```swift +import CoreLocation +import EventKit +import Foundation +import TaskForgeReminderCore + +enum ReminderBackupAdapter { + static func capture(_ reminder: EKReminder) -> ReminderPruneBackupItem + static func restore( + _ backup: ReminderPruneBackupItem, + into reminder: EKReminder + ) +} +``` + +逐字段映射: + +- `title`、`notes`、`url`、`priority`; +- `dueDateComponents`、`startDateComponents`; +- `alarms` 的 absolute/relative/location/proximity; +- `recurrenceRules` 的 frequency、interval、weekday、月份/周/日位置和 end。 + +恢复时先清除新提醒的默认 alarms/recurrence,再按 DTO 重建。 +EventKit 不允许写回的创建时间和原 ID只作审计,不伪装成已恢复字段。 + +- [ ] **步骤 3:实现只查询目标列表的清理器** + +`ReminderPruner.swift` 的公共接口: + +```swift +import CryptoKit +import EventKit +import Foundation +import TaskForgeReminderCore + +public struct ReminderPruneConfiguration: Sendable { + public var listName: String + public var localRoot: URL + public var confirmationInterval: TimeInterval + public var restoreGraceInterval: TimeInterval + + public init( + listName: String, + localRoot: URL, + confirmationInterval: TimeInterval = 60, + restoreGraceInterval: TimeInterval = 86_400 + ) { + self.listName = listName + self.localRoot = localRoot + self.confirmationInterval = confirmationInterval + self.restoreGraceInterval = restoreGraceInterval + } +} + +public struct ReminderPruneCounts: Equatable, Sendable { + public var scanned = 0 + public var firstSeen = 0 + public var waiting = 0 + public var ready = 0 + public var deleted = 0 + public var restored = 0 + public var protected = 0 + public var failed = 0 +} + +@MainActor +public final class ReminderPruner { + public init( + eventStore: EKEventStore, + configuration: ReminderPruneConfiguration, + log: @escaping @Sendable (String) -> Void, + logError: @escaping @Sendable (String) -> Void + ) + + public func dryRun( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts + + public func advance( + snapshot: TaskForgeSnapshot, + now: Date = Date() + ) async throws -> ReminderPruneCounts + + public func restoreLast( + now: Date = Date() + ) async throws -> ReminderPruneCounts +} +``` + +核心约束按此顺序编码: + +1. 用 `store.calendars(for: .reminder).first { $0.title == listName }` + 找到唯一目标列表;找不到时返回零。 +2. 只用 `predicateForReminders(in: [targetCalendar])` 查询,不获取其他列表。 +3. marker 匹配当前快照时返回 `.currentSnapshot`。 +4. marker 不匹配缓存时,解码 `TaskSourceReference`: + - 源路径不在 Vault 内:`.indeterminate`; + - 文件不存在:`.absent`; + - 读取或 UTF-8 失败:`.indeterminate`; + - `TaskSourcePresenceInspector` 的结果映射到 presence。 +5. 指纹只包含目标列表 ID、item ID、完成、优先级、标题、notes 和 presence, + 先做 SHA-256,原内容不写账本。 +6. `dryRun` 只读取账本并计算,不保存账本、不备份、不删除。 +7. `advance` 先保存 nextLedger;ready 项逐条重新获取和分类,仍 ready 才加入 + 备份批次。 +8. 备份写入并回读校验后才调用 `remove(reminder, commit: false)`; + 最后 `commit()`。 +9. commit 后重新读取目标列表,只把实际已不存在的 item ID 从 ledger 移除并 + 再次原子保存;仍存在的 ready entry 保留以便下轮重试。 +10. commit 抛错时同样重新读取目标列表,按实际存在情况统计,不能假定批次 + 全成或全败;备份始终保留。 +11. 下轮 `advance` 用 `latestUnresolvedDeletionBackup()` 找到尚未记录实际结果 + 的最新备份,回读目标列表并固化实际删除 ID;空结果保留但不进入恢复。 +12. 日志只用本机盐对 EventKit ID 做 SHA-256 后截取前 12 位。 + +- [ ] **步骤 4:实现恢复与 24 小时宽限** + +`restoreLast`: + +1. 读取 `latestRestorableBackup()`;只选择尚未恢复且实际删除结果非空的最新 + 批次,跳过 unresolved、空结果和已恢复备份; +2. 查找目标列表;不存在时用同一 EventKit source 创建; +3. 为每个备份项目创建 `EKReminder` 并调用 adapter; +4. commit 成功后重新读取恢复项,按与 advance 相同的函数计算 fingerprint, + 再把新 item ID 写入 ledger,`firstSeen = now`、 + `graceUntil = now + 86_400`; +5. 成功写入 ledger 后再 `markRestored`; +6. 任一步失败都保留未消费备份。 + +- [ ] **步骤 5:构建并提交 EventKit 边界** + +运行: + +```bash +swift build +swift run TaskForgeReminderCoreTests +``` + +预期:构建成功,Core 测试全部通过。 + +提交: + +```bash +git add Package.swift Sources/TaskForgeReminderEventKit \ +git commit -m "feat(EventKit): 实现可恢复提醒清理器" +``` + +## Task 6:接入命令行和同步循环 + +**文件:** + +- 修改:`Sources/TaskForgeReminderSync/Command.swift:5-329` +- 修改:`Sources/TaskForgeReminderSync/SyncEngine.swift:7-645` + +- [ ] **步骤 1:添加命令解析分支并先确认编译失败** + +在 `RunMode` 增加: + +```swift +case pruneDryRun +case pruneOnce +case restoreLastPrune +``` + +在 parser 中映射: + +```swift +case "--prune-dry-run": + options.mode = .pruneDryRun +case "--prune-once": + options.mode = .pruneOnce +case "--restore-last-prune": + options.mode = .restoreLastPrune +``` + +把三种模式加入需要 EventKit 权限的 switch,然后在 `run` 中暂时调用尚未定义的 +engine 方法。运行: + +```bash +swift build +``` + +预期:编译失败,包含 +`value of type 'SyncEngine' has no member 'prune'`。 + +- [ ] **步骤 2:让 SyncEngine 持有同一个 EventKit store 和清理器** + +修改 imports 和属性: + +```swift +import TaskForgeReminderEventKit + +private let store: EKEventStore +private let pruner: ReminderPruner + +init(configuration: SyncConfiguration, calendar: Calendar) { + let store = EKEventStore() + self.configuration = configuration + self.calendar = calendar + self.store = store + self.pruner = ReminderPruner( + eventStore: store, + configuration: ReminderPruneConfiguration( + listName: configuration.listName, + localRoot: ReminderPruneLocalStore.defaultRoot, + confirmationInterval: 60, + restoreGraceInterval: 86_400 + ), + log: log, + logError: logError + ) +} +``` + +新增包装方法: + +```swift +func prune(dryRun: Bool) async throws -> ReminderPruneCounts { + let snapshot = try loadSnapshotWithRetry() + return dryRun + ? try await pruner.dryRun(snapshot: snapshot) + : try await pruner.advance(snapshot: snapshot) +} + +func restoreLastPrune() async throws -> ReminderPruneCounts { + try await pruner.restoreLast() +} +``` + +- [ ] **步骤 3:固定协调顺序为反向、正向、清理** + +在 `reconcile(reason:)` 中: + +```swift +let reverseCounts = try await reverse( + dryRun: false, + taskIdentifier: nil, + requireCandidate: false +) +let forwardCounts = try await forward() +let pruneCounts = try await prune(dryRun: false) +log( + "清理同步:首次 \(pruneCounts.firstSeen)," + + "等待 \(pruneCounts.waiting),删除 \(pruneCounts.deleted)," + + "失败 \(pruneCounts.failed)" +) +``` + +普通 `--sync` 同样在 forward 后调用 `prune(dryRun: false)`。 +`--prune-dry-run` 只调用 dry-run;`--prune-once` 只推进一轮; +`--restore-last-prune` 只恢复最近批次。 + +- [ ] **步骤 4:补全帮助和匿名输出** + +帮助文本加入三条命令;输出仅包含计数: + +```swift +print( + "清理预演:扫描 \(counts.scanned),首次候选 \(counts.firstSeen)," + + "已满足二次确认 \(counts.ready)。" +) +print("预演模式:没有写候选账本,没有删除提醒。") +``` + +禁止在清理命令输出 `title`、`notes`、Vault 路径或原始 ID。 + +- [ ] **步骤 5:构建并运行回归测试** + +```bash +swift build +swift run TaskForgeReminderCoreTests +.build/debug/TaskForgeReminderSync --help +``` + +预期: + +- 构建和测试通过; +- help 中出现三个新命令; +- 默认 `--dry-run` 行为不变。 + +- [ ] **步骤 6:提交命令接入** + +```bash +git add Sources/TaskForgeReminderSync/Command.swift \ + Sources/TaskForgeReminderSync/SyncEngine.swift +git commit -m "feat(同步): 接入自动清理和恢复命令" +``` + +## Task 7:完成隔离 EventKit 端到端测试 + +**文件:** + +- 修改:`Package.swift` +- 创建:`Tests/TaskForgeReminderEventKitTests/main.swift` + +- [ ] **步骤 1:增加真实测试目标和带显式开关的隔离测试入口** + +在 `Package.swift` 增加: + +```swift +.executableTarget( + name: "TaskForgeReminderEventKitTests", + dependencies: [ + "TaskForgeReminderCore", + "TaskForgeReminderEventKit" + ], + path: "Tests/TaskForgeReminderEventKitTests" +) +``` + +未设置环境变量时只输出: + +```swift +import Darwin +import EventKit +import Foundation +import TaskForgeReminderCore +import TaskForgeReminderEventKit + +struct IntegrationFailure: Error, CustomStringConvertible { + let description: String +} + +func require( + _ condition: @autoclosure () -> Bool, + _ message: String +) throws { + guard condition() else { + throw IntegrationFailure(description: message) + } +} + +guard ProcessInfo.processInfo.environment[ + "TASKFORGE_RUN_EVENTKIT_TESTS" +] == "1" else { + print("SKIP EventKit integration tests require explicit opt-in") + exit(0) +} +``` + +启用后: + +1. 请求 reminders full access; +2. 创建 `TaskForgeReminderSync Test ` 目标列表和另一个列表; +3. 用 `defer` 删除两个临时列表; +4. 创建普通外来、priority=1、六个标题前缀、已完成、当前 TaskForge、 + 有效源引用、其他列表外来提醒; +5. 在临时 Vault 写一个 `- [ ] 历史任务` 源文件,并把对应 + `TaskSourceReference` 放入历史提醒 notes; +6. 用临时目录保存 ledger 和备份; +7. 所有 EventKit fetch 都显式传入临时 calendar 数组,测试代码不得调用 + `predicateForReminders(in: nil)`。 + +- [ ] **步骤 2:断言第一次扫描不删除、第二次扫描只删除普通外来提醒** + +调用时传入可控时间: + +```swift +let first = try await pruner.advance( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 10_000) +) +try require(first.deleted == 0, "first scan deleted immediately") + +let second = try await pruner.advance( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 10_061) +) +try require(second.deleted == 1, "only the plain external reminder should delete") +``` + +重新读取两个临时列表,逐项断言: + +- 目标列表普通外来提醒不存在; +- priority、六个标题前缀、已完成、当前任务、历史源提醒仍存在; +- 另一个列表的提醒仍存在。 + +- [ ] **步骤 3:断言备份权限、字段恢复和宽限** + +1. 检查最新备份文件权限为 `0600`; +2. 调用 `restoreLast(now:)`; +3. 重新读取目标列表,验证标题、notes、URL、priority、dates、alarms、 + recurrence; +4. 在 `now + 61` 再推进,断言 24 小时宽限阻止删除; +5. 在 `now + 86_401` 后首次扫描只登记,不立即删除。 + +- [ ] **步骤 4:运行隔离测试和普通构建** + +```bash +swift build +TASKFORGE_RUN_EVENTKIT_TESTS=1 \ + swift run TaskForgeReminderEventKitTests +``` + +预期:测试输出只含临时列表名和 PASS 计数,不含生产提醒;末行 +`EventKit integration tests passed`。退出后在 Apple 提醒事项中不存在测试列表。 + +- [ ] **步骤 5:提交隔离测试** + +```bash +git add Package.swift Tests/TaskForgeReminderEventKitTests/main.swift +git commit -m "test(EventKit): 验证清理隔离和恢复闭环" +``` + +## Task 8:更新公共文档和隐私边界 + +**文件:** + +- 修改:`.gitignore` +- 修改:`README.md:7-225` +- 修改:`docs/ARCHITECTURE.md:1-120` +- 修改:`docs/TROUBLESHOOTING.md` +- 修改:`PRIVACY.md:5-55` +- 修改:`SECURITY.md:24-32` +- 修改:`CHANGELOG.md` + +- [ ] **步骤 1:更新 README 的功能、命令和安全规则** + +必须明确: + +- 只清理配置列表、未完成、不重要、TaskForge 缓存和源都不存在的提醒; +- 六个标题前缀和 EventKit priority 保护; +- 两次扫描至少相隔 60 秒; +- `--prune-dry-run`、`--prune-once`、`--restore-last-prune`; +- 备份和候选账本路径; +- 恢复后 24 小时宽限; +- TaskForge 源任务仍只改为 `done`,绝不因清理而删除。 + +删除 README 原来的“不会根据 TaskForge 删除操作自动删除 Apple 提醒事项” +限制,替换为新规则,避免文档自相矛盾。 + +- [ ] **步骤 2:更新架构、排障、隐私、安全和 changelog** + +逐文件加入规格中的固定边界。`.gitignore` 增加: + +```gitignore +PruneCandidates.json +PruneBackups/ +PruneHashSalt +PruneHashSalt.lock +*.prune-test.json +``` + +注意这些运行文件正常位于仓库外,规则用于防止用户复制调试数据后误提交。 + +- [ ] **步骤 3:执行文档矛盾和隐私扫描** + +```bash +public_docs=( + README.md + docs/ARCHITECTURE.md + docs/TROUBLESHOOTING.md + PRIVACY.md + SECURITY.md + CHANGELOG.md +) +rg -n "不会.*删除|不自动删除|其他.*列表|prune|清理|恢复" \ + "${public_docs[@]}" +rg -n "[/]Users/[^/]+/|[/]var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,}" \ + "${public_docs[@]}" +git diff --check +``` + +预期: + +- 旧限制已被新边界替换,没有矛盾; +- 第二条命令无输出; +- `git diff --check` 无输出。 + +- [ ] **步骤 4:提交公共文档** + +```bash +git add .gitignore README.md docs/ARCHITECTURE.md \ + docs/TROUBLESHOOTING.md PRIVACY.md SECURITY.md CHANGELOG.md +git commit -m "docs(清理): 说明自动删除和恢复边界" +``` + +## Task 9:完整验证、生产部署和 GitHub 发布 + +**文件:** + +- 验证:全部代码、测试、文档和运行时安装 +- 不创建包含私人数据的新仓库文件 + +### 最终审查修复说明 + +- Core 提供共享的私有运行时目录初始化:普通写路径只迁移本人拥有、非 + symlink、无扩展 ACL、且 group/world 不可写的旧目录;目录和文件分别 + 收紧为 `0700` / `0600`,并在 descriptor 上 `fstat` 复核。反向源备份与 + prune store 使用同一套策略。 +- `--prune-dry-run` 不负责迁移:目录不存在时按空账本处理;既有目录不是 + 严格 `0700` 时匿名失败,并且不创建文件、不 `chmod`。普通同步、watcher + 的 prune 推进或反向备份写入会在写操作前完成安全迁移。 +- 现有 `Backups` 只按已知树迁移;必须全部是本人拥有的真实目录或普通 + 文件、无扩展 ACL、且 group/world 不可写。整棵树先验证再改权限,异常 + 一律失败关闭,不删除或改写备份内容。 +- 所有非只读 prune store 初始化都会迁移已经存在的 `Backups` 已知树; + `Backups` 不存在时不为 prune 空跑创建它。`PruneHashSalt` 同样按需生成, + 首次只登记候选的成功扫描不要求它存在。 +- watcher 无限循环本轮不新增伪造的直接测试:协调顺序由 + `reconcile(reason:)` 固定为 reverse → forward → prune,prune 的候选撤销、 + 日历变化、双扫描与恢复宽限由状态机测试直接覆盖,运行模式由进程级 CLI + 测试覆盖。真实 watcher 生命周期仍保留在部署验收步骤 3–5,本轮禁止启动 + production EventKit 或 LaunchAgent。 + +- [ ] **步骤 1:运行完整本地验证** + +```bash +swift run TaskForgeReminderCoreTests +swift build +TASKFORGE_RUN_EVENTKIT_TESTS=1 \ + swift run TaskForgeReminderEventKitTests +./scripts/build-app.sh +plutil -lint dist/TaskForgeReminderSync.app/Contents/Info.plist +codesign --verify --deep --strict \ + dist/TaskForgeReminderSync.app +``` + +预期:Core 测试全部通过、EventKit 隔离测试通过、构建成功、plist 和签名通过。 + +- [ ] **步骤 2:在生产列表执行匿名预演** + +```bash +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync \ + --prune-dry-run +``` + +记录并向用户报告扫描、首次候选和 ready 数量;不得输出标题、笔记或原始 ID。 +如果命令输出任何私人字段,停止部署并修复。 + +- [ ] **步骤 3:安装并确认第一轮只登记候选** + +```bash +./scripts/install-daily-sync.sh +launchctl print \ + "gui/$(id -u)/local.codex.taskforge-reminder-sync" +tail -80 "$HOME/Library/Logs/TaskForgeReminderSync.log" +``` + +预期: + +- LaunchAgent state 为 `running`; +- 第一轮清理 `deleted 0`; +- 日志只有数量和截断哈希; +- `PruneCandidates.json` 权限为 `600`。 + +- [ ] **步骤 4:等待二次确认并核对可恢复备份** + +等待 watcher 的下一次每分钟兜底,随后运行: + +```bash +tail -120 "$HOME/Library/Logs/TaskForgeReminderSync.log" +find "$HOME/Library/Application Support/TaskForgeReminderSync/PruneBackups" \ + -type f -exec stat -f '%Sp %N' {} \\; +./dist/TaskForgeReminderSync.app/Contents/MacOS/TaskForgeReminderSync --audit +``` + +预期: + +- 只有连续两次符合条件的数量被删除; +- 每个删除批次先有 `-rw-------` 备份; +- audit 仍显示重复状态通过。 + +如果生产 dry-run 为零,接受“没有需要删除的真实候选”;不创建生产测试提醒。 + +- [ ] **步骤 5:检查监听、计划时间和错误日志** + +```bash +rg -n "Apple 提醒事项变化|每分钟漏失兜底|7:00|11:00|15:00|清理同步" \ + "$HOME/Library/Logs/TaskForgeReminderSync.log" +tail -80 "$HOME/Library/Logs/TaskForgeReminderSync.error.log" +``` + +预期:新清理和原有触发均存在;错误日志没有新的未处理错误。 + +- [ ] **步骤 6:执行推送前隐私和 Git 检查** + +```bash +git status --short --branch +git diff origin/main...HEAD --check +git diff origin/main...HEAD --name-only +git diff origin/main...HEAD -- . ':(exclude)docs/superpowers/**' | \ + rg -n '([/]Users/[^/]+/|[/]var/folders/|TaskForge-Task-ID: [A-Za-z0-9+/=]{16,})' +``` + +预期: + +- worktree 干净; +- diff 检查无错误; +- 变更文件均在计划内; +- 隐私扫描无输出。 + +- [ ] **步骤 7:推送当前分支并检查 GitHub Actions** + +```bash +git push origin HEAD +gh run list --limit 5 +``` + +预期:push 成功;新 workflow run 最终为 `completed success`。如果 CI 失败, +先读取失败日志并修复,不把本机 EventKit 权限测试加入云端 CI。 + +- [ ] **步骤 8:最终交付报告** + +向用户报告: + +- GitHub commit 和仓库 URL; +- Core 与隔离 EventKit 测试数量; +- 生产 dry-run、首次扫描、二次确认删除的匿名数量; +- LaunchAgent listener/PID/state; +- 备份路径与权限; +- `--restore-last-prune` 用法; +- 其他提醒列表、已完成提醒和 TaskForge 源任务保持不变的验证证据。 diff --git a/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md new file mode 100644 index 0000000..7f59f44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-reminder-pruning-design.md @@ -0,0 +1,257 @@ +# TaskForge 今日提醒自动清理设计 + +日期:2026-07-30 +状态:已于 2026-07-30 完成书面规格复核 + +## 目标 + +在不影响 TaskForge 双向完成同步的前提下,自动删除 Apple 提醒事项 +`TaskForge 今日` 列表中的外来提醒。清理必须接近实时、可恢复、默认保护 +历史记录,并且不能读取或删除其他提醒列表中的项目。 + +## 非目标 + +- 不删除或修改 TaskForge 源任务。 +- 不清理 Apple 提醒事项中的其他列表。 +- 不清理已完成提醒。 +- 不清理带重要标记的提醒。 +- 不把“任务暂时离开 TaskForge 当前缓存”直接解释为任务已删除。 +- 不把提醒标题、笔记、Vault 路径或原始 EventKit ID 写入公开日志、 + Git 历史或 GitHub。 + +## 固定安全边界 + +清理器只能获取配置列表 `TaskForge 今日` 中的提醒。列表过滤应在 +EventKit 查询入口完成,而不是先读取所有列表后再过滤。去重归档列表、 +清理备份以及任何其他 Apple 提醒列表都不在清理范围内。 + +只有未完成提醒可能成为候选。已完成提醒无论是否带 TaskForge 标记都保留, +以免破坏历史记录。 + +以下任一条件成立时,提醒属于重要提醒并受到保护: + +- EventKit 可见的 Apple 内建优先级大于零; +- 标题去除开头空白后,以 `!`、`!`、`❗`、`‼️`、`⭐` 或 `📌` + 之一开头。 + +## “TaskForge 中存在”的定义 + +清理器按以下顺序确认提醒是否属于 TaskForge: + +1. 提醒带有当前 Vault 的有效 TaskForge 标记,并且标记中的任务 ID + 存在于当前 TaskForge 快照中,则任务存在。 +2. 当前快照没有对应 ID 时,如果提醒包含有效的持久源引用,并且可以从 + Vault 源文件中确认同一任务仍然存在,则任务存在。 +3. 没有有效标记,或标记既不能匹配当前快照、也不能通过源引用确认时, + 才视为 TaskForge 中不存在。 + +源文件不可读、TaskForge 快照解码失败、权限不足、源位置存在歧义或检查 +过程中出现 I/O 错误时,必须失败关闭:保留提醒,不得把错误降级为“不存在”。 + +这一定义允许已经离开 TaskForge 缓存、但源任务仍存在的历史提醒继续参与 +Apple 完成状态反向回写。旧版本创建且缺少持久源引用的未完成提醒,只有在 +当前快照也无法匹配时才会进入清理候选。 + +## 候选判定 + +提醒必须同时满足以下条件: + +1. 位于配置的 `TaskForge 今日` 列表; +2. 未完成; +3. 不属于重要提醒; +4. 当前 TaskForge 快照没有对应任务; +5. 真实源文件也无法确认对应任务存在。 + +候选判定实现为 Foundation-only 的纯逻辑策略,EventKit 协调层只负责把 +提醒属性转换为策略输入。这样可以在不访问真实提醒库的情况下完整测试判定表。 + +## 双扫描确认状态机 + +第一次发现候选时不得删除,只把以下信息写入本机候选账本: + +- EventKit calendar item identifier; +- 目标列表 identifier; +- 首次发现时间; +- 与候选判定相关的属性指纹; +- 清理规则版本。 + +候选账本位于: + +`~/Library/Application Support/TaskForgeReminderSync/PruneCandidates.json` + +账本使用权限 `0600`,通过临时文件加原子替换写入,不进入 Git 仓库。 + +第二次或后续扫描只有同时满足以下条件才可进入删除阶段: + +- 距离首次发现至少 60 秒; +- EventKit ID 和目标列表 ID 仍一致; +- 重新获取的提醒仍满足全部候选条件; +- 与候选判定相关的属性指纹没有变化; +- TaskForge 快照和源文件检查均成功完成。 + +如果提醒被完成、增加优先级、增加重要标题符号、恢复 TaskForge 身份、 +移出目标列表、属性发生变化或不再存在,应立即撤销对应候选记录。属性变化后 +若提醒仍符合条件,应作为新的首次发现重新计时。 + +候选账本损坏或无法写入时,清理器记录匿名错误并跳过删除。watcher 重启后 +从账本继续确认;规则版本变化时旧候选必须重新经过首次发现。 + +## 同步顺序与触发 + +每轮协调按以下顺序执行: + +1. 扫描 Apple 已完成提醒并反向回写 TaskForge 源; +2. 把 TaskForge 今日任务正向创建或更新到 Apple; +3. 在最新状态上运行自动清理。 + +该顺序确保提醒不会在完成状态尚未回写前被清理,也确保正向同步能够先恢复 +正确的 TaskForge 身份。 + +清理沿用现有 watcher 触发源: + +- Apple `EKEventStoreChanged` 通知,750 毫秒防抖; +- TaskForge 任务库每秒 mtime 检查; +- 每分钟漏失兜底; +- 07:00、11:00、15:00 定时兜底。 + +60 秒是最短确认间隔,不承诺精确删除时刻。正常情况下,候选会在首次发现后 +约 60–120 秒内完成二次确认。 + +## 删除前备份 + +每批准备删除的提醒都必须先写入本机备份: + +`~/Library/Application Support/TaskForgeReminderSync/PruneBackups/` + +备份文件包含: + +- 批次 ID、创建时间、规则版本和校验摘要; +- 原列表信息; +- 标题、笔记、URL、优先级; +- 截止日期、开始日期; +- EventKit 可读取并可重建的闹钟和重复规则。 + +原始 EventKit ID 和创建时间可用于审计,但 EventKit 不保证恢复时复用这些 +系统生成字段。恢复验收以用户可编辑字段为准。 + +备份以权限 `0600` 原子写入,并在 EventKit 删除前重新读取和校验。任何备份 +写入或校验失败都必须拒绝整批删除。 + +删除使用 EventKit 延迟提交组成批次。提交失败后必须重新读取实际状态, +按实际结果报告,不能假设整批成功或失败。无论结果如何,备份都保留用于恢复。 + +## 命令接口 + +新增以下命令: + +| 命令 | 行为 | +|---|---| +| `--prune-dry-run` | 只读分类并报告首次候选数和已满足二次确认数;不写账本、不删除 | +| `--prune-once` | 推进一次候选状态;登记首次候选或删除已等待至少 60 秒的候选 | +| `--restore-last-prune` | 将最近一个尚未恢复的删除批次恢复到 `TaskForge 今日` | + +恢复成功后,新 EventKit ID 会重新进入普通规则。为了给用户修正任务身份或增加 +重要标记的时间,恢复批次享有 24 小时的本机清理宽限期;宽限信息只保存在 +候选账本中。宽限结束后,如仍满足候选条件,必须重新完成两次扫描才可删除。 + +`--watch` 在每轮反向和正向同步后自动推进相同状态机。普通 `--sync` 也推进 +一次,以保持命令行为一致。 + +## 日志与隐私 + +日志只允许输出: + +- 首次候选、撤销、待确认、删除、恢复和失败的数量; +- 使用本机固定随机盐生成的截断 EventKit ID 哈希; +- 不包含用户内容的错误类别。 + +日志不得输出提醒标题、笔记、Vault 路径、源文件路径、原始任务 ID 或原始 +EventKit ID。现有反向同步日志会继续遵循项目当前行为,本功能不得扩大其内容 +暴露范围。 + +候选账本、备份、运行日志和本机盐都属于运行时私有数据。仓库的忽略规则、 +隐私文档和安全文档必须明确说明这些数据不会进入公共 GitHub 仓库。 + +## 错误处理 + +- TaskForge 快照不可用:整轮清理跳过,反向和正向流程按现有错误路径处理。 +- 源文件检查不可判定:相关提醒保留,候选记录撤销或暂停,不删除。 +- 目标列表不存在:清理返回零,不为清理单独创建列表。 +- 候选账本不可读写:保留所有提醒并报告匿名错误。 +- 备份不可写或校验失败:整批不删除。 +- EventKit 删除提交失败:重新读取实际状态并保留备份。 +- 恢复时目标列表不存在:沿用正向同步的安全建表逻辑;无法建表则不消费备份。 +- 并发协调:沿用现有单轮互斥和追加一次协调机制,不并行删除。 + +## 测试策略 + +### 纯逻辑测试 + +覆盖以下判定和状态转换: + +- 其他列表永远不进入候选; +- 已完成提醒受保护; +- EventKit 优先级和六种标题前缀分别受保护; +- 当前 TaskForge ID 匹配时受保护; +- 当前缓存缺席但真实源任务存在时受保护; +- 快照、权限、I/O 或源位置检查失败时受保护; +- 无标记手工提醒成为候选; +- 缺失源引用且当前快照无匹配的旧提醒成为候选; +- 首次发现不删除; +- 不足 60 秒不删除; +- 至少 60 秒且属性未变才允许删除; +- 状态变化撤销候选; +- 属性变化后重新计时; +- 规则版本变化后重新计时; +- 恢复后 24 小时宽限及宽限结束后的重新确认。 + +### 隔离 EventKit 测试 + +使用独立临时提醒列表和临时 TaskForge 数据,验证: + +- 普通外来提醒只在二次确认后删除; +- 重要提醒、已完成提醒、当前 TaskForge 提醒和有效历史 TaskForge 提醒保留; +- 另一个临时列表中的外来提醒保持不变; +- 删除前备份存在、权限正确且摘要有效; +- `--restore-last-prune` 能恢复用户可编辑字段; +- watcher 收到 EventKit 变化后不会形成删除循环; +- 测试结束后清理临时列表和临时文件。 + +隔离测试不得使用或修改生产 `TaskForge 今日` 中的真实提醒。 + +### 生产验收 + +1. 运行完整纯逻辑测试和现有回归测试。 +2. 完成隔离 EventKit 端到端测试。 +3. 对生产 `TaskForge 今日` 运行 `--prune-dry-run`,只报告匿名数量。 +4. 构建、签名并安装 App,重启 LaunchAgent。 +5. 检查第一轮只登记候选,没有立即删除。 +6. 等待第二轮,核对删除数量、备份权限和匿名日志。 +7. 验证 `--audit` 仍然无重复。 +8. 验证 watcher、每分钟兜底及 07:00、11:00、15:00 配置仍然存在。 + +## 公共仓库交付 + +实现提交必须同步更新: + +- `README.md`:功能、命令、判定表、恢复步骤和示例; +- `docs/ARCHITECTURE.md`:清理状态机、同步顺序和数据边界; +- `docs/TROUBLESHOOTING.md`:误删恢复、候选不删除和权限故障; +- `PRIVACY.md`:本机候选账本、备份和匿名日志; +- `SECURITY.md`:失败关闭和跨列表隔离; +- `CHANGELOG.md`:新增功能和兼容性说明。 + +推送前检查 Git 暂存内容和历史,确保没有真实提醒标题、笔记、Vault 路径、 +用户名、本机绝对路径、运行日志、候选账本或备份文件。 + +## 验收标准 + +- 生产清理只作用于配置的 `TaskForge 今日` 列表。 +- 只有未完成、不重要、且不能从缓存或真实源确认的提醒会被删除。 +- 删除至少经过间隔 60 秒的两次独立确认。 +- 所有删除在提交前都有权限 `0600` 的可校验本机备份。 +- 最近删除批次能够恢复用户可编辑字段。 +- 历史 TaskForge 完成回写继续工作,TaskForge 源任务只会变为 `done`, + 不会因本功能被删除。 +- 其他 Apple 提醒列表和已完成历史保持不变。 +- 公共仓库不含私人提醒内容或本机运行数据。 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index d697bce..d95a231 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -9,7 +9,8 @@ TASKFORGE_SYNC_SIGNING_IDENTITY=${TASKFORGE_SYNC_CODESIGN_IDENTITY:--} swift build \ --package-path "$PROJECT_ROOT" \ - --configuration release + --configuration release \ + --product TaskForgeReminderSync BIN_DIR=$(swift build \ --package-path "$PROJECT_ROOT" \