26namespace fs = std::filesystem;
56 const std::filesystem::path& path) {
58 const int stdout_copy = ::dup(STDOUT_FILENO);
59 if (stdout_copy < 0) {
62 if (::dup2(STDERR_FILENO, STDOUT_FILENO) < 0) {
68 ::dup2(stdout_copy, STDOUT_FILENO);
75 std::error_code error;
76 const fs::path executable = fs::weakly_canonical(fs::absolute(argv0), error);
77 return error ? fs::absolute(argv0).parent_path() : executable.parent_path();
82 std::error_code error;
83 start = fs::absolute(start, error);
84 while (!start.empty() && start != start.root_path()) {
85 if (fs::exists(start /
"CMakeLists.txt")) {
88 start = start.parent_path();
90 return fs::current_path();
98 const std::string key(name);
99 const char* value = std::getenv(key.c_str());
100 if (value ==
nullptr) {
103 if (*value ==
'\0') {
106 std::string normalized(value);
107 std::ranges::transform(normalized, normalized.begin(), [](
unsigned char character) {
108 return static_cast<char>(std::tolower(character));
110 return normalized !=
"0" && normalized !=
"false" && normalized !=
"off" &&
111 normalized !=
"unchecked";
120 std::error_code error;
121 fs::create_directories(root /
"data", error);
123 return std::format(
"cannot create data directory: {}", error.message());
125 fs::create_directories(root /
"log", error);
127 return std::format(
"cannot create log directory: {}", error.message());
130 const auto create_if_missing = [](
const fs::path& path, std::string_view content) {
132 if (fs::exists(path)) {
135 std::ofstream output(path, std::ios::binary | std::ios::trunc);
141 return static_cast<bool>(output);
144 if (!create_if_missing(root /
"data" /
"Project.json",
145 R
"({"library_paths":[],"nodes":[],"updateTime":""})")) {
146 return "cannot create data/Project.json";
148 if (!create_if_missing(root /
"data" /
"Stack.json", R
"({"nodes":[],"updateTime":""})")) {
149 return "cannot create data/Stack.json";
151 if (!create_if_missing(root /
"data" /
"Timers.json", R
"({"timers":{}})")) {
152 return "cannot create data/Timers.json";
163 const char* configured_value = std::getenv(
"STACKS_WORKFLOW_DIR");
164 const fs::path configured = configured_value !=
nullptr && *configured_value !=
'\0'
165 ? fs::path(configured_value)
168 std::error_code path_error;
169 const fs::path canonical_configured = fs::weakly_canonical(configured, path_error);
171 if (fs::exists(executable_dir /
"data" /
"Project.json") ||
172 fs::exists(executable_dir /
"info.plist") ||
173 (!canonical_configured.empty() && !path_error && canonical_configured == executable_dir)) {
174 root = executable_dir;
179 fs::path workflow_dir;
180 if (!configured.empty()) {
181 workflow_dir = configured;
182 }
else if (fs::exists(root.parent_path() /
"info.plist")) {
183 workflow_dir = root.parent_path();
188 const fs::path project_db = root /
"data" /
"Project.json";
189 const fs::path stack_db = root /
"data" /
"Stack.json";
190 const fs::path timer_db = root /
"data" /
"Timers.json";
192 std::optional<fs::path> inbox_library;
194 std::ifstream input(project_db);
196 nlohmann::json project;
197 if (input >> project && project.contains(
"library_paths") &&
198 project[
"library_paths"].is_array() && !project[
"library_paths"].empty() &&
199 project[
"library_paths"][0].is_string()) {
200 inbox_library = project[
"library_paths"][0].get<std::string>();
202 }
catch (
const std::exception&) {
207 .workflow_dir = workflow_dir,
208 .databases = {project_db, stack_db, timer_db},
209 .inbox_library = inbox_library,
215 std::println(
"Stacks_Tools 0.2");
216 std::println(
" Stacks_Tools sync <library_path>");
218 " Stacks_Tools add <project_or_stack_id> \"title|description\" [--start d] [--due d] "
220 std::println(
" Stacks_Tools inbox \"title|description\" [--start d] [--due d] [--priority p]");
221 std::println(
" Stacks_Tools boards [query]");
222 std::println(
" Stacks_Tools tasks [--board <id>] [--query <text>] [--todo-only]");
223 std::println(
" Stacks_Tools week [anchor_date]");
224 std::println(
" Stacks_Tools get <task_id>");
226 " Stacks_Tools edit <task_id> [--title t] [--description d] [--start d] [--due d] "
228 std::println(
" Stacks_Tools due <task_id> <date>");
229 std::println(
" Stacks_Tools start <task_id> <date>");
230 std::println(
" Stacks_Tools schedule <task_id> <start_date> <due_date>");
231 std::println(
" Stacks_Tools done|reopen|archive|delete <task_id>");
232 std::println(
" Stacks_Tools timer-start|timer-stop <task_id>");
233 std::println(
" Stacks_Tools timers");
234 std::println(
" Stacks_Tools sync-reminder <task_id> reminders|omnifocus|both [--dry-run]");
235 std::println(
" Stacks_Tools sync-week reminders|omnifocus|both [--dry-run]");
236 std::println(
" Stacks_Tools reminder-script reminders|omnifocus");
237 std::println(
" Stacks_Tools ui <query>");
238 std::println(
" Stacks_Tools action <action_token>");
243 std::println(
"{}\t{}\t{}\t{} / {}\t{}", task.
id, task.
done ?
"done" :
"todo",
256 std::println(
"synced: projects={} stacks={} failed_projects={}", result->project_count,
257 result->stack_count, result->failed_projects.size());
269int run_add(
const Runtime& runtime,
const std::string& target,
const std::string& payload,
270 const std::vector<std::string>& values) {
272 const auto separator = payload.find(
'|');
273 const std::string title = separator == std::string::npos ? payload : payload.substr(0, separator);
274 const std::string description =
275 separator == std::string::npos ? std::string{} : payload.substr(separator + 1);
276 std::optional<std::string> start;
277 std::optional<std::string> due;
278 std::string priority;
280 for (std::size_t index = 0; index + 1 < values.size(); ++index) {
281 if (values[index] ==
"--start") {
282 start = values[index + 1];
283 }
else if (values[index] ==
"--due") {
284 due = values[index + 1];
285 }
else if (values[index] ==
"--priority") {
286 priority = values[index + 1];
293 std::move(start), std::move(due), priority},
300 std::println(
"created: id={} kind={} file={}", result->task_id.value, result->target_kind,
301 result->task_file.string());
313 const std::vector<std::string>& values) {
315 const auto separator = payload.find(
'|');
316 const std::string title = separator == std::string::npos ? payload : payload.substr(0, separator);
317 const std::string description =
318 separator == std::string::npos ? std::string{} : payload.substr(separator + 1);
319 std::optional<std::string> start;
320 std::optional<std::string> due;
321 std::string priority;
322 for (std::size_t index = 0; index + 1 < values.size(); ++index) {
323 if (values[index] ==
"--start") {
324 start = values[index + 1];
325 }
else if (values[index] ==
"--due") {
326 due = values[index + 1];
327 }
else if (values[index] ==
"--priority") {
328 priority = values[index + 1];
334 std::move(due), priority},
340 std::println(
"created: id={} file={}", result->task_id.value, result->task_file.string());
345std::vector<std::string>
arguments(
int argc,
char* argv[],
int start) {
346 std::vector<std::string> result;
347 for (
int index = start; index < argc; ++index) {
348 result.emplace_back(argv[index]);
354std::optional<std::string>
option_value(
const std::vector<std::string>& values,
355 std::string_view option) {
356 for (std::size_t index = 0; index + 1 < values.size(); ++index) {
357 if (values[index] == option) {
358 return values[index + 1];
365bool has_option(
const std::vector<std::string>& values, std::string_view option) {
366 return std::ranges::find(values, option) != values.end();
381 for (
const auto& board : *result) {
382 const std::string text = std::format(
"{} {} {}", board.id, board.project_title, board.title);
383 if (query.empty() || text.find(query) != std::string::npos) {
384 std::println(
"{}\t{}\t{} / {}", board.id, board.kind, board.project_title, board.title);
398 if (
const auto board =
option_value(values,
"--board")) {
401 if (
const auto query =
option_value(values,
"--query")) {
402 options.
query = *query;
410 for (
const auto& task : *result) {
428 for (
const auto& task : *result) {
446 std::println(
"id: {}", result->id);
447 std::println(
"title: {}", result->title);
448 std::println(
"description: {}", result->description);
449 std::println(
"project: {} ({})", result->project_title, result->project_id);
450 std::println(
"board: {} ({})", result->board_title, result->board_id);
451 std::println(
"startdate: {}", result->start_date);
452 std::println(
"duedate: {}", result->due_date);
453 std::println(
"priority: {}", result->priority.empty() ?
"-" : result->priority);
454 std::println(
"done: {} progress: {} completed: {}", result->done, result->progress,
456 std::println(
"archived: {}", result->archived);
457 std::println(
"file: {}", result->file.string());
469 const std::vector<std::string>& values) {
492template <
typename Result>
495 std::println(std::cerr,
"{} failed: {}", command,
499 if constexpr (std::same_as<Result,
500 std::expected<TaskManager::TaskRecord, TaskManager::TaskError>>) {
504 std::println(
"timer started: task={} title={} at={}", result->task_id, result->title,
508 std::println(
"timer stopped: duration={} log_id={} file={}", result->duration_seconds,
509 result->timelog_id, result->task_file.string());
512 std::println(
"task moved to trash: {}", result->trash_file.string());
520 if (value ==
"reminders") {
523 if (value ==
"omnifocus") {
526 if (value ==
"both") {
542 const std::string& provider_text,
bool dry_run) {
546 std::println(std::cerr,
"provider must be reminders, omnifocus or both");
558 .title = task->title,
559 .description = task->description,
560 .due_date = task->due_date,
567 std::println(
"synced: reminders={} omnifocus={} marker={}", result->reminders_ok,
568 result->omnifocus_ok, result->marker);
583 std::println(std::cerr,
"provider must be reminders, omnifocus or both");
591 std::size_t succeeded = 0;
592 for (
const auto& task : *tasks) {
598 .description = task.description,
599 .due_date = task.due_date,
603 std::println(std::cerr,
"sync-week failed at {}: {}", task.id,
609 std::println(
"sync-week ok: tasks={} dry_run={}", succeeded, dry_run);
633 std::println(
"{}", result.error());
636 std::println(
"{}", *result);
642int main(
int argc,
char* argv[]) {
653 std::println(std::cerr,
"startup failed: {}", *layout_error);
660 if (logging_enabled) {
661 const auto log_init =
initialize_log(runtime.root /
"log" /
"interface.log");
663 std::println(std::cerr,
"warning: {}; in-memory log remains active",
666 const std::size_t removed =
668 const std::vector<std::string> invocation(argv, argv + argc);
672 "argc={} argv={}", argc,
673 invocation | std::views::join_with(
' ') | std::ranges::to<std::string>())),
675 runtime.root.string(), removed,
681 const std::string command = argv[1];
684 if (command ==
"ui") {
685 return run_ui(runtime, argc >= 3 ? argv[2] :
"");
687 if (command ==
"action") {
689 std::println(std::cerr,
"action requires an action token");
694 if (command ==
"sync") {
696 std::println(std::cerr,
"sync requires <library_path>");
701 if (command ==
"add") {
703 std::println(std::cerr,
"add requires <target_id> and \"title|description\"");
708 if (command ==
"inbox") {
710 std::println(std::cerr,
"inbox requires \"title|description\"");
715 if (command ==
"boards") {
716 return run_boards(runtime, argc >= 3 ? argv[2] :
"");
718 if (command ==
"tasks") {
721 if (command ==
"week") {
722 return run_week(runtime, argc >= 3 ? std::optional<std::string>(argv[2]) : std::nullopt);
724 if (command ==
"get") {
726 std::println(std::cerr,
"get requires <task_id>");
729 return run_get(runtime, argv[2]);
731 if (command ==
"edit") {
733 std::println(std::cerr,
"edit requires <task_id>");
738 if (command ==
"due" || command ==
"start" || command ==
"schedule") {
740 std::println(std::cerr,
"{} requires task id and date argument(s)", command);
743 if (command ==
"due") {
745 argv[3], runtime.databases));
747 if (command ==
"start") {
749 argv[3], runtime.databases));
752 std::println(std::cerr,
"schedule requires start date and due date");
759 if (command ==
"done" || command ==
"reopen" || command ==
"archive" || command ==
"delete") {
761 std::println(std::cerr,
"{} requires <task_id>", command);
764 if (command ==
"delete") {
768 if (command ==
"archive") {
776 if (command ==
"timer-start" || command ==
"timer-stop") {
778 std::println(std::cerr,
"{} requires <task_id>", command);
781 if (command ==
"timer-start") {
788 if (command ==
"timers") {
791 std::println(std::cerr,
"timers failed: {}",
795 for (
const auto& timer : *timers) {
796 std::println(
"{}\t{}\t{}", timer.task_id, timer.started_at, timer.title);
800 if (command ==
"sync-reminder") {
802 std::println(std::cerr,
"sync-reminder requires <task_id> reminders|omnifocus|both");
808 if (command ==
"sync-week") {
810 std::println(std::cerr,
"sync-week requires reminders|omnifocus|both");
815 if (command ==
"reminder-script") {
817 (std::string_view(argv[2]) !=
"reminders" && std::string_view(argv[2]) !=
"omnifocus")) {
818 std::println(std::cerr,
"reminder-script requires reminders|omnifocus");
821 std::println(
"{}", std::string_view(argv[2]) ==
"reminders"
826 if (command ==
"list") {
828 std::println(std::cerr,
"list requires <project_file>");
831 std::size_t count = 0;
833 std::println(
"- {}",
id);
840 std::println(
"backlog tasks: {}", count);
844 std::println(std::cerr,
"unknown command: {}", command);
847 }
catch (
const std::exception& error) {
849 std::println(std::cerr,
"fatal: {}", error.what());
int main(int argc, char *argv[])
将带截止日期的 Stacks 任务同步到 Apple Reminders / OmniFocus。
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 build_reminders_script()
返回可在 osacompile 中独立编译的 Reminders AppleScript 源。
std::expected< SyncResult, SyncError > sync_task(const TaskReminder &task, Provider provider, const SyncOptions &options={})
将一个任务同步到指定外部应用。
std::string build_omnifocus_script()
返回可在 osacompile 中独立编译的 OmniFocus AppleScript 源。
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< std::size_t, TaskError > for_each_backlog_id(const std::filesystem::path &project_file, Visitor &&visitor)
以函数对象遍历项目 backlog ID。
std::expected< TaskResult, TaskError > create_inbox_task(TaskInput input, const CreateTaskOptions &options)
在指定/默认工作区创建 Stacks project=inbox 任务。
std::expected< TaskRecord, TaskError > set_due_date(TaskId task_id, std::string due_date, const DatabasePaths &paths)
仅更新截止日期;内部复用 update_task。
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 时间,保留任务文件供后续恢复。
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::expected< TaskRecord, TaskError > set_start_date(TaskId task_id, std::string start_date, const DatabasePaths &paths)
仅更新开始日期;内部复用 update_task。
std::string_view task_error_message(TaskError error) noexcept
将 TaskError 映射为稳定的 CLI/Alfred 文本。
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/通知内容。
int run_sync_week(const Runtime &runtime, const std::string &provider_text, bool dry_run)
CLI sync-week:逐个同步本周截止任务。
ReminderSync::Provider parse_provider_or_default(const std::string &value, bool *valid)
将 provider 文本解析为枚举;invalid 通过输出参数报告,避免抛异常。
Runtime discover_runtime(const char *argv0)
发现运行根目录、workflow 目录和默认 Inbox。
std::vector< std::string > arguments(int argc, char *argv[], int start)
从 argv[start..] 复制额外参数;start 之后的参数所有权仍属于进程。
int run_action(const Runtime &runtime, const std::string &token)
CLI action:执行 Alfred 选中的 action token。
int run_edit(const Runtime &runtime, const std::string &id, const std::vector< std::string > &values)
CLI edit:构造三态 TaskPatch 并写回任务。
std::optional< std::string > ensure_runtime_layout(const fs::path &root)
首次运行补齐 data/log 和三个空 JSON。
int run_inbox(const Runtime &runtime, const std::string &payload, const std::vector< std::string > &values)
CLI inbox:在默认/显式工作区创建收件箱任务。
bool environment_flag(std::string_view name, bool fallback)
Alfred 布尔变量的宽容解析。
int run_get(const Runtime &runtime, const std::string &id)
CLI get:输出单个任务的 Stacks 兼容字段。
void print_usage()
打印完整 CLI 帮助;仅用于终端调试,不进入 Alfred Script Filter 输出。
int run_week(const Runtime &runtime, std::optional< std::string > anchor)
CLI week:返回指定锚点周的未完成截止任务。
int run_sync(const Runtime &runtime, const fs::path &path)
CLI sync:把新工作区交给 ProjectSync,并输出项目/清单统计。
void print_task(const TaskManager::TaskRecord &task)
任务列表行的稳定 TSV 格式,便于 awk/人工调试。
int print_task_result(const std::string &command, const Result &result)
统一打印各种 expected 任务操作结果。
std::expected< std::monostate, interface_log::LogError > initialize_log(const std::filesystem::path &path)
初始化 bqlog,并临时把 stdout 指向 stderr。
bool has_option(const std::vector< std::string > &values, std::string_view option)
检查布尔开关参数是否存在,例如 --dry-run、--todo-only。
int run_tasks(const Runtime &runtime, const std::vector< std::string > &values)
CLI tasks:把 –board/–query/–todo-only 映射为过滤条件。
fs::path find_source_root(fs::path start)
源码运行时向上查找 CMakeLists.txt,作为项目根目录。
std::optional< std::string > option_value(const std::vector< std::string > &values, std::string_view option)
返回第一个 --option value 的值;缺失返回 nullopt。
int run_ui(const Runtime &runtime, const std::string &query)
CLI ui:把查询交给 WorkflowUI。
int run_boards(const Runtime &runtime, std::string query)
CLI boards:按显示名/ID 过滤项目与清单。
int run_add(const Runtime &runtime, const std::string &target, const std::string &payload, const std::vector< std::string > &values)
CLI add:向项目或清单创建任务。
fs::path executable_directory(const char *argv0)
解析 argv[0] 的绝对目录;weakly_canonical 失败时回退到 absolute。
int run_sync_reminder(const Runtime &runtime, const std::string &id, const std::string &provider_text, bool dry_run)
CLI sync-reminder:读取任务快照并同步到指定外部应用。
void set_enabled(bool enabled)
启用/禁用接口记录(内存与文件同时生效)。
std::size_t cleanup_old_logs(const std::filesystem::path &log_path, int months_back=3)
删除超过保留期的接口日志与每日 trace 日志。
std::string_view log_error_message(LogError error) noexcept
LogError 到可读消息(供 CLI / 测试使用)。
void record(ApiName api, LogText input, LogText output, ApiStatus status)
记录一次接口调用。
std::expected< std::monostate, LogError > init(const std::filesystem::path &log_path="log/interface.log")
初始化 bqlog TextFileAppender(可重复调用;路径变化会重建后端)。
constexpr std::string_view kDeveloperWebsite
开发者网站水印;每次初始化 bqlog 与 CLI 调用日志都会写出。
bool dry_run
只验证,不调用 osascript。
三个运行数据库路径;一次传参避免同类 path 参数顺序错误。
std::filesystem::path stack_db
项目/清单扁平索引。
std::filesystem::path project_db
项目/工作区索引。
可恢复删除结果;trash_file 指向 tasks/.trash 中的副本。
停止计时结果;timelog_id 可在任务文件中定位新记录。
任务描述;允许空字符串,不改写 Markdown/HTML 内容。
已校验任务 ID;作为所有任务读写接口的稳定主键。
任务列表的过滤条件;默认保留已完成任务、排除已归档任务。
std::optional< TargetId > target
项目、清单或特殊值 inbox。
std::string query
标题/描述/ID/项目/清单的模糊关键词。
bool include_done
是否显示已完成任务。
部分更新任务;optional 表示“未提供”,clear_* 表示“显式清空”。
bool clear_due_date
从 Stacks 文件移除 duedate。
std::optional< std::string > start_date
新开始日期表达式。
std::optional< std::string > title
新标题。
bool clear_start_date
从 Stacks 文件移除 startdate。
bool clear_priority
从 Stacks 文件移除 priority。
std::optional< std::string > description
新描述。
std::optional< std::string > priority
新优先级表达式。
std::optional< std::string > due_date
新截止日期表达式。
跨模块使用的任务只读快照;字段名对应 Stacks 文件与 UI 展示语义。
std::string due_date
Stacks duedate。
std::string board_title
清单显示名。
std::string project_title
项目显示名。
任务标题;create/update 时仍会执行 trim 与空值校验。
一次进程调用共享的根目录、数据库路径和默认 Inbox 工作区。
fs::path root
data/log 所在根目录。
std::optional< fs::path > inbox_library
library_paths[0]。
fs::path workflow_dir
Alfred workflow 根目录。
TaskManager::DatabasePaths databases
三个运行 JSON。
WorkflowUI::Context ui_context() const
把 CLI Runtime 映射为 WorkflowUI 的只读调用上下文。
接口名强类型。 语义:调用方必须显式构造 ApiName,避免把任意字符串误当接口名。
接口输入/输出文本强类型。 语义:与 ApiName 一样显式构造,参数顺序在编译期被类型约束。