ADHDOS 云端 API

与本机 MCP 桥接独立。API 操作同一份 Supabase 云同步数据,电脑关闭时仍可使用。 先登录并启用云同步,完成一次同步,再在网站设置中的 配置 MCP / API 或桌面设置 MCP / API → API 生成密钥。 桌面是否及时显示更新取决于云同步是否开启、账户同步权益以及同步周期。

对外契约

本说明对应 supabase/functions/tasks-api/handler.js、queries.js、mcp.js 和 board-image.js。数据库校验由 supabase/migrations 中的 API 迁移实现。仓库实现不代表已部署到线上。

读写范围

数据可读可写
BrainDump 活动事项、子事项列表、搜索、今日上下文、快照创建、修改文字/日期/分区/完成状态、添加/修改/删除子事项、完成、归档、移入已取消
归档事项列表、搜索、快照仅点赞/取消点赞;复用通过创建新事项实现
已取消事项列表、搜索、快照无恢复或编辑接口
Board列表、分区名称、搜索、快照、私有图片预览 URL创建内容及初始想法、追加想法、移入回收站
周期、能量菜单等其他导出数据快照;今日上下文包含周期无通用写入接口
账户、支付、其他用户数据不在快照接口范围内无接口

没有 Board 正文修改、想法修改/删除、附件上传、任意存储键写入或永久删除接口。GET /snapshot 是导出数据结构,不是允许原样回写的数据模型。密钥没有只读、模块级或单工具权限划分。

字段含义

字段含义与客户端处理
id历史数字或字符串,新建为 UUID;原样保存,放入 URL 时编码,不转换成数字
text / content事项正文 / Board 正文;创建时去首尾空格;自然语言日期按现有解析器处理
done是否完成;完成仍保留在活动列表,不等于归档
source列表筛选为 active/archived/cancelled;组合查询的规范化结果中,已完成活动事项可标为 completed
partition / tab事项 partition 限定为两个 BrainDump 分区;Board 请求使用 tab,原始存储使用 partition,列表结果使用 tab
dueDate有效日期、带时区日期时间或 null;MCP 与 REST 接受相同输入,由服务端校验;今日上下文按 UTC 计算
updatedAt实体版本,读取后原样用作 expectedUpdatedAt;不要自行格式化或用 createdAt 代替
expectedUpdatedAt写入前置版本;历史实体缺少 updatedAt 时显式传 null;省略与 null 含义不同
requestId / Idempotency-KeyMCP 参数 / REST 请求头;同一新操作生成新编号,相同操作重试保留编号和全部输入;长度 1-128
createdAt / exportedAt创建时间 / 最近云同步或写入时间;不是客户端本次请求时间
thoughts追加想法列表,每项含 id、content、createdAt、updatedAt
contentTruncatedBoard 正文是否截断;需要完整正文时传 includeFullContent=true
expiresAt图片预览链接到期的 Unix 毫秒数;链接不是永久附件地址

任务普通列表保留原始历史字段;组合查询、搜索和今日上下文返回规范化任务。响应允许额外字段,客户端只依赖已说明字段。历史时间字段可能为字符串、毫秒数或 null,不要把所有返回时间强制解析为同一种格式。Board 的 recent 排序按 createdAt(历史条目回退到 ID)降序,since 筛选使用 updatedAt 或 createdAt,二者不是同一排序规则。

快照字段范围

data 仅包含存在于云同步数据中的允许键,缺失键不会自动补齐。允许键为:

键数据含义
adhd-tasks / adhd-archived-tasks / adhd-cancelled-tasks活动、归档、已取消事项
adhd-pasteboardBoard 原始内容与想法
adhd-pasteboard-tabs / adhd-pasteboard-archived-tabsBoard 分区及归档分区元数据
adhd-cycle周期记录
adhd-neuro-menu-v5能量菜单
adhd-reframer-thoughts-v7 / adhd-reframer-active-tags-v2重构想法及当前标签
adhd-saved-layouts / adhd-recent-archive-searches布局及最近归档搜索
adhd-xp / adhd-xp-multiplier积分及倍率
adhd-deleted-ids / adhd-trash-bin同步删除标记及回收站

除事项与 Board 的公开字段外,上述模块保持导出结构,未提供逐字段的可写契约。客户端应将这些模块当作只读导出值,不依赖其内部布局或原样回写。

兼容规则

REST 主版本为路径 /api/v1;OpenAPI 的 info.version 是文档契约版本,MCP 的 serverInfo.version 是工具契约版本,均不等于桌面应用版本。

在 v1 中,增加可选输入、新接口或新工具、增加响应字段属于兼容扩展。客户端应忽略未知响应字段,但不能发送未声明的写入字段。删除/重命名字段或工具、改变已有字段类型、增加必填参数、改变完成/归档/删除语义属于不兼容变更,需要新主版本或明确迁移说明,不能静默替换已有调用方式。安全和权限校验的必要修复在变更记录中说明。

更新接口时,同步修改本说明、OpenAPI、MCP 工具定义、示例和相关契约测试,并在接口变更记录中写明兼容影响。当前没有公布固定弃用期限,不承诺未实现的旧版本并行服务。

地址与鉴权

页面提供完整任务 URL:https://adhdos.app/api/v1/tasks。 所有请求携带 Authorization: Bearer <访问密钥>。无需向 Scriptable 提供 Supabase anon 或 service-role key。 每个账户一个密钥。新密钥加密保存在 Vault,登录后可再次查看;API 验证仍使用 SHA-256 摘要。旧版仅保存摘要的密钥需要重置一次才能查看。重置、停用立即使旧密钥失效。 密钥可读取当前账户的完整云端快照,读写 BrainDump 和 Board。快照字段与桌面导出范围一致,不包含账户、支付信息或其他账户数据。

订阅与到期

云端 MCP 和 API 需要有效的 Pro 订阅,复用 user_subscriptions 的 tier 和 expires_at:tier=pro 且到期时间为空或晚于当前服务器时间。生成/重置密钥、每次数据请求和 MCP 握手均由服务端检查,不依赖桌面缓存。

到期或降级后返回 403 subscription_required,包括已成功写入的请求重试;不删除云端数据、不撤销密钥。续费生效后原密钥自动恢复,无需重新配置。到期用户仍可查看状态和撤销密钥;主动撤销的密钥不会因续费复活。本地 MCP 始终不受订阅限制。

密钥状态中的 enabled 表示密钥存在,entitled 表示订阅有效,accessEnabled 表示两者同时满足。无有效订阅时生成/重置返回 subscription_required,且不修改现有密钥。

读取

GET <任务 URL>?done=false&partition=brain-dump-primary&limit=100&offset=0

所有筛选可省略。done 只接受 true/false;partition 只接受 brain-dump-primary / brain-dump-secondary。 limit 默认为 100,范围 1–500;offset 为非负整数。按 order 排序,返回:

{"tasks":[{"id":42,"text":"浇花","done":false,"dueDate":null,"updatedAt":"2026-10-16T00:00:00.000Z","order":0}],"total":1,"nextOffset":null}

id 可能是历史数字 ID 或新 UUID,客户端须原样保留。使用 nextOffset 分页;并发增删时分页不是快照一致的。 返回任务保留桌面已有字段(包括子任务);子任务完成状态通过同一个 PATCH 接口更新。

新增

POST <任务 URL>,Content-Type: application/json,Idempotency-Key: <唯一 UUID>。

{"text":"给植物浇水","dueDate":"2026-10-17","partition":"brain-dump-primary"}

text 必填,去除首尾空格后 1–5000 字符;dueDate 可为有效 YYYY-MM-DD 日期、带时区的 ISO 日期时间或 null;partition 默认 primary;done 可选布尔值。 可选 subtasks 数组最多 30 项,每项 text 为 1–1000 字符,可选 done 布尔值;子事项 ID 和时间戳由服务端生成。 服务端生成 id、排序和时间戳。201 返回 {"task":{...}}。

修改与完成

PATCH <任务 URL>/<id>,同样携带 JSON 和唯一 Idempotency-Key。

{"done":true,"expectedUpdatedAt":"2026-10-16T00:00:00.000Z"}

可更新 text、done、dueDate、partition,以及新增子任务、子任务文字和完成状态。 子任务使用 {"subtasks":[{"id":"子任务 ID","done":true}],"expectedUpdatedAt":"父事项 updatedAt"},按 ID 合并,保留其他子任务和字段;done:false 撤销完成。子任务文字使用 {"subtasks":[{"id":"子任务 ID","text":"修改后的内容"}],"expectedUpdatedAt":"父事项 updatedAt"},保留完成状态与其他字段。子任务变化不会自动完成父事项。 新增子任务使用 {"subtasks":[{"text":"新子任务"}],"expectedUpdatedAt":"父事项 updatedAt"};不传 id,由服务端生成 ID、创建和更新时间,追加后最多 30 项。重试必须沿用原 Idempotency-Key。 expectedUpdatedAt 必填,传读取任务时的 updatedAt;历史任务没有该字段时传 null。 200 返回更新后的 task;409 version_conflict 表示其他设备已修改,响应 task 为当前版本,重新读取并让用户确认后重试。 完成表示设置 done=true,不自动归档,也不发放桌面积分;done=false 可撤销完成。 现有本地 MCP 和云端 MCP 的 complete_task 同样只标记完成,归档由 archive_task 显式执行。

显式完成与归档

POST <任务 URL>/<id>/complete,请求体只含 expectedUpdatedAt,与 PATCH done=true 完全等价。 POST <任务 URL>/<id>/archive,请求体只含 expectedUpdatedAt,仅归档已完成任务,未完成返回 409 task_not_complete。 两者均须提供 Idempotency-Key。归档会写入删除标记,防止旧设备同步将事项恢复到活动列表。 读取时可增加 source=active|archived|cancelled,默认 active。也可以用 includeArchived=true&includeCancelled=true 同时读取多个来源(不可与 source 混用)。

Board、搜索、今日上下文与完整快照

以下路径基于 https://adhdos.app/api/v1,鉴权、额度和错误规则与任务 API 相同。

接口参数与返回
GET /board-itemslimit 1–200(默认 30)、since ISO 日期、tab、query、sort=recent/snapshot、includeFullContent、contentLimit 80–2000;返回 exportedAt 和 items
POST /board-itemscontent 必填 1–10000 字符、tab 可选最多 100 字符;需要 Idempotency-Key;201 返回 item
GET /searchquery 必填、limit 1–100(默认 20);搜索活动/归档/取消事项、子事项和 Board,返回 results
GET /today-context无参数;返回 date、tasks、overdueTasks、recentBoardItems、cycle、exportedAt;日期与本地 MCP 一致使用 UTC
GET /snapshot无参数;返回 snapshot,含 source=cloud-sync、exportedAt 和 data;只含现有桌面快照允许导出的字段

云端和本地共用查询逻辑。exportedAt 是云端最后同步/写入时间,不表示刚从桌面获取。未同步到云端的内容无法由云端接口读取。Board 与任务使用现有同步合并机制;写入不会替换其他模块的数据。 完整快照属于广泛读取,仅在确实需要且用户明确要求时调用;常规场景优先使用具体查询工具。

