Files
LLS/src/Repl.cpp
T
2026-10-29 01:02:06 -03:00

542 lines
18 KiB
C++

#include "Repl.h"
#include <algorithm>
#include <ctime>
#include <iostream>
#include <limits>
#include <optional>
#include <stdexcept>
#include "ExternalEditor.h"
#include "StringUtil.h"
#include "Terminal.h"
#include "Tokenizer.h"
namespace {
const std::size_t kUnlimited = std::numeric_limits<std::size_t>::max();
bool parseVisibility(const std::string& word) {
const std::string value = StringUtil::lower(word);
if (value == "show") {
return true;
}
if (value == "hide") {
return false;
}
throw std::runtime_error("Expected 'show' or 'hide', got '" + word + "'.");
}
} // namespace
Repl::Repl(Database& db, Display& display, Config& config)
: db_(db), display_(display), config_(config), tracker_(db), completer_(db, specs_), quit_(false) {
registerCommands();
}
void Repl::add(const std::string& key, const std::string& usage, const std::string& help,
std::size_t minArgs, std::size_t maxArgs, const std::vector<ArgKind>& argKinds,
Handler handler) {
CommandSpec spec;
spec.key = key;
spec.usage = usage;
spec.help = help;
spec.minArgs = minArgs;
spec.maxArgs = maxArgs;
spec.argKinds = argKinds;
Command command;
command.spec = spec;
command.handler = handler;
commands_.push_back(command);
specs_.push_back(spec);
}
void Repl::registerCommands() {
add("acc add", "acc add <name>", "create an account", 1, 1, {ArgKind::None}, &Repl::cmdAccAdd);
add("acc del", "acc del <name>", "delete an account (its tasks become unassigned)", 1, 1,
{ArgKind::Account}, &Repl::cmdAccDel);
add("acc rename", "acc rename <name> <new>", "rename an account", 2, 2,
{ArgKind::Account, ArgKind::None}, &Repl::cmdAccRename);
add("acc wait", "acc wait <name> <time>",
"the account hit its limit until <time>; tasks set to 'wait' wait for it, then show Limit reset",
2, kUnlimited, {ArgKind::Account, ArgKind::None}, &Repl::cmdAccWait);
add("acc unwait", "acc unwait <name>", "end an account's wait now (its Wait tasks become Limit reset)",
1, 1, {ArgKind::Account}, &Repl::cmdAccUnwait);
add("acc ls", "acc ls", "show the account table", 0, 0, {}, &Repl::cmdAccLs);
add("tsk add", "tsk add <name> [acc] [status]", "create a task", 1, kUnlimited,
{ArgKind::None, ArgKind::AccountOrStatus, ArgKind::Status}, &Repl::cmdTskAdd);
add("tsk del", "tsk del <name>", "delete a task", 1, 1, {ArgKind::Task}, &Repl::cmdTskDel);
add("tsk ls", "tsk ls", "show the task table", 0, 0, {}, &Repl::cmdTskLs);
add("tsk rename", "tsk rename <name> <new>", "rename a task", 2, 2,
{ArgKind::Task, ArgKind::None}, &Repl::cmdTskRename);
add("tsk reacc", "tsk reacc <name> <new>", "move a task to another account ('-' = none)", 2, 2,
{ArgKind::Task, ArgKind::AccountOrDash}, &Repl::cmdTskReacc);
add("tsk restat", "tsk restat <name> <new>", "change a task's status ('wait <time>' sets its account's wait; plain 'wait' joins a running one)", 2, kUnlimited,
{ArgKind::Task, ArgKind::Status}, &Repl::cmdTskRestat);
add("tsk notes", "tsk notes <name>", "edit a task's notes in your editor (see 'editor')", 1, 1,
{ArgKind::Task}, &Repl::cmdTskNotes);
add("disp", "disp", "show the current display settings", 0, 0, {}, &Repl::cmdDisp);
add("disp tsk", "disp tsk (hide|show)", "auto-print the task table after changes", 1, 1,
{ArgKind::Visibility}, &Repl::cmdDispTsk);
add("disp tsk level", "disp tsk level (hide|show) (prompt|limit-reset|testing|wait|uploading|frozen|done|all)",
"hide or show task rows by status", 2, 2,
{ArgKind::Visibility, ArgKind::TaskLevel}, &Repl::cmdDispTskLevel);
add("disp acc", "disp acc (hide|show)", "auto-print the account table after changes", 1, 1,
{ArgKind::Visibility}, &Repl::cmdDispAcc);
add("disp acc level", "disp acc level (hide|show) (busy|taken|free|all)",
"hide or show account rows by status", 2, 2,
{ArgKind::Visibility, ArgKind::AccountLevel}, &Repl::cmdDispAccLevel);
add("db save", "db save", "write the database to its file", 0, 0, {}, &Repl::cmdDbSave);
add("db backup", "db backup [file]", "write a copy (timestamped name if no file given)", 0, 1,
{ArgKind::File}, &Repl::cmdDbBackup);
add("db restore", "db restore <file>", "replace the current data with a file's contents", 1, 1,
{ArgKind::File}, &Repl::cmdDbRestore);
add("editor", "editor [command | default]", "show or set the editor used by 'tsk notes'", 0, kUnlimited,
{ArgKind::Editor}, &Repl::cmdEditor);
add("help", "help", "list the commands", 0, 0, {}, &Repl::cmdHelp);
add("quit", "quit", "exit (also: exit, Ctrl-D)", 0, 0, {}, &Repl::cmdQuit);
add("exit", "exit", "exit", 0, 0, {}, &Repl::cmdQuit);
}
const Repl::Command* Repl::find(const Args& tokens, std::size_t& consumed) const {
// Longest match wins: "disp tsk level" before "disp tsk" before "disp".
for (std::size_t words = std::min<std::size_t>(3, tokens.size()); words >= 1; --words) {
std::string key;
for (std::size_t i = 0; i < words; ++i) {
if (i > 0) {
key += " ";
}
key += StringUtil::lower(tokens[i]);
}
for (const Command& command : commands_) {
if (command.spec.key == key) {
consumed = words;
return &command;
}
}
}
return nullptr;
}
int Repl::run() {
lineEditor_.setCompleter([this](const std::string& before) { return completer_.complete(before); });
lineEditor_.setHistoryFile(config_.historyPath());
refresh();
std::cout << "Task tracker - database: " << db_.path()
<< " ('help' for commands, Tab completes, Enter on an empty line refreshes)\n";
std::string line;
while (!quit_) {
if (!lineEditor_.readLine("> ", line)) {
if (endOfInput()) {
return 0;
}
continue;
}
execute(line);
}
return 0;
}
bool Repl::endOfInput() {
if (!db_.dirty()) {
return true;
}
if (!Terminal::stdinIsInteractive()) {
db_.save();
std::cout << "Input closed - changes saved to " << db_.path() << ".\n";
return true;
}
const std::optional<Answer> answer = ask("Save changes before exiting?", true);
if (answer == Answer::Cancel) {
return false;
}
if (!answer || *answer == Answer::Yes) { // input closed: keep the changes
db_.save();
std::cout << "Saved to " << db_.path() << ".\n";
}
return true;
}
void Repl::say(const std::string& message) {
messages_.push_back(message);
}
void Repl::flushMessages() {
for (const std::string& message : messages_) {
std::cout << message << "\n";
}
messages_.clear();
}
void Repl::execute(const std::string& line) {
messages_.clear();
try {
Args tokens = Tokenizer::split(line);
if (tokens.empty()) {
refresh();
return;
}
std::size_t consumed = 0;
const Command* command = find(tokens, consumed);
if (!command) {
std::cout << "Unknown command. Type 'help' for the list.\n";
return;
}
Args args(tokens.begin() + static_cast<std::ptrdiff_t>(consumed), tokens.end());
if (args.size() < command->spec.minArgs || args.size() > command->spec.maxArgs) {
std::cout << "Usage: " << command->spec.usage << "\n"
<< "(wrap names containing spaces in double quotes)\n";
return;
}
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";
}
}
void Repl::refresh() const {
const DisplaySettings& settings = display_.settings();
if (!settings.showTasks && !settings.showAccounts) {
return;
}
const std::time_t now = tracker_.now();
Terminal::clearScreen();
if (settings.showTasks) {
std::cout << display_.renderTasks(now) << "\n";
}
if (settings.showAccounts) {
std::cout << display_.renderAccounts(now) << "\n";
}
}
std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCancel) const {
for (;;) {
std::cout << question << (allowCancel ? " (y/n/c): " : " (y/n): ") << std::flush;
std::string reply;
if (!std::getline(std::cin, reply)) {
return std::nullopt;
}
reply = StringUtil::lower(StringUtil::trim(reply));
if (reply == "y" || reply == "yes") {
return Answer::Yes;
}
if (reply == "n" || reply == "no") {
return Answer::No;
}
if (allowCancel && (reply == "c" || reply == "cancel")) {
return Answer::Cancel;
}
std::cout << (allowCancel ? "Please answer y (save), n (discard) or c (cancel).\n"
: "Please answer y or n.\n");
}
}
// ---- Accounts ---------------------------------------------------------------
bool Repl::cmdAccAdd(const Args& args) {
tracker_.addAccount(args[0]);
return true;
}
bool Repl::cmdAccDel(const Args& args) {
tracker_.deleteAccount(args[0]);
return true;
}
bool Repl::cmdAccRename(const Args& args) {
tracker_.renameAccount(args[0], args[1]);
return true;
}
bool Repl::cmdAccWait(const Args& args) {
tracker_.setAccountWaitText(args[0], StringUtil::join(args, 1, " "));
return true;
}
bool Repl::cmdAccUnwait(const Args& args) {
return tracker_.clearAccountWait(args[0]);
}
bool Repl::cmdAccLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderAccounts(tracker_.now()) << "\n";
return false;
}
// ---- Tasks ------------------------------------------------------------------
bool Repl::cmdTskAdd(const Args& args) {
// 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()) {
if (std::optional<std::string> found = db_.findAccount(args[next])) {
account = found;
++next;
} else if (args[next] == "-") {
++next;
} else {
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() + ").");
}
}
}
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) {
tracker_.deleteTask(args[0]);
return true;
}
bool Repl::cmdTskLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderTasks(tracker_.now()) << "\n";
return false;
}
bool Repl::cmdTskRename(const Args& args) {
tracker_.renameTask(args[0], args[1]);
return true;
}
bool Repl::cmdTskReacc(const Args& args) {
std::optional<std::string> account;
if (args[1] != "-") {
account = args[1];
}
tracker_.setTaskAccount(args[0], account);
return true;
}
bool Repl::cmdTskRestat(const Args& args) {
tracker_.setTaskStatus(args[0], StringUtil::join(args, 1, " "));
return true;
}
bool Repl::cmdTskNotes(const Args& args) {
std::optional<TaskRow> task = db_.getTask(args[0]);
if (!task) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
std::string command = config_.editor();
if (command.empty()) {
command = ExternalEditor::defaultCommand();
}
if (command.empty()) {
throw std::runtime_error("No editor found. Set one with: editor <command> (e.g. editor nano)");
}
const std::string initial = task->notes.empty() ? "" : task->notes + "\n";
const std::string edited = StringUtil::trim(ExternalEditor::edit(command, initial));
return tracker_.setTaskNotes(task->id, edited);
}
// ---- Display ----------------------------------------------------------------
bool Repl::cmdDisp(const Args&) {
const DisplaySettings& settings = display_.settings();
std::cout << "Task table: " << (settings.showTasks ? "shown" : "hidden") << " after changes\n";
std::cout << "Account table: " << (settings.showAccounts ? "shown" : "hidden") << " after changes\n";
std::cout << "Hidden task levels:";
if (settings.hiddenTaskLevels.empty()) {
std::cout << " none";
}
for (StatusKind kind : settings.hiddenTaskLevels) {
std::cout << " " << StatusUtil::kindName(kind);
}
std::cout << "\nHidden account levels:";
if (settings.hiddenAccountLevels.empty()) {
std::cout << " none";
}
for (AccountLevel level : settings.hiddenAccountLevels) {
std::cout << " " << accountLevelName(level);
}
std::cout << "\n";
return false;
}
bool Repl::cmdDispTsk(const Args& args) {
display_.settings().showTasks = parseVisibility(args[0]);
return true;
}
bool Repl::cmdDispTskLevel(const Args& args) {
const bool visible = parseVisibility(args[0]);
const std::string level = StringUtil::lower(args[1]);
std::set<StatusKind>& hidden = display_.settings().hiddenTaskLevels;
std::vector<StatusKind> targets;
if (level == "all") {
targets = StatusUtil::allKinds();
} else {
std::optional<StatusKind> kind = StatusUtil::kindFromName(level);
if (!kind) {
throw std::runtime_error("Unknown level '" + args[1] +
"'. Use: prompt, limit-reset, testing, wait, uploading, frozen, done, all");
}
targets = {*kind};
}
for (StatusKind target : targets) {
if (visible) {
hidden.erase(target);
} else {
hidden.insert(target);
}
}
return true;
}
bool Repl::cmdDispAcc(const Args& args) {
display_.settings().showAccounts = parseVisibility(args[0]);
return true;
}
bool Repl::cmdDispAccLevel(const Args& args) {
const bool visible = parseVisibility(args[0]);
const std::string level = StringUtil::lower(args[1]);
std::set<AccountLevel>& hidden = display_.settings().hiddenAccountLevels;
std::vector<AccountLevel> targets;
if (level == "all") {
targets = {AccountLevel::Busy, AccountLevel::Taken, AccountLevel::Free};
} else if (level == "busy") {
targets = {AccountLevel::Busy};
} else if (level == "taken") {
targets = {AccountLevel::Taken};
} else if (level == "free") {
targets = {AccountLevel::Free};
} else {
throw std::runtime_error("Unknown level '" + args[1] + "'. Use: busy, taken, free, all");
}
for (AccountLevel target : targets) {
if (visible) {
hidden.erase(target);
} else {
hidden.insert(target);
}
}
return true;
}
// ---- Database ---------------------------------------------------------------
bool Repl::cmdDbSave(const Args&) {
db_.save();
say("Saved to " + db_.path() + ".");
return false;
}
bool Repl::cmdDbBackup(const Args& args) {
const std::string target = db_.backup(args.empty() ? "" : args[0]);
say("Backup written to " + target + ".");
return false;
}
bool Repl::cmdDbRestore(const Args& args) {
if (db_.dirty() && ask("Unsaved changes will be replaced. Continue?", false) != Answer::Yes) {
say("Restore cancelled.");
return false;
}
db_.restore(args[0]);
say("Restored from " + args[0] + ". Use 'db save' to keep it as the current database.");
return true;
}
// ---- Misc -------------------------------------------------------------------
bool Repl::cmdEditor(const Args& args) {
if (args.empty()) {
if (!config_.editor().empty()) {
say("Editor: " + config_.editor());
} else {
const std::string fallback = ExternalEditor::defaultCommand();
say(fallback.empty()
? "No editor configured and none found. Set one with: editor <command>"
: "Editor: " + fallback + " (default, from $VISUAL / $EDITOR / PATH)");
}
return false;
}
const std::string command = StringUtil::join(args, 0, " ");
if (StringUtil::lower(command) == "default") {
config_.setEditor("");
config_.save();
say("Editor reset to the default.");
return false;
}
if (!ExternalEditor::commandExists(command)) {
throw std::runtime_error("'" + args[0] + "' was not found on your PATH.");
}
config_.setEditor(command);
config_.save();
say("Editor set to: " + command);
return false;
}
bool Repl::cmdHelp(const Args&) {
std::cout << "\nCommands (wrap names with spaces in double quotes):\n";
for (const Command& command : commands_) {
if (command.spec.key == "exit") {
continue;
}
std::cout << " " << command.spec.usage << "\n " << command.spec.help << "\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;
}
bool Repl::cmdQuit(const Args&) {
if (db_.dirty()) {
const std::optional<Answer> answer = ask("Save changes before exiting?", true);
if (answer == Answer::Cancel) {
say("Exit cancelled.");
return false;
}
if (!answer || *answer == Answer::Yes) { // input closed: keep the changes
db_.save();
say("Saved to " + db_.path() + ".");
}
}
quit_ = true;
return false;
}