C and Python API + Smarter account-based wait times

This commit is contained in:
alejo committed 2026-10-28 01:02:10 -03:00
1 parent 35c568333d
commit 10badb282c
18 files changed
+2204 -429

No files matched your search

+33 -211
View File
@@ -16,10 +16,6 @@ namespace {
const std::size_t kUnlimited = std::numeric_limits<std::size_t>::max();
const char* const kStatusHelp =
"prompt, limit-reset, testing, wait <time>, uploading, frozen, done "
"(time: 16:30, 4:30pm, 4pm)";
bool parseVisibility(const std::string& word) {
const std::string value = StringUtil::lower(word);
if (value == "show") {
@@ -34,7 +30,7 @@ bool parseVisibility(const std::string& word) {
} // namespace
Repl::Repl(Database& db, Display& display, Config& config)
: db_(db), display_(display), config_(config), completer_(db, specs_), quit_(false) {
: db_(db), display_(display), config_(config), tracker_(db), completer_(db, specs_), quit_(false) {
registerCommands();
}
@@ -203,11 +199,16 @@ void Repl::execute(const std::string& line) {
return;
}
if ((this->*command->handler)(args)) {
const bool redraw = (this->*command->handler)(args);
for (const std::string& message : tracker_.takeMessages()) {
say(message);
}
if (redraw) {
refresh();
}
flushMessages();
} catch (const std::exception& error) {
tracker_.takeMessages();
messages_.clear();
std::cout << "Error: " << error.what() << "\n";
}
@@ -219,7 +220,7 @@ void Repl::refresh() const {
return;
}
const std::time_t now = std::time(nullptr);
const std::time_t now = tracker_.now();
Terminal::clearScreen();
if (settings.showTasks) {
std::cout << display_.renderTasks(now) << "\n";
@@ -251,146 +252,43 @@ std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCan
}
}
bool Repl::isBareWait(const Args& args, std::size_t from) {
return StringUtil::lower(StringUtil::join(args, from, " ")) == "wait";
}
// The account's wait if it is still running (not yet a Limit reset).
std::optional<std::time_t> Repl::runningWait(const std::string& account) const {
std::optional<AccountRow> row = db_.getAccount(account);
if (!row || !row->cooldown) {
return std::nullopt;
}
std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
if (until && *until > std::time(nullptr)) {
return until;
}
return std::nullopt;
}
// Prompt and Testing tasks follow their account's wait; anything else becomes Prompt.
void Repl::makeTaskFollowAccount(const TaskRow& task) {
std::optional<Status> current = StatusUtil::deserialize(task.status);
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
db_.setTaskStatus(task.id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
}
}
void Repl::requireName(const std::string& name) {
if (name.empty() || name == "-") {
throw std::runtime_error("'" + name + "' is not a valid name.");
}
}
Status Repl::parseStatusFrom(const Args& args, std::size_t from) {
const std::string text = StringUtil::join(args, from, " ");
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
if (!status) {
throw std::runtime_error("Invalid status '" + text + "'. Use: " + kStatusHelp);
}
return *status;
}
// ---- Accounts ---------------------------------------------------------------
bool Repl::cmdAccAdd(const Args& args) {
requireName(args[0]);
if (db_.findAccount(args[0])) {
throw std::runtime_error("Account '" + args[0] + "' already exists.");
}
db_.addAccount(args[0]);
say("Account '" + args[0] + "' added.");
tracker_.addAccount(args[0]);
return true;
}
bool Repl::cmdAccDel(const Args& args) {
std::optional<std::string> name = db_.findAccount(args[0]);
if (!name) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
db_.removeAccount(*name);
say("Account '" + *name + "' deleted.");
tracker_.deleteAccount(args[0]);
return true;
}
bool Repl::cmdAccRename(const Args& args) {
std::optional<std::string> name = db_.findAccount(args[0]);
if (!name) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
requireName(args[1]);
if (!StringUtil::equalsIgnoreCase(*name, args[1]) && db_.findAccount(args[1])) {
throw std::runtime_error("Account '" + args[1] + "' already exists.");
}
db_.renameAccount(*name, args[1]);
say("Account '" + *name + "' renamed to '" + args[1] + "'.");
tracker_.renameAccount(args[0], args[1]);
return true;
}
void Repl::setAccountWait(const std::string& account, std::time_t until) {
db_.setAccountCooldown(account, StatusUtil::timeToText(until));
say("Account '" + account + "' waits until " + StatusUtil::clockLabel(until) +
": its Prompt and Testing tasks show Wait, then Limit reset (acc unwait " + account + " to clear).");
}
// Tells the user when a task's own status is hidden by its account's wait.
void Repl::noteAccountWait(const std::string& taskName) {
std::optional<TaskRow> task = db_.getTask(taskName);
std::optional<Status> stored = task ? StatusUtil::deserialize(task->status) : std::nullopt;
if (!task || !task->account || !stored || !StatusUtil::followsAccountWait(stored->kind)) {
return;
}
std::optional<AccountRow> account = db_.getAccount(*task->account);
if (account && account->cooldown) {
say("It shows as Wait / Limit reset because account '" + account->id + "' is waiting (acc unwait " +
account->id + " to clear).");
}
}
bool Repl::cmdAccWait(const Args& args) {
std::optional<AccountRow> account = db_.getAccount(args[0]);
if (!account) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
const std::string text = "wait " + StringUtil::join(args, 1, " ");
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
if (!status) {
throw std::runtime_error("Invalid time '" + StringUtil::join(args, 1, " ") +
"'. Use e.g. 16:30, 4:30pm or 4pm.");
}
setAccountWait(account->id, status->waitUntil);
tracker_.setAccountWaitText(args[0], StringUtil::join(args, 1, " "));
return true;
}
bool Repl::cmdAccUnwait(const Args& args) {
std::optional<AccountRow> account = db_.getAccount(args[0]);
if (!account) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
if (!account->cooldown) {
say("Account '" + account->id + "' is not waiting.");
return false;
}
db_.setAccountCooldown(account->id, std::nullopt);
say("Wait cleared on '" + account->id + "'.");
return true;
return tracker_.clearAccountWait(args[0]);
}
bool Repl::cmdAccLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderAccounts(std::time(nullptr)) << "\n";
std::cout << display_.renderAccounts(tracker_.now()) << "\n";
return false;
}
// ---- Tasks ------------------------------------------------------------------
bool Repl::cmdTskAdd(const Args& args) {
const std::string& name = args[0];
requireName(name);
if (db_.findTask(name)) {
throw std::runtime_error("Task '" + name + "' already exists.");
}
// tsk add <name> [acc] [status]: the second word is an account if one exists
// with that name ("-" = none), otherwise it starts the status.
std::size_t next = 1;
std::optional<std::string> account;
if (next < args.size()) {
@@ -399,119 +297,50 @@ bool Repl::cmdTskAdd(const Args& args) {
++next;
} else if (args[next] == "-") {
++next;
} else if (!isBareWait(args, next) && !StatusUtil::parse(StringUtil::join(args, next, " "), std::time(nullptr))) {
throw std::runtime_error("'" + args[next] + "' is neither an existing account nor a valid status (" +
kStatusHelp + ").");
}
}
Status status = StatusUtil::make(StatusKind::Prompt);
if (next < args.size()) {
if (isBareWait(args, next)) {
// "wait" without a time: only while the account is already waiting.
if (!account || !runningWait(*account)) {
throw std::runtime_error("A time is required unless the task's account is already waiting: wait <time>");
}
} else {
status = parseStatusFrom(args, next);
const std::string text = StringUtil::join(args, next, " ");
if (!StatusUtil::isBareWait(text) && !StatusUtil::parse(text, tracker_.now())) {
throw std::runtime_error("'" + args[next] + "' is neither an existing account nor a valid status (" +
StatusUtil::helpText() + ").");
}
}
}
// A wait on a task that has an account is the account's wait; the task itself
// stays Prompt and follows it.
const bool accountWait = (status.kind == StatusKind::Wait && account);
const Status stored = accountWait ? StatusUtil::make(StatusKind::Prompt) : status;
db_.addTask(name, account, StatusUtil::serialize(stored));
say("Task '" + name + "' added.");
if (accountWait) {
setAccountWait(*account, status.waitUntil);
} else {
noteAccountWait(name);
std::optional<std::string> statusText;
if (next < args.size()) {
statusText = StringUtil::join(args, next, " ");
}
tracker_.addTask(args[0], account, statusText);
return true;
}
bool Repl::cmdTskDel(const Args& args) {
std::optional<std::string> name = db_.findTask(args[0]);
if (!name) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
db_.removeTask(*name);
say("Task '" + *name + "' deleted.");
tracker_.deleteTask(args[0]);
return true;
}
bool Repl::cmdTskLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderTasks(std::time(nullptr)) << "\n";
std::cout << display_.renderTasks(tracker_.now()) << "\n";
return false;
}
bool Repl::cmdTskRename(const Args& args) {
std::optional<std::string> name = db_.findTask(args[0]);
if (!name) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
requireName(args[1]);
if (!StringUtil::equalsIgnoreCase(*name, args[1]) && db_.findTask(args[1])) {
throw std::runtime_error("Task '" + args[1] + "' already exists.");
}
db_.renameTask(*name, args[1]);
say("Task '" + *name + "' renamed to '" + args[1] + "'.");
tracker_.renameTask(args[0], args[1]);
return true;
}
bool Repl::cmdTskReacc(const Args& args) {
std::optional<std::string> name = db_.findTask(args[0]);
if (!name) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
std::optional<std::string> account;
if (args[1] != "-") {
account = db_.findAccount(args[1]);
if (!account) {
throw std::runtime_error("No account named '" + args[1] + "'.");
}
account = args[1];
}
db_.setTaskAccount(*name, account);
say("Task '" + *name + "' now on account " + (account ? "'" + *account + "'" : std::string("(none)")) + ".");
tracker_.setTaskAccount(args[0], account);
return true;
}
bool Repl::cmdTskRestat(const Args& args) {
std::optional<TaskRow> task = db_.getTask(args[0]);
if (!task) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
if (isBareWait(args, 1)) {
// "wait" without a time: the task follows its account's running wait.
if (!task->account || !runningWait(*task->account)) {
throw std::runtime_error(
task->account ? "Account '" + *task->account + "' is not waiting, so a time is required: wait <time>"
: "Task '" + task->id + "' has no account, so a time is required: wait <time>");
}
makeTaskFollowAccount(*task);
say("Task '" + task->id + "' now follows account '" + *task->account + "'.");
noteAccountWait(task->id);
return true;
}
Status status = parseStatusFrom(args, 1);
if (status.kind == StatusKind::Wait && task->account) {
// The wait belongs to the account. Keep an active task as it is so it
// follows the wait; any other status becomes Prompt.
makeTaskFollowAccount(*task);
setAccountWait(*task->account, status.waitUntil);
return true;
}
db_.setTaskStatus(task->id, StatusUtil::serialize(status));
say("Task '" + task->id + "' set to " + StatusUtil::label(status) + ".");
noteAccountWait(task->id);
tracker_.setTaskStatus(args[0], StringUtil::join(args, 1, " "));
return true;
}
@@ -531,14 +360,7 @@ bool Repl::cmdTskNotes(const Args& args) {
const std::string initial = task->notes.empty() ? "" : task->notes + "\n";
const std::string edited = StringUtil::trim(ExternalEditor::edit(command, initial));
if (edited == StringUtil::trim(task->notes)) {
say("Notes unchanged.");
return false;
}
db_.setTaskNotes(task->id, edited);
say(edited.empty() ? "Notes cleared." : "Notes updated.");
return true;
return tracker_.setTaskNotes(task->id, edited);
}
// ---- Display ----------------------------------------------------------------
@@ -696,7 +518,7 @@ bool Repl::cmdHelp(const Args&) {
}
std::cout << " " << command.spec.usage << "\n " << command.spec.help << "\n";
}
std::cout << "\nStatuses: " << kStatusHelp << "\n"
std::cout << "\nStatuses: " << StatusUtil::helpText() << "\n"
<< "Keys: Up/Down history, Left/Right edit, Tab completes, Ctrl-A/E start/end, "
"Ctrl-U/K/W delete, Ctrl-L clear\n\n";
return false;