云端 MCP

同一服务的 https://adhdos.app/mcp 提供 Streamable HTTP MCP,使用同一访问密钥。 工具与 API 操作共用处理器、数据库事务、版本检查、去重记录和账号额度。握手只校验密钥、不读取事项,但也计入请求额度;一次 MCP 工具调用可能消耗握手和数据操作两次额度。 详见 MCP 文档。

重试与限制

所有写操作必须使用 Idempotency-Key,重试同一操作必须沿用原编号和请求体。相同编号换内容返回 409。 记录保留 7 天,超过期限不要盲目重试新增。重放成功结果仍会消耗请求和响应预算。

服务端额度与节流(待部署)

以下能力需按 supabase/functions/media-download/README.md 完成两个额度 SQL 源文件的迁移、Edge Function/客户端发布和旧图片迁移;本文更新不代表线上已启用。

每账号默认限制数值
请求每分钟 60 次、每天 2,000 次、每月 30,000 次
响应预算每天 5 MiB、每月 50 MiB
API 完整快照每天最多 4 次
数据库单次返回普通 API 操作最多 256 KiB,API 完整快照最多 1 MiB,同步 RPC 最多 2 MiB;图片单文件最多 2 MiB,均受累计预算约束

按 UTC 日历日/月重置。REST、云端 MCP、主应用同步 RPC、图片凭证和实际图片下载、批量导入共享同一额度;额度绑定账号,轮换或撤销密钥不能重置。无效凭证不能读取数据。超额返回 429 和 Retry-After 秒数;返回体过大返回 413 response_too_large,应减小分页或使用摘要。写入若因响应过大/预算不足被拒绝,其数据与重试记录一起回滚。

响应预算是保守的应用层计数,不是 Supabase 账单:通用 API 每次预留 2 KiB,另按数据库 JSON 字节数六倍记账,覆盖 REST/MCP 表示;主应用直连同步每次预留 1 KiB 响应开销,正文按数据库 JSON 字节数计一次。本地缓存命中不调用云端,不增加请求或流量额度;条件请求、无变化检查仍有网络响应,仍计额度。图片下载在访问 Storage 之前,按文件元数据大小的两倍预留两段传输预算,另计响应元数据预算;失败不返还预留预算。gzip 不减少预算计数,避免不同客户端争用或绕过限制。管理员可在数据库中调整单账号上限;客户端无权修改计数和上限。

Board、搜索、任务分页、今日上下文、图片引用先在数据库中筛选,再返回结果。摘要模式会缩短正文、备注和想法;contentTruncated / detailsTruncated 表示需进一步读取。普通任务列表的分页字段保持不变。

REST GET 返回 ETag;重复读取同一路径与查询参数时可发送 If-None-Match。选中内容没变会返回无正文的 304(仍计一次小额请求),与该查询无关的编辑不会强制重新传正文。请将 ETag 与账号、URL、查询参数和对应缓存绑定;不能拿一个查询的 ETag 搭配另一个查询的缓存。支持 Accept-Encoding: gzip 的客户端可接收压缩响应。Scriptable Board 示例已使用条件读取:小组件使用 view=widget,在最近 20 条范围内,只返回实际最多显示的 5 条预览(id、120 字以内标题和 80 字以内首条想法),另以 total 返回所选范围的条数,不传正文、搜索文本、时间戳和分区。显示之外的内容变化不影响小组件 ETag。打开脚本列表才读取最近 20 条完整内容,两种缓存独立保存。小组件自动检查间隔为一小时(系统调度可能更晚),间隔内直接读本地缓存、不发 API 请求;失败时保留缓存并退避,429 遵循 Retry-After。手动打开脚本会检查最新列表。没有 API 请求时不会主动检查额度或拉取数据。

主应用已取消直接读取 sync_data 的兼容回退,普通账号没有该表的直接权限;同步数据、元信息和存在性检查均经过计费 RPC。私有图片不再签发 Storage 直链,每次下载都检查账号预算,本地缓存保存图片字节。超额时保留本地编辑并暂停后续联网请求。纯本地 MCP 操作不消耗云预算,但其云图片预览仍受下载限制。旧签名链接需要迁移旧桶对象并等待/确认缓存失效才能堵住;未完成迁移不能宣称已阻止旧链接绕过。无效请求、Auth 等服务成本和整个项目总账单仍不等同于此应用层预算;当前没有自动监控或预警任务。 最大 JSON 请求体 128 KiB,活动任务上限 5000。客户端不得设置服务端时间戳或任意同步字段。

状态说明
400无效字段、日期、筛选或缺少 Idempotency-Key
401密钥无效、已重置或停用
403subscription_required:没有有效 Pro 订阅
404接口或任务不存在(包括已删除/归档)
409版本冲突、重试编号冲突、尚无云同步数据、任务数上限
413 / 415请求体太大 / 非 JSON
428修改缺少 expectedUpdatedAt
429频率、日/月请求、响应预算或完整快照额度耗尽;遵守 Retry-After
503云服务暂不可用

错误体为 {"error":"错误码"}。网络超时不代表写入失败,必须按同一请求编号重试。

Scriptable

示例文件:public/examples/ADHDOS.scriptable.js。复制到 iPhone 的 Scriptable 新建脚本中,在 Scriptable 内点击运行,首次运行可选择「本地模式」或「ADHDOS 云端」。本地模式无需账号、密钥或订阅,事项保存在当前设备的 Scriptable 文件夹中;云端模式已预填 https://adhdos.app/api/v1/tasks,只需粘贴访问密钥。无需修改代码;以后点击列表中的「设置」即可修改。 之后可添加 Scriptable 小组件并选择该脚本。小组件只读取,点击打开脚本,可新增或完成事项。列表中点击事项可展开子任务及完成状态;点击主事项或子任务文字可编辑,右侧箭头负责展开;展开后可添加子任务,主事项和子任务均可直接勾选或撤销,完成项保留显示。勾选立即反馈,后台保存后使用返回事项更新列表;保存期间只锁住当前父事项和子任务。明确失败时回退,网络结果不明时保留待确认操作及原请求编号。 两种模式共用新增、编辑、完成、删除和子任务界面。设置中可切换模式;本地与云端数据独立,不自动上传或迁移。纯本地模式不提供跨设备同步,删除 Scriptable 数据会丢失本地事项。 凭证保存到 Keychain,缓存和待确认操作保存在 Scriptable 本机文件夹。重新配置会清除旧缓存和待确认操作。 请求失败后,下次手动运行可重试待确认操作。小组件刷新由 iOS 调度,不保证实时更新。 示例只展示前 100 件未完成事项;更大型客户端可使用 nextOffset 分页。

部署与验证

  1. 依次应用 20261015234712_cloud_tasks_api.sql 、20261016000042_remote_mcp_task_semantics.sql 、20261016002042_full_cloud_mcp.sql 和 20261016003041_cloud_api_subscription_entitlement.sql,前提是已应用现有同步合并函数迁移。
  2. 应用后续 API 迁移,包括 20261026010426_board_api_numeric_version.sql,再部署 tasks-api Edge Function,禁用网关 JWT 校验(supabase/config.toml 已配置)。函数自行验证专用密钥,不能绕过数据库校验。
  3. 发布包含 API 设置页的新桌面版本。仅部署云函数不会更新已安装的桌面程序。
  4. 使用专门测试账户完成一次云同步,生成密钥,验证读取、新增、修改、冲突、重试、撤销和桌面合并。
  5. 执行 npm run test:cloud-api,包含真实 Postgres(PGlite)合并及权限测试;不能代替部署后的端到端验证。

不要将本地 mcp/adhdos-api-server.mjs 或桌面桥接端口暴露到公网。

删除事项与子任务

主事项使用同一 PATCH 接口,发送 {"deleted":true,"expectedUpdatedAt":"事项 updatedAt"}。事项移入 BrainDump 的已取消列表,保留内容和子任务,记录同步删除标记;可在 BrainDump 恢复。

子任务发送 {"subtasks":[{"id":"子任务 ID","deleted":true}],"expectedUpdatedAt":"父事项 updatedAt"},仅移除指定子任务。删除标记不能与同项的 text/done 修改混用。两者均复用 Idempotency-Key 和版本检查。

Scriptable 中点击事项文字,在编辑弹窗选择「删除事项」或「删除」。

Scriptable 提醒事项与自然语言日期

设置 → Apple 提醒事项 → 授权 → 选择列表。新增、编辑、勾选与删除直接作用于选定列表;在提醒事项 App 中的改动,重新打开脚本或刷新可见。不会自动导入本地事项或上传 ADHDOS。小组件刷新仍由 iOS 调度。

Scriptable 没有原生子任务层级接口,提醒事项模式不提供子任务操作,也不改写原有层级;删除直接从提醒事项列表移除。其接口没有原子版本条件,脚本写前会比较可读取字段,但不能保证跨应用并发修改完全无冲突。若系统保存中断且结果不明,请先在提醒事项核对,再丢弃待确认记录,脚本不会盲目重复创建。

三种模式均复用 BrainDump 的日期解析:买牛奶 @明天、查资料 @周五、开会 @明天 @15:00。识别后保留事项文字并设置截止日期/时间;未识别的表达保留原文。新增、编辑及支持子任务的模式均可使用。

点击主事项或子任务文字,在编辑框内修改日期标记(例如 @明天 @15:00);已有日期会自动带入,删除日期标记即可清除日期。不再显示独立日期按钮。纯本地、云端和提醒事项主事项均支持,提醒事项原生子任务层级仍不受 Scriptable 接口支持。

归档点赞与复用

API 可通过 GET <任务 URL>?source=archived&limit=100&offset=0 读取归档。归档点赞使用同一 PATCH 接口:

{"source":"archived","liked":true,"expectedUpdatedAt":"读取时的 updatedAt"}

传 liked:false 取消点赞。此形式只允许更新归档的点赞状态,沿用访问权限、版本校验及 Idempotency-Key;不会修改活跃事项。

复用沿用 POST 新增事项:保留文字、子任务文字和分区,生成新 ID,重置完成状态与日期,原归档不变。Scriptable 不提供归档查看入口。

Board 请求示例

读取 Board:

GET https://adhdos.app/api/v1/board-items?limit=30
Authorization: Bearer YOUR_API_KEY

新增 Board 内容(每次新操作生成一个唯一的 Idempotency-Key;重试同一操作时沿用原值):

POST https://adhdos.app/api/v1/board-items
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Idempotency-Key: REPLACE_WITH_A_UNIQUE_UUID

{"content":"记录一个想法"}

iOS 快捷指令:添加到 BrainDump

下载 examples/ADHDOS-BrainDump.shortcut,导入时填写访问密钥。启动后输入事项,选择「添加子事项」或「直接提交」。多行按 BrainDump 规则拆分,缩进或列表项归入上一个主事项;单独添加的子事项归入最后一个主事项。无需读取剪贴板。

