OmniStacksStaticModules 2.0.0
Loading...
Searching...
No Matches
TaskManager.cppm
Go to the documentation of this file.
1
24module;
25
26#include <fcntl.h>
27#include <sys/file.h>
28#include <unistd.h>
29
30#include <algorithm>
31#include <atomic>
32#include <cassert>
33#include <cctype>
34#include <chrono>
35#include <cstdint>
36#include <ctime>
37#include <filesystem>
38#include <format>
39#include <fstream>
40#include <optional>
41#include <random>
42#include <string>
43#include <string_view>
44#include <unordered_map>
45#include <utility>
46#include <vector>
47
48#include "json.hpp"
49
50export module TaskManager;
51
52import std;
53import InterfaceLog;
54
55export namespace TaskManager {
56
57// ---- 公共值类型与查询模型 ------------------------------------------------
58
60inline constexpr std::size_t kTaskIdLength = 20;
61
66struct TargetId {
67 std::string value;
68 explicit TargetId(std::string id) : value(std::move(id)) {}
69};
70
72struct TaskId {
73 std::string value;
74 explicit TaskId(std::string id) : value(std::move(id)) {}
75};
76
78struct TaskTitle {
79 std::string value;
80 explicit TaskTitle(std::string title) : value(std::move(title)) {}
81};
82
85 std::string value;
86 explicit TaskDescription(std::string description) : value(std::move(description)) {}
87};
88
91 std::filesystem::path project_db{"data/Project.json"};
92 std::filesystem::path stack_db{"data/Stack.json"};
93 std::filesystem::path timer_db{"data/Timers.json"};
94};
95
99 std::optional<std::filesystem::path> inbox_library;
100};
101
103struct TaskInput {
104 TaskTitle title{std::string{}};
106 std::optional<std::string> start_date;
107 std::optional<std::string> due_date;
108 std::string priority;
109};
110
115struct TaskPatch {
116 std::optional<std::string> title;
117 std::optional<std::string> description;
118 std::optional<std::string> start_date;
119 std::optional<std::string> due_date;
120 std::optional<std::string> priority;
121 bool clear_start_date = false;
122 bool clear_due_date = false;
123 bool clear_priority = false;
124};
125
128 std::optional<TargetId> target;
129 std::string query;
130 bool include_done = true;
131 bool include_archived = false;
132};
133
136 std::string id;
137 std::string project_id;
138 std::string title;
139 std::string project_title;
140 std::string kind;
141 std::filesystem::path project_file;
142 std::filesystem::path library_path;
143};
144
147 std::string id;
148 std::string title;
149 std::string description;
150 std::string project_id;
151 std::string project_title;
152 std::string board_id;
153 std::string board_title;
154 std::string start_date;
155 std::string due_date;
156 std::string priority;
157 std::string completed;
158 std::string archived;
159 bool done = false;
160 int progress = 0;
161 std::filesystem::path file;
162 std::filesystem::path library_path;
163};
164
167 TaskId task_id{std::string{}};
168 std::filesystem::path task_file;
169 std::string target_kind;
170 std::string target_id;
171};
172
175 std::string task_id;
176 std::string title;
177 std::string started_at;
178 std::int64_t start_epoch = 0;
179 std::filesystem::path task_file;
180};
181
184 TaskId task_id{std::string{}};
185 std::int64_t duration_seconds = 0;
186 std::string timelog_id;
187 std::filesystem::path task_file;
188};
189
192 TaskId task_id{std::string{}};
193 std::filesystem::path trash_file;
194 std::size_t project_files_updated = 0;
195};
196
199 std::string first;
200 std::string last;
201};
202
228
229// ---- 创建与查询 API -------------------------------------------------------
230
232TaskId generate_id();
233
235std::expected<TaskResult, TaskError> create_task(TargetId target_id, TaskInput input,
236 const CreateTaskOptions& options);
237
239std::expected<TaskResult, TaskError> create_inbox_task(TaskInput input,
240 const CreateTaskOptions& options);
241
243std::expected<std::vector<BoardTarget>, TaskError> list_boards(const DatabasePaths& paths);
244
246std::expected<std::vector<TaskRecord>, TaskError> list_tasks(const DatabasePaths& paths,
247 const TaskListOptions& options = {});
248
250std::expected<std::vector<TaskRecord>, TaskError> list_week_due(
251 const DatabasePaths& paths, std::optional<std::string> anchor_date = std::nullopt);
252
254std::expected<TaskRecord, TaskError> get_task(TaskId task_id, const DatabasePaths& paths);
255
256// ---- 编辑与状态 API -------------------------------------------------------
257
259std::expected<TaskRecord, TaskError> update_task(TaskId task_id, const TaskPatch& patch,
260 const DatabasePaths& paths);
261
263std::expected<TaskRecord, TaskError> set_due_date(TaskId task_id, std::string due_date,
264 const DatabasePaths& paths);
265
267std::expected<TaskRecord, TaskError> set_start_date(TaskId task_id, std::string start_date,
268 const DatabasePaths& paths);
269
271std::expected<TaskRecord, TaskError> set_schedule(TaskId task_id, std::string start_date,
272 std::string due_date, const DatabasePaths& paths);
273
275std::expected<TaskRecord, TaskError> set_task_done(TaskId task_id, bool done,
276 const DatabasePaths& paths);
277
279std::expected<TaskRecord, TaskError> archive_task(TaskId task_id, const DatabasePaths& paths);
280
282std::expected<DeleteResult, TaskError> delete_task(TaskId task_id, const DatabasePaths& paths);
283
284// ---- 计时 API -------------------------------------------------------------
285
287std::expected<TimerRecord, TaskError> start_timer(TaskId task_id, const DatabasePaths& paths);
288
290std::expected<StopTimerResult, TaskError> stop_timer(TaskId task_id, const DatabasePaths& paths);
291
293std::expected<std::vector<TimerRecord>, TaskError> list_timers(const DatabasePaths& paths);
294
295// ---- 日期工具与错误映射 ---------------------------------------------------
296
298std::expected<std::string, TaskError> normalize_date(
299 std::string_view value, std::optional<std::string> anchor_date = std::nullopt);
300
302WeekBounds week_bounds(std::optional<std::string> anchor_date = std::nullopt);
303
305std::string_view task_error_message(TaskError error) noexcept;
306
312template <std::invocable<std::string_view> Visitor>
313std::expected<std::size_t, TaskError> for_each_backlog_id(const std::filesystem::path& project_file,
314 Visitor&& visitor) {
315 std::ifstream input(project_file, std::ios::binary);
316 try {
317 nlohmann::json content;
318 if (!(input >> content) || !content.is_object()) {
319 return std::unexpected(TaskError::project_file_unreadable);
320 }
321 if (!content.contains("backlog") || !content["backlog"].is_array()) {
322 return std::size_t{0};
323 }
324 std::size_t count = 0;
325 for (const auto& item : content["backlog"]) {
326 if (item.is_string()) {
327 std::invoke(visitor, item.get_ref<const std::string&>());
328 ++count;
329 }
330 }
331 return count;
332 } catch (const std::exception&) {
333 return std::unexpected(TaskError::project_file_unreadable);
334 }
335}
336
337} // namespace TaskManager
338
339namespace TaskManager {
340
341namespace {
342
343namespace fs = std::filesystem;
344using json = nlohmann::json;
345
348 std::string id;
349 std::string title;
350 fs::path path;
351 fs::path library_path;
352};
353
356 std::string project_id;
357 std::string project_title;
358 std::string board_id;
359 std::string board_title;
360};
361
367
373 public:
374 explicit ScopedFileLock(const fs::path& path) {
375 std::error_code error;
376 fs::create_directories(path.parent_path(), error);
377 if (error) {
378 return;
379 }
380 descriptor_ = ::open(path.c_str(), O_CREAT | O_RDWR, 0600);
381 if (descriptor_ < 0) {
382 return;
383 }
384 if (::flock(descriptor_, LOCK_EX | LOCK_NB) != 0) {
385 ::close(descriptor_);
386 descriptor_ = -1;
387 }
388 }
389
391 if (descriptor_ >= 0) {
392 ::flock(descriptor_, LOCK_UN);
393 ::close(descriptor_);
394 }
395 }
396
399
400 [[nodiscard]] bool ok() const noexcept { return descriptor_ >= 0; }
401
402 private:
403 int descriptor_ = -1;
404};
405
407std::atomic<std::uint64_t> g_sequence{0};
408
410std::string trim(std::string value) {
411 const auto not_space = [](unsigned char character) { return !std::isspace(character); };
412 value.erase(value.begin(), std::ranges::find_if(value, not_space));
413 value.erase(std::ranges::find_if(value.rbegin(), value.rend(), not_space).base(), value.end());
414 return value;
415}
416
418std::string lower_ascii(std::string value) {
419 std::ranges::transform(value, value.begin(), [](unsigned char character) {
420 return static_cast<char>(std::tolower(character));
421 });
422 return value;
423}
424
426bool valid_identifier(std::string_view value) {
427 return !value.empty() && std::ranges::all_of(value, [](char character) {
428 return std::isalnum(static_cast<unsigned char>(character)) || character == '-' ||
429 character == '_';
430 });
431}
432
434std::string local_timestamp() {
435 const std::time_t now = std::time(nullptr);
436 std::tm local{};
437 if (localtime_r(&now, &local) == nullptr) {
438 return {};
439 }
440 char buffer[32]{};
441 if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local) == 0) {
442 return {};
443 }
444 return buffer;
445}
446
448std::string utc_timestamp() {
449 const auto now = std::chrono::system_clock::now();
450 const auto micros =
451 std::chrono::duration_cast<std::chrono::microseconds>(now.time_since_epoch()) %
452 std::chrono::seconds(1);
453 const std::time_t seconds = std::chrono::system_clock::to_time_t(now);
454 std::tm utc{};
455 if (gmtime_r(&seconds, &utc) == nullptr) {
456 return {};
457 }
458 char buffer[48]{};
459 if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &utc) == 0) {
460 return {};
461 }
462 return std::format("{}.{:06}.000Z", buffer, micros.count());
463}
464
466std::string local_date() {
467 const std::string timestamp = local_timestamp();
468 return timestamp.size() >= 10 ? timestamp.substr(0, 10) : std::string{};
469}
470
472fs::path normalized_path(const fs::path& value) {
473 std::error_code error;
474 const fs::path canonical = fs::weakly_canonical(value, error);
475 if (!error) {
476 return canonical;
477 }
478 return fs::absolute(value, error).lexically_normal();
479}
480
486std::expected<json, TaskError> load_json(const fs::path& file, TaskManager::TaskError parse_error) {
487 std::ifstream input(file, std::ios::binary);
488 try {
489 json content;
490 if (!(input >> content)) {
491 return std::unexpected(parse_error);
492 }
493 return content;
494 } catch (const std::exception&) {
495 return std::unexpected(parse_error);
496 }
497}
498
503bool atomic_write_json(const fs::path& target, const json& value) {
504 std::error_code error;
505 fs::create_directories(target.parent_path(), error);
506 if (error) {
507 return false;
508 }
509 const fs::path temporary =
510 target.parent_path() /
511 std::format(".{}.tmp.{}.{}", target.filename().string(), ::getpid(),
512 std::chrono::steady_clock::now().time_since_epoch().count());
513 {
514 std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
515 if (!output) {
516 return false;
517 }
518 output << value.dump(4) << '\n';
519 output.flush();
520 if (!output) {
521 fs::remove(temporary, error);
522 return false;
523 }
524 }
525 fs::rename(temporary, target, error);
526 if (error) {
527 fs::remove(temporary, error);
528 return false;
529 }
530 return true;
531}
532
534std::string string_value(const json& object, std::string_view key, std::string fallback = {}) {
535 if (object.is_object() && object.contains(key) && object[key].is_string()) {
536 return object[key].get<std::string>();
537 }
538 return fallback;
539}
540
542int int_value(const json& object, std::string_view key, int fallback = 0) {
543 if (object.is_object() && object.contains(key) && object[key].is_number_integer()) {
544 return object[key].get<int>();
545 }
546 return fallback;
547}
548
550bool bool_value(const json& object, std::string_view key, bool fallback = false) {
551 if (object.is_object() && object.contains(key) && object[key].is_boolean()) {
552 return object[key].get<bool>();
553 }
554 return fallback;
555}
556
558void erase_json_id(json& array, std::string_view task_id) {
559 if (!array.is_array()) {
560 return;
561 }
562 array.erase(std::remove_if(array.begin(), array.end(),
563 [task_id](const json& item) {
564 return item.is_string() &&
565 item.get_ref<const std::string&>() == task_id;
566 }),
567 array.end());
568}
569
571std::optional<std::chrono::year_month_day> parse_iso_date(std::string_view value) {
572 if (value.size() != 10 || value[4] != '-' || value[7] != '-') {
573 return std::nullopt;
574 }
575 int parts[3]{};
576 std::size_t part = 0;
577 for (std::size_t index = 0; index < value.size();) {
578 if (value[index] == '-') {
579 ++index;
580 ++part;
581 continue;
582 }
583 if (!std::isdigit(static_cast<unsigned char>(value[index])) || part >= 3) {
584 return std::nullopt;
585 }
586 int number = 0;
587 std::size_t digits = 0;
588 while (index < value.size() && std::isdigit(static_cast<unsigned char>(value[index]))) {
589 number = number * 10 + (value[index] - '0');
590 ++index;
591 ++digits;
592 }
593 if (digits == 0) {
594 return std::nullopt;
595 }
596 parts[part] = number;
597 }
598 const std::chrono::year_month_day date{
599 std::chrono::year{parts[0]},
600 std::chrono::month{static_cast<unsigned>(parts[1])},
601 std::chrono::day{static_cast<unsigned>(parts[2])},
602 };
603 if (!date.ok()) {
604 return std::nullopt;
605 }
606 return date;
607}
608
610std::string format_date(std::chrono::year_month_day date) {
611 return std::format("{:04d}-{:02d}-{:02d}", static_cast<int>(date.year()),
612 static_cast<unsigned>(date.month()), static_cast<unsigned>(date.day()));
613}
614
616std::string effective_anchor(const std::optional<std::string>& anchor) {
617 if (anchor) {
618 const auto parsed = parse_iso_date(*anchor);
619 if (parsed) {
620 return *anchor;
621 }
622 }
623 return local_date();
624}
625
630std::optional<std::string> normalize_priority_value(std::string_view value) {
631 const std::string normalized = lower_ascii(trim(std::string(value)));
632 if (normalized.empty() || normalized == "none" || normalized == "default" || normalized == "无" ||
633 normalized == "默认") {
634 return std::string{};
635 }
636 if (normalized == "low" || normalized == "低" || normalized == "低优先级") {
637 return "low";
638 }
639 if (normalized == "medium" || normalized == "mid" || normalized == "中" ||
640 normalized == "中优先级") {
641 return "medium";
642 }
643 if (normalized == "high" || normalized == "高" || normalized == "高优先级") {
644 return "high";
645 }
646 if (normalized == "critical" || normalized == "urgent" || normalized == "紧急" ||
647 normalized == "紧急优先级") {
648 return "critical";
649 }
650 return std::nullopt;
651}
652
654std::expected<std::vector<ProjectNode>, TaskError> load_projects(const DatabasePaths& paths) {
655 // Project.json 是唯一可信的项目文件索引;字段不完整或重复的节点直接跳过。
656 const auto content = load_json(paths.project_db, TaskError::project_db_unreadable);
657 if (!content) {
658 return std::unexpected(content.error());
659 }
660 if (!content->is_object() || !content->contains("nodes") || !(*content)["nodes"].is_array()) {
661 return std::unexpected(TaskError::project_db_unreadable);
662 }
663
664 std::vector<ProjectNode> projects;
665 std::unordered_map<std::string, bool> seen;
666 for (const auto& node : (*content)["nodes"]) {
667 if (!node.is_object()) {
668 continue;
669 }
670 const std::string id = string_value(node, "id");
671 const std::string path = string_value(node, "path");
672 // ID 必须可安全用于 Alfred token;路径必须存在,否则交由调用方忽略。
673 if (!valid_identifier(id) || path.empty() || seen.contains(id)) {
674 continue;
675 }
676 ProjectNode project{
677 .id = id,
678 .title = string_value(node, "title", "Untitled"),
679 .path = normalized_path(path),
680 .library_path = normalized_path(string_value(node, "library_path", "")),
681 };
682 if (project.library_path.empty()) {
683 // 旧索引没有 library_path 时,以项目文件父目录作为兼容回退。
684 project.library_path = project.path.parent_path();
685 }
686 seen.emplace(id, true);
687 projects.push_back(std::move(project));
688 }
689 return projects;
690}
691
693std::expected<std::set<fs::path>, TaskError> load_libraries(const DatabasePaths& paths) {
694 // 先取显式 library_paths,再合并项目节点推导路径,兼容索引演进前后的数据。
695 std::set<fs::path> libraries;
696 if (fs::exists(paths.project_db)) {
697 const auto content = load_json(paths.project_db, TaskError::project_db_unreadable);
698 if (!content) {
699 return std::unexpected(content.error());
700 }
701 if (content->contains("library_paths") && (*content)["library_paths"].is_array()) {
702 for (const auto& item : (*content)["library_paths"]) {
703 if (item.is_string() && !item.get<std::string>().empty()) {
704 libraries.insert(normalized_path(item.get<std::string>()));
705 }
706 }
707 }
708 }
709 const auto projects = load_projects(paths);
710 if (projects) {
711 for (const auto& project : *projects) {
712 libraries.insert(project.library_path);
713 }
714 }
715 return libraries;
716}
717
722std::expected<std::vector<BoardTarget>, TaskError> load_boards_impl(const DatabasePaths& paths) {
723 // 项目索引给出文件定位;每个 .project 实际内容决定最终标题和 Stack 列表。
724 const auto projects = load_projects(paths);
725 if (!projects) {
726 return std::unexpected(projects.error());
727 }
728
729 std::vector<BoardTarget> result;
730 for (const auto& project : *projects) {
731 // 文件可能被用户删除;静默跳过,让下一轮 sync 重建索引。
732 if (!fs::exists(project.path)) {
733 continue;
734 }
735 const auto project_json = load_json(project.path, TaskError::project_file_unreadable);
736 // 看板列表是查询入口,单坏项目不应让整个 Alfred 页面不可用。
737 if (!project_json || !project_json->is_object()) {
738 continue;
739 }
740 const std::string project_title = string_value(*project_json, "title", project.title);
741 // 项目自身也作为一级可选目标,允许任务直接进入 backlog。
742 result.push_back({
743 .id = project.id,
744 .project_id = project.id,
745 .title = project_title,
746 .project_title = project_title,
747 .kind = "project",
748 .project_file = project.path,
749 .library_path = project.library_path,
750 });
751 if (project_json->contains("stacks") && (*project_json)["stacks"].is_array()) {
752 for (const auto& stack : (*project_json)["stacks"]) {
753 if (!stack.is_object()) {
754 continue;
755 }
756 const std::string stack_id = string_value(stack, "id");
757 if (!valid_identifier(stack_id)) {
758 // 无效 Stack ID 不进入 UI,避免生成不可执行的 action token。
759 continue;
760 }
761 result.push_back({
762 .id = stack_id,
763 .project_id = project.id,
764 .title = string_value(stack, "title", "Untitled"),
765 .project_title = project_title,
766 .kind = "stack",
767 .project_file = project.path,
768 .library_path = project.library_path,
769 });
770 }
771 }
772 }
773 // 同级项目按标题稳定排序,项目节点排在所属清单之前。
774 std::ranges::sort(result, {}, [](const BoardTarget& board) {
775 return std::tuple(board.project_title, board.kind == "stack", board.title);
776 });
777 return result;
778}
779
781std::expected<BoardTarget, TaskError> resolve_board(std::string_view id,
782 const DatabasePaths& paths) {
783 // 创建任务必须找到唯一的项目/清单目标;找不到时返回 target_not_found。
784 const auto boards = load_boards_impl(paths);
785 if (!boards) {
786 return std::unexpected(boards.error());
787 }
788 const auto iterator =
789 std::ranges::find_if(*boards, [id](const BoardTarget& board) { return board.id == id; });
790 if (iterator == boards->end()) {
791 return std::unexpected(TaskError::target_not_found);
792 }
793 return *iterator;
794}
795
800std::expected<std::unordered_map<std::string, TaskLocation>, TaskError> load_memberships(
801 const DatabasePaths& paths) {
802 // 成员关系用于把扫描到的 .task 映射回项目/清单;任务文件本身不保存 board id。
803 std::unordered_map<std::string, TaskLocation> result;
804 const auto projects = load_projects(paths);
805 if (!projects) {
806 return std::unexpected(projects.error());
807 }
808 for (const auto& project : *projects) {
809 const auto content = load_json(project.path, TaskError::project_file_unreadable);
810 if (!content || !content->is_object()) {
811 continue;
812 }
813 const std::string project_title = string_value(*content, "title", project.title);
814 if (content->contains("backlog") && (*content)["backlog"].is_array()) {
815 // backlog 中的所有任务统一归入虚拟 board "backlog"。
816 for (const auto& item : (*content)["backlog"]) {
817 if (item.is_string()) {
818 result.try_emplace(item.get<std::string>(),
819 TaskLocation{project.id, project_title, "backlog", "Backlog"});
820 }
821 }
822 }
823 if (!content->contains("stacks") || !(*content)["stacks"].is_array()) {
824 continue;
825 }
826 for (const auto& stack : (*content)["stacks"]) {
827 // 同一任务若被多个清单引用,保留首次出现的清单归属。
828 if (!stack.is_object() || !stack.contains("tasks") || !stack["tasks"].is_array()) {
829 continue;
830 }
831 const std::string stack_id = string_value(stack, "id");
832 const std::string stack_title = string_value(stack, "title", "Untitled");
833 for (const auto& item : stack["tasks"]) {
834 if (item.is_string()) {
835 result.try_emplace(item.get<std::string>(),
836 TaskLocation{project.id, project_title, stack_id, stack_title});
837 }
838 }
839 }
840 }
841 return result;
842}
843
845std::expected<LoadedTask, TaskError> load_task_from_file(
846 const fs::path& file, const std::filesystem::path& library_path,
847 const std::unordered_map<std::string, TaskLocation>& memberships) {
848 // 以 JSON id 为准,缺失时回退文件名 stem;两者都必须通过 ID 校验。
849 const auto content = load_json(file, TaskError::task_file_unreadable);
850 if (!content || !content->is_object()) {
851 return std::unexpected(TaskError::task_file_unreadable);
852 }
853 const std::string id = string_value(*content, "id", file.stem().string());
854 if (!valid_identifier(id)) {
855 return std::unexpected(TaskError::invalid_task_id);
856 }
857
858 TaskLocation location{
859 .project_id = string_value(*content, "project"),
860 .project_title = "Inbox",
861 .board_id = "inbox",
862 .board_title = "Inbox",
863 };
864 if (const auto found = memberships.find(id); found != memberships.end()) {
865 // 项目/清单成员关系优先于任务文件里的 project 字段,适配 Inbox 与旧数据。
866 location = found->second;
867 }
868
869 // 仅把 Alfred/TaskManager 需要的字段复制到 snapshot;raw 保留全部未知字段。
870 TaskRecord record{
871 .id = id,
872 .title = string_value(*content, "title"),
873 .description = string_value(*content, "description"),
874 .project_id = location.project_id,
875 .project_title = location.project_title,
876 .board_id = location.board_id,
877 .board_title = location.board_title,
878 .start_date = string_value(*content, "startdate"),
879 .due_date = string_value(*content, "duedate"),
880 .priority = string_value(*content, "priority"),
881 .completed = string_value(*content, "completed"),
882 .archived = string_value(*content, "archived"),
883 .done = bool_value(*content, "done"),
884 .progress = int_value(*content, "progress"),
885 .file = normalized_path(file),
886 .library_path = normalized_path(library_path),
887 };
888 return LoadedTask{std::move(record), std::move(*content)};
889}
890
892std::expected<std::vector<TaskRecord>, TaskError> scan_tasks(const DatabasePaths& paths) {
893 // 扫描范围来自 Project.json 的工作区集合;每个工作区只看 tasks/ 顶层。
894 const auto libraries = load_libraries(paths);
895 if (!libraries) {
896 return std::unexpected(libraries.error());
897 }
898 const auto memberships = load_memberships(paths);
899 if (!memberships) {
900 return std::unexpected(memberships.error());
901 }
902
903 std::vector<TaskRecord> records;
904 std::set<fs::path> seen_files;
905 for (const auto& library : *libraries) {
906 const fs::path task_directory = library / "tasks";
907 if (!fs::exists(task_directory) || !fs::is_directory(task_directory)) {
908 continue;
909 }
910 std::error_code error;
911 // directory_iterator 的单目录错误只跳过该目录,不影响其他工作区。
912 for (const auto& entry : fs::directory_iterator(task_directory, error)) {
913 if (error || !entry.is_regular_file(error) || entry.path().extension() != ".task") {
914 continue;
915 }
916 const fs::path normalized = normalized_path(entry.path());
917 // 同一工作区可能因相对路径别名出现两次;按规范绝对路径去重。
918 if (!seen_files.insert(normalized).second) {
919 continue;
920 }
921 const auto loaded = load_task_from_file(normalized, library, *memberships);
922 if (loaded) {
923 records.push_back(loaded->record);
924 }
925 }
926 }
927 return records;
928}
929
931std::expected<LoadedTask, TaskError> find_task_raw(TaskId task_id, const DatabasePaths& paths) {
932 // 任务 ID 已是全局主键;按工作区顺序定位文件即可,无需构造全量任务表。
933 if (!valid_identifier(task_id.value)) {
934 return std::unexpected(TaskError::invalid_task_id);
935 }
936 const auto libraries = load_libraries(paths);
937 if (!libraries) {
938 return std::unexpected(libraries.error());
939 }
940 const auto memberships = load_memberships(paths);
941 if (!memberships) {
942 return std::unexpected(memberships.error());
943 }
944 for (const auto& library : *libraries) {
945 // 采用 tasks/<id>.task 约定;不递归搜索,避免误命中 .trash 中的副本。
946 const fs::path candidate = library / "tasks" / (task_id.value + ".task");
947 if (fs::exists(candidate)) {
948 return load_task_from_file(candidate, library, *memberships);
949 }
950 }
951 return std::unexpected(TaskError::task_not_found);
952}
953
958std::expected<TaskRecord, TaskError> save_task(TaskId task_id, json& raw,
959 const DatabasePaths& paths) {
960 // 写回前刷新 updated;随后重新读取,确保返回值反映磁盘上的最终字段。
961 raw["updated"] = local_timestamp();
962 const auto task = find_task_raw(task_id, paths);
963 if (!task) {
964 return std::unexpected(task.error());
965 }
966 if (!atomic_write_json(task->record.file, raw)) {
967 return std::unexpected(TaskError::write_failure);
968 }
969 // 重新加载成员关系,避免调用方拿到只更新了一半的 TaskRecord。
970 const auto libraries = load_libraries(paths);
971 const auto memberships = load_memberships(paths);
972 if (!libraries || !memberships) {
973 return std::unexpected(!libraries ? libraries.error() : memberships.error());
974 }
975 return load_task_from_file(task->record.file, task->record.library_path, *memberships)
976 .transform([](LoadedTask&& loaded) { return std::move(loaded.record); });
977}
978
980bool task_matches_query(const TaskRecord& task, std::string_view query) {
981 // 空 query 是通配;搜索只针对用户可见字段,不扫描 JSON 内部键。
982 if (query.empty()) {
983 return true;
984 }
985 const std::string needle = lower_ascii(std::string(query));
986 const std::string haystack =
987 lower_ascii(std::format("{} {} {} {} {}", task.title, task.description, task.id,
988 task.project_title, task.board_title));
989 return haystack.find(needle) != std::string::npos;
990}
991
992} // namespace
993
995 // 64 字符字母表恰好让每个字符占用 6 bit;ID 可安全用于 Alfred token 和文件名。
996 static constexpr std::string_view kAlphabet =
997 "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
998 static_assert(kAlphabet.size() == 64);
999
1000 const auto now_ns =
1001 static_cast<std::uint64_t>(std::chrono::steady_clock::now().time_since_epoch().count());
1002 std::string id(kTaskIdLength, ' ');
1003
1004 // 前 8 字符编码 steady_clock 低位:提供时间局部性,但不承担唯一性保证。
1005 auto time_bits = now_ns;
1006 for (std::size_t index = 0; index < 8; ++index) {
1007 id[index] = kAlphabet[static_cast<std::size_t>(time_bits & 63U)];
1008 time_bits >>= 6;
1009 }
1010
1011 // 后 12 字符使用 seed_seq 混合时间、进程内序号和 random_device。
1012 // seed_seq 消除多个低熵来源之间的相关性,降低并发碰撞概率。
1013 const std::uint64_t sequence = g_sequence.fetch_add(1, std::memory_order_relaxed);
1014 std::random_device random;
1015 const auto mix = [](std::uint64_t value) {
1016 return static_cast<std::uint32_t>(value ^ (value >> 32));
1017 };
1018 std::seed_seq seed{mix(now_ns), mix(sequence), random()};
1019 std::mt19937_64 generator(seed);
1020 std::uniform_int_distribution<int> distribution(0, 63);
1021 for (std::size_t index = 8; index < id.size(); ++index) {
1022 id[index] = kAlphabet[static_cast<std::size_t>(distribution(generator))];
1023 }
1024 return TaskId(std::move(id));
1025}
1026
1027std::expected<TaskResult, TaskError> create_task(TargetId target_id, TaskInput input,
1028 const CreateTaskOptions& options) {
1029 // 先冻结输入摘要;后续 raw JSON 可能移动/修改局部值,不影响日志可追踪性。
1030 const std::string input_log = std::format(
1031 "target={} title={} start={} due={} priority={}", target_id.value, input.title.value,
1032 input.start_date.value_or(""), input.due_date.value_or(""), input.priority);
1033 const auto operation = [&]() -> std::expected<TaskResult, TaskError> {
1034 // 校验阶段:ID、标题、优先级、目标看板和项目文件逐项失败即返回。
1035 if (!valid_identifier(target_id.value)) {
1036 return std::unexpected(TaskError::invalid_target_id);
1037 }
1038 const std::string title = trim(input.title.value);
1039 if (title.empty()) {
1040 return std::unexpected(TaskError::empty_title);
1041 }
1042 const auto priority = normalize_priority_value(input.priority);
1043 if (!priority) {
1044 // 未知优先级直接拒绝,不能静默降级为“无优先级”。
1045 return std::unexpected(TaskError::invalid_priority);
1046 }
1047 const auto board = resolve_board(target_id.value, options.databases);
1048 if (!board) {
1049 return std::unexpected(board.error());
1050 }
1051 if (!fs::exists(board->project_file)) {
1052 return std::unexpected(TaskError::project_file_not_found);
1053 }
1054 const auto project_json = load_json(board->project_file, TaskError::project_file_unreadable);
1055 if (!project_json || !project_json->is_object()) {
1056 return std::unexpected(TaskError::project_file_unreadable);
1057 }
1058
1059 // 日期统一在领域层归一化;UI/CLI 只传原始表达式。
1060 std::optional<std::string> start;
1061 std::optional<std::string> due;
1062 if (input.start_date) {
1063 auto normalized = normalize_date(*input.start_date);
1064 if (!normalized) {
1065 return std::unexpected(normalized.error());
1066 }
1067 start = *normalized;
1068 }
1069 if (input.due_date) {
1070 auto normalized = normalize_date(*input.due_date);
1071 if (!normalized) {
1072 return std::unexpected(normalized.error());
1073 }
1074 due = *normalized;
1075 }
1076 if (start && due && *start > *due) {
1077 return std::unexpected(TaskError::invalid_date_range);
1078 }
1079
1080 // 所有跨文件写操作先取得同一把锁,避免任务与项目索引交叉损坏。
1081 ScopedFileLock lock(options.databases.timer_db.parent_path() / ".stacks-tools.lock");
1082 if (!lock.ok()) {
1083 return std::unexpected(TaskError::lock_failure);
1084 }
1085
1086 json project = *project_json;
1087 const TaskId task_id = generate_id();
1088 const std::string created = utc_timestamp();
1089 // 项目级新任务头插 backlog;Stack 级新任务头插对应 stack.tasks。
1090 if (board->kind == "project") {
1091 // 项目节点代表 backlog;数组缺失时创建空数组,不假设旧文件结构。
1092 if (!project.contains("backlog") || !project["backlog"].is_array()) {
1093 project["backlog"] = json::array();
1094 }
1095 project["backlog"].insert(project["backlog"].begin(), task_id.value);
1096 } else {
1097 // Stack 任务只写对应 stack.tasks;缺失目标时保持文件不变并报错。
1098 bool inserted = false;
1099 if (project.contains("stacks") && project["stacks"].is_array()) {
1100 for (auto& stack : project["stacks"]) {
1101 if (string_value(stack, "id") != target_id.value) {
1102 continue;
1103 }
1104 if (!stack.contains("tasks") || !stack["tasks"].is_array()) {
1105 stack["tasks"] = json::array();
1106 }
1107 stack["tasks"].insert(stack["tasks"].begin(), task_id.value);
1108 inserted = true;
1109 break;
1110 }
1111 }
1112 if (!inserted) {
1113 return std::unexpected(TaskError::target_not_found);
1114 }
1115 }
1116 project["updated"] = local_timestamp();
1117
1118 // 任务 JSON 保留 Stacks 已知公共字段,并把插件字段控制为向后兼容的增量。
1119 json task = {
1120 {"title", title},
1121 {"description", input.description.value},
1122 {"id", task_id.value},
1123 {"project", board->project_id},
1124 {"created", created},
1125 {"updated", created},
1126 {"archived", ""},
1127 {"public", true},
1128 {"owner", ""},
1129 {"visibleUsers", json::array()},
1130 {"visibleRoles", json::array()},
1131 {"tags", json::array()},
1132 {"assignees", json::array()},
1133 };
1134 if (start) {
1135 task["startdate"] = *start;
1136 }
1137 if (due) {
1138 task["duedate"] = *due;
1139 }
1140 // 只有非空优先级才写字段;空值表示沿用 Stacks 默认。
1141 if (!priority->empty()) {
1142 task["priority"] = *priority;
1143 }
1144
1145 const fs::path task_directory = board->library_path / "tasks";
1146 const fs::path task_file = task_directory / (task_id.value + ".task");
1147 std::error_code error;
1148 fs::create_directories(task_directory, error);
1149
1150 // 两阶段提交:先写 .task,再写 .project;第二步失败删除第一步文件。
1151 if (error || !atomic_write_json(task_file, task)) {
1152 return std::unexpected(TaskError::write_failure);
1153 }
1154 if (!atomic_write_json(board->project_file, project)) {
1155 fs::remove(task_file, error);
1156 return std::unexpected(TaskError::write_failure);
1157 }
1158 return TaskResult{task_id, task_file, board->kind, board->id};
1159 }();
1160
1161 // 只在操作结束后记录一次输入/输出,避免错误分支重复刷日志。
1162 const bool ok = operation.has_value();
1163 const std::string output = ok ? std::format("id={} file={} kind={}", operation->task_id.value,
1164 operation->task_file.string(), operation->target_kind)
1165 : std::string(task_error_message(operation.error()));
1166 interface_log::record(interface_log::ApiName("TaskManager::create_task"),
1169 return operation;
1170}
1171
1172std::expected<TaskResult, TaskError> create_inbox_task(TaskInput input,
1173 const CreateTaskOptions& options) {
1174 // Inbox 不依赖项目索引;它直接在选定工作区 tasks/ 下创建 project=inbox 的任务。
1175 const std::string input_log =
1176 std::format("target=inbox title={} start={} due={} priority={}", input.title.value,
1177 input.start_date.value_or(""), input.due_date.value_or(""), input.priority);
1178 const auto operation = [&]() -> std::expected<TaskResult, TaskError> {
1179 const std::string title = trim(input.title.value);
1180 if (title.empty()) {
1181 return std::unexpected(TaskError::empty_title);
1182 }
1183 const auto priority = normalize_priority_value(input.priority);
1184 if (!priority) {
1185 return std::unexpected(TaskError::invalid_priority);
1186 }
1187 fs::path library;
1188 // 显式工作区优先;未指定时沿用已保存路径集合的第一个路径。
1189 if (options.inbox_library && !options.inbox_library->empty()) {
1190 library = normalized_path(*options.inbox_library);
1191 } else {
1192 const auto libraries = load_libraries(options.databases);
1193 if (!libraries) {
1194 return std::unexpected(libraries.error());
1195 }
1196 if (libraries->empty()) {
1197 return std::unexpected(TaskError::no_inbox_library);
1198 }
1199 library = *libraries->begin();
1200 }
1201 if (!fs::exists(library) || !fs::is_directory(library)) {
1202 return std::unexpected(TaskError::invalid_library_path);
1203 }
1204
1205 // Inbox 与看板任务共享同一日期/优先级验证规则。
1206 std::optional<std::string> start;
1207 std::optional<std::string> due;
1208 if (input.start_date) {
1209 auto normalized = normalize_date(*input.start_date);
1210 if (!normalized) {
1211 return std::unexpected(normalized.error());
1212 }
1213 start = *normalized;
1214 }
1215 if (input.due_date) {
1216 auto normalized = normalize_date(*input.due_date);
1217 if (!normalized) {
1218 return std::unexpected(normalized.error());
1219 }
1220 due = *normalized;
1221 }
1222 if (start && due && *start > *due) {
1223 return std::unexpected(TaskError::invalid_date_range);
1224 }
1225
1226 ScopedFileLock lock(options.databases.timer_db.parent_path() / ".stacks-tools.lock");
1227 if (!lock.ok()) {
1228 return std::unexpected(TaskError::lock_failure);
1229 }
1230 const TaskId task_id = generate_id();
1231 const std::string created = utc_timestamp();
1232 // project=inbox 是 Stacks 识别全局收件箱任务的关键语义,不写 backlog。
1233 json task = {
1234 {"title", title},
1235 {"description", input.description.value},
1236 {"id", task_id.value},
1237 {"project", "inbox"},
1238 {"created", created},
1239 {"updated", created},
1240 {"archived", ""},
1241 {"public", true},
1242 {"owner", ""},
1243 {"visibleUsers", json::array()},
1244 {"visibleRoles", json::array()},
1245 {"tags", json::array()},
1246 {"assignees", json::array()},
1247 };
1248 if (start) {
1249 task["startdate"] = *start;
1250 }
1251 if (due) {
1252 task["duedate"] = *due;
1253 }
1254 // Inbox 文件与其他任务文件结构一致,仅 project 固定为 inbox、无 board 引用。
1255 if (!priority->empty()) {
1256 task["priority"] = *priority;
1257 }
1258
1259 // Inbox 只需要写入单个任务文件,但仍受统一排他锁保护。
1260 const fs::path task_file = library / "tasks" / (task_id.value + ".task");
1261 std::error_code error;
1262 fs::create_directories(task_file.parent_path(), error);
1263 if (error || !atomic_write_json(task_file, task)) {
1264 return std::unexpected(TaskError::write_failure);
1265 }
1266 return TaskResult{task_id, task_file, "inbox", "inbox"};
1267 }();
1268
1269 const bool ok = operation.has_value();
1270 const std::string output =
1271 ok ? std::format("id={} file={}", operation->task_id.value, operation->task_file.string())
1272 : std::string(task_error_message(operation.error()));
1273 interface_log::record(interface_log::ApiName("TaskManager::create_inbox_task"),
1276 return operation;
1277}
1278
1279std::expected<std::vector<BoardTarget>, TaskError> list_boards(const DatabasePaths& paths) {
1280 // 查询成功与失败都记录数量/分类,方便 Alfred Debugger 对齐 UI 空列表原因。
1281 auto result = load_boards_impl(paths);
1283 interface_log::ApiName("TaskManager::list_boards"),
1284 interface_log::LogText(std::format("project_db={}", paths.project_db.string())),
1285 interface_log::LogText(result ? std::format("boards={}", result->size())
1286 : std::string(task_error_message(result.error()))),
1288 return result;
1289}
1290
1291std::expected<std::vector<TaskRecord>, TaskError> list_tasks(const DatabasePaths& paths,
1292 const TaskListOptions& options) {
1293 // 输入中只记录过滤条件,不记录全量任务正文,控制日志体积与隐私暴露。
1294 const std::string input =
1295 std::format("target={} query={} include_done={} include_archived={}",
1296 options.target ? options.target->value : std::string{}, options.query,
1297 options.include_done, options.include_archived);
1298 const auto operation = [&]() -> std::expected<std::vector<TaskRecord>, TaskError> {
1299 // 全量扫描后使用纯内存过滤;数据规模适合个人 Stacks 工作区。
1300 auto records = scan_tasks(paths);
1301 if (!records) {
1302 return std::unexpected(records.error());
1303 }
1304
1305 std::optional<BoardTarget> target;
1306 if (options.target) {
1307 // inbox 是虚拟看板,不需要在 Project.json 中出现。
1308 if (options.target->value == "inbox") {
1309 target = BoardTarget{.id = "inbox",
1310 .project_id = "inbox",
1311 .title = "Inbox",
1312 .project_title = "Inbox",
1313 .kind = "inbox",
1314 .project_file = {},
1315 .library_path = {}};
1316 } else {
1317 auto resolved = resolve_board(options.target->value, paths);
1318 if (!resolved) {
1319 return std::unexpected(resolved.error());
1320 }
1321 target = std::move(*resolved);
1322 }
1323 }
1324
1325 // 过滤顺序:完成态 → 归档态 → 目标层级 → 模糊关键词。
1326 std::erase_if(*records, [&](const TaskRecord& task) {
1327 if (!options.include_done && task.done) {
1328 return true;
1329 }
1330 if (!options.include_archived && !task.archived.empty()) {
1331 return true;
1332 }
1333 if (target) {
1334 if (target->kind == "inbox" && task.project_id != "inbox") {
1335 return true;
1336 }
1337 if (target->kind == "project" && task.project_id != target->id) {
1338 return true;
1339 }
1340 if (target->kind == "stack" && task.board_id != target->id) {
1341 return true;
1342 }
1343 }
1344 return !task_matches_query(task, options.query);
1345 });
1346
1347 // 有截止日期的任务优先,其次按日期和标题;空日期排在最后。
1348 std::ranges::sort(*records, [](const TaskRecord& left, const TaskRecord& right) {
1349 return std::tuple(left.due_date.empty(), left.due_date, left.title) <
1350 std::tuple(right.due_date.empty(), right.due_date, right.title);
1351 });
1352 return records;
1353 }();
1354
1355 const bool ok = operation.has_value();
1357 interface_log::ApiName("TaskManager::list_tasks"), interface_log::LogText(input),
1358 interface_log::LogText(ok ? std::format("tasks={}", operation->size())
1359 : std::string(task_error_message(operation.error()))),
1361 return operation;
1362}
1363
1364std::expected<std::vector<TaskRecord>, TaskError> list_week_due(
1365 const DatabasePaths& paths, std::optional<std::string> anchor_date) {
1366 // 本周边界固定为 ISO 周一至周日;先复用通用列表过滤排除完成/归档。
1367 const WeekBounds bounds = week_bounds(anchor_date);
1368 TaskListOptions options;
1369 options.include_done = false;
1370 options.include_archived = false;
1371 auto tasks = list_tasks(paths, options);
1372 if (!tasks) {
1373 return std::unexpected(tasks.error());
1374 }
1375
1376 // 日期是规范化 YYYY-MM-DD,可直接按字典序比较,无需逐项构造 time_point。
1377 std::erase_if(*tasks, [&](const TaskRecord& task) {
1378 return task.due_date.empty() || task.due_date < bounds.first || task.due_date > bounds.last;
1379 });
1380 std::ranges::sort(*tasks, {}, &TaskRecord::due_date);
1382 interface_log::ApiName("TaskManager::list_week_due"),
1383 interface_log::LogText(std::format("first={} last={}", bounds.first, bounds.last)),
1384 interface_log::LogText(std::format("tasks={}", tasks->size())), interface_log::ApiStatus::ok);
1385 return tasks;
1386}
1387
1388std::expected<TaskRecord, TaskError> get_task(TaskId task_id, const DatabasePaths& paths) {
1389 // 查询命中返回标题摘要;未命中返回稳定错误分类,不向 UI 抛异常。
1390 auto task = find_task_raw(task_id, paths);
1392 interface_log::ApiName("TaskManager::get_task"),
1393 interface_log::LogText(std::format("task_id={}", task_id.value)),
1394 interface_log::LogText(task ? std::format("title={}", task->record.title)
1395 : std::string(task_error_message(task.error()))),
1397 if (!task) {
1398 return std::unexpected(task.error());
1399 }
1400 return task->record;
1401}
1402
1403std::expected<TaskRecord, TaskError> update_task(TaskId task_id, const TaskPatch& patch,
1404 const DatabasePaths& paths) {
1405 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1406 // 读改写全过程持锁,避免两个 Alfred action 交错覆盖同一任务。
1407 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1408 if (!lock.ok()) {
1409 return std::unexpected(TaskError::lock_failure);
1410 }
1411 auto loaded = find_task_raw(task_id, paths);
1412 if (!loaded) {
1413 return std::unexpected(loaded.error());
1414 }
1415 json& task = loaded->raw;
1416
1417 // 标题/描述按字段存在性应用;缺失 optional 表示“不修改”。
1418 if (patch.title) {
1419 const std::string title = trim(*patch.title);
1420 if (title.empty()) {
1421 return std::unexpected(TaskError::empty_title);
1422 }
1423 task["title"] = title;
1424 }
1425 if (patch.description) {
1426 task["description"] = *patch.description;
1427 }
1428
1429 // clear_* 优先于新值:显式清空是独立语义,不能用空字符串代替。
1430 if (patch.clear_start_date) {
1431 task.erase("startdate");
1432 } else if (patch.start_date) {
1433 auto value = normalize_date(*patch.start_date);
1434 if (!value) {
1435 return std::unexpected(value.error());
1436 }
1437 task["startdate"] = *value;
1438 }
1439 if (patch.clear_due_date) {
1440 task.erase("duedate");
1441 } else if (patch.due_date) {
1442 auto value = normalize_date(*patch.due_date);
1443 if (!value) {
1444 return std::unexpected(value.error());
1445 }
1446 task["duedate"] = *value;
1447 }
1448 if (patch.clear_priority) {
1449 task.erase("priority");
1450 } else if (patch.priority) {
1451 const auto value = normalize_priority_value(*patch.priority);
1452 if (!value) {
1453 return std::unexpected(TaskError::invalid_priority);
1454 }
1455 if (value->empty()) {
1456 task.erase("priority");
1457 } else {
1458 task["priority"] = *value;
1459 }
1460 }
1461
1462 // 所有字段应用后再做跨字段日期区间校验,支持一次 patch 同时改两端。
1463 const std::string start = string_value(task, "startdate");
1464 const std::string due = string_value(task, "duedate");
1465 if (!start.empty() && !due.empty() && start > due) {
1466 return std::unexpected(TaskError::invalid_date_range);
1467 }
1468 return save_task(task_id, task, paths);
1469 }();
1470
1471 const bool ok = operation.has_value();
1473 interface_log::ApiName("TaskManager::update_task"),
1474 interface_log::LogText(std::format(
1475 "task_id={} title={} description={} start={} due={} priority={}", task_id.value,
1476 patch.title.value_or(""), patch.description.value_or(""), patch.start_date.value_or(""),
1477 patch.due_date.value_or(""), patch.priority.value_or(""))),
1478 interface_log::LogText(ok ? std::format("title={}", operation->title)
1479 : std::string(task_error_message(operation.error()))),
1481 return operation;
1482}
1483
1484std::expected<TaskRecord, TaskError> set_due_date(TaskId task_id, std::string due_date,
1485 const DatabasePaths& paths) {
1486 // 单字段 setter 只构造最小 patch,日期校验与落盘仍由 update_task 统一负责。
1487 TaskPatch patch;
1488 patch.due_date = std::move(due_date);
1489 return update_task(task_id, patch, paths);
1490}
1491
1492std::expected<TaskRecord, TaskError> set_start_date(TaskId task_id, std::string start_date,
1493 const DatabasePaths& paths) {
1494 // 单字段 setter 不绕过锁、日期规范化和原子写入。
1495 TaskPatch patch;
1496 patch.start_date = std::move(start_date);
1497 return update_task(task_id, patch, paths);
1498}
1499
1500std::expected<TaskRecord, TaskError> set_schedule(TaskId task_id, std::string start_date,
1501 std::string due_date,
1502 const DatabasePaths& paths) {
1503 // 两端在同一 patch 中提交,避免分别更新时短暂出现非法区间。
1504 TaskPatch patch;
1505 patch.start_date = std::move(start_date);
1506 patch.due_date = std::move(due_date);
1507 return update_task(task_id, patch, paths);
1508}
1509
1510std::expected<TaskRecord, TaskError> set_task_done(TaskId task_id, bool done,
1511 const DatabasePaths& paths) {
1512 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1513 // 完成状态与时间戳必须一次原子写入,避免 done=true 但 completed 为空。
1514 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1515 if (!lock.ok()) {
1516 return std::unexpected(TaskError::lock_failure);
1517 }
1518 auto loaded = find_task_raw(task_id, paths);
1519 if (!loaded) {
1520 return std::unexpected(loaded.error());
1521 }
1522 json& task = loaded->raw;
1523 if (done) {
1524 // Stacks 以 done/progress/completed 三字段共同表达完成态。
1525 const std::string completed = local_timestamp();
1526 task["done"] = true;
1527 task["progress"] = 100;
1528 task["completed"] = completed;
1529 } else {
1530 // 重开时清除 completed,防止旧完成时间被 UI 误认为仍然完成。
1531 task["done"] = false;
1532 task["progress"] = 0;
1533 task.erase("completed");
1534 }
1535 return save_task(task_id, task, paths);
1536 }();
1537
1538 const bool ok = operation.has_value();
1540 interface_log::ApiName("TaskManager::set_task_done"),
1541 interface_log::LogText(std::format("task_id={} done={}", task_id.value, done)),
1542 interface_log::LogText(ok ? "updated" : std::string(task_error_message(operation.error()))),
1544 return operation;
1545}
1546
1547std::expected<TaskRecord, TaskError> archive_task(TaskId task_id, const DatabasePaths& paths) {
1548 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1549 // 归档只设置 archived 时间,不移动文件;后续仍可通过全局 ID 恢复编辑。
1550 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1551 if (!lock.ok()) {
1552 return std::unexpected(TaskError::lock_failure);
1553 }
1554 auto loaded = find_task_raw(task_id, paths);
1555 if (!loaded) {
1556 return std::unexpected(loaded.error());
1557 }
1558 loaded->raw["archived"] = local_timestamp();
1559 return save_task(task_id, loaded->raw, paths);
1560 }();
1561 const bool ok = operation.has_value();
1563 interface_log::ApiName("TaskManager::archive_task"),
1564 interface_log::LogText(std::format("task_id={}", task_id.value)),
1565 interface_log::LogText(ok ? "archived" : std::string(task_error_message(operation.error()))),
1567 return operation;
1568}
1569
1570std::expected<DeleteResult, TaskError> delete_task(TaskId task_id, const DatabasePaths& paths) {
1571 const auto operation = [&]() -> std::expected<DeleteResult, TaskError> {
1572 // 删除涉及多个项目引用和任务文件移动,整个事务在工作区锁内执行。
1573 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1574 if (!lock.ok()) {
1575 return std::unexpected(TaskError::lock_failure);
1576 }
1577 const auto task = find_task_raw(task_id, paths);
1578 if (!task) {
1579 return std::unexpected(task.error());
1580 }
1581 const auto projects = load_projects(paths);
1582 if (!projects) {
1583 return std::unexpected(projects.error());
1584 }
1585
1586 struct PendingProject {
1587 fs::path path;
1588 json before;
1589 json after;
1590 };
1591 std::vector<PendingProject> pending;
1592
1593 // 阶段 1:只构造“修改前/修改后”快照,不立刻写文件。
1594 for (const auto& project : *projects) {
1595 const auto content = load_json(project.path, TaskError::project_file_unreadable);
1596 if (!content || !content->is_object()) {
1597 continue;
1598 }
1599 json before = *content;
1600 json after = before;
1601 bool changed = false;
1602 if (after.contains("backlog") && after["backlog"].is_array()) {
1603 const std::size_t old_size = after["backlog"].size();
1604 erase_json_id(after["backlog"], task_id.value);
1605 changed = changed || old_size != after["backlog"].size();
1606 }
1607 if (after.contains("stacks") && after["stacks"].is_array()) {
1608 for (auto& stack : after["stacks"]) {
1609 if (!stack.is_object() || !stack.contains("tasks") || !stack["tasks"].is_array()) {
1610 continue;
1611 }
1612 const std::size_t old_size = stack["tasks"].size();
1613 erase_json_id(stack["tasks"], task_id.value);
1614 changed = changed || old_size != stack["tasks"].size();
1615 }
1616 }
1617 if (changed) {
1618 after["updated"] = local_timestamp();
1619 pending.push_back({normalized_path(project.path), std::move(before), std::move(after)});
1620 }
1621 }
1622
1623 // 阶段 2:逐个项目原子写入;任一步失败就恢复此前成功写入的快照。
1624 std::vector<fs::path> written;
1625 for (auto& project : pending) {
1626 if (!atomic_write_json(project.path, project.after)) {
1627 for (const auto& path : written) {
1628 const auto original = std::ranges::find_if(
1629 pending, [&](const PendingProject& item) { return item.path == path; });
1630 if (original != pending.end()) {
1631 atomic_write_json(original->path, original->before);
1632 }
1633 }
1634 return std::unexpected(TaskError::delete_failure);
1635 }
1636 written.push_back(project.path);
1637 }
1638
1639 const fs::path trash_directory = task->record.library_path / "tasks" / ".trash";
1640 std::error_code error;
1641 fs::create_directories(trash_directory, error);
1642 if (error) {
1643 // 无法创建垃圾箱时恢复所有项目引用,保持任务仍可见。
1644 for (const auto& project : pending) {
1645 atomic_write_json(project.path, project.before);
1646 }
1647 return std::unexpected(TaskError::delete_failure);
1648 }
1649 const fs::path trash_file =
1650 trash_directory / std::format("{}-{}.task", local_timestamp(), task_id.value);
1651 fs::rename(task->record.file, trash_file, error);
1652 if (error) {
1653 // 阶段 3 失败(rename)同样回滚项目文件,避免产生悬空任务 ID。
1654 for (const auto& project : pending) {
1655 atomic_write_json(project.path, project.before);
1656 }
1657 return std::unexpected(TaskError::delete_failure);
1658 }
1659 return DeleteResult{task_id, trash_file, pending.size()};
1660 }();
1661 const bool ok = operation.has_value();
1663 interface_log::ApiName("TaskManager::delete_task"),
1664 interface_log::LogText(std::format("task_id={}", task_id.value)),
1665 interface_log::LogText(ok ? std::format("trash={}", operation->trash_file.string())
1666 : std::string(task_error_message(operation.error()))),
1668 return operation;
1669}
1670
1671std::expected<TimerRecord, TaskError> start_timer(TaskId task_id, const DatabasePaths& paths) {
1672 const auto operation = [&]() -> std::expected<TimerRecord, TaskError> {
1673 // Timers.json 是插件私有状态;启动前确认任务仍存在于某个工作区。
1674 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1675 if (!lock.ok()) {
1676 return std::unexpected(TaskError::lock_failure);
1677 }
1678 const auto task = find_task_raw(task_id, paths);
1679 if (!task) {
1680 return std::unexpected(task.error());
1681 }
1682 json timers = {{"timers", json::object()}};
1683 // 首次使用创建空结构;已有文件损坏时不自动覆盖,返回分类错误。
1684 if (fs::exists(paths.timer_db)) {
1685 const auto loaded = load_json(paths.timer_db, TaskError::timer_db_unreadable);
1686 if (!loaded || !loaded->is_object()) {
1687 return std::unexpected(TaskError::timer_db_unreadable);
1688 }
1689 timers = *loaded;
1690 if (!timers.contains("timers") || !timers["timers"].is_object()) {
1691 timers["timers"] = json::object();
1692 }
1693 }
1694 if (timers["timers"].contains(task_id.value)) {
1695 return std::unexpected(TaskError::timer_already_running);
1696 }
1697
1698 // Unix 秒是可跨进程重启稳定比较的时间基准;本地时间仅用于显示。
1699 const auto epoch = std::chrono::duration_cast<std::chrono::seconds>(
1700 std::chrono::system_clock::now().time_since_epoch())
1701 .count();
1702 const TimerRecord record{
1703 .task_id = task_id.value,
1704 .title = task->record.title,
1705 .started_at = local_timestamp(),
1706 .start_epoch = epoch,
1707 .task_file = task->record.file,
1708 };
1709 timers["timers"][task_id.value] = {
1710 {"title", record.title},
1711 {"started_at", record.started_at},
1712 {"start_epoch", record.start_epoch},
1713 {"task_file", record.task_file.string()},
1714 };
1715 if (!atomic_write_json(paths.timer_db, timers)) {
1716 return std::unexpected(TaskError::write_failure);
1717 }
1718 return record;
1719 }();
1720 const bool ok = operation.has_value();
1722 interface_log::ApiName("TaskManager::start_timer"),
1723 interface_log::LogText(std::format("task_id={}", task_id.value)),
1724 interface_log::LogText(ok ? std::format("started_at={}", operation->started_at)
1725 : std::string(task_error_message(operation.error()))),
1727 return operation;
1728}
1729
1730std::expected<StopTimerResult, TaskError> stop_timer(TaskId task_id, const DatabasePaths& paths) {
1731 const auto operation = [&]() -> std::expected<StopTimerResult, TaskError> {
1732 // 停止计时同时修改任务文件与 Timers.json,必须放在同一排他锁内。
1733 ScopedFileLock lock(paths.timer_db.parent_path() / ".stacks-tools.lock");
1734 if (!lock.ok()) {
1735 return std::unexpected(TaskError::lock_failure);
1736 }
1737 const auto timers_json = load_json(paths.timer_db, TaskError::timer_db_unreadable);
1738 if (!timers_json || !timers_json->is_object() || !timers_json->contains("timers") ||
1739 !(*timers_json)["timers"].is_object() ||
1740 !(*timers_json)["timers"].contains(task_id.value)) {
1741 return std::unexpected(TaskError::timer_not_running);
1742 }
1743 json timers = *timers_json;
1744 auto task = find_task_raw(task_id, paths);
1745 if (!task) {
1746 return std::unexpected(task.error());
1747 }
1748 // 保存原始任务 JSON,以便第二步写入 Timers.json 失败时整体回滚。
1749 const json original_task = task->raw;
1750
1751 // 无有效 start_epoch 时按 0 处理,最终 duration 仍保证至少 1 秒。
1752 const json& timer = timers["timers"][task_id.value];
1753 const std::int64_t start_epoch =
1754 timer.contains("start_epoch") && timer["start_epoch"].is_number_integer()
1755 ? timer["start_epoch"].get<std::int64_t>()
1756 : 0;
1757 const std::int64_t now_epoch = std::chrono::duration_cast<std::chrono::seconds>(
1758 std::chrono::system_clock::now().time_since_epoch())
1759 .count();
1760 const std::int64_t duration = std::max<std::int64_t>(1, now_epoch - start_epoch);
1761 const std::string timelog_id = generate_id().value;
1762
1763 // people 是 Stacks 工作区级文件;缺失或损坏时用空 person,不阻断计时保存。
1764 const std::string person = [&]() {
1765 std::ifstream people_file(task->record.library_path / "people");
1766 try {
1767 json people;
1768 if (people_file >> people && people.is_array() && !people.empty() &&
1769 people[0].is_object()) {
1770 return string_value(people[0], "id");
1771 }
1772 } catch (const std::exception&) {
1773 }
1774 return std::string{};
1775 }();
1776
1777 json& raw_task = task->raw;
1778 if (!raw_task.contains("timelogs") || !raw_task["timelogs"].is_array()) {
1779 raw_task["timelogs"] = json::array();
1780 }
1781 // 字段名对齐 Stacks timelog;duration 使用秒,billable/billed 固定为 false。
1782 raw_task["timelogs"].push_back({
1783 {"date", string_value(timer, "started_at", local_timestamp())},
1784 {"duration", duration},
1785 {"billable", false},
1786 {"billed", false},
1787 {"description", ""},
1788 {"person", person},
1789 {"project", task->record.project_id},
1790 {"id", timelog_id},
1791 });
1792 raw_task["updated"] = local_timestamp();
1793
1794 if (!atomic_write_json(task->record.file, raw_task)) {
1795 return std::unexpected(TaskError::write_failure);
1796 }
1797 timers["timers"].erase(task_id.value);
1798 if (!atomic_write_json(paths.timer_db, timers)) {
1799 // Timers.json 写失败时撤掉已追加的 timelog,保证跨文件状态一致。
1800 atomic_write_json(task->record.file, original_task);
1801 return std::unexpected(TaskError::write_failure);
1802 }
1803 return StopTimerResult{task_id, duration, timelog_id, task->record.file};
1804 }();
1805 const bool ok = operation.has_value();
1807 interface_log::ApiName("TaskManager::stop_timer"),
1808 interface_log::LogText(std::format("task_id={}", task_id.value)),
1809 interface_log::LogText(ok ? std::format("duration={} log_id={}", operation->duration_seconds,
1810 operation->timelog_id)
1811 : std::string(task_error_message(operation.error()))),
1813 return operation;
1814}
1815
1816std::expected<std::vector<TimerRecord>, TaskError> list_timers(const DatabasePaths& paths) {
1817 std::vector<TimerRecord> result;
1818 // 首次运行没有 Timers.json 是合法空状态,不创建文件。
1819 if (!fs::exists(paths.timer_db)) {
1820 return result;
1821 }
1822 const auto timers = load_json(paths.timer_db, TaskError::timer_db_unreadable);
1823 if (!timers || !timers->is_object() || !timers->contains("timers") ||
1824 !(*timers)["timers"].is_object()) {
1825 return std::unexpected(TaskError::timer_db_unreadable);
1826 }
1827 for (const auto& [task_id, value] : (*timers)["timers"].items()) {
1828 if (!value.is_object()) {
1829 continue;
1830 }
1831 result.push_back({
1832 .task_id = task_id,
1833 .title = string_value(value, "title"),
1834 .started_at = string_value(value, "started_at"),
1835 .start_epoch = value.contains("start_epoch") && value["start_epoch"].is_number_integer()
1836 ? value["start_epoch"].get<std::int64_t>()
1837 : 0,
1838 .task_file = string_value(value, "task_file"),
1839 });
1840 }
1841 // 按 started_at 排序,使 Alfred 列表与用户开始计时的顺序一致。
1842 std::ranges::sort(result, {}, &TimerRecord::started_at);
1843 interface_log::record(interface_log::ApiName("TaskManager::list_timers"),
1845 interface_log::LogText(std::format("timers={}", result.size())),
1847 return result;
1848}
1849
1850std::expected<std::string, TaskError> normalize_date(std::string_view value,
1851 std::optional<std::string> anchor_date) {
1852 // 所有输入先 trim + ASCII 小写化,再区分 ISO 日期与相对表达式。
1853 std::string normalized = lower_ascii(trim(std::string(value)));
1854 if (normalized.empty()) {
1855 return std::unexpected(TaskError::invalid_date);
1856 }
1857 if (parse_iso_date(normalized)) {
1858 return normalized;
1859 }
1860
1861 const std::string anchor = effective_anchor(anchor_date);
1862 const auto anchor_parsed = parse_iso_date(anchor);
1863 if (!anchor_parsed) {
1864 return std::unexpected(TaskError::invalid_date);
1865 }
1866 std::chrono::sys_days day = *anchor_parsed;
1867 if (normalized == "today") {
1868 return format_date(*anchor_parsed);
1869 }
1870 if (normalized == "tomorrow") {
1871 return format_date(std::chrono::year_month_day{day + std::chrono::days{1}});
1872 }
1873 if (normalized == "yesterday") {
1874 return format_date(std::chrono::year_month_day{day - std::chrono::days{1}});
1875 }
1876 if (normalized.starts_with('+') && normalized.size() > 1) {
1877 // 支持 +3 与 +3d;上限 3650 天,避免极端输入制造明显错误的日期。
1878 std::string digits = normalized.substr(1);
1879 if (digits.ends_with('d')) {
1880 digits.pop_back();
1881 }
1882 int days = 0;
1883 const auto [pointer, error] =
1884 std::from_chars(digits.data(), digits.data() + digits.size(), days);
1885 if (error == std::errc{} && pointer == digits.data() + digits.size() && days >= 0 &&
1886 days <= 3650) {
1887 return format_date(std::chrono::year_month_day{day + std::chrono::days{days}});
1888 }
1889 }
1890
1891 constexpr std::array<std::string_view, 7> weekdays{"monday", "tuesday", "wednesday", "thursday",
1892 "friday", "saturday", "sunday"};
1893 // 英文星期表示“下一次该星期几”;与周一为 0 的内部索引对齐。
1894 const auto weekday_it = std::ranges::find(weekdays, normalized);
1895 if (weekday_it != weekdays.end()) {
1896 const int current = static_cast<int>(std::chrono::weekday{*anchor_parsed}.c_encoding());
1897 const int current_monday_based = (current + 6) % 7;
1898 const int target = static_cast<int>(weekday_it - weekdays.begin());
1899 int delta = target - current_monday_based;
1900 if (delta < 0) {
1901 delta += 7;
1902 }
1903 return format_date(std::chrono::year_month_day{day + std::chrono::days{delta}});
1904 }
1905 return std::unexpected(TaskError::invalid_date);
1906}
1907
1908WeekBounds week_bounds(std::optional<std::string> anchor_date) {
1909 // weekday.c_encoding: Sunday=0;先换算为 Monday=0,再回退到本周一。
1910 const std::string anchor = effective_anchor(anchor_date);
1911 const auto parsed = parse_iso_date(anchor);
1912 if (!parsed) {
1913 return {};
1914 }
1915 const int weekday_value = static_cast<int>(std::chrono::weekday{*parsed}.c_encoding());
1916 const int monday_based = (weekday_value + 6) % 7;
1917 const std::chrono::sys_days monday =
1918 std::chrono::sys_days{*parsed} - std::chrono::days{monday_based};
1919 return {
1920 .first = format_date(std::chrono::year_month_day{monday}),
1921 .last = format_date(std::chrono::year_month_day{monday + std::chrono::days{6}}),
1922 };
1923}
1924
1925std::string_view task_error_message(TaskError error) noexcept {
1926 // 所有可恢复错误都有稳定文本;UI 不解析枚举,只显示这里的消息。
1927 switch (error) {
1929 return "target id is invalid";
1931 return "task id is invalid";
1933 return "title is empty";
1935 return "date must be YYYY-MM-DD, today, tomorrow, +Nd or a weekday";
1937 return "start date must not be later than due date";
1939 return "priority must be low, medium, high or critical";
1941 return "cannot parse project database";
1943 return "cannot parse stack database";
1945 return "board target does not exist";
1947 return "board target kind is unknown";
1949 return "project file does not exist";
1951 return "cannot parse project file";
1953 return "task does not exist";
1955 return "cannot parse task file";
1957 return "timer is not running";
1959 return "timer is already running";
1961 return "cannot parse timer database";
1963 return "no saved Stacks library is available for Inbox";
1965 return "Stacks library path is invalid";
1967 return "another Stacks Tools write is in progress";
1969 return "failed to write Stacks data";
1971 return "failed to move task to trash";
1972 }
1973 return "unknown task error";
1974}
1975
1976} // namespace TaskManager
ScopedFileLock & operator=(const ScopedFileLock &)=delete
接口输入/输出日志模块(STATIC 模块库)。
Stacks 2 任务业务模块。
std::expected< std::vector< ProjectNode >, TaskError > load_projects(const DatabasePaths &paths)
读取 Project.json,过滤无效/重复节点并恢复项目到工作区的归属。
std::expected< TaskRecord, TaskError > save_task(TaskId task_id, json &raw, const DatabasePaths &paths)
原子写回编辑后的任务,并重新读取规范化快照。
std::string format_date(std::chrono::year_month_day date)
将日历日期格式化为 Alfred/Stacks 共用的 ISO 文本。
std::optional< std::string > normalize_priority_value(std::string_view value)
将用户优先级表达式归一化为 Stacks 值。
int int_value(const json &object, std::string_view key, int fallback=0)
只在目标字段为整数时返回值;浮点和字符串一律回退。
std::expected< std::vector< BoardTarget >, TaskError > load_boards_impl(const DatabasePaths &paths)
从项目文件恢复项目和清单的扁平看板列表。
std::optional< std::chrono::year_month_day > parse_iso_date(std::string_view value)
严格解析 YYYY-MM-DD,并借助 year_month_day::ok 拒绝 2 月 30 日等非法日期。
bool valid_identifier(std::string_view value)
校验 Stacks ID 的跨 Alfred token 安全字符集。
bool bool_value(const json &object, std::string_view key, bool fallback=false)
只在目标字段为布尔值时返回值。
fs::path normalized_path(const fs::path &value)
规范化绝对路径;不要求目标路径已存在,适配首次创建工作区目录。
std::string local_date()
从本地时间戳提取 YYYY-MM-DD;转换失败时返回空串。
std::string trim(std::string value)
去除首尾 ASCII 空白,保留标题/描述内部内容。
std::expected< std::set< fs::path >, TaskError > load_libraries(const DatabasePaths &paths)
合并 library_paths 与项目节点推导出的工作区,确保旧索引也能扫描任务。
std::string lower_ascii(std::string value)
仅做 ASCII 小写化,足够处理命令别名、优先级和任务搜索。
bool atomic_write_json(const fs::path &target, const json &value)
以同目录临时文件 + rename 原子替换 JSON。
void erase_json_id(json &array, std::string_view task_id)
从 JSON 字符串数组中移除指定任务 ID,保留其他元素顺序。
std::expected< json, TaskError > load_json(const fs::path &file, TaskManager::TaskError parse_error)
防御性读取 JSON 文件。
std::expected< LoadedTask, TaskError > find_task_raw(TaskId task_id, const DatabasePaths &paths)
通过任务 ID 在所有工作区中查找原始 JSON,避免依赖全局唯一路径索引。
std::expected< BoardTarget, TaskError > resolve_board(std::string_view id, const DatabasePaths &paths)
在扁平看板列表中解析项目/清单目标,供创建任务定位 .project 文件。
std::string string_value(const json &object, std::string_view key, std::string fallback={})
只在目标字段为字符串时返回值,避免 nlohmann 类型异常穿透 API。
std::string local_timestamp()
本地秒级时间,用于 Stacks UI 可读的 updated/completed/archived 字段。
std::string effective_anchor(const std::optional< std::string > &anchor)
优先使用显式锚点;锚点非法或缺失时回退到当前本地日期。
std::atomic< std::uint64_t > g_sequence
进程内原子序号,与时间/随机设备一起构成 ID 种子的去重分量。
std::string utc_timestamp()
UTC 微秒时间,用于 task.created/updated 的 ISO 兼容格式。
bool task_matches_query(const TaskRecord &task, std::string_view query)
对标题、描述、ID、项目名和清单名执行不区分大小写的子串匹配。
std::expected< LoadedTask, TaskError > load_task_from_file(const fs::path &file, const std::filesystem::path &library_path, const std::unordered_map< std::string, TaskLocation > &memberships)
读取单个 .task:保留未知字段,并把 Stacks 原生字段映射为 TaskRecord。
std::expected< std::unordered_map< std::string, TaskLocation >, TaskError > load_memberships(const DatabasePaths &paths)
建立“任务 ID → 项目/清单位置”缓存。
std::expected< std::vector< TaskRecord >, TaskError > scan_tasks(const DatabasePaths &paths)
扫描所有已保存工作区的 tasks 目录,跳过重复文件与损坏任务。
constexpr std::size_t kTaskIdLength
任务 ID 固定为 20 字符,变异测试会硬编码检查此协议。
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)
应用部分更新;修改前后都会验证日期区间与优先级。
TaskId generate_id()
生成线程安全的 20 字符任务 ID。
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< std::string, TaskError > normalize_date(std::string_view value, std::optional< std::string > anchor_date=std::nullopt)
将 YYYY-MM-DD、today、tomorrow、+Nd 或英文星期解析为 ISO 日期。
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。
@ invalid_priority
未识别优先级文本。
@ target_kind_unknown
目标类型不是 project/stack。
@ no_inbox_library
没有可用于 Inbox 的工作区。
@ project_db_unreadable
Project.json 无法解析。
@ project_file_unreadable
.project 无法解析。
@ task_file_unreadable
.task 无法解析。
@ empty_title
trim 后标题为空。
@ stack_db_unreadable
Stack.json 无法解析。
@ invalid_date
日期表达式或日历日期非法。
@ delete_failure
项目引用回滚或移入垃圾箱失败。
@ task_not_found
全部工作区中无此任务。
@ timer_db_unreadable
Timers.json 无法解析。
@ timer_already_running
目标任务已有计时。
@ invalid_date_range
开始日期晚于截止日期。
@ invalid_target_id
项目/清单 ID 含非法字符或为空。
@ target_not_found
索引中无目标看板。
@ lock_failure
另一个进程持写锁。
@ invalid_library_path
指定工作区不存在或不是目录。
@ invalid_task_id
任务 ID 格式非法。
@ timer_not_running
目标任务未在计时。
@ write_failure
写任务/索引文件失败。
@ project_file_not_found
.project 路径不存在。
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 文本。
void record(ApiName api, LogText input, LogText output, ApiStatus status)
记录一次接口调用。
@ failed
调用失败(错误已记录在 output 文本中)
项目或清单的统一展示目标;WorkflowUI 用它构造 Alfred 条目。
std::string id
项目/清单 ID。
std::filesystem::path library_path
工作区根目录。
std::string project_title
上层项目标题。
std::string project_id
所属项目 ID。
std::string kind
project | stack。
std::filesystem::path project_file
对应 .project 文件。
std::string title
当前层级标题。
创建任务所需的数据库与 Inbox 默认工作区。
DatabasePaths databases
全部索引路径。
std::optional< std::filesystem::path > inbox_library
Inbox 显式工作区;空则取首路径。
三个运行数据库路径;一次传参避免同类 path 参数顺序错误。
std::filesystem::path stack_db
项目/清单扁平索引。
std::filesystem::path timer_db
插件计时状态。
std::filesystem::path project_db
项目/工作区索引。
可恢复删除结果;trash_file 指向 tasks/.trash 中的副本。
TaskId task_id
被删除任务。
std::filesystem::path trash_file
新位置。
std::size_t project_files_updated
成功移除任务引用的项目文件数。
停止计时结果;timelog_id 可在任务文件中定位新记录。
std::string timelog_id
新 timelog 的 ID。
TaskId task_id
停止计时的任务。
std::int64_t duration_seconds
至少 1 秒的计时时长。
std::filesystem::path task_file
已更新的任务文件。
可创建任务的看板目标 ID。
TargetId(std::string id)
std::string value
Stacks 项目或清单 ID。
任务描述;允许空字符串,不改写 Markdown/HTML 内容。
std::string value
用户输入描述。
TaskDescription(std::string description)
已校验任务 ID;作为所有任务读写接口的稳定主键。
TaskId(std::string id)
std::string value
20 字符任务 ID。
创建任务输入;日期保留用户文本,由 normalize_date 在业务层校验。
std::optional< std::string > due_date
可空截止日期表达式。
TaskDescription description
可空描述。
std::string priority
低/中/高/紧急或空。
TaskTitle title
必填标题。
std::optional< std::string > start_date
可空开始日期表达式。
任务列表的过滤条件;默认保留已完成任务、排除已归档任务。
bool include_archived
是否显示 archived 非空任务。
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 project_id
所属项目;Inbox 为 inbox。
std::string completed
完成时间文本;重开时删除。
std::string title
任务标题。
std::filesystem::path library_path
所属工作区。
std::filesystem::path file
.task 文件路径。
int progress
Stacks progress 百分比。
std::string board_id
backlog、inbox 或 Stack ID。
std::string due_date
Stacks duedate。
bool done
Stacks done。
std::string description
任务描述。
std::string board_title
清单显示名。
std::string priority
low/medium/high/critical。
std::string id
任务 ID。
std::string archived
归档时间文本。
std::string project_title
项目显示名。
std::string start_date
Stacks startdate。
创建任务的稳定结果,不返回可被后续修改的内部 JSON。
std::filesystem::path task_file
已写入的 .task 文件。
std::string target_kind
project | stack | inbox。
std::string target_id
实际目标 ID。
TaskId task_id
新任务 ID。
任务标题;create/update 时仍会执行 trim 与空值校验。
std::string value
用户输入标题。
TaskTitle(std::string title)
Timers.json 中的运行中计时快照。
std::string task_id
计时任务 ID。
std::string started_at
本地开始时间。
std::int64_t start_epoch
Unix 秒,用于稳定计算时长。
std::string title
启动时标题快照。
std::filesystem::path task_file
对应 .task 文件。
周一至周日的 ISO 日期区间。
std::string last
周日 YYYY-MM-DD。
std::string first
周一 YYYY-MM-DD。
原始 JSON 与展示快照一起返回,编辑时可保留未知 Stacks 字段。
Project.json 节点的内部视图,不进入公开 API。
任务在项目/清单层级中的成员关系缓存。
接口名强类型。 语义:调用方必须显式构造 ApiName,避免把任意字符串误当接口名。
接口输入/输出文本强类型。 语义:与 ApiName 一样显式构造,参数顺序在编译期被类型约束。