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 迁移实现。仓库实现不代表已部署到线上。
- OpenAPI 3.1 JSON 描述 REST 路径、输入、输出和错误;仓库源文件为
public/openapi.json。 - MCP 的输入结构由连接后的
tools/list返回;MCP 工具对应表区分云端和本地。 - Node.js 示例支持读取、创建和带版本更新。使用方式见示例说明。
- 接口变更记录记录契约变动;不代表线上发布状态。
读写范围
| 数据 | 可读 | 可写 |
|---|---|---|
| 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-Key | MCP 参数 / REST 请求头;同一新操作生成新编号,相同操作重试保留编号和全部输入;长度 1-128 |
| createdAt / exportedAt | 创建时间 / 最近云同步或写入时间;不是客户端本次请求时间 |
| thoughts | 追加想法列表,每项含 id、content、createdAt、updatedAt |
| contentTruncated | Board 正文是否截断;需要完整正文时传 includeFullContent=true |
| expiresAt | 图片预览链接到期的 Unix 毫秒数;链接不是永久附件地址 |
任务普通列表保留原始历史字段;组合查询、搜索和今日上下文返回规范化任务。响应允许额外字段,客户端只依赖已说明字段。历史时间字段可能为字符串、毫秒数或 null,不要把所有返回时间强制解析为同一种格式。Board 的 recent 排序按 createdAt(历史条目回退到 ID)降序,since 筛选使用 updatedAt 或 createdAt,二者不是同一排序规则。
快照字段范围
data 仅包含存在于云同步数据中的允许键,缺失键不会自动补齐。允许键为:
| 键 | 数据含义 |
|---|---|
| adhd-tasks / adhd-archived-tasks / adhd-cancelled-tasks | 活动、归档、已取消事项 |
| adhd-pasteboard | Board 原始内容与想法 |
| adhd-pasteboard-tabs / adhd-pasteboard-archived-tabs | Board 分区及归档分区元数据 |
| 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-items | limit 1–200(默认 30)、since ISO 日期、tab、query、sort=recent/snapshot、includeFullContent、contentLimit 80–2000;返回 exportedAt 和 items |
| POST /board-items | content 必填 1–10000 字符、tab 可选最多 100 字符;需要 Idempotency-Key;201 返回 item |
| GET /search | query 必填、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 | 密钥无效、已重置或停用 |
| 403 | subscription_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 分页。
部署与验证
- 依次应用
20261015234712_cloud_tasks_api.sql、20261016000042_remote_mcp_task_semantics.sql、20261016002042_full_cloud_mcp.sql和20261016003041_cloud_api_subscription_entitlement.sql,前提是已应用现有同步合并函数迁移。 - 应用后续 API 迁移,包括
20261026010426_board_api_numeric_version.sql,再部署tasks-apiEdge Function,禁用网关 JWT 校验(supabase/config.toml已配置)。函数自行验证专用密钥,不能绕过数据库校验。 - 发布包含 API 设置页的新桌面版本。仅部署云函数不会更新已安装的桌面程序。
- 使用专门测试账户完成一次云同步,生成密钥,验证读取、新增、修改、冲突、重试、撤销和桌面合并。
- 执行
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('iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAANgAAAABAAAA2AAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAABWu1DfAAAACXBIWXMAACE4AAAhOAFFljFgAAAIu0lEQVRoBe1Z+W8bxxl9e3B3RYqkTkt2asdG7KQw4iYt+lPbP75A2xRF26RpjgZF4yCILNsSbZOiDh579L1vdiklJnVZVGGgA+3OcnZn5r3ve99c8gomvMXJf4uxG/T/E/hfe/Ct90B41RYscqDIykvDgy6vgEdT2RW4nPcr6fpKCOQjID0E8gGvlJhJwoBPIDqwEwK1AsECr4Q0RMi7PJk3IpAeAKOXDjxo9QqHjJ6TRQFe/CF4gR9ObF4ULOGfHxcIW0CtlcMPPUfkgly8y8wD+Rg4egIMCZ4YDXhajDHAPvazLg7TPeQeXUHZhGGIqBZhcDBE5NXR8NtoBG3EXmJOEsGgXiDeKFBrs4p/MSIXJjB6BfT/A2SUDPvCyBugH+7iMHiJESks1BfQbrWwvLSCPM/tCsMAvb091GohDg+PsPusg3jcxIr3M9TRNC+JbHwzx8ItSivyGS8iMtHgzIcLETik1fe+dgEqPe8nz9GNnqC5WsfGjU3KJEAcx4iiaGaHaZqi2+uiv7+Hgz1a4WUdy8PbCPKaeSTeyNG4T2klHvxAke88PKvBcxM4+B549Smbocszb4TeyvdI3smxurKGVrNlUrloMPb7fTx9vo2DnTFaL+4iGS8ydoDkVo7Ww5yBHpAECZzijXMROHoK7Pye4BmoaTDE+METtG8nWFlaNWtfFHhlzUpivX4PW4+3kXx3B8mgbZKqP8jQfkg5xb4LcJFQxZ/I6sxRSCONwDMuUdRSjB9uY/lOgs0btyocl859n+B4rS6vIr+b45m3heCbGN5+gv6XAYM7xeI9jckioYwk6KGTJM4k0PmEI84269CVwztbuPVBAyvttUuDnlVRJKKohq3RFvy/vgs/raH7twC15THiJWf2aSQYJbPT4Q+Msb+7CWrQfIGlRx7azWWz2uxal3sjT7QW21h/0KA3OjbKDbY97H0VIBtzNEs5q2QFJ0le6sJu8s0pafePwLhH+YwzJL/Yx+bmBmph7ZQab/ZKsbS+uonlXzHcGkdGYu9zH8Muf48JXEsUEShJaA6ZSWDwHOh+TusfsfJ6F0v3IkQh5/45J4Vqa6WB4H4PGfsePPWw/2/PQMsLRoLA3T6smE3gFcEPO7T+oEDjwxGai805Qz9uvrHQxNovI2ThGBkHkf4XPrKhJESDKqbLtZbWXDM90P0HwXOeQX2M9v0Q9WTxuIc5P8kLEddI3tqhYTj4zsOIMnLyEQnJyIGYSiDd5yz7LaVD9rXNFM31hTlDfr35xeYikrupxcFw1zMpaYIQicoDCuSpBCSdwTPngfpND/Wo/noPcy7xuGJt36tZ8KYcSBQLNvTQ8gpexYDyqfPA8AUXaXIZNRetqOJPpr85g1fzgUdoC4wBDZ8HlBC9IBgOOIOa4IVqKgFJSJfSOBvSUzV+zJnsmlMtCWCbJUp5zJWAY8DMXCA2MwicxBnHEcGL6/WnOE64eij7VsbrJBJ5Y6oHQg44IUfNgnuSfFTWvHb8HtKjHFJS2KCUudn5UaL1RWcqgWiVo88SwdN1o44IaMy6bgkVyPpcxAX0Pw0arxOumb/UQ+mKqaNQTAILG4RM5sNnnESqQVekrzENnnA/wL2R9s3JOzS59tJKZaYgmEqgRsaL72mvyuDZCTkfTP3MNTanOwdJHDz2ERJDcoMGvSkClRdKDnTJdGRkuPQRmbNyduih940kZKKbE9zXmx10cgy2fAQ0ZuO93CRt4iE220CVXphOgO0tfcSTAi77A07C3U99G39f72ZeJQV6/2SfKYOURmw/olQUrUIr4CV4ZTMJKAbaj5yMjrYC9Lm0uK6kNVj3Mx+Ssk4pFt/X9Ev7E7EF8gkSMwnoy7XfcgpjAAUMpBef+Mi5Jr+O1PmzZ2sgeb/9MeXDIX0iG+JyJJwbZhMg0sa7wPLH3FjTjaMO0PmLCMyXRP9xwV0Y40/W58jT5OmENGPHkpUHrIQ3plMJiPXqb9jQJr3ABvtf++j9y1Wcx33YKdD5Exct3DdpIl36dWYxoGMV07029ULsjM98xjB6EpzW5Wu/c26UJ7qfeTyZu3ovaAG5+wcCZOfqp/Uht7HUv/Ri1idwJx3ljpC+PvtciG1oTBbovS/ZSom9+XN28gEbu4IJWocH2r5mPOUWgWhTp3OZOw/iTMz9Pvs5JuKYiAgvLojONKct/rIcOp07+JbDmwYF1oqXgeb7WnKf9Nn5n3XmtP/YHRRbLQKK1nIkt0vwlIxO5nQeJBISvMmJjyKq27kI6FvthHSsMdhhhz9wPc5FnpjLCiKQcNi10+UzDi1EPu1zg8J2hrtcZfGk23RNcLXVjBeP2s3ax+CPJSTEQuP6VQycm4DQylnaYIz7BUY7HOqO2KtY6GLyYwYf10/SsIbeSl4CrXW9Thlk9Yz/CLH1oazK5Ecc6doZ6+k/OQLu5CLruH+AlCRlLSa7q674nEdCVos3Exs9IRLZMCcR4jhkS1lJpPqmJFRZy+qXZWZtFQiFT8Ax5ZIw5z847H8DbMp5pNS8vhNQElOye1UmghchoAYqEpJTNiqQkgg3bTQte87ZoBq1HsvOVInJ6tFVxsOjSwKB5lXKpZKJqoqI6b0CWoJXO+YEljtnzNgP6MNZSRULuVkfRISqZwKxvauOALmPtgNYZ6uyI35rYAhfnZuVVVf/yOBve3eiXL/VAV84oHzkT9eGFZc3Zhf1gNpRMovyJsAFvWGHTjq71G/KzLYQ+kgmF2iBMVDu2ZWV7yqwJ8kIuaEuMz2XF1s6fndZAmyuJOEA22GTgXfldn4j8LrczUjo14/ACxS9qMzJ5hiciqxcNxVbbiV6ZenSHqgacNoW6NLqlLd5QMafECgpsKACMcFSPli5Gi3xWVY+T8DbQ9Wzy9+YgDVjVi5JlKAr8CKmVGb2PAGrXwRZ4Tx+kLXLF5ad/MiamNymbuonb8/7wPYF0wmBnlB/hpslzAWeRaen8gPLdDOW8lg5YU6prU/+C3KGZXXgjHW6AAAAAElFTkSuQmCC'));
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();