POST /api/v1/tasks 支持 { "input": "事项一\n 子事项\n事项二", "subtaskInput": "可选子事项" },返回 { "tasks": [...] }。每次最多 50 个主事项,沿用单项的校验、订阅权益与日期解析;整批原子保存,重试使用同一 Idempotency-Key。

iOS 快捷指令:收藏到 Board

下载 examples/ADHDOS-Board.shortcut,在 iPhone「快捷指令」中导入并填写访问密钥。优先使用分享的文字或链接,没有分享内容时读取剪贴板;读取的内容预填到唯一的输入框中,必须由用户确认或修改;没有内容时文本框留空,可输入新的想法。用途说明不会作为正文提交。确认内容后填写收藏想法(可留空),随后选择已有分区或「全部(不指定分区)」保存;选择全部时不设置分区,不会创建新分区。保存成功后显示通知。确认的内容作为 Board 正文保存,收藏想法单独保存。无需 Scriptable。密钥保存在快捷指令中,分享前应移除。

POST /api/v1/board-items 新增可选 thought 字符串(最多 10000 字符)。去除首尾空格后为空则不创建想法。服务端为想法生成 ID、创建和更新时间,与 Board 内容原子写入;相同 Idempotency-Key 重试不会重复创建。

{"content":"https://example.com/article","thought":"准备写作时参考其中的案例"}

Board Scriptable

在 API 页的 Board 部分复制 Scriptable 脚本,粘贴到 Scriptable 运行。可复用已配置的 BrainDump 云端密钥。点击右侧数量或箭头展开想法,在展开区域底部「添加想法」。支持文字、链接、桌面小组件和新增占位反馈。

POST /api/v1/board-items/<id>/thoughts,JSON { "content": "继续添加的想法" },携带同一访问密钥及唯一 Idempotency-Key。返回 { "item": {...} };追加想法并保留已有内容,每条想法 1–10000 字符,每张卡片最多 500 条。重试沿用同一编号。Board 列表返回 thoughts,包含 id、content、createdAt、updatedAt。

Board 图片预览

GET /api/v1/board-items/<id>/image,使用同一 Bearer 密钥。仅支持当前账户 Board 图片卡片对应的私有 user-uploads-metered 文件。返回 {url, expiresAt},下载凭证有效期 300 秒,响应禁止公开缓存。URL 指向受限下载入口,每次使用都会重新扣共享额度,不是 Storage 签名直链。复用 API 权益、限流及删除过滤,额外验证存储路径属于密钥对应用户;不接受客户端指定文件路径。两个上传桶均设为私有,并禁止普通账号直接读取、签名、改写及删除对象。公开图片及内嵌图片由客户端直接预览。

Board 分区选择

Board 快捷指令与 Scriptable 新增内容支持“不分区”、选择已有分区、输入新分区名。选择列表包含空分区,排除已归档分区;取消不会提交。Scriptable 的待确认请求保留原分区,重试不会更改目标。

GET /api/v1/board-items?tabsOnly=true 返回 { exportedAt, tabs: string[] },不返回内容列表。普通 Board 列表也附带 tabs。新增沿用 POST /api/v1/board-items 的 tab 字段,存储为 Board 的 partition。

删除 Board 内容

POST /api/v1/board-items/<id>/delete,请求体 { "expectedUpdatedAt": "读取时的 updatedAt" };历史条目没有版本时传 null。携带 Bearer 密钥和唯一 Idempotency-Key;重复请求沿用原编号。成功返回 { "deleted": true, "id": "条目 ID" }。版本不匹配返回 409。内容及其想法以现有格式移入 adhd-trash-bin,写入 adhd-pasteboard 删除标记防止旧设备同步复活,可在 ADHDOS 回收站恢复。

Scriptable 示例脚本

/*
ADHDOS · BrainDump
享受分心,自然专注。

在 Scriptable 中新建脚本,粘贴这份代码并运行,首次选择使用方式:
• 本地:无需账号,事项只保存在这台设备。
• Apple 提醒事项:按提示授权并选择列表。
• ADHDOS 云端:先开启数据同步(需要 Pro 会员),创建访问密钥并填入;API 地址保持默认即可。

点“新增事项”随手记下来,需要时再补上子事项。
云端事项可用“打开独立链接”在网页中查看;本地和提醒事项不会自动同步到 ADHDOS。

有新版会提醒,不想更新就选“跳过此版本”。访问密钥保存在本机钥匙串,请自己保管。

登录与数据同步:https://adhdos.app/?panel=data
有问题,写信给我:roamzen@proton.me
官网:https://adhdos.app/product
*/

// BEGIN SHARED BRAINDUMP DATE PARSER
/**
 * Input: i18n keys, Date strings
 * Output: parseTaskInput(), formatDateDisplay(), formatRelativeTime()
 * Pos: 神经多样性友好的日期处理核心,支持当前时间点、中文时刻、分钟/小时偏移、周几、相对天数等自然语言解析与相对时间格式化。
 * 一旦更新,请务必更新本注释及所属文件夹的 README.md。
 */
const DATE_ONLY_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/;
const LEGACY_DATE_REGEX = /^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/;

const isDateOnlyString = (value) =>
    typeof value === 'string' && DATE_ONLY_REGEX.test(value.trim());

const dateOnlyFromDate = (date) => {
    if (!(date instanceof Date) || isNaN(date.getTime())) return '';
    const y = date.getFullYear();
    const m = String(date.getMonth() + 1).padStart(2, '0');
    const d = String(date.getDate()).padStart(2, '0');
    return `${y}-${m}-${d}`;
};

const parseStoredDate = (value) => {
    if (!value) return null;
    if (value instanceof Date) {
        return isNaN(value.getTime()) ? null : new Date(value.getTime());
    }
    if (typeof value !== 'string') {
        const parsed = new Date(value);
        return isNaN(parsed.getTime()) ? null : parsed;
    }

    const trimmed = value.trim();
    const dateOnlyMatch = trimmed.match(DATE_ONLY_REGEX) || trimmed.match(LEGACY_DATE_REGEX);
    if (dateOnlyMatch) {
        const year = parseInt(dateOnlyMatch[1], 10);
        const month = parseInt(dateOnlyMatch[2], 10) - 1;
        const day = parseInt(dateOnlyMatch[3], 10);
        const parsedLocal = new Date(year, month, day);
        return isNaN(parsedLocal.getTime()) ? null : parsedLocal;
    }

    const parsed = new Date(trimmed);
    return isNaN(parsed.getTime()) ? null : parsed;
};

const parseTaskInput = (text) => {
    // Normalize full-width @ (U+FF20) to half-width @ (U+0040) for Chinese input method compatibility
    const normalizedText = text.replace(/@/g, '@');

    if (!normalizedText.includes('@')) {
        return { text: text.trim(), date: null };
    }

    const regex = /@(\S+)/g;
    let cleanText = normalizedText;

    // Accumulator for date and time components
    let accDate = null;
    let accTime = null; // { hours, minutes, seconds?, milliseconds? }

    cleanText = normalizedText.replace(regex, (match, tag) => {
        const dateObj = parseDateFromText(tag);
        if (dateObj) {
            if (dateObj.isDateOnly) {
                // If we already have an accumulated date, verify if we should overwrite it or just ignore?
                // Overwrite seems correct (user correction).
                accDate = dateObj;
            } else if (dateObj.isTimeOnly) {
                accTime = { hours: dateObj.getHours(), minutes: dateObj.getMinutes() };
            } else {
                // Formatting like full offsets or full timestamps -> Reset both
                accDate = dateObj;
                accTime = {
                    hours: dateObj.getHours(), minutes: dateObj.getMinutes(),
                    seconds: dateObj.getSeconds(), milliseconds: dateObj.getMilliseconds()
                };
            }
            return '';
        }
        return match;
    });

    let finalDate = null;

    const shouldStoreAsDateOnly = Boolean(accDate?.isDateOnly) && !accTime;

    if (accDate || accTime) {
        // Base is accDate (YYYY-MM-DD 00:00 usually) or Today
        const base = accDate ? new Date(accDate) : new Date();

        // If we have a time component, apply it
        if (accTime) {
            base.setHours(accTime.hours, accTime.minutes, accTime.seconds ?? 0, accTime.milliseconds ?? 0);
        } else if (accDate) {
            // If date only, ensure time is reset to 00:00 (parseDateFromText usually does this for DateOnly)
            // But if accDate came from "Tomorrow" it is already 00:00
            // If accDate came from offset, it might have time. But offsets are not isDateOnly.
            if (accDate.isDateOnly) {
                base.setHours(0, 0, 0, 0);
            }
        }
        finalDate = base;
    }

    return {
        text: cleanText.replace(/\s+/g, ' ').trim(),
        date: finalDate ? (shouldStoreAsDateOnly ? dateOnlyFromDate(finalDate) : finalDate.toISOString()) : null
    };
};

