44#include <unordered_map>
92 std::filesystem::path
stack_db{
"data/Stack.json"};
93 std::filesystem::path
timer_db{
"data/Timers.json"};
211 project_db_unreadable,
216 project_file_unreadable,
235std::expected<TaskResult, TaskError> create_task(TargetId target_id, TaskInput input,
236 const CreateTaskOptions& options);
239std::expected<TaskResult, TaskError> create_inbox_task(TaskInput input,
240 const CreateTaskOptions& options);
243std::expected<std::vector<BoardTarget>, TaskError> list_boards(
const DatabasePaths& paths);
246std::expected<std::vector<TaskRecord>, TaskError> list_tasks(
const DatabasePaths& paths,
247 const TaskListOptions& options = {});
251 const DatabasePaths& paths, std::optional<std::string> anchor_date = std::nullopt);
263std::expected<TaskRecord, TaskError>
set_due_date(
TaskId task_id, std::string due_date,
271std::expected<TaskRecord, TaskError>
set_schedule(
TaskId task_id, std::string start_date,
299 std::string_view value, std::optional<std::string> anchor_date = std::nullopt);
312template <std::invocable<std::
string_view> Visitor>
315 std::ifstream input(project_file, std::ios::binary);
317 nlohmann::json content;
318 if (!(input >> content) || !content.is_object()) {
321 if (!content.contains(
"backlog") || !content[
"backlog"].is_array()) {
322 return std::size_t{0};
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&>());
332 }
catch (
const std::exception&) {
343namespace fs = std::filesystem;
375 std::error_code error;
376 fs::create_directories(path.parent_path(), error);
380 descriptor_ = ::open(path.c_str(), O_CREAT | O_RDWR, 0600);
384 if (::flock(
descriptor_, LOCK_EX | LOCK_NB) != 0) {
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());
419 std::ranges::transform(value, value.begin(), [](
unsigned char character) {
420 return static_cast<char>(std::tolower(character));
427 return !value.empty() && std::ranges::all_of(value, [](
char character) {
428 return std::isalnum(
static_cast<unsigned char>(character)) || character ==
'-' ||
435 const std::time_t now = std::time(
nullptr);
437 if (localtime_r(&now, &local) ==
nullptr) {
441 if (std::strftime(buffer,
sizeof(buffer),
"%Y-%m-%d %H:%M:%S", &local) == 0) {
449 const auto now = std::chrono::system_clock::now();
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);
455 if (gmtime_r(&seconds, &utc) ==
nullptr) {
459 if (std::strftime(buffer,
sizeof(buffer),
"%Y-%m-%dT%H:%M:%S", &utc) == 0) {
462 return std::format(
"{}.{:06}.000Z", buffer, micros.count());
468 return timestamp.size() >= 10 ? timestamp.substr(0, 10) : std::string{};
473 std::error_code error;
474 const fs::path canonical = fs::weakly_canonical(value, error);
478 return fs::absolute(value, error).lexically_normal();
487 std::ifstream input(file, std::ios::binary);
490 if (!(input >> content)) {
491 return std::unexpected(parse_error);
494 }
catch (
const std::exception&) {
495 return std::unexpected(parse_error);
504 std::error_code error;
505 fs::create_directories(target.parent_path(), error);
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());
514 std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
518 output << value.dump(4) <<
'\n';
521 fs::remove(temporary, error);
525 fs::rename(temporary, target, error);
527 fs::remove(temporary, error);
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>();
543 if (
object.is_object() &&
object.contains(key) &&
object[key].is_number_integer()) {
544 return object[key].get<
int>();
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>();
559 if (!array.is_array()) {
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;
571std::optional<std::chrono::year_month_day>
parse_iso_date(std::string_view value) {
572 if (value.size() != 10 || value[4] !=
'-' || value[7] !=
'-') {
576 std::size_t part = 0;
577 for (std::size_t index = 0; index < value.size();) {
578 if (value[index] ==
'-') {
583 if (!std::isdigit(
static_cast<unsigned char>(value[index])) || part >= 3) {
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');
596 parts[part] = number;
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])},
611 return std::format(
"{:04d}-{:02d}-{:02d}",
static_cast<int>(date.year()),
612 static_cast<unsigned>(date.month()),
static_cast<unsigned>(date.day()));
632 if (normalized.empty() || normalized ==
"none" || normalized ==
"default" || normalized ==
"无" ||
633 normalized ==
"默认") {
634 return std::string{};
636 if (normalized ==
"low" || normalized ==
"低" || normalized ==
"低优先级") {
639 if (normalized ==
"medium" || normalized ==
"mid" || normalized ==
"中" ||
640 normalized ==
"中优先级") {
643 if (normalized ==
"high" || normalized ==
"高" || normalized ==
"高优先级") {
646 if (normalized ==
"critical" || normalized ==
"urgent" || normalized ==
"紧急" ||
647 normalized ==
"紧急优先级") {
658 return std::unexpected(content.error());
660 if (!content->is_object() || !content->contains(
"nodes") || !(*content)[
"nodes"].is_array()) {
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()) {
686 seen.emplace(
id,
true);
687 projects.push_back(std::move(project));
695 std::set<fs::path> libraries;
699 return std::unexpected(content.error());
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()) {
711 for (
const auto& project : *projects) {
712 libraries.insert(project.library_path);
726 return std::unexpected(projects.error());
729 std::vector<BoardTarget> result;
730 for (
const auto& project : *projects) {
732 if (!fs::exists(project.path)) {
737 if (!project_json || !project_json->is_object()) {
740 const std::string project_title =
string_value(*project_json,
"title", project.title);
744 .project_id = project.id,
745 .title = project_title,
746 .project_title = project_title,
748 .project_file = project.path,
749 .library_path = project.library_path,
751 if (project_json->contains(
"stacks") && (*project_json)[
"stacks"].is_array()) {
752 for (
const auto& stack : (*project_json)[
"stacks"]) {
753 if (!stack.is_object()) {
763 .project_id = project.id,
765 .project_title = project_title,
767 .project_file = project.path,
768 .library_path = project.library_path,
774 std::ranges::sort(result, {}, [](
const BoardTarget& board) {
775 return std::tuple(board.project_title, board.kind ==
"stack", board.title);
786 return std::unexpected(boards.error());
788 const auto iterator =
789 std::ranges::find_if(*boards, [
id](
const BoardTarget& board) {
return board.
id == id; });
790 if (iterator == boards->end()) {
803 std::unordered_map<std::string, TaskLocation> result;
806 return std::unexpected(projects.error());
808 for (
const auto& project : *projects) {
810 if (!content || !content->is_object()) {
813 const std::string project_title =
string_value(*content,
"title", project.title);
814 if (content->contains(
"backlog") && (*content)[
"backlog"].is_array()) {
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"});
823 if (!content->contains(
"stacks") || !(*content)[
"stacks"].is_array()) {
826 for (
const auto& stack : (*content)[
"stacks"]) {
828 if (!stack.is_object() || !stack.contains(
"tasks") || !stack[
"tasks"].is_array()) {
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});
846 const fs::path& file,
const std::filesystem::path& library_path,
847 const std::unordered_map<std::string, TaskLocation>& memberships) {
850 if (!content || !content->is_object()) {
853 const std::string
id =
string_value(*content,
"id", file.stem().string());
860 .project_title =
"Inbox",
862 .board_title =
"Inbox",
864 if (
const auto found = memberships.find(
id); found != memberships.end()) {
866 location = found->second;
884 .progress =
int_value(*content,
"progress"),
888 return LoadedTask{std::move(record), std::move(*content)};
896 return std::unexpected(libraries.error());
900 return std::unexpected(memberships.error());
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)) {
910 std::error_code error;
912 for (
const auto& entry : fs::directory_iterator(task_directory, error)) {
913 if (error || !entry.is_regular_file(error) || entry.path().extension() !=
".task") {
918 if (!seen_files.insert(normalized).second) {
923 records.push_back(loaded->record);
938 return std::unexpected(libraries.error());
942 return std::unexpected(memberships.error());
944 for (
const auto& library : *libraries) {
946 const fs::path candidate = library /
"tasks" / (task_id.
value +
".task");
947 if (fs::exists(candidate)) {
964 return std::unexpected(task.error());
972 if (!libraries || !memberships) {
973 return std::unexpected(!libraries ? libraries.error() : memberships.error());
976 .transform([](
LoadedTask&& loaded) {
return std::move(loaded.record); });
985 const std::string needle =
lower_ascii(std::string(query));
986 const std::string haystack =
989 return haystack.find(needle) != std::string::npos;
996 static constexpr std::string_view kAlphabet =
997 "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
998 static_assert(kAlphabet.size() == 64);
1001 static_cast<std::uint64_t
>(std::chrono::steady_clock::now().time_since_epoch().count());
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)];
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));
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))];
1024 return TaskId(std::move(
id));
1030 const std::string input_log = std::format(
1031 "target={} title={} start={} due={} priority={}", target_id.
value, input.
title.
value,
1033 const auto operation = [&]() -> std::expected<TaskResult, TaskError> {
1035 if (!valid_identifier(target_id.
value)) {
1038 const std::string title = trim(input.
title.
value);
1039 if (title.empty()) {
1042 const auto priority = normalize_priority_value(input.
priority);
1047 const auto board = resolve_board(target_id.
value, options.
databases);
1049 return std::unexpected(board.error());
1051 if (!fs::exists(board->project_file)) {
1055 if (!project_json || !project_json->is_object()) {
1060 std::optional<std::string> start;
1061 std::optional<std::string> due;
1065 return std::unexpected(normalized.error());
1067 start = *normalized;
1072 return std::unexpected(normalized.error());
1076 if (start && due && *start > *due) {
1081 ScopedFileLock lock(options.
databases.
timer_db.parent_path() /
".stacks-tools.lock");
1086 json project = *project_json;
1088 const std::string created = utc_timestamp();
1090 if (board->kind ==
"project") {
1092 if (!project.contains(
"backlog") || !project[
"backlog"].is_array()) {
1093 project[
"backlog"] = json::array();
1095 project[
"backlog"].insert(project[
"backlog"].begin(), task_id.
value);
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) {
1104 if (!stack.contains(
"tasks") || !stack[
"tasks"].is_array()) {
1105 stack[
"tasks"] = json::array();
1107 stack[
"tasks"].insert(stack[
"tasks"].begin(), task_id.
value);
1116 project[
"updated"] = local_timestamp();
1122 {
"id", task_id.
value},
1123 {
"project", board->project_id},
1124 {
"created", created},
1125 {
"updated", created},
1129 {
"visibleUsers", json::array()},
1130 {
"visibleRoles", json::array()},
1131 {
"tags", json::array()},
1132 {
"assignees", json::array()},
1135 task[
"startdate"] = *start;
1138 task[
"duedate"] = *due;
1141 if (!priority->empty()) {
1142 task[
"priority"] = *priority;
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);
1151 if (error || !atomic_write_json(task_file, task)) {
1154 if (!atomic_write_json(board->project_file, project)) {
1155 fs::remove(task_file, error);
1158 return TaskResult{task_id, task_file, board->kind, board->id};
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)
1175 const std::string input_log =
1176 std::format(
"target=inbox title={} start={} due={} priority={}", input.
title.
value,
1178 const auto operation = [&]() -> std::expected<TaskResult, TaskError> {
1179 const std::string title = trim(input.
title.
value);
1180 if (title.empty()) {
1183 const auto priority = normalize_priority_value(input.
priority);
1192 const auto libraries = load_libraries(options.
databases);
1194 return std::unexpected(libraries.error());
1196 if (libraries->empty()) {
1199 library = *libraries->begin();
1201 if (!fs::exists(library) || !fs::is_directory(library)) {
1206 std::optional<std::string> start;
1207 std::optional<std::string> due;
1211 return std::unexpected(normalized.error());
1213 start = *normalized;
1218 return std::unexpected(normalized.error());
1222 if (start && due && *start > *due) {
1226 ScopedFileLock lock(options.
databases.
timer_db.parent_path() /
".stacks-tools.lock");
1231 const std::string created = utc_timestamp();
1236 {
"id", task_id.
value},
1237 {
"project",
"inbox"},
1238 {
"created", created},
1239 {
"updated", created},
1243 {
"visibleUsers", json::array()},
1244 {
"visibleRoles", json::array()},
1245 {
"tags", json::array()},
1246 {
"assignees", json::array()},
1249 task[
"startdate"] = *start;
1252 task[
"duedate"] = *due;
1255 if (!priority->empty()) {
1256 task[
"priority"] = *priority;
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)) {
1266 return TaskResult{task_id, task_file,
"inbox",
"inbox"};
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())
1281 auto result = load_boards_impl(paths);
1294 const std::string input =
1295 std::format(
"target={} query={} include_done={} include_archived={}",
1298 const auto operation = [&]() -> std::expected<std::vector<TaskRecord>,
TaskError> {
1300 auto records = scan_tasks(paths);
1302 return std::unexpected(records.error());
1305 std::optional<BoardTarget> target;
1308 if (options.
target->value ==
"inbox") {
1310 .project_id =
"inbox",
1312 .project_title =
"Inbox",
1315 .library_path = {}};
1317 auto resolved = resolve_board(options.
target->value, paths);
1319 return std::unexpected(resolved.error());
1321 target = std::move(*resolved);
1326 std::erase_if(*records, [&](
const TaskRecord& task) {
1334 if (target->kind ==
"inbox" && task.
project_id !=
"inbox") {
1337 if (target->kind ==
"project" && task.
project_id != target->id) {
1340 if (target->kind ==
"stack" && task.
board_id != target->id) {
1344 return !task_matches_query(task, options.
query);
1355 const bool ok = operation.has_value();
1365 const DatabasePaths& paths, std::optional<std::string> anchor_date) {
1373 return std::unexpected(tasks.error());
1377 std::erase_if(*tasks, [&](
const TaskRecord& task) {
1390 auto task = find_task_raw(task_id, paths);
1398 return std::unexpected(task.error());
1400 return task->record;
1405 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1407 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
1411 auto loaded = find_task_raw(task_id, paths);
1413 return std::unexpected(loaded.error());
1415 json& task = loaded->raw;
1419 const std::string title = trim(*patch.
title);
1420 if (title.empty()) {
1423 task[
"title"] = title;
1431 task.erase(
"startdate");
1435 return std::unexpected(value.error());
1437 task[
"startdate"] = *value;
1440 task.erase(
"duedate");
1444 return std::unexpected(value.error());
1446 task[
"duedate"] = *value;
1449 task.erase(
"priority");
1451 const auto value = normalize_priority_value(*patch.
priority);
1455 if (value->empty()) {
1456 task.erase(
"priority");
1458 task[
"priority"] = *value;
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) {
1468 return save_task(task_id, task, paths);
1471 const bool ok = operation.has_value();
1475 "task_id={} title={} description={} start={} due={} priority={}", task_id.
value,
1488 patch.
due_date = std::move(due_date);
1501 std::string due_date,
1506 patch.
due_date = std::move(due_date);
1512 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1514 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
1518 auto loaded = find_task_raw(task_id, paths);
1520 return std::unexpected(loaded.error());
1522 json& task = loaded->raw;
1525 const std::string completed = local_timestamp();
1526 task[
"done"] =
true;
1527 task[
"progress"] = 100;
1528 task[
"completed"] = completed;
1531 task[
"done"] =
false;
1532 task[
"progress"] = 0;
1533 task.erase(
"completed");
1535 return save_task(task_id, task, paths);
1538 const bool ok = operation.has_value();
1548 const auto operation = [&]() -> std::expected<TaskRecord, TaskError> {
1550 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
1554 auto loaded = find_task_raw(task_id, paths);
1556 return std::unexpected(loaded.error());
1558 loaded->raw[
"archived"] = local_timestamp();
1559 return save_task(task_id, loaded->raw, paths);
1561 const bool ok = operation.has_value();
1571 const auto operation = [&]() -> std::expected<DeleteResult, TaskError> {
1573 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
1577 const auto task = find_task_raw(task_id, paths);
1579 return std::unexpected(task.error());
1581 const auto projects = load_projects(paths);
1583 return std::unexpected(projects.error());
1586 struct PendingProject {
1591 std::vector<PendingProject> pending;
1594 for (
const auto& project : *projects) {
1596 if (!content || !content->is_object()) {
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();
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()) {
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();
1618 after[
"updated"] = local_timestamp();
1619 pending.push_back({normalized_path(project.path), std::move(before), std::move(after)});
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);
1636 written.push_back(project.path);
1639 const fs::path trash_directory = task->record.library_path /
"tasks" /
".trash";
1640 std::error_code error;
1641 fs::create_directories(trash_directory, error);
1644 for (
const auto& project : pending) {
1645 atomic_write_json(project.path, project.before);
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);
1654 for (
const auto& project : pending) {
1655 atomic_write_json(project.path, project.before);
1659 return DeleteResult{task_id, trash_file, pending.size()};
1661 const bool ok = operation.has_value();
1672 const auto operation = [&]() -> std::expected<TimerRecord, TaskError> {
1674 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
1678 const auto task = find_task_raw(task_id, paths);
1680 return std::unexpected(task.error());
1682 json timers = {{
"timers", json::object()}};
1686 if (!loaded || !loaded->is_object()) {
1690 if (!timers.contains(
"timers") || !timers[
"timers"].is_object()) {
1691 timers[
"timers"] = json::object();
1694 if (timers[
"timers"].contains(task_id.
value)) {
1699 const auto epoch = std::chrono::duration_cast<std::chrono::seconds>(
1700 std::chrono::system_clock::now().time_since_epoch())
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,
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()},
1715 if (!atomic_write_json(paths.
timer_db, timers)) {
1720 const bool ok = operation.has_value();
1731 const auto operation = [&]() -> std::expected<StopTimerResult, TaskError> {
1733 ScopedFileLock lock(paths.
timer_db.parent_path() /
".stacks-tools.lock");
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)) {
1743 json timers = *timers_json;
1744 auto task = find_task_raw(task_id, paths);
1746 return std::unexpected(task.error());
1749 const json original_task = task->raw;
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>()
1757 const std::int64_t now_epoch = std::chrono::duration_cast<std::chrono::seconds>(
1758 std::chrono::system_clock::now().time_since_epoch())
1760 const std::int64_t duration = std::max<std::int64_t>(1, now_epoch - start_epoch);
1764 const std::string person = [&]() {
1765 std::ifstream people_file(task->record.library_path /
"people");
1768 if (people_file >> people && people.is_array() && !people.empty() &&
1769 people[0].is_object()) {
1770 return string_value(people[0],
"id");
1772 }
catch (
const std::exception&) {
1774 return std::string{};
1777 json& raw_task = task->raw;
1778 if (!raw_task.contains(
"timelogs") || !raw_task[
"timelogs"].is_array()) {
1779 raw_task[
"timelogs"] = json::array();
1782 raw_task[
"timelogs"].push_back({
1783 {
"date", string_value(timer,
"started_at", local_timestamp())},
1784 {
"duration", duration},
1785 {
"billable",
false},
1787 {
"description",
""},
1789 {
"project", task->record.project_id},
1792 raw_task[
"updated"] = local_timestamp();
1794 if (!atomic_write_json(task->record.file, raw_task)) {
1797 timers[
"timers"].erase(task_id.
value);
1798 if (!atomic_write_json(paths.
timer_db, timers)) {
1800 atomic_write_json(task->record.file, original_task);
1803 return StopTimerResult{task_id, duration, timelog_id, task->record.file};
1805 const bool ok = operation.has_value();
1810 operation->timelog_id)
1817 std::vector<TimerRecord> result;
1823 if (!timers || !timers->is_object() || !timers->contains(
"timers") ||
1824 !(*timers)[
"timers"].is_object()) {
1827 for (
const auto& [task_id, value] : (*timers)[
"timers"].items()) {
1828 if (!value.is_object()) {
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>()
1838 .task_file = string_value(value,
"task_file"),
1851 std::optional<std::string> anchor_date) {
1853 std::string normalized = lower_ascii(trim(std::string(value)));
1854 if (normalized.empty()) {
1857 if (parse_iso_date(normalized)) {
1861 const std::string anchor = effective_anchor(anchor_date);
1862 const auto anchor_parsed = parse_iso_date(anchor);
1863 if (!anchor_parsed) {
1866 std::chrono::sys_days day = *anchor_parsed;
1867 if (normalized ==
"today") {
1868 return format_date(*anchor_parsed);
1870 if (normalized ==
"tomorrow") {
1871 return format_date(std::chrono::year_month_day{day + std::chrono::days{1}});
1873 if (normalized ==
"yesterday") {
1874 return format_date(std::chrono::year_month_day{day - std::chrono::days{1}});
1876 if (normalized.starts_with(
'+') && normalized.size() > 1) {
1878 std::string digits = normalized.substr(1);
1879 if (digits.ends_with(
'd')) {
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 &&
1887 return format_date(std::chrono::year_month_day{day + std::chrono::days{days}});
1891 constexpr std::array<std::string_view, 7> weekdays{
"monday",
"tuesday",
"wednesday",
"thursday",
1892 "friday",
"saturday",
"sunday"};
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;
1903 return format_date(std::chrono::year_month_day{day + std::chrono::days{delta}});
1910 const std::string anchor = effective_anchor(anchor_date);
1911 const auto parsed = parse_iso_date(anchor);
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};
1920 .first = format_date(std::chrono::year_month_day{monday}),
1921 .last = format_date(std::chrono::year_month_day{monday + std::chrono::days{6}}),
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";
1973 return "unknown task error";
ScopedFileLock(const ScopedFileLock &)=delete
ScopedFileLock & operator=(const ScopedFileLock &)=delete
ScopedFileLock(const fs::path &path)
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 无法解析。
@ 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
索引中无目标看板。
@ 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::filesystem::path library_path
工作区根目录。
std::string project_title
上层项目标题。
std::string project_id
所属项目 ID。
std::string kind
project | stack。
std::filesystem::path project_file
对应 .project 文件。
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 中的副本。
std::filesystem::path trash_file
新位置。
std::size_t project_files_updated
成功移除任务引用的项目文件数。
停止计时结果;timelog_id 可在任务文件中定位新记录。
std::string timelog_id
新 timelog 的 ID。
std::int64_t duration_seconds
至少 1 秒的计时时长。
std::filesystem::path task_file
已更新的任务文件。
std::string value
Stacks 项目或清单 ID。
任务描述;允许空字符串,不改写 Markdown/HTML 内容。
TaskDescription(std::string description)
已校验任务 ID;作为所有任务读写接口的稳定主键。
std::string value
20 字符任务 ID。
任务列表的过滤条件;默认保留已完成任务、排除已归档任务。
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::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。
std::string description
任务描述。
std::string board_title
清单显示名。
std::string priority
low/medium/high/critical。
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。
任务标题;create/update 时仍会执行 trim 与空值校验。
TaskTitle(std::string title)
std::string task_id
计时任务 ID。
std::string started_at
本地开始时间。
std::int64_t start_epoch
Unix 秒,用于稳定计算时长。
std::string title
启动时标题快照。
std::filesystem::path task_file
对应 .task 文件。
std::string last
周日 YYYY-MM-DD。
std::string first
周一 YYYY-MM-DD。
原始 JSON 与展示快照一起返回,编辑时可保留未知 Stacks 字段。
TaskRecord record
跨模块只读快照。
Project.json 节点的内部视图,不进入公开 API。
fs::path path
.project 文件。
fs::path library_path
工作区根目录。
std::string board_id
backlog/Stack ID。
std::string board_title
清单显示名。
std::string project_id
所属项目。
std::string project_title
项目显示名。
接口名强类型。 语义:调用方必须显式构造 ApiName,避免把任意字符串误当接口名。
接口输入/输出文本强类型。 语义:与 ApiName 一样显式构造,参数顺序在编译期被类型约束。