OmniStacksStaticModules 2.0.0
Loading...
Searching...
No Matches
WorkflowUI.cppm
Go to the documentation of this file.
1
15module;
16
17#include <array>
18#include <cctype>
19#include <cstdlib>
20#include <filesystem>
21#include <string>
22#include <string_view>
23#include <vector>
24
25#include "json.hpp"
26
27export module WorkflowUI;
28
29import std;
30import InterfaceLog;
31import ProjectSync;
32import ReminderSync;
33import TaskManager;
34
35export namespace WorkflowUI {
36
38struct Context {
39 std::filesystem::path workflow_dir;
41 std::optional<std::filesystem::path> inbox_library;
42};
43
45std::string filter(const Context& context, std::string_view query);
46
48std::expected<std::string, std::string> action(const Context& context, std::string_view token);
49
51std::string base64url_encode(std::string_view value);
52
54std::optional<std::string> base64url_decode(std::string_view value);
55
56} // namespace WorkflowUI
57
58namespace WorkflowUI {
59
60namespace {
61
62namespace fs = std::filesystem;
63using json = nlohmann::json;
64
65// ---- 通用文本与图标工具 ---------------------------------------------------
66
68std::string trim(std::string value) {
69 const auto not_space = [](unsigned char character) { return !std::isspace(character); };
70 value.erase(value.begin(), std::ranges::find_if(value, not_space));
71 value.erase(std::ranges::find_if(value.rbegin(), value.rend(), not_space).base(), value.end());
72 return value;
73}
74
76std::pair<std::string, std::string> split_first(std::string_view value, char separator) {
77 const auto position = value.find(separator);
78 if (position == std::string_view::npos) {
79 return {std::string(value), std::string{}};
80 }
81 return {std::string(value.substr(0, position)), std::string(value.substr(position + 1))};
82}
83
85std::string icon_path(const Context& context) {
86 const fs::path path = context.workflow_dir / "icons" / "icon.png";
87 return path.string();
88}
89
91std::string system_icon(std::string_view name) {
92 return (fs::path("/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources") /
93 std::string(name))
94 .string();
95}
96
98std::string app_icon(std::string_view app, std::string_view icon) {
99 return (fs::path("/System/Applications") / std::string(app) / "Contents/Resources" /
100 std::string(icon))
101 .string();
102}
103
105std::string bilingual(std::string_view chinese, std::string_view english) {
106 return std::format("{} / {}", chinese, english);
107}
108
110std::string alias_from_env(std::string_view variable, std::string_view fallback) {
111 const std::string key(variable);
112 if (const char* value = std::getenv(key.c_str()); value != nullptr && *value != '\0') {
113 std::string cleaned = trim(value);
114 if (!cleaned.empty()) {
115 return cleaned;
116 }
117 }
118 return std::string(fallback);
119}
120
123 std::string add_path = alias_from_env("STACKS_ALIAS_ADD_PATH", "ap");
124 std::string reload = alias_from_env("STACKS_ALIAS_RELOAD", "rl");
125 std::string config = alias_from_env("STACKS_ALIAS_CONFIG", "cf");
126 std::string view = alias_from_env("STACKS_ALIAS_VIEW", "v");
127 std::string tasks = alias_from_env("STACKS_ALIAS_TASKS", "t");
128 std::string week = alias_from_env("STACKS_ALIAS_WEEK", "w");
129 std::string timers = alias_from_env("STACKS_ALIAS_TIMERS", "tm");
130 std::string inbox = alias_from_env("STACKS_ALIAS_INBOX", "i");
131};
132
134std::string icon_project() {
135 return "icons/project.png";
136}
137
138std::string icon_stack() {
139 return "icons/board.png";
140}
141
142std::string icon_task() {
143 return "icons/task.png";
144}
145
146std::string icon_inbox() {
147 return "icons/inbox.png";
148}
149
150std::string icon_week() {
151 return app_icon("Calendar.app", "AppIcon.icns");
152}
153
154std::string icon_timer() {
155 return app_icon("Clock.app", "AppIcon.icns");
156}
157
158std::string icon_sync() {
159 return system_icon("Sync.icns");
160}
161
162std::string icon_info() {
163 return system_icon("ToolbarInfo.icns");
164}
165
166std::string icon_note() {
167 return "icons/note.png";
168}
169
171std::string priority_label(std::string_view value) {
172 if (value == "low") {
173 return "低";
174 }
175 if (value == "medium") {
176 return "中";
177 }
178 if (value == "high") {
179 return "高";
180 }
181 if (value == "critical") {
182 return "紧急";
183 }
184 return value.empty() ? "无" : std::string(value);
185}
186
197json item(std::string title, std::string subtitle, std::string argument = {},
198 std::string autocomplete = {}, bool valid = true, std::string icon = "icons/icon.png") {
199 std::uint32_t hash = 2166136261U;
200 for (const char character : title + argument + autocomplete) {
201 hash ^= static_cast<unsigned char>(character);
202 hash *= 16777619U;
203 }
204 json value = {
205 {"uid", std::format("{:08x}", hash)}, {"title", std::move(title)},
206 {"subtitle", std::move(subtitle)}, {"valid", valid},
207 {"icon", {{"path", std::move(icon)}}},
208 };
209 if (!argument.empty()) {
210 value["arg"] = std::move(argument);
211 }
212 if (!autocomplete.empty()) {
213 value["autocomplete"] = std::move(autocomplete);
214 }
215 value["text"] = {{"copy", value["title"]}, {"largetype", value["subtitle"]}};
216 return value;
217}
218
220json error_item(std::string message, std::string subtitle = {}) {
221 const std::string chinese = subtitle.empty() ? std::move(message) : std::move(subtitle);
222 return item("无法完成操作 / Operation failed",
223 bilingual(chinese, "Open Alfred Debugger to inspect the failure"), "", "", false,
224 icon_info());
225}
226
228std::string feedback(const json& items) {
229 return json{{"skipknowledge", true}, {"items", items}}.dump();
230}
231
234 std::string title;
235 std::string description;
236 std::string start_date;
237 std::string due_date;
238 std::string priority;
239};
240
245ParsedTaskInput parse_task_fields(std::string_view text) {
246 std::array<std::string, 5> fields;
247 std::size_t field = 0;
248 std::size_t start = 0;
249 while (field < fields.size()) {
250 const std::size_t end = text.find('|', start);
251 fields[field] = trim(std::string(
252 text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start)));
253 if (end == std::string_view::npos) {
254 break;
255 }
256 ++field;
257 start = end + 1;
258 }
259 return {
260 .title = fields[0],
261 .description = fields[1],
262 .start_date = fields[2],
263 .due_date = fields[3],
264 .priority = fields[4],
265 };
266}
267
269std::vector<std::string> library_paths(const Context& context) {
270 std::vector<std::string> paths;
271 std::ifstream input(context.databases.project_db);
272 try {
273 nlohmann::json project;
274 if (input >> project && project.contains("library_paths") &&
275 project["library_paths"].is_array()) {
276 for (const auto& entry : project["library_paths"]) {
277 if (entry.is_string()) {
278 paths.push_back(entry.get<std::string>());
279 }
280 }
281 }
282 } catch (const std::exception&) {
283 }
284 return paths;
285}
286
288json task_items(const std::vector<TaskManager::TaskRecord>& tasks) {
289 json items = json::array();
290 for (const auto& task : tasks) {
291 const std::string due = task.due_date.empty() ? "未设置" : task.due_date;
292 const std::string status_cn =
293 task.done ? "已完成" : (!task.archived.empty() ? "已归档" : "待办");
294 const std::string status_en =
295 task.done ? "done" : (!task.archived.empty() ? "archived" : "todo");
296 const std::string subtitle = bilingual(
297 std::format("任务 · {} · {} → {} · 截止 {} · 优先级 {}", status_cn, task.project_title,
298 task.board_title, due, priority_label(task.priority)),
299 std::format("Task · {} · {} → {} · due {} · prio {}", status_en, task.project_title,
300 task.board_title, due, task.priority.empty() ? "none" : task.priority));
301 items.push_back(item(std::format("任务|{} {}", task.done ? "✓" : "○", task.title), subtitle,
302 "", std::format("task:{}", task.id), false, icon_task()));
303 }
304 if (items.empty()) {
305 items.push_back(item("任务|没有匹配结果",
306 bilingual("换个关键词,或先执行 s rl 重载全部路径",
307 "Try another keyword, or run s rl to reload all paths"),
308 "", "", false, icon_task()));
309 }
310 return items;
311}
312
317bool fuzzy_match(std::string_view haystack, std::string_view needle) {
318 if (needle.empty()) {
319 return true;
320 }
321 std::string haystack_lower(haystack);
322 std::string needle_lower(needle);
323 std::ranges::transform(haystack_lower, haystack_lower.begin(),
324 [](unsigned char character) { return std::tolower(character); });
325 std::ranges::transform(needle_lower, needle_lower.begin(),
326 [](unsigned char character) { return std::tolower(character); });
327 if (haystack_lower.find(needle_lower) != std::string::npos) {
328 return true;
329 }
330 std::size_t cursor = 0;
331 for (const char character : haystack_lower) {
332 if (cursor < needle_lower.size() && character == needle_lower[cursor]) {
333 ++cursor;
334 }
335 }
336 return cursor == needle_lower.size();
337}
338
340std::vector<TaskManager::BoardTarget> sorted_matching_boards(
341 const std::vector<TaskManager::BoardTarget>& boards, std::string_view query) {
342 std::vector<TaskManager::BoardTarget> result;
343 for (const auto& board : boards) {
344 const std::string haystack = std::format("{} {}", board.title, board.project_title);
345 if (fuzzy_match(haystack, query)) {
346 result.push_back(board);
347 }
348 }
349 return result;
350}
351
359json board_items(const std::vector<TaskManager::BoardTarget>& boards, std::string_view query,
360 bool for_add, std::string_view payload = {}) {
361 json items = json::array();
362 for (const auto& board : sorted_matching_boards(boards, query)) {
363 const bool project = board.kind == "project";
364 const std::string title =
365 project ? std::format("项目|{}", board.title)
366 : std::format("项目|{} → 清单|{}", board.project_title, board.title);
367 const std::string icon = project ? icon_project() : icon_stack();
368 if (for_add && !payload.empty()) {
369 const auto parsed = parse_task_fields(payload);
370 items.push_back(item(
371 title,
372 bilingual(
373 std::format("三级:任务 · 回车创建:{} · 开始 {} · 截止 {} · 优先级 {} · {}",
374 parsed.title, parsed.start_date.empty() ? "未设置" : parsed.start_date,
375 parsed.due_date.empty() ? "未设置" : parsed.due_date,
376 parsed.priority.empty() ? "无" : priority_label(parsed.priority),
377 parsed.description.empty() ? "无描述" : parsed.description),
378 std::format("Level 3: task · create {} · start {} · due {} · priority {}{}",
379 parsed.title, parsed.start_date.empty() ? "none" : parsed.start_date,
380 parsed.due_date.empty() ? "none" : parsed.due_date,
381 parsed.priority.empty() ? "none" : parsed.priority,
382 parsed.description.empty()
383 ? ""
384 : std::format(" · description: {}", parsed.description))),
385 std::format("create:{}:{}", board.id, base64url_encode(payload)), "",
386 !parsed.title.empty(), icon));
387 } else {
388 const std::string prefix = for_add ? "add:" : "board:";
389 items.push_back(item(
390 title,
391 bilingual(
392 std::format(
393 "{} · {} · {}", project ? "一级:项目" : "二级:清单", board.id,
394 for_add ? "继续输入:标题|描述|开始|截止|优先级" : "回车查看该项目/清单下的任务"),
395 std::format("{} · {} · {}", project ? "Level 1: project" : "Level 2: list", board.id,
396 for_add ? "type title|description|start|due|priority"
397 : "press enter to view its tasks")),
398 "", prefix + board.id + " ", false, icon));
399 }
400 }
401 if (items.empty()) {
402 items.push_back(item("项目/清单|没有匹配结果",
403 bilingual("先执行 s ap 添加路径,或 s rl 重载全部路径",
404 "Run s ap to add a path, or s rl to reload all paths"),
405 "", "", false, icon_project()));
406 }
407 return items;
408}
409
411json config_items(const Context& context, const CommandAliases& aliases) {
412 json items = json::array();
413 items.push_back(item(
414 "添加新路径",
415 bilingual(std::format("add-path · {} — 选择新目录并加入索引", aliases.add_path),
416 std::format("add-path · {} — choose a new folder and add it", aliases.add_path)),
417 "add-path", "", true, icon_path(context)));
418 items.push_back(
419 item("重载全部路径",
420 bilingual(std::format("reload · {} — 重新扫描所有已保存路径", aliases.reload),
421 std::format("reload · {} — rescan every saved path", aliases.reload)),
422 "reload-all", "", true, icon_path(context)));
423 const auto paths = library_paths(context);
424 for (std::size_t index = 0; index < paths.size(); ++index) {
425 const std::string& path = paths[index];
426 items.push_back(
427 item(std::format("路径|{}", fs::path(path).filename().string()),
428 bilingual(std::format("{} · {}", path, index == 0 ? "默认 Inbox 路径" : "已保存路径"),
429 index == 0 ? "path · default Inbox library" : "path · saved library"),
430 path, "", false, system_icon("GenericFolderIcon.icns")));
431 }
432 return items;
433}
434
440 json items = json::array();
441
442 // 返回查询必须回到任务原层级:Inbox 带工作区,Backlog 带项目,Stack 带清单。
443 std::string back_query;
444 if (task.board_id == "inbox") {
445 back_query = std::format("inbox-workspace:{} ", base64url_encode(task.library_path.string()));
446 } else if (task.board_id == "backlog") {
447 back_query = std::format("board:{} ", task.project_id);
448 } else {
449 back_query = std::format("board:{} ", task.board_id);
450 }
451 const json help_item =
452 item(std::format("三级:任务|{} {}", task.done ? "✓" : "○", task.title),
453 bilingual(std::format("项目:{} → 清单:{} · 开始 {} · 截止 {} · 优先级 {} · {}",
454 task.project_title, task.board_title,
455 task.start_date.empty() ? "未设置" : task.start_date,
456 task.due_date.empty() ? "未设置" : task.due_date,
457 priority_label(task.priority), task.id),
458 std::format("project: {} → list: {} · start {} · due {} · priority {} · {}",
459 task.project_title, task.board_title,
460 task.start_date.empty() ? "none" : task.start_date,
461 task.due_date.empty() ? "none" : task.due_date,
462 task.priority.empty() ? "none" : task.priority, task.id)),
463 "", "", false, icon_task());
464
465 // 编辑组:条目本身不可选,autocomplete 让用户先输入值,再由 filter 生成 action。
466 items.push_back(item(
467 "修改标题", bilingual("输入新标题后回车,描述保持不变", "Type a new title, then press Enter"),
468 "", std::format("rename:{} ", task.id), false, icon_info()));
469 items.push_back(item(
470 "修改描述",
471 bilingual("输入新描述后回车,支持 Markdown/HTML", "Type a new description, then press Enter"),
472 "", std::format("desc:{} ", task.id), false, icon_task()));
473 items.push_back(item("设置截止日期",
474 bilingual("支持 2026-11-08、today、tomorrow、+3d、monday",
475 "Supports YYYY-MM-DD, today, tomorrow, +3d and weekdays"),
476 "", std::format("due:{} ", task.id), false, icon_week()));
477 items.push_back(
478 item("设置开始日期",
479 bilingual("与截止日期组成计划区间", "Combine with due date to define a planned range"),
480 "", std::format("start:{} ", task.id), false, icon_week()));
481 items.push_back(item("设置开始/截止区间",
482 bilingual("格式:开始日期|截止日期", "Format: start-date|due-date"), "",
483 std::format("schedule:{} ", task.id), false, icon_week()));
484 items.push_back(item("设置优先级",
485 bilingual("可选:low/低、medium/中、high/高、critical/紧急",
486 "Choose low, medium, high or critical priority"),
487 "", std::format("priority:{} ", task.id), false, icon_task()));
488
489 // 完成状态以可执行 token 提交;1=完成、0=重开,避免文本布尔值歧义。
490 items.push_back(item(
491 task.done ? "重新打开任务" : "标记为已完成",
492 task.done ? bilingual("恢复为待办并清空完成时间", "Reopen as To do and clear completion")
493 : bilingual("写入完成状态和 100% 进度", "Mark done and write 100 percent progress"),
494 std::format("done:{}:{}", task.id, task.done ? "0" : "1"), "", true, icon_task()));
495
496 // 计时组保留一个不可选说明项,start/stop 各自是独立 action。
497 items.push_back(
498 item("计时操作",
499 bilingual("启动计时或停止并写入 timelog", "Start a timer, or stop and write a timelog"),
500 "", "", false, icon_timer()));
501 items.back()["autocomplete"] = std::format("timer:{} ", task.id);
502 items.push_back(item("启动计时", bilingual("立即为此任务开始计时", "Start timing this task now"),
503 std::format("timer-start:{}", task.id), "", true, icon_timer()));
504 items.push_back(
505 item("停止并写入时间记录",
506 bilingual("生成 Stacks 兼容 timelog", "Stop and write a Stacks-compatible timelog"),
507 std::format("timer-stop:{}", task.id), "", true, icon_timer()));
508
509 // 外部提醒同步要求 duedate;无截止日期时显示不可选原因,不提前调用应用。
510 if (!task.due_date.empty()) {
511 items.push_back(
512 item("同步到 Apple 提醒",
513 bilingual(std::format("截止 {}", task.due_date), std::format("due {}", task.due_date)),
514 std::format("sync:{}:reminders", task.id), "", true,
515 app_icon("Reminders.app", "AppIcon.icns")));
516 items.push_back(
517 item("同步到 OmniFocus",
518 bilingual(std::format("截止 {}", task.due_date), std::format("due {}", task.due_date)),
519 std::format("sync:{}:omnifocus", task.id), "", true, icon_sync()));
520 items.push_back(
521 item("同步到提醒 + OmniFocus",
522 bilingual("按 Stacks-ID 去重并更新", "Deduplicate and update by Stacks-ID"),
523 std::format("sync:{}:both", task.id), "", true, icon_sync()));
524 } else {
525 items.push_back(
526 item("提醒同步不可用",
527 bilingual("先为该任务设置截止日期", "Set a due date before syncing reminders"), "", "",
528 false, icon_info()));
529 }
530
531 // 归档保留任务文件;删除采用两步确认,真正执行移到 .trash。
532 items.push_back(item("归档任务",
533 bilingual("写入 archived 时间,之后仍可恢复编辑",
534 "Set archived time; the task remains recoverable"),
535 std::format("archive:{}", task.id), "", true, icon_project()));
536 items.push_back(item("移到可恢复垃圾箱",
537 bilingual("从清单移除,文件移动到 tasks/.trash",
538 "Remove from lists and move the file to tasks/.trash"),
539 "", std::format("delete:{} ", task.id), false,
540 system_icon("ToolbarDeleteIcon.icns")));
541 items.push_back(help_item);
542 items.push_back(item("← 返回上一级 / Back",
543 bilingual("返回任务列表,再选择其他操作",
544 "Return to the task list and choose another action"),
545 "", back_query, false, icon_note()));
546 return feedback(items);
547}
548
549// ---- Action 解析与业务分发 -------------------------------------------------
550
553 return ReminderSync::provider_name(provider);
554}
555
557std::optional<ReminderSync::Provider> parse_provider(std::string_view value) {
558 if (value == "reminders") {
560 }
561 if (value == "omnifocus") {
563 }
564 if (value == "both") {
566 }
567 return std::nullopt;
568}
569
571std::expected<std::string, std::string> task_error(TaskManager::TaskError error) {
572 return std::unexpected(std::string(TaskManager::task_error_message(error)));
573}
574
579std::expected<std::string, std::string> sync_single(const Context& context,
580 std::string_view task_id,
581 ReminderSync::Provider provider,
582 bool dry_run = false) {
583 const auto task =
584 TaskManager::get_task(TaskManager::TaskId(std::string(task_id)), context.databases);
585 if (!task) {
586 return task_error(task.error());
587 }
589 options.dry_run = dry_run;
590 const auto result = ReminderSync::sync_task(
592 .task_id = task->id,
593 .title = task->title,
594 .description = task->description,
595 .due_date = task->due_date,
596 .done = task->done,
597 },
598 provider, options);
599 if (!result) {
600 return std::unexpected(std::string(ReminderSync::sync_error_message(result.error())));
601 }
602 return std::format("已同步到 {} · {}\nSynced to {} · {}", provider_label(provider),
603 result->marker, provider_label(provider), result->marker);
604}
605
610std::expected<std::string, std::string> dispatch_action(const Context& context,
611 std::string_view token) {
612 // token 不使用 shell 引号协议,按冒号恢复字段即可。
613 const auto parts = [&] {
614 std::vector<std::string> result;
615 std::size_t start = 0;
616 while (start <= token.size()) {
617 const std::size_t end = token.find(':', start);
618 result.emplace_back(token.substr(start, end == std::string_view::npos ? end : end - start));
619 if (end == std::string_view::npos) {
620 break;
621 }
622 start = end + 1;
623 }
624 return result;
625 }();
626 if (parts.empty()) {
627 return std::unexpected("empty action");
628 }
629
630 const std::string& command = parts[0];
631
632 // 创建任务:create:<board-id>:<base64(payload)>。
633 if (command == "create" && parts.size() == 3) {
634 const auto payload = base64url_decode(parts[2]);
635 if (!payload) {
636 return std::unexpected("invalid create payload");
637 }
638 const auto parsed = parse_task_fields(*payload);
640 const auto result = TaskManager::create_task(
641 TaskManager::TargetId(parts[1]),
643 TaskManager::TaskTitle(parsed.title),
644 TaskManager::TaskDescription(parsed.description),
645 parsed.start_date.empty() ? std::nullopt
646 : std::optional<std::string>(parsed.start_date),
647 parsed.due_date.empty() ? std::nullopt : std::optional<std::string>(parsed.due_date),
648 parsed.priority,
649 },
650 options);
651 if (!result) {
652 return task_error(result.error());
653 }
654 return std::format("任务已创建 · {}\nTask created · {}", result->task_id.value,
655 result->task_id.value);
656 }
657
658 // 创建 Inbox:create-inbox:[base64(path):]<base64(payload)>。
659 if (command == "create-inbox" && (parts.size() == 2 || parts.size() == 3)) {
660 const auto payload = base64url_decode(parts.back());
661 if (!payload) {
662 return std::unexpected("invalid Inbox payload");
663 }
665 if (parts.size() == 3) {
666 const auto library = base64url_decode(parts[1]);
667 if (!library || library->empty()) {
668 return std::unexpected("invalid Inbox library path");
669 }
670 options.inbox_library = fs::path(*library);
671 }
672 const auto parsed = parse_task_fields(*payload);
673 const auto result = TaskManager::create_inbox_task(
675 TaskManager::TaskTitle(parsed.title),
676 TaskManager::TaskDescription(parsed.description),
677 parsed.start_date.empty() ? std::nullopt
678 : std::optional<std::string>(parsed.start_date),
679 parsed.due_date.empty() ? std::nullopt : std::optional<std::string>(parsed.due_date),
680 parsed.priority,
681 },
682 options);
683 if (!result) {
684 return task_error(result.error());
685 }
686 return std::format("已添加至收件箱 · {}\nAdded to Inbox · {}", result->task_id.value,
687 result->task_id.value);
688 }
689
690 // 单字段编辑:操作名:<task-id>:<base64(value)>。
691 if ((command == "rename" || command == "desc" || command == "due" || command == "start" ||
692 command == "priority") &&
693 parts.size() == 3) {
694 const auto payload = base64url_decode(parts[2]);
695 if (!payload) {
696 return std::unexpected("invalid edit payload");
697 }
699 if (command == "rename") {
700 patch.title = *payload;
701 } else if (command == "desc") {
702 patch.description = *payload;
703 } else if (command == "due") {
704 patch.due_date = *payload;
705 } else if (command == "start") {
706 patch.start_date = *payload;
707 } else {
708 patch.priority = *payload;
709 }
710 const auto result =
711 TaskManager::update_task(TaskManager::TaskId(parts[1]), patch, context.databases);
712 if (!result) {
713 return task_error(result.error());
714 }
715 return "任务已更新\nTask updated";
716 }
717
718 // 计划区间:schedule:<task-id>:<base64(start)>:<base64(due)>。
719 if (command == "schedule" && parts.size() == 4) {
720 const auto start = base64url_decode(parts[2]);
721 const auto due = base64url_decode(parts[3]);
722 if (!start || !due) {
723 return std::unexpected("invalid schedule payload");
724 }
725 const auto result =
726 TaskManager::set_schedule(TaskManager::TaskId(parts[1]), *start, *due, context.databases);
727 if (!result) {
728 return task_error(result.error());
729 }
730 return std::format("计划已更新 · {} → {}\nSchedule updated · {} → {}", result->start_date,
731 result->due_date, result->start_date, result->due_date);
732 }
733
734 // 完成/重开使用 1/0 token,避免布尔文本大小写歧义。
735 if (command == "done" && parts.size() == 3) {
736 const auto result = TaskManager::set_task_done(TaskManager::TaskId(parts[1]), parts[2] == "1",
737 context.databases);
738 if (!result) {
739 return task_error(result.error());
740 }
741 return result->done ? "任务已完成\nTask completed" : "任务已重新打开\nTask reopened";
742 }
743
744 // 计时 action 直接使用任务 ID;业务模块负责状态冲突分类。
745 if (command == "timer-start" && parts.size() == 2) {
746 const auto result = TaskManager::start_timer(TaskManager::TaskId(parts[1]), context.databases);
747 if (!result) {
748 return task_error(result.error());
749 }
750 return std::format("计时已开始 · {}\nTimer started · {}", result->title, result->title);
751 }
752 if (command == "timer-stop" && parts.size() == 2) {
753 const auto result = TaskManager::stop_timer(TaskManager::TaskId(parts[1]), context.databases);
754 if (!result) {
755 return task_error(result.error());
756 }
757 return std::format("计时已记录 · {} 秒\nTimer saved · {} seconds", result->duration_seconds,
758 result->duration_seconds);
759 }
760 if (command == "archive" && parts.size() == 2) {
761 const auto result = TaskManager::archive_task(TaskManager::TaskId(parts[1]), context.databases);
762 if (!result) {
763 return task_error(result.error());
764 }
765 return "任务已归档\nTask archived";
766 }
767
768 // Alfred 第二次回车才发送 delete-confirm,第一次 delete 仅显示说明页。
769 if (command == "delete-confirm" && parts.size() == 2) {
770 const auto result = TaskManager::delete_task(TaskManager::TaskId(parts[1]), context.databases);
771 if (!result) {
772 return task_error(result.error());
773 }
774 return std::format("任务已移到 {}\nTask moved to {}", result->trash_file.filename().string(),
775 result->trash_file.filename().string());
776 }
777
778 // 单任务/本周同步共用 sync_single;批量模式收集失败明细后整体返回错误。
779 if (command == "sync" && parts.size() == 3) {
780 const auto provider = parse_provider(parts[2]);
781 if (!provider) {
782 return std::unexpected("unknown sync provider");
783 }
784 return sync_single(context, parts[1], *provider);
785 }
786 if (command == "sync-week" && parts.size() == 2) {
787 const auto provider = parse_provider(parts[1]);
788 if (!provider) {
789 return std::unexpected("unknown sync provider");
790 }
791 const auto tasks = TaskManager::list_week_due(context.databases);
792 if (!tasks) {
793 return task_error(tasks.error());
794 }
795 std::size_t succeeded = 0;
796 std::vector<std::string> failures;
797 for (const auto& task : *tasks) {
798 const auto result = sync_single(context, task.id, *provider);
799 if (result) {
800 ++succeeded;
801 } else {
802 failures.push_back(std::format("{}: {}", task.id, result.error()));
803 }
804 }
805 if (!failures.empty()) {
806 return std::unexpected(
807 std::format("{} 个成功,{} 个失败:{}", succeeded, failures.size(), failures.front()));
808 }
809 return std::format("本周 {} 个截止任务已同步到 {}\n{} due tasks synced to {}", succeeded,
810 provider_label(*provider), succeeded, provider_label(*provider));
811 }
812
813 // 路径操作走 ProjectSync,不经过 TaskManager,保持模块职责分离。
814 if (command == "add-path" && parts.size() == 2) {
815 const auto path = base64url_decode(parts[1]);
816 if (!path) {
817 return std::unexpected("invalid add-path payload");
818 }
819 const auto result =
821 if (!result) {
822 return std::unexpected(std::string(ProjectSync::sync_error_message(result.error())));
823 }
824 return std::format("已添加路径 · {} 个项目 · {} 个清单\nPath added · {} projects · {} lists",
825 result->project_count, result->stack_count, result->project_count,
826 result->stack_count);
827 }
828 if (command == "reload-all" && parts.size() == 1) {
829 const auto result =
831 if (!result) {
832 return std::unexpected(std::string(ProjectSync::sync_error_message(result.error())));
833 }
834 return std::format(
835 "已重载全部路径 · {} 个项目 · {} 个清单\nAll paths reloaded · {} projects · {} lists",
836 result->project_count, result->stack_count, result->project_count, result->stack_count);
837 }
838 return std::unexpected(std::format("unknown action: {}", token));
839}
840
841} // namespace
842
843std::string base64url_encode(std::string_view value) {
844 // 6-bit 分组累积;`-_` 替换标准 `+/`,且省略 padding,适合 Alfred token。
845 static constexpr std::string_view alphabet =
846 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
847 std::string output;
848 output.reserve((value.size() * 4 + 2) / 3);
849 std::uint32_t buffer = 0;
850 int bits = 0;
851 for (const char raw_character : value) {
852 const auto character = static_cast<unsigned char>(raw_character);
853 buffer = (buffer << 8U) | character;
854 bits += 8;
855 while (bits >= 6) {
856 bits -= 6;
857 output.push_back(alphabet[(buffer >> bits) & 0x3FU]);
858 }
859 }
860 if (bits > 0) {
861 output.push_back(alphabet[(buffer << (6 - bits)) & 0x3FU]);
862 }
863 return output;
864}
865
866std::optional<std::string> base64url_decode(std::string_view value) {
867 // 解码前严格拒绝 alphabet 外字符;不包含 padding,因此无需宽容跳过。
868 static constexpr std::string_view alphabet =
869 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
870 std::string output;
871 output.reserve(value.size() * 3 / 4);
872 std::uint32_t buffer = 0;
873 int bits = 0;
874 for (const char character : value) {
875 const std::size_t index = alphabet.find(character);
876 if (index == std::string_view::npos) {
877 return std::nullopt;
878 }
879 buffer = (buffer << 6U) | static_cast<std::uint32_t>(index);
880 bits += 6;
881 if (bits >= 8) {
882 bits -= 8;
883 output.push_back(static_cast<char>((buffer >> bits) & 0xFFU));
884 }
885 }
886 return output;
887}
888
889std::string filter_impl(const Context& context, std::string_view raw_query) {
890 // filter 是纯表现层:所有分支只调用领域模块并组装 JSON,不直接写文件。
891 const std::string query = trim(std::string(raw_query));
892 json items = json::array();
893
894 try {
895 const CommandAliases aliases;
896
897 // 空查询:第一行是所有操作说明,后面是可选命令和项目/清单三级入口。
898 if (query.empty()) {
899 const json help_item = item(
900 "所有可用操作",
901 bilingual(
902 std::format("{} 添加路径 · {} 重载 · {} 查看 · {} 搜索 · {} 本周 · {} 计时 · {} "
903 "收件箱 · {} 配置",
904 aliases.add_path, aliases.reload, aliases.view, aliases.tasks,
905 aliases.week, aliases.timers, aliases.inbox, aliases.config),
906 std::format("{} add path · {} reload · {} view · {} search · {} week · {} timers · "
907 "{} inbox · {} config",
908 aliases.add_path, aliases.reload, aliases.view, aliases.tasks,
909 aliases.week, aliases.timers, aliases.inbox, aliases.config)),
910 "", "", false, icon_note());
911 items.push_back(
912 item("添加路径",
913 bilingual(
914 std::format("add-path · {} — 选择新目录并加入索引", aliases.add_path),
915 std::format("add-path · {} — choose and index a new library", aliases.add_path)),
916 "", aliases.add_path + " ", false, icon_path(context)));
917 items.push_back(
918 item("重载全部路径",
919 bilingual(std::format("reload · {} — 重新扫描所有已保存路径", aliases.reload),
920 std::format("reload · {} — rescan all saved libraries", aliases.reload)),
921 "", aliases.reload, false, icon_path(context)));
922 items.push_back(
923 item("查看项目 / 清单任务",
924 bilingual(std::format("view · {} — 项目级 → 清单级 → 任务级", aliases.view),
925 std::format("view · {} — project → list → task", aliases.view)),
926 "", aliases.view + " ", false, icon_path(context)));
927 items.push_back(item(
928 "全局搜索任务",
929 bilingual(
930 std::format("tasks · {} — 搜索全部工作区、项目和清单", aliases.tasks),
931 std::format("tasks · {} — search every library, project and list", aliases.tasks)),
932 "", aliases.tasks + " ", false, icon_path(context)));
933 items.push_back(item(
934 "本周截止任务",
935 bilingual(std::format("week · {} — 聚合全部路径", aliases.week),
936 std::format("week · {} — due this week across all libraries", aliases.week)),
937 "", aliases.week, false, icon_path(context)));
938 items.push_back(item(
939 "运行中的计时",
940 bilingual(std::format("timers · {} — 查看或停止计时", aliases.timers),
941 std::format("timers · {} — inspect or stop running timers", aliases.timers)),
942 "", aliases.timers, false, icon_path(context)));
943 items.push_back(item(
944 "添加到收件箱",
945 bilingual(std::format("inbox · {} — 先选择路径,默认第一个", aliases.inbox),
946 std::format("inbox · {} — choose a library; first is default", aliases.inbox)),
947 "", aliases.inbox + " ", false, icon_path(context)));
948 items.push_back(
949 item("路径配置",
950 bilingual(std::format("config · {} — 查看已保存路径", aliases.config),
951 std::format("config · {} — inspect saved library paths", aliases.config)),
952 "", aliases.config, false, icon_path(context)));
953 const auto boards = TaskManager::list_boards(context.databases);
954 if (!boards) {
955 items.push_back(error_item(std::string(TaskManager::task_error_message(boards.error()))));
956 return feedback(items);
957 }
958 const json board_rows = board_items(*boards, "", true);
959 for (const auto& board : board_rows) {
960 items.push_back(board);
961 }
962 items.push_back(help_item);
963 return feedback(items);
964 }
965
966 // 路径操作只返回一个可执行条目,避免 Alfred 对 action token 二次猜测。
967 if (query == "reload-all") {
968 return feedback(json::array({item("重载全部路径",
969 bilingual("reload · rl — 重新扫描所有已保存路径",
970 "reload · rl — rescan all saved libraries"),
971 "reload-all", "", true, icon_path(context))}));
972 }
973
974 if (query == "add-path") {
975 return feedback(json::array({item("添加新路径",
976 bilingual("add-path · ap — 选择目录并加入索引",
977 "add-path · ap — choose a folder and add it"),
978 "add-path", "", true, icon_path(context))}));
979 }
980
981 if (query == "config") {
982 return feedback(config_items(context, aliases));
983 }
984
985 // 兼容 `add`/`board`:`add` 用于快速创建,`board` 用于三级浏览入口。
986 if (query == "add" || query == "board" || query.starts_with("board ")) {
987 const auto boards = TaskManager::list_boards(context.databases);
988 if (!boards) {
989 return feedback(json::array(
990 {error_item(std::string(TaskManager::task_error_message(boards.error())))}));
991 }
992 json help_item;
993 if (query == "board" || query.starts_with("board ")) {
994 help_item = item(
995 "查看与编辑 / View and edit",
996 bilingual(
997 "输入关键词模糊匹配项目或清单,回车进入下一级;任务详情内可返回上一级",
998 "Fuzzy-match projects or lists, press Enter to drill down, and use Back inside"),
999 "", "", false, icon_note());
1000 }
1001 const std::string board_query = query.starts_with("board ") ? trim(query.substr(6)) : "";
1002 const json board_rows = board_items(*boards, board_query, query == "add");
1003 for (const auto& board : board_rows) {
1004 items.push_back(board);
1005 }
1006 if (!help_item.is_null()) {
1007 items.push_back(help_item);
1008 }
1009 return feedback(items);
1010 }
1011
1012 // 选中目标后继续输入任务字段,最终生成 create:<board>:<payload>。
1013 if (query.starts_with("add:")) {
1014 const auto [target, payload] = split_first(query.substr(4), ' ');
1015 const auto boards = TaskManager::list_boards(context.databases);
1016 if (!boards) {
1017 return feedback(json::array(
1018 {error_item(std::string(TaskManager::task_error_message(boards.error())))}));
1019 }
1020 const auto board = std::ranges::find_if(
1021 *boards, [&target](const TaskManager::BoardTarget& value) { return value.id == target; });
1022 if (board == boards->end()) {
1023 return feedback(json::array({error_item("找不到目标项目/清单", target)}));
1024 }
1025 if (trim(payload).empty()) {
1026 return feedback(json::array(
1027 {item(std::format("{}:{}", board->kind == "project" ? "项目" : "清单", board->title),
1028 bilingual("继续输入:标题|描述|开始|截止|优先级",
1029 "Type title|description|start|due|priority"),
1030 "", "", false, board->kind == "project" ? icon_project() : icon_stack())}));
1031 }
1032 const auto parsed = parse_task_fields(payload);
1033 if (parsed.title.empty()) {
1034 return feedback(json::array(
1035 {error_item("任务标题不能为空", "Format: title|description|start|due|priority")}));
1036 }
1037 const std::string encoded = base64url_encode(payload);
1038 return feedback(json::array({item(
1039 std::format("三级:任务|创建:{}", parsed.title),
1040 bilingual(std::format("项目:{} → 清单:{} · 开始 {} · 截止 {} · 优先级 {}",
1041 board->project_title, board->title,
1042 parsed.start_date.empty() ? "未设置" : parsed.start_date,
1043 parsed.due_date.empty() ? "未设置" : parsed.due_date,
1044 parsed.priority.empty() ? "无" : priority_label(parsed.priority)),
1045 "Press Enter to create this task in the selected project/list"),
1046 std::format("create:{}:{}", target, encoded), "", true,
1047 board->kind == "project" ? icon_project() : icon_stack())}));
1048 }
1049
1050 // Inbox 第一层只列工作区;第一个路径作为默认值但不能越过选择步骤。
1051 if (query == "inbox") {
1052 const auto paths = library_paths(context);
1053 if (paths.empty()) {
1054 return feedback(
1055 json::array({item("收件箱|没有已保存路径",
1056 bilingual("先执行 s ap 添加路径", "Run s ap to add a library first"),
1057 "", "", false, icon_inbox())}));
1058 }
1059 const json help_item =
1060 item("收件箱 / Inbox",
1061 bilingual("先选择工作区;进入后直接查看、模糊搜索或新建收件箱任务",
1062 "Choose a library, then view, fuzzy-search or create Inbox tasks"),
1063 "", "", false, icon_note());
1064 for (std::size_t index = 0; index < paths.size(); ++index) {
1065 const std::string& path = paths[index];
1066 items.push_back(item(
1067 std::format("收件箱路径|{}{}", fs::path(path).filename().string(),
1068 index == 0 ? "(默认)" : ""),
1069 bilingual(std::format("{} · {}", path, index == 0 ? "默认第一个路径" : "已保存路径"),
1070 index == 0 ? "Default (first) Inbox library" : "Saved Inbox library"),
1071 "", std::format("inbox-workspace:{} ", base64url_encode(path)), false, icon_inbox()));
1072 }
1073 items.push_back(help_item);
1074 return feedback(items);
1075 }
1076
1077 // Inbox 工作区页:无任务直接显示新建;有任务显示模糊搜索结果。
1078 if (query.starts_with("inbox-workspace:")) {
1079 const auto [encoded_path, search] = split_first(query.substr(16), ' ');
1080 const auto path = base64url_decode(encoded_path);
1081 if (!path || path->empty()) {
1082 return feedback(json::array({error_item("收件箱路径无效", "Invalid Inbox library path")}));
1083 }
1084 const std::string path_token = base64url_encode(*path);
1085 const std::string search_text = trim(search);
1087 options.target = TaskManager::TargetId("inbox");
1088 const auto tasks = TaskManager::list_tasks(context.databases, options);
1089 if (!tasks) {
1090 return feedback(
1091 json::array({error_item(std::string(TaskManager::task_error_message(tasks.error())))}));
1092 }
1093 std::vector<TaskManager::TaskRecord> filtered;
1094 for (const auto& task : *tasks) {
1095 if (task.library_path.lexically_normal() != fs::path(*path).lexically_normal()) {
1096 continue;
1097 }
1098 const std::string haystack = std::format("{} {}", task.title, task.description);
1099 if (fuzzy_match(haystack, search_text)) {
1100 filtered.push_back(task);
1101 }
1102 }
1103 const json help_item = item(
1104 std::format("收件箱工作区 / Inbox workspace:{}", fs::path(*path).filename().string()),
1105 bilingual("直接显示当前收件箱任务;输入关键词模糊匹配,无匹配时进入新建",
1106 "Inbox tasks are shown directly; fuzzy-match or create when nothing matches"),
1107 "", "", false, icon_note());
1108
1109 if (!filtered.empty()) {
1110 const json task_rows = task_items(filtered);
1111 for (const auto& task : task_rows) {
1112 items.push_back(task);
1113 }
1114 } else if (!search_text.empty()) {
1115 items.push_back(
1116 item(std::format("新建任务 / Create task:{}", search_text),
1117 bilingual("当前收件箱没有匹配任务,回车自动进入新建流程",
1118 "No matching Inbox task; press Enter to create it"),
1119 std::format("create-inbox:{}:{}", path_token, base64url_encode(search_text)), "",
1120 true, icon_task()));
1121 }
1122
1123 if (!filtered.empty()) {
1124 items.push_back(item(
1125 "新建收件箱任务 / Create another Inbox task",
1126 bilingual("保持当前任务列表,继续录入新任务", "Keep the list and create another task"),
1127 "", std::format("inbox-create:{} ", path_token), false, icon_task()));
1128 } else if (search_text.empty()) {
1129 items.push_back(item("录入收件箱任务 / Create Inbox task",
1130 bilingual("当前工作区收件箱无任务时直接进入录入",
1131 "Create directly when this Inbox has no matching tasks"),
1132 "", std::format("inbox-create:{} ", path_token), false, icon_task()));
1133 }
1134 items.push_back(help_item);
1135 items.push_back(item("← 返回路径列表 / Back",
1136 bilingual("返回全部收件箱工作区", "Back to all Inbox libraries"), "",
1137 "inbox ", false, icon_note()));
1138 return feedback(items);
1139 }
1140
1141 // 新建 Inbox 任务:路径与 payload 均保留到最终 action token。
1142 if (query.starts_with("inbox-create:")) {
1143 const auto [encoded_path, payload] = split_first(query.substr(13), ' ');
1144 const auto path = base64url_decode(encoded_path);
1145 if (!path || path->empty()) {
1146 return feedback(json::array({error_item("收件箱路径无效", "Invalid Inbox library path")}));
1147 }
1148 if (trim(payload).empty()) {
1149 return feedback(json::array({item("收件箱|填写任务",
1150 bilingual("继续输入:标题|描述|开始|截止|优先级",
1151 "Type title|description|start|due|priority"),
1152 "", "", false, icon_inbox())}));
1153 }
1154 const auto parsed = parse_task_fields(payload);
1155 if (parsed.title.empty()) {
1156 return feedback(
1157 json::array({error_item("任务标题不能为空", "Task title cannot be empty")}));
1158 }
1159 return feedback(json::array({item(
1160 std::format("三级:任务|添加到收件箱:{}", parsed.title),
1161 bilingual(std::format("路径:{} · 开始 {} · 截止 {} · 优先级 {}", *path,
1162 parsed.start_date.empty() ? "未设置" : parsed.start_date,
1163 parsed.due_date.empty() ? "未设置" : parsed.due_date,
1164 parsed.priority.empty() ? "无" : priority_label(parsed.priority)),
1165 "Library, plan dates and priority are shown above; press Enter to create"),
1166 std::format("create-inbox:{}:{}", base64url_encode(*path), base64url_encode(payload)), "",
1167 true, icon_inbox())}));
1168 }
1169
1170 // 用户看到 `s i xxx` 时先阻止直接录入,确保工作区选择具有明确语义。
1171 if (query.starts_with("inbox ")) {
1172 return feedback(
1173 json::array({item("s i 后不接受直接文本 / Text after s i is blocked",
1174 bilingual("请先选择工作区,再直接搜索或新建收件箱任务",
1175 "Select a library first, then search or create Inbox tasks"),
1176 "", "inbox", false, icon_inbox())}));
1177 }
1178
1179 // 全局任务搜索覆盖所有工作区,不使用 board target 过滤。
1180 if (query == "tasks") {
1181 const auto tasks = TaskManager::list_tasks(context.databases);
1182 if (!tasks) {
1183 return feedback(
1184 json::array({error_item(std::string(TaskManager::task_error_message(tasks.error())))}));
1185 }
1186 json rows = task_items(*tasks);
1187 rows.push_back(item("全局任务搜索",
1188 bilingual("范围:全部工作区 → 全部项目 → 全部清单",
1189 "Scope: every library → every project → every list"),
1190 "", "", false, icon_note()));
1191 return feedback(rows);
1192 }
1193 if (query.starts_with("tasks ")) {
1195 options.query = trim(query.substr(6));
1196 const auto tasks = TaskManager::list_tasks(context.databases, options);
1197 if (!tasks) {
1198 return feedback(
1199 json::array({error_item(std::string(TaskManager::task_error_message(tasks.error())))}));
1200 }
1201 json rows = task_items(*tasks);
1202 rows.push_back(item(std::format("全局任务搜索|{}", trim(query.substr(6))),
1203 bilingual("范围:全部工作区 → 全部项目 → 全部清单",
1204 "Scope: every library → every project → every list"),
1205 "", "", false, icon_note()));
1206 return feedback(rows);
1207 }
1208
1209 // 项目/清单任务页:任务匹配不到时按当前输入创建,Back 固定在最后。
1210 if (query.starts_with("board:")) {
1211 const auto [target, search] = split_first(query.substr(6), ' ');
1213 options.target = TaskManager::TargetId(target);
1214 const auto tasks = TaskManager::list_tasks(context.databases, options);
1215 if (!tasks) {
1216 return feedback(
1217 json::array({error_item(std::string(TaskManager::task_error_message(tasks.error())))}));
1218 }
1219 const json help_item =
1220 item("项目 / 清单任务页",
1221 bilingual("输入关键词模糊匹配任务;回车编辑,匹配不到时进入插入流程",
1222 "Fuzzy-match tasks, press Enter to edit, or create when nothing matches"),
1223 "", "", false, icon_note());
1224 std::vector<TaskManager::TaskRecord> filtered;
1225 const std::string search_text = trim(search);
1226 for (const auto& task : *tasks) {
1227 const std::string haystack = std::format("{} {} {} {}", task.title, task.description,
1228 task.project_title, task.board_title);
1229 if (fuzzy_match(haystack, search_text)) {
1230 filtered.push_back(task);
1231 }
1232 }
1233 if (!search_text.empty() && filtered.empty()) {
1234 items.push_back(item(std::format("新建任务 / Create task:{}", search_text),
1235 bilingual("没有匹配任务,回车进入插入流程",
1236 "No matching task; press Enter to start the insert flow"),
1237 std::format("create:{}:{}", target, base64url_encode(search_text)), "",
1238 true, icon_task()));
1239 } else if (!filtered.empty()) {
1240 const json task_rows = task_items(filtered);
1241 for (const auto& task : task_rows) {
1242 items.push_back(task);
1243 }
1244 } else {
1245 items.push_back(
1246 item("当前项目 / 清单没有任务",
1247 bilingual("继续输入标题即可创建新任务", "Type a title to create the first task"),
1248 "", "", false, icon_task()));
1249 }
1250 items.push_back(help_item);
1251 items.push_back(item("← 返回上一级 / Back",
1252 bilingual("返回项目与清单列表,继续选择其他层级",
1253 "Return to the project and list selection"),
1254 "", "board ", false, icon_note()));
1255 return feedback(items);
1256 }
1257
1258 // 本周页第一项为批量同步 action,随后是本周边界说明和任务列表。
1259 if (query == "week") {
1260 const auto tasks = TaskManager::list_week_due(context.databases);
1261 if (!tasks) {
1262 return feedback(
1263 json::array({error_item(std::string(TaskManager::task_error_message(tasks.error())))}));
1264 }
1265 const auto bounds = TaskManager::week_bounds();
1266 json due_items = task_items(*tasks);
1267 due_items.insert(due_items.begin(),
1268 item("同步本周到提醒 + OmniFocus",
1269 bilingual("任务必须已有截止日期", "Tasks must already have due dates"),
1270 "sync-week:both", "", true, icon_sync()));
1271 due_items.push_back(item(
1272 "本周截止任务",
1273 bilingual(std::format("{} → {} · {} 项", bounds.first, bounds.last, tasks->size()),
1274 std::format("{} → {} · {} tasks", bounds.first, bounds.last, tasks->size())),
1275 "", "", false, icon_note()));
1276 return feedback(due_items);
1277 }
1278
1279 // 计时页每一项回车即停止并写入 timelog;空列表给出操作说明。
1280 if (query == "timers") {
1281 const auto timers = TaskManager::list_timers(context.databases);
1282 if (!timers) {
1283 return feedback(json::array(
1284 {error_item(std::string(TaskManager::task_error_message(timers.error())))}));
1285 }
1286 json timer_items = json::array();
1287 for (const auto& timer : *timers) {
1288 timer_items.push_back(
1289 item(std::format("▶ {}", timer.title),
1290 bilingual(std::format("开始于 {} · {}", timer.started_at, timer.task_id),
1291 std::format("started {} · task {}", timer.started_at, timer.task_id)),
1292 std::format("timer-stop:{}", timer.task_id), "", true, icon_timer()));
1293 }
1294 if (timer_items.empty()) {
1295 timer_items.push_back(
1296 item("没有运行中的计时",
1297 bilingual("从任务详情页启动计时", "Start a timer from a task detail page"), "", "",
1298 false, icon_timer()));
1299 }
1300 return feedback(timer_items);
1301 }
1302
1303 // 任务详情页集中提供编辑、日期、完成、计时、同步、归档和删除入口。
1304 if (query.starts_with("task:")) {
1305 const std::string id = trim(query.substr(5));
1306 const auto task = TaskManager::get_task(TaskManager::TaskId(id), context.databases);
1307 if (!task) {
1308 return feedback(
1309 json::array({error_item(std::string(TaskManager::task_error_message(task.error())))}));
1310 }
1311 return task_detail(*task);
1312 }
1313
1314 // 单字段编辑先显示输入页,再生成对应 action 的确认条目。
1315 for (const std::string_view command : {"rename:", "desc:", "due:", "start:", "priority:"}) {
1316 if (!query.starts_with(command)) {
1317 continue;
1318 }
1319 const std::string remainder = query.substr(command.size());
1320 const auto [id, payload] = split_first(remainder, ' ');
1321 if (trim(payload).empty()) {
1322 const std::string label = command == "rename:" ? "新标题"
1323 : command == "desc:" ? "新描述"
1324 : command == "due:" ? "截止日期"
1325 : command == "start:" ? "开始日期"
1326 : "优先级";
1327 const std::string english = command == "priority:" ? "Type low, medium, high or critical"
1328 : "Type the new value, then press Enter";
1329 return feedback(json::array(
1330 {item(std::format("填写{}", label),
1331 bilingual(command == "priority:"
1332 ? "可选:low/低、medium/中、high/高、critical/紧急"
1333 : "继续输入;日期可用 today、tomorrow、+3d、monday 或 YYYY-MM-DD",
1334 english),
1335 "", "", false, icon_info())}));
1336 }
1337 const std::string action_prefix(command.substr(0, command.size() - 1));
1338 return feedback(
1339 json::array({item(std::format("三级:任务|应用更改:{}", trim(payload)),
1340 bilingual(std::format("任务 {}", id), std::format("Task {}", id)),
1341 std::format("{}:{}:{}", action_prefix, id, base64url_encode(payload)),
1342 "", true, icon_task())}));
1343 }
1344
1345 // 开始/截止区间必须一次输入两端,后端统一校验先后关系。
1346 if (query.starts_with("schedule:")) {
1347 const std::string remainder = query.substr(9);
1348 const auto [id, payload] = split_first(remainder, ' ');
1349 const auto [start, due] = split_first(payload, '|');
1350 if (trim(start).empty() || trim(due).empty()) {
1351 return feedback(
1352 json::array({error_item("格式:开始日期|截止日期", "Format: start-date|due-date")}));
1353 }
1354 return feedback(
1355 json::array({item(std::format("三级:任务|应用计划:{} → {}", trim(start), trim(due)),
1356 bilingual(std::format("任务 {}", id), std::format("Task {}", id)),
1357 std::format("schedule:{}:{}:{}", id, base64url_encode(trim(start)),
1358 base64url_encode(trim(due))),
1359 "", true, icon_week())}));
1360 }
1361
1362 // timer 查询保留为详情页快捷入口;与 timers 总列表职责不同。
1363 if (query.starts_with("timer:")) {
1364 const std::string id = trim(query.substr(6));
1365 const auto task = TaskManager::get_task(TaskManager::TaskId(id), context.databases);
1366 if (!task) {
1367 return feedback(
1368 json::array({error_item(std::string(TaskManager::task_error_message(task.error())))}));
1369 }
1370 return feedback(json::array({
1371 item("启动计时", bilingual(task->title, "Start timing this task"),
1372 std::format("timer-start:{}", id), "", true, icon_timer()),
1373 item("停止并写入 timelog", bilingual(task->title, "Stop and write a timelog"),
1374 std::format("timer-stop:{}", id), "", true, icon_timer()),
1375 }));
1376 }
1377
1378 // 删除采用二次确认,真正删除前不发送破坏性 action。
1379 if (query.starts_with("delete:")) {
1380 const std::string id = trim(query.substr(7));
1381 return feedback(json::array({item(
1382 "确认移到可恢复垃圾箱?",
1383 bilingual("再次回车执行;任务文件移入 tasks/.trash,并从清单移除",
1384 "Press Enter again; move the task to tasks/.trash and remove it from lists"),
1385 std::format("delete-confirm:{}", id), "", true, system_icon("ToolbarDeleteIcon.icns"))}));
1386 }
1387
1388 // 默认分支把任意文本视为任务字段,每个项目/清单都显示创建预览。
1389 {
1390 const auto boards = TaskManager::list_boards(context.databases);
1391 if (!boards) {
1392 return feedback(json::array(
1393 {error_item(std::string(TaskManager::task_error_message(boards.error())))}));
1394 }
1395 return feedback(board_items(*boards, "", true, query));
1396 }
1397
1398 return feedback(json::array(
1399 {error_item("没有匹配的命令", "按 Esc 回到 s 总览;可用 ap、rl、v、t、w、tm、i、cf")}));
1400 } catch (const std::exception& error) {
1401 // UI 适配层最后一道异常边界;任何异常转换为可选错误条目而非崩溃 Alfred。
1402 return feedback(
1403 json::array({error_item("界面数据处理失败", std::format("UI error: {}", error.what()))}));
1404 }
1405}
1406
1407std::string filter(const Context& context, std::string_view raw_query) {
1408 // 包装 impl 以统一记录 query、输出字节数和 item 数量。
1409 const std::string output = filter_impl(context, raw_query);
1410 int item_count = -1;
1411 try {
1412 const auto parsed = json::parse(output);
1413 if (parsed.contains("items") && parsed["items"].is_array()) {
1414 item_count = static_cast<int>(parsed["items"].size());
1415 }
1416 } catch (const std::exception&) {
1417 }
1419 interface_log::ApiName("WorkflowUI::filter"),
1420 interface_log::LogText(std::format("query={}", raw_query)),
1421 interface_log::LogText(std::format("bytes={} items={}", output.size(), item_count)),
1423 return output;
1424}
1425
1426std::expected<std::string, std::string> action(const Context& context, std::string_view token) {
1427 // action 层只负责日志边界;业务错误文本由 dispatch_action 生成。
1428 auto result = dispatch_action(context, token);
1429 interface_log::record(interface_log::ApiName("WorkflowUI::action"),
1430 interface_log::LogText(std::string(token)),
1431 interface_log::LogText(result ? *result : result.error()),
1433 return result;
1434}
1435
1436} // namespace WorkflowUI
接口输入/输出日志模块(STATIC 模块库)。
Stacks 2 项目/看板索引模块。
将带截止日期的 Stacks 任务同步到 Apple Reminders / OmniFocus。
Stacks 2 任务业务模块。
Alfred Script Filter JSON 与动作分发适配层。
std::expected< SyncSummary, SyncError > sync_all(const std::filesystem::path &search_path, const DbPaths &paths={})
同步新增路径与全部已记忆路径,并原子重建两个索引。
std::string_view sync_error_message(SyncError error) noexcept
SyncError 到稳定、可读的 CLI/Alfred 错误文本。
Provider
提醒事项提供方;both 表示两个目标都执行。
@ reminders
Apple Reminders。
@ both
Reminders + OmniFocus。
std::string provider_name(Provider provider) noexcept
Provider 到 CLI/日志稳定名称。
std::expected< SyncResult, SyncError > sync_task(const TaskReminder &task, Provider provider, const SyncOptions &options={})
将一个任务同步到指定外部应用。
std::string_view sync_error_message(SyncError error) noexcept
SyncError 到稳定可读文本。
std::expected< TaskRecord, TaskError > get_task(TaskId task_id, const DatabasePaths &paths)
以任务 ID 在工作区索引中定位并读取任务快照。
std::expected< std::vector< TaskRecord >, TaskError > list_week_due(const DatabasePaths &paths, std::optional< std::string > anchor_date=std::nullopt)
返回指定自然周内未完成、未归档且有截止日期的任务。
std::expected< TaskResult, TaskError > create_inbox_task(TaskInput input, const CreateTaskOptions &options)
在指定/默认工作区创建 Stacks project=inbox 任务。
std::expected< TaskResult, TaskError > create_task(TargetId target_id, TaskInput input, const CreateTaskOptions &options)
在项目或清单下创建任务,并回滚“任务已写、项目未写”的半成功状态。
std::expected< std::vector< TimerRecord >, TaskError > list_timers(const DatabasePaths &paths)
列出 Timers.json 中的运行中计时快照。
std::expected< std::vector< BoardTarget >, TaskError > list_boards(const DatabasePaths &paths)
按项目名排序加载项目与清单的扁平列表。
std::expected< TaskRecord, TaskError > update_task(TaskId task_id, const TaskPatch &patch, const DatabasePaths &paths)
应用部分更新;修改前后都会验证日期区间与优先级。
std::expected< TaskRecord, TaskError > set_schedule(TaskId task_id, std::string start_date, std::string due_date, const DatabasePaths &paths)
同时更新开始与截止日期,并校验区间顺序。
std::expected< StopTimerResult, TaskError > stop_timer(TaskId task_id, const DatabasePaths &paths)
停止计时、写 Stacks timelog,并在第二步失败时回滚任务文件。
std::expected< DeleteResult, TaskError > delete_task(TaskId task_id, const DatabasePaths &paths)
从所有项目引用移除任务,并把 .task 移入可恢复 .trash。
std::expected< TaskRecord, TaskError > archive_task(TaskId task_id, const DatabasePaths &paths)
写入 archived 时间,保留任务文件供后续恢复。
WeekBounds week_bounds(std::optional< std::string > anchor_date=std::nullopt)
计算锚点日期所在周的周一/周日边界。
std::expected< std::vector< TaskRecord >, TaskError > list_tasks(const DatabasePaths &paths, const TaskListOptions &options={})
扫描所有工作区任务并按目标、完成状态、归档状态和关键词过滤。
TaskError
任务业务的可恢复错误分类;UI 只消费 task_error_message。
std::expected< TaskRecord, TaskError > set_task_done(TaskId task_id, bool done, const DatabasePaths &paths)
写入 Stacks 完成字段或清空完成状态。
std::expected< TimerRecord, TaskError > start_timer(TaskId task_id, const DatabasePaths &paths)
在 Timers.json 中登记运行中计时;重复启动返回分类错误。
std::string_view task_error_message(TaskError error) noexcept
将 TaskError 映射为稳定的 CLI/Alfred 文本。
std::optional< ReminderSync::Provider > parse_provider(std::string_view value)
将 action token 中的提供方文本解析为强类型枚举。
json task_items(const std::vector< TaskManager::TaskRecord > &tasks)
把 TaskRecord 批量映射为任务条目;无结果时返回统一说明行。
std::string icon_timer()
计时使用系统时钟图标。
std::vector< std::string > library_paths(const Context &context)
从 Project.json 读取 library_paths;文件缺失/损坏时返回空列表。
std::expected< std::string, std::string > sync_single(const Context &context, std::string_view task_id, ReminderSync::Provider provider, bool dry_run=false)
读取任务并同步到指定外部应用。
std::string icon_info()
不可选错误/提示使用系统信息图标。
json config_items(const Context &context, const CommandAliases &aliases)
渲染路径配置页:添加、重载与已保存路径说明行。
std::string icon_task()
任务使用勾选语义图标。
std::string icon_project()
项目使用文件夹/项目语义图标。
json item(std::string title, std::string subtitle, std::string argument={}, std::string autocomplete={}, bool valid=true, std::string icon="icons/icon.png")
构造 Alfred item。
std::string app_icon(std::string_view app, std::string_view icon)
引用系统应用包内图标;调用方保证路径属于内置应用。
std::string icon_path(const Context &context)
一级命令使用插件品牌图标。
std::string priority_label(std::string_view value)
优先级值到中文短标签;未知值原样显示,空值显示“无”。
std::string trim(std::string value)
去除 Alfred 查询首尾 ASCII 空白。
std::expected< std::string, std::string > dispatch_action(const Context &context, std::string_view token)
将冒号分隔 action token 分发到领域模块。
std::string system_icon(std::string_view name)
引用 macOS 系统 CoreTypes 图标,避免随包分发第三方素材。
json error_item(std::string message, std::string subtitle={})
构造不可选、带双语错误说明的 Alfred 错误条目。
bool fuzzy_match(std::string_view haystack, std::string_view needle)
大小写无关的“子串优先、子序列兜底”匹配。
std::string icon_stack()
清单使用看板语义图标。
std::string icon_week()
本周日期使用系统日历图标。
std::expected< std::string, std::string > task_error(TaskManager::TaskError error)
TaskManager 错误转换为 WorkflowUI 的 string error 通道。
std::string icon_sync()
同步使用系统同步图标。
std::string provider_label(ReminderSync::Provider provider)
外部同步提供方的显示名;与 action token 保持同一套英文名称。
json task_detail(const TaskManager::TaskRecord &task)
渲染任务详情页。
std::string alias_from_env(std::string_view variable, std::string_view fallback)
从 Alfred workflow 配置环境变量读取命令别名;空白值回退默认别名。
std::pair< std::string, std::string > split_first(std::string_view value, char separator)
按首个分隔符拆分,保留其余部分作为 payload。
json board_items(const std::vector< TaskManager::BoardTarget > &boards, std::string_view query, bool for_add, std::string_view payload={})
渲染项目/清单选择列表。
ParsedTaskInput parse_task_fields(std::string_view text)
最多拆分五个字段,多余分隔符不会自动并入前四个字段。
std::string icon_note()
说明行统一使用插件 Note 图标。
std::vector< TaskManager::BoardTarget > sorted_matching_boards(const std::vector< TaskManager::BoardTarget > &boards, std::string_view query)
按项目/清单标题模糊过滤看板,保留 TaskManager 的稳定排序。
std::string feedback(const json &items)
序列化 Alfred Script Filter 根对象;skipknowledge 防止非操作行污染排序。
std::string icon_inbox()
Inbox 使用收件箱语义图标。
std::string bilingual(std::string_view chinese, std::string_view english)
统一 Alfred 副标题的双语格式:中文在前、英文在后。
std::string base64url_encode(std::string_view value)
URL-safe、无 padding 的 Base64 编码,用于 token 中的用户字段。
std::string filter(const Context &context, std::string_view query)
生成 Alfred Script Filter JSON;任何业务错误都转换为不可选错误条目。
std::expected< std::string, std::string > action(const Context &context, std::string_view token)
执行 Alfred action token;错误文本直接作为 Alfred Large Type/通知内容。
std::string filter_impl(const Context &context, std::string_view raw_query)
std::optional< std::string > base64url_decode(std::string_view value)
Base64url 解码;非法字符返回 nullopt,调用方转换为 action 错误。
void record(ApiName api, LogText input, LogText output, ApiStatus status)
记录一次接口调用。
@ failed
调用失败(错误已记录在 output 文本中)
外部应用同步选项。
bool dry_run
只验证,不调用 osascript。
与外部应用同步所需的最小任务快照。
项目或清单的统一展示目标;WorkflowUI 用它构造 Alfred 条目。
std::string id
项目/清单 ID。
创建任务所需的数据库与 Inbox 默认工作区。
std::optional< std::filesystem::path > inbox_library
Inbox 显式工作区;空则取首路径。
三个运行数据库路径;一次传参避免同类 path 参数顺序错误。
std::filesystem::path stack_db
项目/清单扁平索引。
std::filesystem::path project_db
项目/工作区索引。
可创建任务的看板目标 ID。
任务描述;允许空字符串,不改写 Markdown/HTML 内容。
已校验任务 ID;作为所有任务读写接口的稳定主键。
创建任务输入;日期保留用户文本,由 normalize_date 在业务层校验。
任务列表的过滤条件;默认保留已完成任务、排除已归档任务。
std::optional< TargetId > target
项目、清单或特殊值 inbox。
std::string query
标题/描述/ID/项目/清单的模糊关键词。
部分更新任务;optional 表示“未提供”,clear_* 表示“显式清空”。
std::optional< std::string > start_date
新开始日期表达式。
std::optional< std::string > title
新标题。
std::optional< std::string > description
新描述。
std::optional< std::string > priority
新优先级表达式。
std::optional< std::string > due_date
新截止日期表达式。
跨模块使用的任务只读快照;字段名对应 Stacks 文件与 UI 展示语义。
std::string project_id
所属项目;Inbox 为 inbox。
std::string title
任务标题。
std::filesystem::path library_path
所属工作区。
std::string board_id
backlog、inbox 或 Stack ID。
std::string due_date
Stacks duedate。
bool done
Stacks done。
std::string board_title
清单显示名。
std::string priority
low/medium/high/critical。
std::string id
任务 ID。
std::string project_title
项目显示名。
std::string start_date
Stacks startdate。
任务标题;create/update 时仍会执行 trim 与空值校验。
单次 Alfred 调用所需的运行上下文。
std::filesystem::path workflow_dir
插件根目录,用于图标路径。
std::optional< std::filesystem::path > inbox_library
默认 Inbox 工作区。
TaskManager::DatabasePaths databases
三个运行 JSON。
八个可配置命令别名;字段初始化发生在每次 Alfred 进程启动时。
任务文本字段按“标题|描述|开始|截止|优先级”拆分后的内部值。
std::string priority
第五段,原始优先级表达式。
std::string due_date
第四段,原始日期表达式。
std::string start_date
第三段,原始日期表达式。
接口名强类型。 语义:调用方必须显式构造 ApiName,避免把任意字符串误当接口名。
接口输入/输出文本强类型。 语义:与 ApiName 一样显式构造,参数顺序在编译期被类型约束。