// Natural-language numbers from zero through ninety-nine; reject malformed input.
const cnNum = { '零': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
const parseCnNum = (str) => {
    if (/^\d+$/.test(str)) return Number(str);
    if (Object.hasOwn(cnNum, str)) return cnNum[str];
    const tens = str?.match(/^([一二三四五六七八九])?十([一二三四五六七八九])?$/);
    if (tens) return (tens[1] ? cnNum[tens[1]] : 1) * 10 + (tens[2] ? cnNum[tens[2]] : 0);
    const padded = str?.match(/^零([一二三四五六七八九])$/);
    return padded ? cnNum[padded[1]] : NaN;
};

const buildRelativeDayDate = (baseDate, offsetDays) => {
    const d = new Date(baseDate);
    d.setDate(d.getDate() + offsetDays);
    return d;
};

const parseDateFromText = (tag) => {
    if (!tag) return null;
    const now = new Date();
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Clear time
    // Remove common punctuation from end of tag
    const lowerTag = tag.trim().toLowerCase().replace(/[.,!?;:。,!?;:]+$/, '');

    // Helper to mark date as date-only
    const asDate = (d) => { d.isDateOnly = true; return d; };
    // Helper to mark date as time-only (using current date + extracted time)
    const asTime = (d) => { d.isTimeOnly = true; return d; };

    const mergeDateAndTime = (datePart, timePart) => {
        const d = parseDateFromText(datePart);
        const t = parseDateFromText(timePart);
        if (d && d.isDateOnly && t && t.isTimeOnly) {
            d.setHours(t.getHours(), t.getMinutes(), 0, 0);
            delete d.isDateOnly;
            return d;
        }
        return null;
    };

    const spacedDateTimeMatch = lowerTag.match(/^(.+?)\s+(.+)$/);
    if (spacedDateTimeMatch) {
        const merged = mergeDateAndTime(spacedDateTimeMatch[1], spacedDateTimeMatch[2]);
        if (merged) return merged;
    }

    // Current-instant keywords retain time precision and are never date-only.
    if (['now', '刚刚', '现在'].includes(lowerTag)) return now;

    // 1. Keywords
    if (['today', '今天'].includes(lowerTag)) return asDate(today);
    if (['tomorrow', 'tmr', '明天'].includes(lowerTag)) {
        const d = new Date(today);
        d.setDate(d.getDate() + 1);
        return asDate(d);
    }
    if (['后天', 'dat', 'dayaftertomorrow'].includes(lowerTag)) {
        const d = new Date(today);
        d.setDate(d.getDate() + 2);
        return asDate(d);
    }
    if (['yesterday', 'ytd', '昨天'].includes(lowerTag)) {
        const d = new Date(today);
        d.setDate(d.getDate() - 1);
        return asDate(d);
    }
    if (['前天', 'daybeforeyesterday'].includes(lowerTag)) {
        const d = new Date(today);
        d.setDate(d.getDate() - 2);
        return asDate(d);
    }

    const relativeDayMatch = lowerTag.match(/^([0-9]+|[一二三四五六七八九十两]+)\s*天\s*(前|后)$/);
    if (relativeDayMatch) {
        const amount = parseCnNum(relativeDayMatch[1]);
        if (amount > 0) {
            const direction = relativeDayMatch[2] === '后' ? 1 : -1;
            return asDate(buildRelativeDayDate(today, amount * direction));
        }
    }

    // Minute/hour offsets represent elapsed time, retaining seconds and milliseconds.
    const relativeTimeMatch = lowerTag.match(/^([0-9]+|[零一二三四五六七八九十两]+|半)(分钟|小时)(前|后)$/);
    if (relativeTimeMatch) {
        const amount = relativeTimeMatch[1] === '半' ? 0.5 : parseCnNum(relativeTimeMatch[1]);
        const unitMs = relativeTimeMatch[2] === '小时' ? 3600000 : 60000;
        const direction = relativeTimeMatch[3] === '后' ? 1 : -1;
        const result = new Date(now.getTime() + amount * unitMs * direction);
        return Number.isNaN(result.getTime()) ? null : result;
    }

    // 2. Weekdays
    const weekMap = {
        '周日': 0, '周天': 0, '星期日': 0, '星期天': 0, 'sun': 0, 'sunday': 0,
        '周一': 1, '星期一': 1, 'mon': 1, 'monday': 1,
        '周二': 2, '星期二': 2, 'tue': 2, 'tuesday': 2,
        '周三': 3, '星期三': 3, 'wed': 3, 'wednesday': 3,
        '周四': 4, '星期四': 4, 'thu': 4, 'thursday': 4,
        '周五': 5, '星期五': 5, 'fri': 5, 'friday': 5,
        '周六': 6, '星期六': 6, 'sat': 6, 'saturday': 6,
    };

    // Check for "Last" / "上" prefix
    let isLastWeek = false;
    let isNextWeek = false;
    let cleanTag = lowerTag;

    if (cleanTag.startsWith('last')) {
        isLastWeek = true;
        cleanTag = cleanTag.replace('last', '');
    } else if (cleanTag.startsWith('上')) {
        isLastWeek = true;
        cleanTag = cleanTag.replace(/^上(?:周|星期)/, '周');
    } else if (cleanTag.startsWith('next')) {
        isNextWeek = true;
        cleanTag = cleanTag.replace('next', '');
    } else if (cleanTag.startsWith('下')) {
        isNextWeek = true;
        // Handle "下下周"? For now just "下周"
        cleanTag = cleanTag.replace(/^下(?:周|星期)/, '周');
    }

    // Re-check map with cleaned tag
    if (weekMap.hasOwnProperty(cleanTag) || weekMap.hasOwnProperty(lowerTag)) {
        const targetWeekdayStr = weekMap.hasOwnProperty(cleanTag) ? cleanTag : lowerTag;
        const targetDay = weekMap[targetWeekdayStr];
        // Calendar weeks start on Monday, including Sunday as day 7.
        const currentDay = today.getDay() || 7;
        const calendarTargetDay = targetDay || 7;

        let daysToAdd = calendarTargetDay - currentDay;

        if (isLastWeek) {
            // Last week logic: "This week's day" - 7
            // e.g. Wed(3) -> Mon(1). Diff -2. Last Mon = -9.
            // e.g. Wed(3) -> Fri(5). Diff +2. Last Fri = -5.
            daysToAdd = (calendarTargetDay - currentDay) - 7;
        } else if (isNextWeek) {
            // Next week logic: "This week's day" + 7
            // e.g. Wed(3) -> Mon(1). Diff -2. Next Mon (of next week) = -2 + 7 = 5.
            // e.g. Wed(3) -> Fri(5). Diff +2. Next Fri (of next week) = +2 + 7 = 9. 
            // Note: This is distinct from "Next occurrence". "Next week" implies skipping into the next calendar week.
            daysToAdd = (calendarTargetDay - currentDay) + 7;
        }

        const d = new Date(today);
        d.setDate(d.getDate() + daysToAdd);
        return asDate(d);
    }

    // 3. Specific Date (MM.DD, MM/DD)
    // Matches 1.1, 12.25, 12/25, 12-25
    const dateMatch = tag.match(/^(\d{1,2})[.\/-](\d{1,2})$/);
    if (dateMatch) {
        const month = parseInt(dateMatch[1], 10) - 1; // 0-11
        const day = parseInt(dateMatch[2], 10);

        // Use current year
        const d = new Date(today.getFullYear(), month, day);

        // Validate date (e.g. 2.30 is invalid)
        if (d.getMonth() !== month) return null;

        // If date has passed this year, assume next year? 
        // Optional logic: if (d < today) d.setFullYear(d.getFullYear() + 1);
        // For now let's just keep it simple, straightforward interpretation.

        return asDate(d);
    }

    // 4. Specific Date (YYMMDD) - e.g. 251107 -> 2025-11-07
    const compactDateMatch = tag.match(/^(\d{2})(\d{2})(\d{2})$/);
    if (compactDateMatch) {
        const year = parseInt(compactDateMatch[1], 10) + 2000; // Assume 20xx
        const month = parseInt(compactDateMatch[2], 10) - 1; // 0-11
        const day = parseInt(compactDateMatch[3], 10);

        const d = new Date(year, month, day);

        // Validate
        if (d.getMonth() !== month) return null;

        return asDate(d);
    }
    // 5. Short Date (MMDD) - e.g. 1107 -> Current Year-11-07
    const shortDateMatch = tag.match(/^(\d{2})(\d{2})$/);
    if (shortDateMatch) {
        const month = parseInt(shortDateMatch[1], 10) - 1; // 0-11
        const day = parseInt(shortDateMatch[2], 10);

        const d = new Date(today.getFullYear(), month, day);

        // Validate
        if (d.getMonth() !== month) return null;

        return asDate(d);
    }

    // 6. Full Date (YYYY/MM/DD or YYYY-MM-DD)
    const fullDateMatch = tag.match(/^(\d{4})[.\/-](\d{1,2})[.\/-](\d{1,2})$/);
    if (fullDateMatch) {
        const year = parseInt(fullDateMatch[1], 10);
        const month = parseInt(fullDateMatch[2], 10) - 1;
        const day = parseInt(fullDateMatch[3], 10);

        const d = new Date(year, month, day);
        if (d.getMonth() !== month) return null;
        return asDate(d);
    }

    // 7. Time Offset (e.g. 2h, 30m) - Returns 'DateTime' (neither isDateOnly nor isTimeOnly strictly)
    const offsetMatch = tag.match(/^(\d+)(h|m)$/i);
    if (offsetMatch) {
        const value = parseInt(offsetMatch[1], 10);
        const unit = offsetMatch[2].toLowerCase();
        const d = new Date(); // Now

        if (unit === 'h') {
            d.setHours(d.getHours() + value);
        } else if (unit === 'm') {
            d.setMinutes(d.getMinutes() + value);
        }
        return d;
    }

    // 8. Specific Time (HH:mm) - e.g. 14:30
    const timeMatch = tag.match(/^(\d{1,2})[::](\d{2})$/);
    if (timeMatch) {
        const hours = parseInt(timeMatch[1], 10);
        const minutes = parseInt(timeMatch[2], 10);
        if (hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60) {
            const d = new Date();
            d.setHours(hours, minutes, 0, 0);
            return asTime(d);
        }
    }

    // 9. Chinese clock times, including 半 / 一刻 / 三刻 after 点.
    const cnTimeMatch = lowerTag.match(/^(?:(早上|上午|下午|中午|晚上|晚|傍晚|凌晨))?([0-9]+|[零一二三四五六七八九十两]+)(?:点(?:(半|一刻|三刻)|([0-9]+|[零一二三四五六七八九十两]+)(?:分)?)?|[::]([0-9]+|[零一二三四五六七八九十两]+)(?:分)?)$/);

    if (cnTimeMatch) {
        const period = cnTimeMatch[1]; // 下午
        const hourStr = cnTimeMatch[2]; // 六
        const minuteStr = cnTimeMatch[4] ?? cnTimeMatch[5];

        let hours = parseCnNum(hourStr);
        const fractions = { '半': 30, '一刻': 15, '三刻': 45 };
        const minutes = cnTimeMatch[3] ? fractions[cnTimeMatch[3]] : (minuteStr ? parseCnNum(minuteStr) : 0);

        // Adjust for period
        // 凌晨: 0-5 (no change usually, but 凌晨12点 is 00:00?)
        // 上午: 6-11 (no change)
        // 中午: 11-13 (no change? 12? )
        // 下午: 12-18 (add 12 if < 12)
        // 晚上: 18-24 (add 12 if < 12)

        if (period) {
            if (['下午', '晚上', '晚', '傍晚'].includes(period) && hours < 12) {
                hours += 12;
            } else if (period === '凌晨' && hours === 12) {
                hours = 0;
            } else if (period === '中午' && hours < 11) {
                // Ambiguous? 中午1点 = 13:00 Usually
                hours += 12;
            }
        }

        if (hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60) {
            const d = new Date();
            d.setHours(hours, minutes, 0, 0);
            return asTime(d);
        }
    }

    const ampmMatch = tag.match(/^(\d{1,2})(am|pm)$/i);
    if (ampmMatch) {
        let hours = parseInt(ampmMatch[1], 10);
        const suffix = ampmMatch[2].toLowerCase();
        if (hours >= 1 && hours <= 12) {
            if (suffix === 'pm' && hours < 12) hours += 12;
            else if (suffix === 'am' && hours === 12) hours = 0;
            const d = new Date();
            d.setHours(hours, 0, 0, 0);
            return asTime(d);
        }
    }

    // 10. Composite Date + Time Parsing (Recursion)
    // Support: Matches like "明天下午六点", "周五8pm", "12.25 14:00" (if space was preserved in tag? regex removes spaces if not careful)
    // Note: parseTaskInput regex `/@(\S+)/` tokenizes by space. So "12.25 14:00" becomes "@12.25" and "14:00".
    // But "明天下午六点" is a single token.

    // a. Keywords / Weekdays Prefix
    const keywordPrefixMatch = lowerTag.match(/^(今天|tomorrow|tmr|明天|后天|dat|yesterday|ytd|昨天|前天|(?:上|下)?周[一二三四五六日天]|(?:上周|下周|上星期|下星期|星期)[一二三四五六日天]|(?:last|next)?(?:mon|tue|wed|thu|fri|sat|sun))(.*)$/);
    if (keywordPrefixMatch && keywordPrefixMatch[2]) {
        const datePart = keywordPrefixMatch[1];
        const timePart = keywordPrefixMatch[2];
        const merged = mergeDateAndTime(datePart, timePart);
        if (merged) return merged;
    }

    // b. Numeric Prefix (MM.DD etc)
    const numericPrefixMatch = lowerTag.match(/^(\d{4}[./-]\d{1,2}[./-]\d{1,2}|\d{1,2}[./-]\d{1,2})(.+)$/);
    if (numericPrefixMatch) {
        const datePart = numericPrefixMatch[1];
        const timePart = numericPrefixMatch[2];
        const merged = mergeDateAndTime(datePart, timePart);
        if (merged) return merged;
    }

    return null;
};

const formatDateForInput = (isoString) => {
    if (!isoString) return '';
    const date = parseStoredDate(isoString);
    if (!date) return '';
    const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;

    const year = date.getFullYear();
    const dateStr = `${year}.${date.getMonth() + 1}.${date.getDate()}`;
    if (hasTime) {
        const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
        return `@${dateStr} @${timeStr}`;
    }
    return `@${dateStr}`;
};

// END SHARED BRAINDUMP DATE PARSER
// BrainDump · 本地 / Apple 提醒事项 / ADHDOS 云端。
// 首次运行选择模式。本地模式无需账号;云端密钥保存在钥匙串。
const DEFAULT_API_URL = 'https://adhdos.app/api/v1/tasks';
// Widgets only read. Tap the widget to open this script and create/complete tasks.
const keyName = 'adhdos.cloud-api';
const fm = FileManager.local();
const WIDGET_REFRESH_MS = 60*60*1000;
const WIDGET_QUERY = '?done=false&limit=5';
let cachePath = fm.joinPath(fm.documentsDirectory(), 'adhdos-widget-cache.json');
let pendingPath = fm.joinPath(fm.documentsDirectory(), 'adhdos-pending-operation.json');
let settings = Keychain.contains(keyName) ? JSON.parse(Keychain.get(keyName)) : null;
const localDataPath = fm.joinPath(fm.documentsDirectory(), 'braindump-local-data.json');
function selectStorage() {
  const local = settings?.mode === 'local';
  const reminderPrefix = settings?.mode === 'reminders' ? `braindump-reminders-${encodeURIComponent(settings.calendarId)}` : null;
  cachePath = fm.joinPath(fm.documentsDirectory(), reminderPrefix ? `${reminderPrefix}-cache.json` : local ? 'braindump-local-cache.json' : 'adhdos-widget-cache.json');
  pendingPath = fm.joinPath(fm.documentsDirectory(), reminderPrefix ? `${reminderPrefix}-pending.json` : local ? 'braindump-local-pending.json' : 'adhdos-pending-operation.json');
}
selectStorage();


async function notice(message) {
  const a = new Alert(); a.title = 'BrainDump'; a.message = message; a.addAction('好'); await a.presentAlert();
}
async function setup() {
  if (pendingOperations().length) { await notice('请先处理待确认的操作,再切换模式。'); return false; }
  const mode = new Alert(); mode.title = '使用方式';
  mode.message = '本地模式只保存在这台设备,切换模式不会自动迁移事项。';
  mode.addAction('本地模式 · 无需账号'); mode.addAction('ADHDOS 云端'); mode.addAction('Apple 提醒事项'); mode.addCancelAction('取消');
  const choice = await mode.presentSheet();
  if (choice < 0) return false;
  if (choice === 0) {
    settings = {...settings,mode:'local'}; Keychain.set(keyName,JSON.stringify(settings)); selectStorage(); return true;
  }
  if (choice === 2) {
    const calendars = (await Calendar.forReminders()).filter(item=>item.allowsContentModifications);
    if (!calendars.length) { await notice('没有可编辑的提醒事项列表,请检查权限或先创建列表。'); return false; }
    const picker = new Alert(); picker.title = '选择提醒事项列表';
    picker.message = '直接读写此列表;不支持原生子任务层级。';
    calendars.forEach(item=>picker.addAction(item.title)); picker.addCancelAction('取消');
    const index = await picker.presentSheet(); if (index < 0) return false;
    settings = {...settings,mode:'reminders',calendarId:calendars[index].identifier,calendarTitle:calendars[index].title};
    Keychain.set(keyName,JSON.stringify(settings)); selectStorage(); return true;
  }
  if (settings?.mode && settings.mode !== 'cloud' && settings.url && settings.token) {
    settings = {...settings,mode:'cloud'};
    Keychain.set(keyName,JSON.stringify(settings)); selectStorage(); return true;
  }
  const a = new Alert(); a.title = 'ADHDOS API';
  a.message = 'API 地址已填好。从 ADHDOS 设置 → MCP / API → API 复制访问密钥,粘贴到下方。凭证仅存入本机钥匙串。';
  a.addTextField('API URL', settings?.url || DEFAULT_API_URL); a.addSecureTextField('访问密钥', settings?.token || '');
  a.addAction('保存'); a.addCancelAction('取消');
  if (await a.presentAlert() < 0) return false;
  const url = a.textFieldValue(0).trim().replace(/\/+$/, '');
  const token = a.textFieldValue(1).trim();
  if (!/^https:\/\/[^/?#]+(?:\/api\/v1\/tasks|\/functions\/v1\/tasks-api\/v1\/tasks)$/.test(url) || !/^adhdos_[a-f0-9]{64}$/.test(token)) {
    await notice('请填写完整的 HTTPS API 地址和访问密钥。'); return false;
  }
  settings = {...settings,mode:'cloud',url,token}; Keychain.set(keyName,JSON.stringify(settings)); selectStorage();
  for (const path of [cachePath,pendingPath]) if (fm.fileExists(path)) fm.remove(path);
  return true;
}
function localRequest(method, suffix, body, requestId) {
  const store = fm.fileExists(localDataPath) ? JSON.parse(fm.readString(localDataPath)) : {tasks:[],cancelled:[],requests:{}};
  if (!Array.isArray(store.tasks) || !Array.isArray(store.cancelled) || !store.requests || typeof store.requests !== 'object') throw new Error('本地数据无法读取,请保留文件后检查');
  const fail = (status,message) => { const error = new Error(message); error.status = status; throw error; };
  if (method === 'GET') {
    const pairs = Object.fromEntries((suffix.split('?')[1] || '').split('&').filter(Boolean).map(pair=>pair.split('=')));
    const all = store.tasks.filter(task => !['true','false'].includes(pairs.done) || (task.done === true || task.done === 'true') === (pairs.done === 'true'));
    const offset = Math.max(0,Number(pairs.offset)||0), limit = 100;
    return {tasks:all.slice(offset,offset+limit),total:all.length,nextOffset:offset+limit<all.length?offset+limit:null};
  }
  const signature = JSON.stringify({method,suffix,body});
  if (!requestId) fail(400,'缺少请求编号');
  const previous = store.requests[requestId];
  if (previous) {
    if (previous.signature !== signature) fail(409,'请求编号冲突');
    return previous.result;
  }
  const validText = (value,max) => {
    if (typeof value !== 'string' || !value.trim() || value.trim().length > max) fail(400,'事项内容为空或过长');
    return value.trim();
  };
  let task, index;
  const now = Date.now();
  if (method === 'POST' && !suffix) {
    task = {id:UUID.string(),text:validText(body.text,5000),dueDate:body.dueDate || null,done:false,subtasks:[],createdAt:new Date(now).toISOString(),updatedAt:new Date(now).toISOString(),order:Math.min(0,...store.tasks.map(item=>Number(item.order)||0))-1};
    task.subtasks = (body.subtasks || []).map(child=>({id:UUID.string(),text:validText(child.text,1000),done:false,dueDate:null,createdAt:task.createdAt,updatedAt:task.updatedAt}));
    if (body.partition) task.partition=body.partition;
    store.tasks.unshift(task);
  } else if (method === 'PATCH') {
    const id = decodeURIComponent(suffix.slice(1));
    index = store.tasks.findIndex(item => String(item.id) === id);
    if (index < 0) fail(404,'事项不存在');
    const old = store.tasks[index];
    if ((body.expectedUpdatedAt ?? null) !== (old.updatedAt ?? null)) fail(409,'事项已更新');
    const stamp = new Date(Math.max(now,(Date.parse(old.updatedAt)||0)+1)).toISOString();
    task = {...old,updatedAt:stamp,subtasks:[...(old.subtasks || [])]};
    if (body.liked !== undefined) task.liked=body.liked;
    if (body.text !== undefined) task.text = validText(body.text,5000);
    if (body.dueDate !== undefined) task.dueDate = body.dueDate;
    if (body.done !== undefined) { task.done = body.done; task.completedAt = body.done ? old.completedAt || stamp : null; }
    for (const patch of body.subtasks || []) {
      if (patch.id == null) {
        if (task.subtasks.length >= 30) fail(400,'子任务最多 30 项');
        task.subtasks.push({id:UUID.string(),text:validText(patch.text,1000),dueDate:patch.dueDate || null,done:false,createdAt:stamp,updatedAt:stamp});
        continue;
      }
      const childIndex = task.subtasks.findIndex(item=>String(item.id)===String(patch.id));
      if (childIndex < 0) fail(404,'子任务不存在');
      if (patch.deleted) { task.subtasks.splice(childIndex,1); continue; }
      const child = {...task.subtasks[childIndex],updatedAt:stamp};
      if (patch.text !== undefined) child.text = validText(patch.text,1000);
      if (patch.dueDate !== undefined) child.dueDate = patch.dueDate;
      if (patch.done !== undefined) { child.done=patch.done; child.completedAt=patch.done ? child.completedAt || stamp : null; }
      task.subtasks[childIndex]=child;
    }
    if (body.deleted) {
      store.tasks.splice(index,1); store.cancelled.unshift({...task,id:UUID.string(),cancelledAt:stamp}); task={...task,deleted:true};
    } else store.tasks[index]=task;
  } else fail(400,'不支持的操作');
  const result = {task};
  for (const [id,entry] of Object.entries(store.requests)) if (now-entry.createdAt > 7*24*60*60*1000) delete store.requests[id];
  store.requests[requestId] = {signature,result,createdAt:now};
  // Task data and retry receipt are written together; replay never duplicates additions.
  fm.writeString(localDataPath,JSON.stringify(store));
  return result;
}
function reminderTask(item) {
  const date = item.dueDate;
  const dueDate = date ? item.dueDateIncludesTime ? date.toISOString() : dateOnlyFromDate(date) : null;
  // Scriptable exposes no modification timestamp; compare the available fields before writing.
  const version = JSON.stringify([item.title,item.isCompleted,item.notes,dueDate,item.priority,item.calendar.identifier]);
  return {id:item.identifier,text:item.title || '',done:item.isCompleted,dueDate,createdAt:item.creationDate?.toISOString() || null,updatedAt:version,subtasks:[]};
}
async function reminderRequest(method, suffix, body, requestId) {
  const fail=(status,message)=>{const error=new Error(message);error.status=status;throw error;};
  const calendar=(await Calendar.forReminders()).find(item=>item.identifier===settings.calendarId);
  if (!calendar) fail(404,'所选提醒事项列表已不可用,请重新选择');
  const items=await Reminder.all([calendar]);
  if (method==='GET') {
    const pairs=Object.fromEntries((suffix.split('?')[1] || '').split('&').filter(Boolean).map(pair=>pair.split('=')));
    const tasks=items.map(reminderTask).filter(item=>!['true','false'].includes(pairs.done) || item.done===(pairs.done==='true'));
    const offset=Math.max(0,Number(pairs.offset)||0);
    return {tasks:tasks.slice(offset,offset+100),total:tasks.length,nextOffset:offset+100<tasks.length?offset+100:null};
  }
  if (!calendar.allowsContentModifications) fail(403,'所选提醒事项列表不可编辑');
  if (body.subtasks) fail(400,'提醒事项模式不支持原生子任务');
  const journalPath=fm.joinPath(fm.documentsDirectory(),`braindump-reminders-${encodeURIComponent(settings.calendarId)}-receipts.json`);
  const receipts=fm.fileExists(journalPath)?JSON.parse(fm.readString(journalPath)):{};
  const signature=JSON.stringify({method,suffix,body});
  const previous=receipts[requestId];
  if (previous) {
    if (previous.signature!==signature) fail(409,'请求编号冲突');
    if (previous.result) return previous.result;
    // EventKit and the local receipt cannot commit atomically. Never blindly repeat an uncertain write.
    throw new Error('请先在提醒事项中核对保存结果,再丢弃本地重试记录');
  }
  let item, before;
  if (method==='POST' && !suffix) { item=new Reminder(); item.calendar=calendar; }
  else if (method==='PATCH') {
    item=items.find(entry=>entry.identifier===decodeURIComponent(suffix.slice(1)));
    if (!item) fail(404,'提醒事项不存在');
    before=reminderTask(item);
    if (body.expectedUpdatedAt!==before.updatedAt) fail(409,'提醒事项已修改,请刷新');
  } else fail(400,'不支持的操作');
  if (body.text!==undefined) {
    if(typeof body.text!=='string' || !body.text.trim() || body.text.length>5000) fail(400,'事项内容为空或过长');
    item.title=body.text.trim();
  }
  if(body.done!==undefined) item.isCompleted=body.done;
  if(body.dueDate!==undefined) {
    item.dueDateIncludesTime=!isDateOnlyString(body.dueDate);
    item.dueDate=body.dueDate ? parseStoredDate(body.dueDate) : null;
  }
  receipts[requestId]={signature,startedAt:Date.now()};
  fm.writeString(journalPath,JSON.stringify(receipts));
  if(body.deleted) await item.remove(); else await item.save();
  const result={task:body.deleted?{...before,deleted:true}:reminderTask(item)};
  // Re-read journal because another parent may have finished while awaiting EventKit.
  const latest=JSON.parse(fm.readString(journalPath));
  latest[requestId]={signature,result,startedAt:Date.now()};
  fm.writeString(journalPath,JSON.stringify(latest));
  return result;
}

async function request(method, suffix = '', body = null, requestId = null, conditional = null) {
  if (settings?.mode === 'reminders' && typeof reminderRequest === 'function') return reminderRequest(method,suffix,body,requestId);
  if (settings?.mode === 'local' && typeof localRequest === 'function') return localRequest(method,suffix,body,requestId);
  const req = new Request(settings.url + suffix); req.method = method; req.timeoutInterval = method === 'GET' ? 8 : 15;
  const headers = {Authorization:`Bearer ${settings.token}`};
  if (method === 'GET' && conditional?.etag && conditional.cached) headers['If-None-Match']=conditional.etag;
  const parse = (raw,response) => {
    if (response?.statusCode===304 && conditional?.cached) return conditional.cached;
    let data;
    try { data=parseResponse(raw,response?.statusCode); }
    catch(error) {
      const retry=Object.entries(response?.headers || {}).find(([name])=>name.toLowerCase()==='retry-after')?.[1];
      const seconds=retry == null ? NaN : /^\d+$/.test(String(retry)) ? Number(retry) : (Date.parse(retry)-Date.now())/1000;
      if(Number.isFinite(seconds)) error.retryAfter=Math.max(0,seconds);
      throw error;
    }
    const etag=Object.entries(response?.headers || {}).find(([name])=>name.toLowerCase()==='etag')?.[1];
    if (etag) conditional?.onEtag?.(etag);
    return data;
  };
  if (body) {
    headers['Content-Type'] = 'application/json'; headers['Idempotency-Key'] = requestId;
    req.body = JSON.stringify(body);
  }
  // Assign the complete dictionary: native Request properties may return a copy.
  req.headers = headers;
  let raw;
  try {
    raw = await req.loadString();
  } catch (error) {
    // Only retry reads here; writes retain the existing pending-operation journal.
    if (method !== 'GET' || (typeof config !== 'undefined' && config.runsInWidget)) throw error;
    const retry = new Request(settings.url + suffix);
    retry.method = method; retry.headers = headers; retry.timeoutInterval = 8;
    try { raw = await retry.loadString(); }
    catch { throw new Error('连接超时或网络不可用。访问密钥已保存在本机,请稍后重试,无需重新填写。'); }
    return parse(raw,retry.response);
  }
  return parse(raw,req.response);
}
function parseResponse(raw,status) {
  let data;
  try { data = JSON.parse(raw); } catch { /* Proxies can return non-JSON errors. */ }
  if (!status || status < 200 || status >= 300 || !data) {
    const error = new Error(data?.error || (status ? `HTTP ${status}${!data ? '(响应不是 JSON)' : ''}` : '未收到服务器响应'));
    error.status = status; throw error;
  }
  return data;
}
async function load(includeCompleted = false) {
  const data = await request('GET',includeCompleted ? '?limit=100' : '?done=false&limit=100');
  const cache = {...data, fetchedAt:new Date().toISOString()};
  if (!includeCompleted) fm.writeString(cachePath,JSON.stringify(cache)); return cache;
}
async function loadCloudWidget() {
  let previous = null;
  try { previous = JSON.parse(fm.readString(cachePath)); } catch { /* No usable cache. */ }
  if (!previous || typeof previous !== 'object') previous = null;
  const usable = Array.isArray(previous?.tasks);
  const matching = previous?.query === WIDGET_QUERY;
  const now = Date.now();
  if (matching && previous.nextCheckAt > now) return {data:usable ? previous : null,stale:!!previous.errorStatus};
  // A persisted lease reduces duplicate reads from overlapping widget launches.
  const lease = UUID.string();
  fm.writeString(cachePath,JSON.stringify({...previous,query:WIDGET_QUERY,nextCheckAt:now+60000,lease}));
  const ownsLease = () => { try { return JSON.parse(fm.readString(cachePath)).lease === lease; } catch { return false; } };
  try {
    let etag = matching ? previous.etag : null;
    const data = await request('GET',WIDGET_QUERY,null,null,{
      etag,cached:matching && usable ? previous : null,onEtag:value=>{etag=value;}
    });
    if (!Array.isArray(data?.tasks) || !Number.isInteger(data.total)) throw new Error('列表响应不完整');
    const cache = {...data,query:WIDGET_QUERY,etag,fetchedAt:new Date().toISOString(),nextCheckAt:Date.now()+WIDGET_REFRESH_MS,errorStatus:null,failures:0,lease:null};
    if (ownsLease()) fm.writeString(cachePath,JSON.stringify(cache));
    return {data:cache,stale:false};
  } catch(error) {
    const failures=(previous?.failures||0)+1;
    const delay=Number.isFinite(error.retryAfter) ? Math.max(60000,error.retryAfter*1000)
      : [401,403].includes(error.status) ? 24*WIDGET_REFRESH_MS : Math.min(6*WIDGET_REFRESH_MS,15*60000*2**Math.min(failures-1,5));
    const cache={...previous,query:WIDGET_QUERY,etag:matching ? previous?.etag : null,nextCheckAt:Date.now()+delay,errorStatus:error.status||503,failures,lease:null};
    if (ownsLease()) fm.writeString(cachePath,JSON.stringify(cache));
    return {data:usable ? previous : null,stale:true};
  }
}
function pendingOperations() {
  if (!fm.fileExists(pendingPath)) return [];
  const value = JSON.parse(fm.readString(pendingPath));
  return Array.isArray(value) ? value : [value]; // Keep old single-operation records compatible.
}
function savePending(operations) {
  if (operations.length) fm.writeString(pendingPath,JSON.stringify(operations));
  else if (fm.fileExists(pendingPath)) fm.remove(pendingPath);
}
function removePending(id) { savePending(pendingOperations().filter(operation => operation.id !== id)); }
async function submit(operation, onFailure = null) {
  // Save before sending: a timeout may mean the server already committed.
  savePending([...pendingOperations().filter(item => item.id !== operation.id), operation]);
  try {
    const result = await request(operation.method,operation.suffix,operation.body,operation.id);
    if (!result.task) throw new Error('服务器未返回保存结果');
    removePending(operation.id);
    if (settings?.mode !== 'local' && settings?.mode !== 'reminders') {
      try { const cache=JSON.parse(fm.readString(cachePath)); fm.writeString(cachePath,JSON.stringify({...cache,nextCheckAt:0,lease:null})); } catch { /* No cache to invalidate. */ }
    }
    return result;
  } catch (error) {
    const definitive = [400,401,403,404,409,413,415,428,429].includes(error.status);
    if (definitive) removePending(operation.id);
    if (onFailure) { onFailure(error, !definitive); return false; }
    if (definitive) {
      await notice(error.status === 409 ? '事项已在其他设备修改。请重新读取后再操作。' : `未保存:${error.message}`);
    } else {
      const reason = error.status === 401 ? '访问密钥已失效,请重新配置 API。'
        : error.status === 403 ? '需要有效的 Pro 订阅,续费后可重试。'
        : error.status === 429 ? '请求过于频繁,请稍后重试。'
        : `尚未确认保存:${error.message || '网络连接失败'}。`;
      await notice(`${reason}\n操作已保留,下次运行可重试。`);
    }
    return false;
  }
}
async function showTasks() {
  const table = new UITable(); table.showSeparators = true;
  const colors = {
    text: Color.dynamic(new Color('#292733'),new Color('#F0EDF5')),
    muted: Color.dynamic(new Color('#7D7888'),new Color('#A8A1B5')),
    accent: Color.dynamic(new Color('#9333EA'),new Color('#C084FC')),
    child: Color.dynamic(new Color('#F6F4F9'),new Color('#25212D')),
    warning: Color.dynamic(new Color('#B07145'),new Color('#E3AF83')),
  };
  function styleText(cell, size = 15, strong = false, color = colors.text) {
    cell.titleFont = strong ? Font.semiboldSystemFont(size) : Font.systemFont(size);
    cell.titleColor = color; cell.subtitleFont = Font.systemFont(11); cell.subtitleColor = colors.muted;
    return cell;
  }
  function styleButton(cell, weight = 1) {
    cell.widthWeight = weight; cell.titleFont = Font.semiboldSystemFont(14); cell.titleColor = colors.accent;
    return cell;
  }
  function checkbox(row, checked) {
    const cell = row.addButton(checked ? '☑' : '☐');
    cell.widthWeight = 1;
    cell.titleFont = Font.systemFont(24);
    cell.titleColor = checked ? colors.accent : colors.muted;
    return cell;
  }
  const expanded = new Set();
  const saving = new Set();
  const feedback = new Map();
  let data = {tasks:[],total:0}, busy = true, connecting = true, connectionError = '';
  try {
    if (fm.fileExists(cachePath)) {
      const cached = JSON.parse(fm.readString(cachePath));
      if (Array.isArray(cached.tasks)) data = cached;
    }
  } catch { /* Ignore damaged cache. */ }
  const isDone = task => task.done === true || task.done === 'true';
  async function action(callback) {
    if (busy || saving.size) return;
    busy = true;
    try { await callback(); }
    catch (error) { await notice(`暂时无法连接:${error.message}`); }
    finally { busy = false; }
  }
  async function refresh() { data = await load(true); connectionError = ''; render(); }
  function replaceTask(task) {
    data = {...data,tasks:data.tasks.map(item => String(item.id) === String(task.id) ? task : item)};
  }
  async function createTask(parsed, parent = null) {
    const id = UUID.string(), temporaryId = `pending:${id}`;
    const key = String(parent?.id ?? temporaryId);
    const fields = {text:parsed.text,...(parsed.date ? {dueDate:parsed.date} : {})};
    const placeholder = {...fields,id:temporaryId,done:false,_pendingCreation:true,subtasks:[]};
    const operation = {method:parent ? 'PATCH' : 'POST',suffix:parent ? `/${encodeURIComponent(parent.id)}` : '',
      body:parent ? {subtasks:[fields],expectedUpdatedAt:parent.updatedAt ?? null} : fields,id,createdAt:Date.now()};
    saving.add(key); feedback.set(key,'保存中…');
    if (parent) replaceTask({...parent,subtasks:[...(parent.subtasks || []),placeholder]});
    else data = {...data,tasks:[placeholder,...data.tasks],total:(data.total ?? data.tasks.length)+1};
    // Release the input dialog lock; only the affected task stays locked during upload.
    busy = false; render();
    try {
      const result = await submit(operation, (error, uncertain) => {
        if (uncertain) feedback.set(key,'待确认 · 重新运行脚本以重试');
        else {
          if (parent) replaceTask(parent);
          else data = {...data,tasks:data.tasks.filter(task=>String(task.id)!==temporaryId),total:Math.max(0,(data.total ?? data.tasks.length)-1)};
          feedback.set(key,`未保存:${error.message}`);
        }
      });
      if (result) {
        if (parent) replaceTask(result.task);
        else data = {...data,tasks:data.tasks.map(task=>String(task.id)===temporaryId ? result.task : task)};
        feedback.delete(key); connectionError = '';
      } else if (!parent && !data.tasks.some(task=>String(task.id)===temporaryId)) {
        await notice(feedback.get(key)); feedback.delete(key);
      }
    } catch (error) {
      feedback.set(key,`待确认:${error.message}`);
    } finally { saving.delete(key); render(); }
  }
  async function updateTask(taskId, subtaskId = null, changes = null) {
    const key = String(taskId);
    if (busy || saving.has(key)) return;
    const task = data.tasks.find(item => String(item.id) === key);
    if (!task || task._pendingCreation) return;
    if (pendingOperations().some(item => item.suffix === `/${encodeURIComponent(task.id)}`)) {
      feedback.set(key,'待确认 · 重新运行脚本以重试'); render(); return;
    }
    const child = subtaskId == null ? null : task.subtasks?.find(item => String(item.id) === String(subtaskId));
    if (subtaskId != null && (!child || child._pendingCreation)) return;
    const patch = changes || {done:!isDone(child || task)};
    const body = child ? {subtasks:[{id:child.id,...patch}]} : patch;
    const optimistic = child ? {...task,subtasks:patch.deleted ? task.subtasks.filter(item => item !== child) : task.subtasks.map(item => item === child ? {...item,...patch} : item)} : {...task,...patch};
    saving.add(key); feedback.set(key,'保存中…'); replaceTask(optimistic); render();
    try {
      const result = await submit({method:'PATCH',suffix:`/${encodeURIComponent(task.id)}`,body:{...body,expectedUpdatedAt:task.updatedAt ?? null},id:UUID.string(),createdAt:Date.now()}, (error, uncertain) => {
        if (!uncertain || patch.deleted) replaceTask(task);
        feedback.set(key,uncertain ? '待确认 · 重新运行脚本以重试' : error.status === 409 ? '事项已更新,请刷新后重试' : `未保存:${error.message}`);
      });
      if (result) { replaceTask(result.task); feedback.delete(key); }
    } catch (error) {
      replaceTask(task); feedback.set(key,`未保存:${error.message}`);
    } finally { saving.delete(key); render(); }
  }
  async function editTask(taskId, subtaskId = null) {
    const key = String(taskId);
    if (busy || saving.has(key)) return;
    const task = data.tasks.find(item => String(item.id) === key);
    const item = subtaskId == null ? task : task?.subtasks?.find(child => String(child.id) === String(subtaskId));
    if (!item || task?._pendingCreation || item._pendingCreation) return;
    if (pendingOperations().some(operation => operation.suffix === `/${encodeURIComponent(task.id)}`)) {
      feedback.set(key,'待确认 · 重新运行脚本以重试'); render(); return;
    }
    const initialValue = item.text + (item.dueDate ? ` ${formatDateForInput(item.dueDate)}` : '');
    let value = initialValue, deleting = false;
    busy = true;
    try {
      for (;;) {
        const input = new Alert(); input.title = subtaskId == null ? '编辑事项' : '编辑子任务';
        input.addTextField('事项内容', value); input.addAction('保存'); input.addDestructiveAction(subtaskId == null ? (settings?.mode === 'reminders' ? '删除提醒事项' : '取消事项') : '删除'); input.addCancelAction('取消');
        const canOpenLink = settings?.mode !== 'local' && settings?.mode !== 'reminders';
        if (canOpenLink) input.addAction('打开独立链接');
        const choice = await input.presentAlert();
        if (canOpenLink && choice === 2) { await Safari.open(`https://adhdos.app/brain-dump?task=${encodeURIComponent(String(task.id))}`); return; }
        if (choice < 0) return;
        if (choice === 1) { deleting = true; break; }
        value = input.textFieldValue(0).trim();
        if (!value || value.length > (subtaskId == null ? 5000 : 1000)) {
          await notice(!value ? '请输入事项内容。' : '内容太长,请缩短后保存。'); continue;
        }
        break;
      }
    } finally { busy = false; }
    if (deleting) { await updateTask(taskId,subtaskId,{deleted:true}); return; }
    if (value === initialValue) return;
    const parsed = parseTaskInput(value);
    if (!parsed.text) { await notice('请输入事项内容。'); return; }
    const changes = {};
    if (parsed.text !== item.text) changes.text = parsed.text;
    if ((parsed.date || null) !== (item.dueDate || null)) changes.dueDate = parsed.date || null;
    if (Object.keys(changes).length) await updateTask(taskId,subtaskId,changes);
  }
  function dateLabel(value) {
    const date = parseStoredDate(value);
    if (!date) return '日期';
    const day = `${date.getMonth()+1}/${date.getDate()}`;
    return isDateOnlyString(value) ? day : `${day} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
  }
  function render() {
    table.removeAllRows();
    const heading = new UITableRow(); heading.isHeader = true; heading.height = 84; heading.cellSpacing = 12;
    styleText(heading.addText('BrainDump', `${settings?.mode === 'reminders' ? `${settings.calendarTitle} · ` : settings?.mode === 'local' ? '本地 · ' : ''}${Math.max(0,(data.total ?? data.tasks.length)-data.tasks.filter(task=>task.deleted).length)} 件事项`),26,true).widthWeight = 5;
    const web = styleButton(heading.addButton('打开网页版'),3); web.onTap = () => Safari.open('https://adhdos.app/brain-dump');
    const reload = styleButton(heading.addButton('刷新')); reload.onTap = () => action(refresh);
    table.addRow(heading);
    const toolbar = new UITableRow(); toolbar.height = 54; toolbar.cellSpacing = 16;
    const add = styleButton(toolbar.addButton('+ 新增事项'),5);
    add.onTap = () => action(async () => {
      if (fm.fileExists(pendingPath)) { await notice('请关闭并重新运行脚本,先处理待确认的操作。'); return; }
      const input = new Alert(); input.title = '新增事项'; input.addTextField('例如:买牛奶 @明天');
      input.addAction('保存'); input.addCancelAction('取消');
      if (await input.presentAlert() < 0) return;
      const parsed = parseTaskInput(input.textFieldValue(0)); const text = parsed.text; if (!text) return;
      await createTask(parsed);
    });
    const configure = styleButton(toolbar.addButton('设置'));
    configure.onTap = () => action(async () => {
      if (fm.fileExists(pendingPath)) { await notice('请关闭并重新运行脚本,先处理待确认的操作。'); return; }
      if (await setup()) await refresh();
    });
    table.addRow(toolbar);
    if (connecting || connectionError) {
      const status = new UITableRow(); status.height = 48; status.dismissOnSelect = false;
      styleText(status.addText(connecting ? '正在连接…' : '连接失败,点击重试'),14,false,colors.muted);
      status.onSelect = () => action(refresh); table.addRow(status);
    }
    if (!connecting && !connectionError && !data.tasks.some(task=>!task.deleted)) {
      const empty = new UITableRow(); empty.height = 140; styleText(empty.addText('暂时没有待办事项','想到什么,随时记下来'),18,true); table.addRow(empty);
    }
    for (const task of data.tasks.filter(task=>!task.deleted)) {
      const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
      const open = expanded.has(String(task.id));
      const row = new UITableRow(); row.dismissOnSelect = false; row.cellSpacing = 14; row.height = Math.min(120,Math.max(72,Math.ceil(task.text.length/20)*22+30));
      const detail = [task.dueDate ? dateLabel(task.dueDate) : '', feedback.get(String(task.id))].filter(Boolean).join(' · ');
      const done = checkbox(row, isDone(task));
      const label = styleText(row.addText(task.text, detail),16,true,isDone(task) ? colors.muted : colors.text); label.widthWeight = 9;
      done.onTap = () => updateTask(task.id);

      const expand = settings?.mode === 'reminders' ? null : styleButton(row.addButton(subtasks.length ? `${subtasks.filter(isDone).length}/${subtasks.length}` : open ? '▾' : '▸'),2);
      if (expand) { expand.rightAligned(); expand.titleColor = colors.muted;
      expand.onTap = () => { if (busy) return; open ? expanded.delete(String(task.id)) : expanded.add(String(task.id)); render(); };
      }
      row.onSelect = () => editTask(task.id);
      table.addRow(row);
      if (open && settings?.mode !== 'reminders') {
        if (task.text.length > 60) {
        const full = new UITableRow(); full.height = Math.min(240, Math.max(64, Math.ceil(task.text.length / 20) * 24));
        full.dismissOnSelect = false; full.backgroundColor = colors.child; styleText(full.addText(task.text),14);
        full.onSelect = () => editTask(task.id); table.addRow(full);
        }
        for (const subtask of subtasks) {
          const child = new UITableRow(); child.height = Math.min(110,Math.max(60,Math.ceil(subtask.text.length/20)*21+20)); child.dismissOnSelect = false; child.cellSpacing = 14; child.backgroundColor = colors.child;
          if (subtask.id != null) {
            const toggle = checkbox(child, isDone(subtask));
            toggle.onTap = () => updateTask(task.id,subtask.id);
          }
          const title = styleText(child.addText(subtask.text,subtask.dueDate ? dateLabel(subtask.dueDate) : ''),14,false,isDone(subtask) ? colors.muted : colors.text); title.widthWeight = 9;

          child.onSelect = () => subtask.id != null ? editTask(task.id,subtask.id) : notice('该子任务缺少 ID,暂时无法编辑。');
          table.addRow(child);
        }
        const addChild = new UITableRow(); addChild.height = 48; addChild.dismissOnSelect = false; addChild.backgroundColor = colors.child;
        styleText(addChild.addText('    + 添加子任务'),14,false,colors.accent);
        addChild.onSelect = () => action(async () => {
          if (fm.fileExists(pendingPath)) { await notice('请关闭并重新运行脚本,先处理待确认的操作。'); return; }
          const input = new Alert(); input.title = '添加子任务'; input.message = task.text;
          input.addTextField('例如:查资料 @周五'); input.addAction('添加'); input.addCancelAction('取消');
          if (await input.presentAlert() < 0) return;
          const parsed = parseTaskInput(input.textFieldValue(0)); const text = parsed.text; if (!text) return;
          await createTask(parsed, task);
        });
        table.addRow(addChild);
      }
    }
    if (data.nextOffset != null) {
      const more = new UITableRow(); more.dismissOnSelect = false; more.height = 56; styleText(more.addText('加载更多'),14,true,colors.accent);
      more.onSelect = () => action(async () => {
        const next = await request('GET',`?limit=100&offset=${data.nextOffset}`);
        const ids = new Set(data.tasks.map(task => String(task.id)));
        data = {...next,tasks:[...data.tasks,...next.tasks.filter(task => !ids.has(String(task.id)))]}; render();
      }); table.addRow(more);
    }
    table.reload();
  }
  render();
  const initialLoad = load(true).then(fresh => { data = fresh; }).catch(error => {
    connectionError = error.message || '网络连接失败';
  }).finally(() => { connecting = false; busy = false; render(); });
  await table.present();
  await initialLoad;
}
// BEGIN SHARED WIDGET BRAND
// Bundled 48px app icon: no network request or filesystem access at widget runtime.
function addWidgetBrandIcon(header) {
  try {
    const image=Image.fromData(Data.fromBase64String('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'));
    if(!image) return;
    const icon=header.addImage(image); icon.imageSize=new Size(16,16);
    header.addSpacer(6);
  } catch { /* The text identity remains available if image decoding fails. */ }
}
// END SHARED WIDGET BRAND

function buildWidget(data, {stale = false, configured = true, family = config.widgetFamily} = {}) {
  const small = family === 'small', large = family === 'large' || family === 'extraLarge';
  const palette = {
    background: Color.dynamic(new Color('#FAF9F6'), new Color('#191C20')),
    primary: Color.dynamic(new Color('#292733'), new Color('#F0EDF5')),
    secondary: Color.dynamic(new Color('#7D7888'), new Color('#A8A1B5')),
    accent: Color.dynamic(new Color('#9333EA'), new Color('#C084FC')),
    warning: Color.dynamic(new Color('#B07145'), new Color('#E3AF83')),
    line: Color.dynamic(new Color('#E5DFED'), new Color('#40364D')),
  };
  const w = new ListWidget(); w.setPadding(small || large ? 16 : 12,16,small || large ? 14 : 10,16); w.backgroundColor = palette.background;
  w.url = URLScheme.forRunningScript(); w.refreshAfterDate = new Date(Date.now()+(settings?.mode === 'local' || settings?.mode === 'reminders' ? 15*60*1000 : WIDGET_REFRESH_MS));
  function label(parent, value, size, color = palette.primary, bold = false) {
    const item = parent.addText(value); item.font = bold ? Font.semiboldSystemFont(size) : Font.systemFont(size);
    item.textColor = color; item.lineLimit = 1; item.minimumScaleFactor = 0.85; return item;
  }
  const header = w.addStack(); header.centerAlignContent(); addWidgetBrandIcon(header);
  label(header, 'BrainDump', 15, palette.primary, true); header.addSpacer();
  if (!small) label(header, settings?.mode === 'reminders' ? '提醒事项' : settings?.mode === 'local' ? '本地' : 'ADHDOS', 9, palette.accent, true);
  w.addSpacer(small ? 10 : large ? 12 : 8);
  const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
  const count = large ? 5 : 3;
  const now = new Date();
  const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
  if (!configured || !data || !tasks.length) {
    w.addSpacer();
    label(w, !configured ? '连接你的 BrainDump' : !data ? '暂时无法连接' : '暂时没有待办', small ? 14 : 17, palette.primary, true);
    w.addSpacer(6);
    const hint = label(w, !configured ? '轻点选择使用方式' : !data ? '轻点检查连接' : '轻点记下新想法', 11, palette.secondary);
    hint.lineLimit = 2;
    w.addSpacer();
  } else {
    for (const [index,task] of tasks.slice(0,count).entries()) {
      const row = w.addStack(); row.centerAlignContent(); row.spacing = 8;
      label(row, '○', 17, palette.accent);
      const content = row.addStack(); content.layoutVertically();
      label(content, task.text || '未命名事项', small ? 12 : 14, palette.primary, true);
      const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
      const done = subtasks.filter(item => item.done === true || item.done === 'true').length;
      const parsedDue = parseStoredDate(task.dueDate);
      const due = parsedDue ? dateOnlyFromDate(parsedDue) : '';
      const overdue = due && due < today;
      const dueLabel = due ? overdue ? '已逾期' : due === today ? '今天' : `${Number(due.slice(5,7))}/${Number(due.slice(8,10))}` : '';
      if (!small) {
        const details = [dueLabel, subtasks.length ? `${done}/${subtasks.length}` : ''].filter(Boolean);
        if (details.length) { content.addSpacer(2); label(content, details.join(' · '), 10, overdue ? palette.warning : palette.secondary); }
        if (large && subtasks.length) {
          const next = subtasks.find(item => item.done !== true && item.done !== 'true');
          if (next) { content.addSpacer(2); label(content, `↳ ${next.text}`, 10, palette.secondary); }
        }
      }
      if (small && (overdue || due === today)) { row.addSpacer(); label(row, '•', 12, overdue ? palette.warning : palette.accent); }
      if (index < Math.min(tasks.length,count)-1) w.addSpacer(large ? 10 : small ? 7 : 6);
    }
    w.addSpacer();
  }
  const footer = w.addStack(); footer.centerAlignContent();
  const total = data?.total ?? tasks.length;
  const remaining = Math.max(0,total-Math.min(tasks.length,count));
  label(footer, 'ADHDOS' + (configured && data ? remaining > 0 ? ` · 还有 ${remaining} 件` : ` · ${total} 件待办` : ''), 9, palette.secondary);
  footer.addSpacer();
  const fetched = data?.fetchedAt ? new Date(data.fetchedAt) : null;
  const time = fetched && !Number.isNaN(fetched.getTime()) ? fetched.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
  label(footer, stale ? '离线缓存' : time, 9, stale ? palette.warning : palette.secondary);
  return w;
}
// BEGIN SHARED UPDATE CHECK
async function checkForClientUpdate(client, revision, filename) {
  // Widgets never prompt; a failed check must not prevent normal use.
  if (config.runsInWidget) return false;
  const skipKey = `adhdos.update.skip.${client}`;
  try {
    const request = new Request(`https://adhdos.app/examples/updates.json?t=${Date.now()}`);
    request.timeoutInterval = 3;
    const manifest = await request.loadJSON();
    const latest = manifest?.[client];
    if (!Number.isSafeInteger(latest) || latest <= revision) return false;
    if (Keychain.contains(skipKey) && Keychain.get(skipKey) === String(latest)) return false;
    const alert = new Alert();
    alert.title = '有新版本可用';
    alert.message = '当前版本仍可继续使用。下载后请重新导入脚本;访问密钥保存在本机钥匙串。';
    alert.addAction('下载更新');
    alert.addAction('跳过此版本');
    alert.addCancelAction('稍后再说');
    const choice = await alert.presentAlert();
    if (choice === 1) Keychain.set(skipKey, String(latest));
    if (choice !== 0) return false;
    await Safari.open(`https://adhdos.app/examples/${filename}`);
    return true;
  } catch { return false; }
}
// END SHARED UPDATE CHECK

async function main() {
  if (await checkForClientUpdate('scriptableBrainDump', 3, 'ADHDOS.scriptable.js')) return;
  if (!settings) {
    if (config.runsInWidget) {
      Script.setWidget(buildWidget(null, {configured:false})); return;
    }
    if (!await setup()) return;
  }
  if (config.runsInWidget) {
    let data = null, stale = false;
    try {
      if (settings?.mode === 'local' || settings?.mode === 'reminders') data = await load();
      else ({data,stale} = await loadCloudWidget());
    }
    catch {
      stale = true;
      try { if (fm.fileExists(cachePath)) data = JSON.parse(fm.readString(cachePath)); } catch { /* Ignore damaged cache. */ }
    }
    Script.setWidget(buildWidget(data, {stale})); return;
  }

  while (pendingOperations().length) {
    const pending = pendingOperations()[0];
    const a = new Alert(); a.title = '有一项操作等待确认';
    a.message = '重试会沿用原请求编号。超过 7 天请先检查云端结果,避免重复新增。';
    a.addAction('重试'); a.addDestructiveAction('丢弃本地重试记录'); a.addCancelAction('取消');
    const choice = await a.presentAlert();
    if (choice < 0) return;
    if (choice === 0) {
      if (Date.now()-pending.createdAt > 7*24*60*60*1000) { await notice('操作已超过去重期限,请先检查云端结果。'); return; }
      if (!await submit(pending)) return;
    } else removePending(pending.id);
  }
  try { await showTasks(); }
  catch (error) {
    const recovery = new Alert(); recovery.title = '暂时无法读取事项'; recovery.message = error.message;
    recovery.addAction('重新设置'); recovery.addCancelAction('取消');
    if (await recovery.presentAlert() === 0 && await setup()) await showTasks();
  }
}
try { await main(); } catch (error) { if (!config.runsInWidget) await notice(`暂时无法连接:${error.message}`); }
Script.complete();