Add source code.

This commit is contained in:
alejo committed 2026-10-17 03:29:11 -03:00
1 parent dab19ace02
commit 0b8a7b3cdc
31 files changed
+2809

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
build/
*.db
+57
View File
@@ -0,0 +1,57 @@
cmake_minimum_required(VERSION 3.14)
project(LLS VERSION 1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
# --- SQLite ------------------------------------------------------------------
# Use the system SQLite if present, otherwise download the amalgamation.
find_package(SQLite3)
if(NOT SQLite3_FOUND)
message(STATUS "System SQLite3 not found - fetching the amalgamation")
include(FetchContent)
if(POLICY CMP0135)
cmake_policy(SET CMP0135 NEW)
endif()
FetchContent_Declare(sqlite3_amalgamation
URL https://www.sqlite.org/2024/sqlite-amalgamation-3450100.zip)
FetchContent_MakeAvailable(sqlite3_amalgamation)
find_package(Threads REQUIRED)
add_library(sqlite3_local STATIC ${sqlite3_amalgamation_SOURCE_DIR}/sqlite3.c)
target_include_directories(sqlite3_local PUBLIC ${sqlite3_amalgamation_SOURCE_DIR})
target_link_libraries(sqlite3_local PUBLIC Threads::Threads ${CMAKE_DL_LIBS})
add_library(SQLite::SQLite3 ALIAS sqlite3_local)
endif()
# --- Application -------------------------------------------------------------
add_executable(lls
src/main.cpp
src/Completer.cpp
src/Config.cpp
src/Database.cpp
src/Display.cpp
src/ExternalEditor.cpp
src/LineEditor.cpp
src/Repl.cpp
src/Status.cpp
src/StringUtil.cpp
src/Table.cpp
src/Terminal.cpp
src/Tokenizer.cpp
)
target_include_directories(lls PRIVATE include)
target_link_libraries(lls PRIVATE SQLite::SQLite3)
if(MSVC)
target_compile_options(lls PRIVATE /W4)
else()
target_compile_options(lls PRIVATE -Wall -Wextra -Wpedantic)
endif()
+1
View File
@@ -0,0 +1 @@
cmake -B build && cmake --build build --parallel -j 8
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
// What kind of value an argument holds (drives tab completion).
enum class ArgKind {
None,
Task,
Account,
AccountOrDash,
AccountOrStatus,
Status,
Visibility,
TaskLevel,
AccountLevel,
File,
Editor
};
struct CommandSpec {
std::string key; // e.g. "tsk add"
std::string usage; // e.g. "tsk add <name> [acc] [status]"
std::string help;
std::size_t minArgs = 0;
std::size_t maxArgs = 0;
std::vector<ArgKind> argKinds;
};
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
#include "CommandSpec.h"
#include "Completion.h"
#include "Database.h"
// Works out tab-completion candidates for the text typed so far: command
// words first, then arguments (task names, account names, statuses, ...).
class Completer {
public:
Completer(const Database& db, const std::vector<CommandSpec>& specs);
Completion complete(const std::string& textBeforeCursor) const;
private:
std::vector<std::string> valuesFor(ArgKind kind, const std::string& current) const;
const Database& db_;
const std::vector<CommandSpec>& specs_;
};
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
// Result of a tab-completion request: the text from `start` up to the cursor
// can be replaced by any of `candidates`.
struct Completion {
std::size_t start = 0;
std::vector<std::string> candidates;
};
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <string>
// User preferences stored outside the database, in
// $XDG_CONFIG_HOME/task_tracker (or ~/.config/task_tracker, or %APPDATA%).
class Config {
public:
Config();
void load();
void save() const;
const std::string& editor() const; // empty = use the default
void setEditor(const std::string& command);
std::string historyPath() const;
static std::string directory();
private:
std::string directory_;
std::string editor_;
};
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "Models.h"
struct sqlite3;
// Works on an in-memory SQLite database that is loaded from, and saved to, a
// file on request (db save / backup / restore).
class Database {
public:
Database();
~Database();
Database(const Database&) = delete;
Database& operator=(const Database&) = delete;
// Loads `path` into memory if it exists, otherwise starts empty.
void open(const std::string& path);
void save();
std::string backup(const std::string& file); // empty = timestamped name; returns the file used
void restore(const std::string& file);
bool dirty() const;
const std::string& path() const;
// Accounts
std::optional<std::string> findAccount(const std::string& name) const; // canonical spelling
std::vector<std::string> listAccounts() const;
void addAccount(const std::string& name);
void removeAccount(const std::string& name);
void renameAccount(const std::string& name, const std::string& newName);
// Tasks
std::optional<std::string> findTask(const std::string& name) const; // canonical spelling
std::optional<TaskRow> getTask(const std::string& name) const;
std::vector<TaskRow> listTasks() const;
void addTask(const std::string& name, const std::optional<std::string>& account, const std::string& status);
void removeTask(const std::string& name);
void renameTask(const std::string& name, const std::string& newName);
void setTaskAccount(const std::string& name, const std::optional<std::string>& account);
void setTaskStatus(const std::string& name, const std::string& status);
void setTaskNotes(const std::string& name, const std::string& notes);
private:
using Param = std::optional<std::string>;
using Row = std::vector<std::optional<std::string>>;
void createSchema();
void exec(const std::string& sql);
std::vector<Row> query(const std::string& sql, const std::vector<Param>& params = {}) const;
void run(const std::string& sql, const std::vector<Param>& params);
sqlite3* db_;
std::string path_;
bool dirty_;
};
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <ctime>
#include <set>
#include <string>
#include "Database.h"
#include "Status.h"
enum class AccountLevel { Busy, Free };
struct DisplaySettings {
bool showTasks = true; // auto-print the task table after each change
bool showAccounts = true; // auto-print the account table after each change
std::set<StatusKind> hiddenTaskLevels;
std::set<AccountLevel> hiddenAccountLevels;
};
// Builds the computed views. The status shown is derived from the stored
// status and the current time, never copied straight from the database.
class Display {
public:
explicit Display(const Database& db);
DisplaySettings& settings();
const DisplaySettings& settings() const;
std::string renderTasks(std::time_t now) const;
std::string renderAccounts(std::time_t now) const;
private:
const Database& db_;
DisplaySettings settings_;
};
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <string>
#include <vector>
namespace ExternalEditor {
// $VISUAL, then $EDITOR, then the first of nano/vim/vi found on PATH.
// Empty if nothing was found.
std::string defaultCommand();
// Is the first word of `command` an executable we can find?
bool commandExists(const std::string& command);
// Well-known editors that are installed (used for tab completion).
std::vector<std::string> installedEditors();
// Opens `initialText` in the editor and returns the edited text.
// Throws std::runtime_error if the editor cannot be run or exits with an error.
std::string edit(const std::string& command, const std::string& initialText);
} // namespace ExternalEditor
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#include "Completion.h"
// Reads one line with cursor editing, history (up/down) and tab completion.
// Falls back to plain std::getline when stdin/stdout are not a terminal.
class LineEditor {
public:
using CompletionFn = std::function<Completion(const std::string& textBeforeCursor)>;
LineEditor();
void setCompleter(const CompletionFn& completer);
void setHistoryFile(const std::string& path); // loads it and appends new entries
void addHistory(const std::string& line);
// Returns false on end of input (Ctrl-D on an empty line, or closed stdin).
bool readLine(const std::string& prompt, std::string& line);
private:
bool readPlain(const std::string& prompt, std::string& line);
bool readInteractive(const std::string& prompt, std::string& line);
CompletionFn completer_;
std::vector<std::string> history_;
std::string historyFile_;
};
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <optional>
#include <string>
struct TaskRow {
std::string id;
std::optional<std::string> account;
std::string status; // serialized, see StatusUtil::serialize
std::string notes; // free text, any length (stored as SQLite TEXT)
};
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
#include "CommandSpec.h"
#include "Completer.h"
#include "Config.h"
#include "Database.h"
#include "Display.h"
#include "LineEditor.h"
#include "Status.h"
// Interactive prompt: commands are typed live, one per line.
class Repl {
public:
Repl(Database& db, Display& display, Config& config);
int run();
private:
using Args = std::vector<std::string>;
using Handler = bool (Repl::*)(const Args&); // returns true if the panels should be redrawn
struct Command {
CommandSpec spec;
Handler handler;
};
void registerCommands();
void 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);
const Command* find(const Args& tokens, std::size_t& consumed) const;
void execute(const std::string& line);
void refresh() const;
void say(const std::string& message);
void flushMessages();
void endOfInput();
bool confirm(const std::string& question) const;
// Helpers
static void requireName(const std::string& name);
static Status parseStatusFrom(const Args& args, std::size_t from);
// Account commands
bool cmdAccAdd(const Args& args);
bool cmdAccDel(const Args& args);
bool cmdAccRename(const Args& args);
bool cmdAccLs(const Args& args);
// Task commands
bool cmdTskAdd(const Args& args);
bool cmdTskDel(const Args& args);
bool cmdTskLs(const Args& args);
bool cmdTskRename(const Args& args);
bool cmdTskReacc(const Args& args);
bool cmdTskRestat(const Args& args);
bool cmdTskNotes(const Args& args);
// Display commands
bool cmdDisp(const Args& args);
bool cmdDispTsk(const Args& args);
bool cmdDispTskLevel(const Args& args);
bool cmdDispAcc(const Args& args);
bool cmdDispAccLevel(const Args& args);
// Database commands
bool cmdDbSave(const Args& args);
bool cmdDbBackup(const Args& args);
bool cmdDbRestore(const Args& args);
// Misc
bool cmdEditor(const Args& args);
bool cmdHelp(const Args& args);
bool cmdQuit(const Args& args);
Database& db_;
Display& display_;
Config& config_;
std::vector<Command> commands_;
std::vector<CommandSpec> specs_;
Completer completer_;
LineEditor lineEditor_;
std::vector<std::string> messages_;
bool quit_;
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <ctime>
#include <optional>
#include <string>
#include <vector>
// Prompt: free to accept prompts.
// LimitReset: computed - a Wait whose time has passed (can also be set by hand).
// The enum order is the sort order of the panels.
enum class StatusKind { Prompt, LimitReset, Testing, Wait, Uploading, Frozen, Done };
struct Status {
StatusKind kind = StatusKind::Prompt;
std::time_t waitUntil = 0; // only meaningful when kind == Wait
};
namespace StatusUtil {
Status make(StatusKind kind);
// User input such as "testing" or "wait 16:30". A wait time that has already
// passed today is taken to mean tomorrow.
std::optional<Status> parse(const std::string& text, std::time_t now);
// Database representation, e.g. "testing" or "wait 2026-10-17T16:30".
std::string serialize(const Status& status);
std::optional<Status> deserialize(const std::string& text);
// The status shown to the user: an expired wait becomes LimitReset.
Status effective(const Status& status, std::time_t now);
std::string label(const Status& status); // "Prompt", "Limit reset", "Wait 16:30", ...
std::string kindName(StatusKind kind); // lowercase token: "prompt", "limit-reset", ...
std::vector<StatusKind> allKinds();
// Accepts "limit-reset", "limit_reset", "limitreset", "reset"; "available" is an
// old name for "prompt".
std::optional<StatusKind> kindFromName(const std::string& name);
int rank(StatusKind kind); // sort order
bool isBusy(StatusKind kind); // does this status hold the account?
} // namespace StatusUtil
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
namespace StringUtil {
std::string lower(const std::string& text);
std::string trim(const std::string& text);
bool equalsIgnoreCase(const std::string& a, const std::string& b);
bool startsWithIgnoreCase(const std::string& text, const std::string& prefix);
std::string join(const std::vector<std::string>& parts, std::size_t from, const std::string& separator);
// Number of characters (UTF-8 code points) in the text.
std::size_t displayWidth(const std::string& text);
// Collapses line breaks into " / ". If `maxChars` > 0 the text is cut to that
// many characters; 0 means no limit.
std::string singleLine(const std::string& text, std::size_t maxChars);
} // namespace StringUtil
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
class TextTable {
public:
explicit TextTable(const std::vector<std::string>& headers);
void addRow(const std::vector<std::string>& cells);
std::size_t rowCount() const;
std::string render() const;
private:
std::vector<std::string> headers_;
std::vector<std::vector<std::string>> rows_;
};
+11
View File
@@ -0,0 +1,11 @@
#pragma once
namespace Terminal {
bool stdinIsInteractive();
bool stdoutIsInteractive();
// Clears the screen. Does nothing when stdout is not a terminal.
void clearScreen();
} // namespace Terminal
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstddef>
#include <string>
#include <vector>
namespace Tokenizer {
// Splits a command line on whitespace. Double quotes group words into a single
// token ("Breeze A"). Throws std::invalid_argument on an unterminated quote.
std::vector<std::string> split(const std::string& line);
// Lenient split for tab completion: completed words, plus the word being typed.
struct PartialLine {
std::vector<std::string> words; // completed words (quotes removed)
std::string current; // word being typed (quotes removed, may be empty)
std::size_t start = 0; // index in the text where that word begins
};
PartialLine splitPartial(const std::string& text);
} // namespace Tokenizer
+178
View File
@@ -0,0 +1,178 @@
#include "Completer.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <sstream>
#include "ExternalEditor.h"
#include "Status.h"
#include "StringUtil.h"
#include "Tokenizer.h"
namespace {
std::string quoteIfNeeded(const std::string& name, bool force) {
const bool hasSpace = std::any_of(name.begin(), name.end(),
[](unsigned char c) { return std::isspace(c) != 0; });
return (force || hasSpace) ? "\"" + name + "\"" : name;
}
std::vector<std::string> splitWords(const std::string& text) {
std::vector<std::string> words;
std::istringstream in(text);
std::string word;
while (in >> word) {
words.push_back(word);
}
return words;
}
std::vector<std::string> statusNames() {
std::vector<std::string> names;
for (StatusKind kind : StatusUtil::allKinds()) {
names.push_back(StatusUtil::kindName(kind));
}
return names;
}
std::vector<std::string> fileNames(const std::string& partial) {
namespace fs = std::filesystem;
std::string directoryPart;
std::string prefix = partial;
const std::size_t slash = partial.find_last_of("/\\");
if (slash != std::string::npos) {
directoryPart = partial.substr(0, slash + 1);
prefix = partial.substr(slash + 1);
}
std::vector<std::string> names;
std::error_code error;
fs::directory_iterator it(directoryPart.empty() ? fs::path(".") : fs::path(directoryPart), error);
for (fs::directory_iterator end; !error && it != end; it.increment(error)) {
const std::string name = it->path().filename().string();
if (!name.empty() && name[0] == '.' && prefix.empty()) {
continue; // hide dotfiles unless asked for
}
std::error_code statusError;
const bool isDirectory = it->is_directory(statusError);
names.push_back(directoryPart + name + (isDirectory ? "/" : ""));
}
return names;
}
} // namespace
Completer::Completer(const Database& db, const std::vector<CommandSpec>& specs)
: db_(db), specs_(specs) {}
std::vector<std::string> Completer::valuesFor(ArgKind kind, const std::string& current) const {
std::vector<std::string> values;
switch (kind) {
case ArgKind::None:
break;
case ArgKind::Task:
for (const TaskRow& task : db_.listTasks()) {
values.push_back(task.id);
}
break;
case ArgKind::Account:
values = db_.listAccounts();
break;
case ArgKind::AccountOrDash:
values = db_.listAccounts();
values.push_back("-");
break;
case ArgKind::AccountOrStatus:
values = db_.listAccounts();
for (const std::string& name : statusNames()) {
values.push_back(name);
}
break;
case ArgKind::Status:
values = statusNames();
break;
case ArgKind::Visibility:
values = {"show", "hide"};
break;
case ArgKind::TaskLevel:
values = statusNames();
values.push_back("all");
break;
case ArgKind::AccountLevel:
values = {"busy", "free", "all"};
break;
case ArgKind::File:
values = fileNames(current);
break;
case ArgKind::Editor:
values = ExternalEditor::installedEditors();
values.push_back("default");
break;
}
return values;
}
Completion Completer::complete(const std::string& textBeforeCursor) const {
const Tokenizer::PartialLine line = Tokenizer::splitPartial(textBeforeCursor);
std::vector<std::string> words;
for (const std::string& word : line.words) {
words.push_back(StringUtil::lower(word));
}
std::vector<std::string> raw;
// 1. Continue a command name: "tsk" -> add/del/ls/..., "disp tsk" -> level
for (const CommandSpec& spec : specs_) {
const std::vector<std::string> parts = splitWords(spec.key);
if (parts.size() <= words.size()) {
continue;
}
bool matches = true;
for (std::size_t i = 0; i < words.size(); ++i) {
matches = matches && (parts[i] == words[i]);
}
if (matches) {
raw.push_back(parts[words.size()]);
}
}
// 2. Complete an argument of the command typed so far (longest match wins).
for (std::size_t consumed = std::min<std::size_t>(3, words.size()); consumed >= 1; --consumed) {
const std::string key = StringUtil::join(
std::vector<std::string>(words.begin(), words.begin() + static_cast<std::ptrdiff_t>(consumed)), 0, " ");
const CommandSpec* found = nullptr;
for (const CommandSpec& spec : specs_) {
if (spec.key == key) {
found = &spec;
break;
}
}
if (!found) {
continue;
}
const std::size_t argIndex = words.size() - consumed;
if (argIndex < found->argKinds.size()) {
for (const std::string& value : valuesFor(found->argKinds[argIndex], line.current)) {
raw.push_back(value);
}
}
break;
}
const bool forceQuote = line.start < textBeforeCursor.size() && textBeforeCursor[line.start] == '"';
Completion completion;
completion.start = line.start;
for (const std::string& value : raw) {
if (StringUtil::startsWithIgnoreCase(value, line.current)) {
completion.candidates.push_back(quoteIfNeeded(value, forceQuote));
}
}
std::sort(completion.candidates.begin(), completion.candidates.end());
completion.candidates.erase(std::unique(completion.candidates.begin(), completion.candidates.end()),
completion.candidates.end());
return completion;
}
+69
View File
@@ -0,0 +1,69 @@
#include "Config.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include "StringUtil.h"
namespace fs = std::filesystem;
Config::Config() : directory_(directory()) {}
std::string Config::directory() {
fs::path base;
#ifdef _WIN32
const char* appData = std::getenv("APPDATA");
base = (appData && *appData) ? fs::path(appData) : fs::current_path();
#else
const char* xdg = std::getenv("XDG_CONFIG_HOME");
const char* home = std::getenv("HOME");
if (xdg && *xdg) {
base = xdg;
} else if (home && *home) {
base = fs::path(home) / ".config";
} else {
base = fs::current_path();
}
#endif
return (base / "task_tracker").string();
}
void Config::load() {
std::ifstream in(fs::path(directory_) / "config");
std::string line;
while (std::getline(in, line)) {
const std::size_t equals = line.find('=');
if (equals == std::string::npos) {
continue;
}
const std::string key = StringUtil::trim(line.substr(0, equals));
const std::string value = StringUtil::trim(line.substr(equals + 1));
if (key == "editor") {
editor_ = value;
}
}
}
void Config::save() const {
std::error_code error;
fs::create_directories(directory_, error);
std::ofstream out(fs::path(directory_) / "config", std::ios::trunc);
if (!out) {
throw std::runtime_error("Cannot write the config file in " + directory_);
}
out << "editor=" << editor_ << "\n";
}
const std::string& Config::editor() const {
return editor_;
}
void Config::setEditor(const std::string& command) {
editor_ = command;
}
std::string Config::historyPath() const {
return (fs::path(directory_) / "history").string();
}
+294
View File
@@ -0,0 +1,294 @@
#include "Database.h"
#include <sqlite3.h>
#include <cstdio>
#include <ctime>
#include <iomanip>
#include <memory>
#include <sstream>
#include <stdexcept>
namespace {
const char* const kSchema =
"CREATE TABLE IF NOT EXISTS Account ("
" id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE"
");"
"CREATE TABLE IF NOT EXISTS Task ("
" id TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,"
" account TEXT COLLATE NOCASE REFERENCES Account(id) ON UPDATE CASCADE ON DELETE SET NULL,"
" status TEXT NOT NULL DEFAULT 'prompt',"
" notes TEXT NOT NULL DEFAULT ''"
");"
"CREATE INDEX IF NOT EXISTS idx_task_account ON Task(account);";
using Connection = std::unique_ptr<sqlite3, int (*)(sqlite3*)>;
Connection openFile(const std::string& path, int flags) {
sqlite3* raw = nullptr;
int rc = sqlite3_open_v2(path.c_str(), &raw, flags, nullptr);
Connection connection(raw, &sqlite3_close);
if (rc != SQLITE_OK) {
throw std::runtime_error("Cannot open '" + path + "': " +
(raw ? sqlite3_errmsg(raw) : "out of memory"));
}
return connection;
}
void copyDatabase(sqlite3* from, sqlite3* to) {
sqlite3_backup* backup = sqlite3_backup_init(to, "main", from, "main");
if (!backup) {
throw std::runtime_error(std::string("Copy failed: ") + sqlite3_errmsg(to));
}
int rc = sqlite3_backup_step(backup, -1);
sqlite3_backup_finish(backup);
if (rc != SQLITE_DONE) {
throw std::runtime_error(std::string("Copy failed: ") + sqlite3_errmsg(to));
}
}
// True if the connection holds a database with our two tables.
bool hasExpectedTables(sqlite3* db) {
sqlite3_stmt* stmt = nullptr;
const char* sql = "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('Task', 'Account')";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
int count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return count == 2;
}
std::string timestampedName(const std::string& basePath) {
std::time_t now = std::time(nullptr);
std::tm parts{};
#ifdef _WIN32
localtime_s(&parts, &now);
#else
localtime_r(&now, &parts);
#endif
std::ostringstream out;
out << basePath << "." << std::put_time(&parts, "%Y%m%d-%H%M%S") << ".bak";
return out.str();
}
} // namespace
Database::Database() : db_(nullptr), dirty_(false) {
if (sqlite3_open(":memory:", &db_) != SQLITE_OK) {
std::string message = db_ ? sqlite3_errmsg(db_) : "out of memory";
sqlite3_close(db_);
db_ = nullptr;
throw std::runtime_error("Cannot create in-memory database: " + message);
}
exec("PRAGMA foreign_keys = ON;");
createSchema();
}
Database::~Database() {
sqlite3_close(db_);
}
void Database::createSchema() {
exec(kSchema);
// "available" was split into "prompt" and "limit-reset"; old files still say "available".
exec("UPDATE Task SET status = 'prompt' WHERE status = 'available';");
}
void Database::exec(const std::string& sql) {
char* error = nullptr;
if (sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &error) != SQLITE_OK) {
std::string message = error ? error : "unknown error";
sqlite3_free(error);
throw std::runtime_error(message);
}
}
std::vector<Database::Row> Database::query(const std::string& sql, const std::vector<Param>& params) const {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(db_));
}
for (std::size_t i = 0; i < params.size(); ++i) {
int index = static_cast<int>(i) + 1;
if (params[i]) {
sqlite3_bind_text64(stmt, index, params[i]->data(), params[i]->size(), SQLITE_TRANSIENT, SQLITE_UTF8);
} else {
sqlite3_bind_null(stmt, index);
}
}
std::vector<Row> rows;
int rc = SQLITE_OK;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
Row row;
int columns = sqlite3_column_count(stmt);
for (int c = 0; c < columns; ++c) {
if (sqlite3_column_type(stmt, c) == SQLITE_NULL) {
row.emplace_back(std::nullopt);
} else {
const unsigned char* text = sqlite3_column_text(stmt, c);
const int bytes = sqlite3_column_bytes(stmt, c);
row.emplace_back(std::string(reinterpret_cast<const char*>(text), static_cast<std::size_t>(bytes)));
}
}
rows.push_back(row);
}
std::string error = (rc == SQLITE_DONE) ? "" : sqlite3_errmsg(db_);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
throw std::runtime_error(error);
}
return rows;
}
void Database::run(const std::string& sql, const std::vector<Param>& params) {
query(sql, params);
dirty_ = true;
}
void Database::open(const std::string& path) {
path_ = path;
dirty_ = false;
std::FILE* probe = std::fopen(path.c_str(), "rb");
if (!probe) {
return; // new database, will be created on first save
}
std::fclose(probe);
restore(path);
dirty_ = false;
}
void Database::save() {
if (path_.empty()) {
throw std::runtime_error("No database file set.");
}
Connection file = openFile(path_, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
copyDatabase(db_, file.get());
dirty_ = false;
}
std::string Database::backup(const std::string& fileName) {
std::string target = fileName.empty() ? timestampedName(path_.empty() ? "tasks.db" : path_) : fileName;
Connection file = openFile(target, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
copyDatabase(db_, file.get());
return target;
}
void Database::restore(const std::string& fileName) {
Connection file = openFile(fileName, SQLITE_OPEN_READONLY);
if (!hasExpectedTables(file.get())) {
throw std::runtime_error("'" + fileName + "' is not a task database.");
}
copyDatabase(file.get(), db_);
exec("PRAGMA foreign_keys = ON;");
createSchema();
dirty_ = true;
}
bool Database::dirty() const {
return dirty_;
}
const std::string& Database::path() const {
return path_;
}
// ---- Accounts ---------------------------------------------------------------
std::optional<std::string> Database::findAccount(const std::string& name) const {
std::vector<Row> rows = query("SELECT id FROM Account WHERE id = ?1", {name});
if (rows.empty()) {
return std::nullopt;
}
return rows[0][0];
}
std::vector<std::string> Database::listAccounts() const {
std::vector<std::string> names;
for (const Row& row : query("SELECT id FROM Account ORDER BY id")) {
names.push_back(*row[0]);
}
return names;
}
void Database::addAccount(const std::string& name) {
run("INSERT INTO Account (id) VALUES (?1)", {name});
}
void Database::removeAccount(const std::string& name) {
run("DELETE FROM Account WHERE id = ?1", {name});
}
void Database::renameAccount(const std::string& name, const std::string& newName) {
run("UPDATE Account SET id = ?2 WHERE id = ?1", {name, newName});
}
// ---- Tasks ------------------------------------------------------------------
std::optional<std::string> Database::findTask(const std::string& name) const {
std::vector<Row> rows = query("SELECT id FROM Task WHERE id = ?1", {name});
if (rows.empty()) {
return std::nullopt;
}
return rows[0][0];
}
std::optional<TaskRow> Database::getTask(const std::string& name) const {
std::vector<Row> rows = query("SELECT id, account, status, notes FROM Task WHERE id = ?1", {name});
if (rows.empty()) {
return std::nullopt;
}
TaskRow task;
task.id = *rows[0][0];
task.account = rows[0][1];
task.status = *rows[0][2];
task.notes = *rows[0][3];
return task;
}
std::vector<TaskRow> Database::listTasks() const {
std::vector<TaskRow> tasks;
for (const Row& row : query("SELECT id, account, status, notes FROM Task ORDER BY id")) {
TaskRow task;
task.id = *row[0];
task.account = row[1];
task.status = *row[2];
task.notes = *row[3];
tasks.push_back(task);
}
return tasks;
}
void Database::addTask(const std::string& name, const std::optional<std::string>& account,
const std::string& status) {
run("INSERT INTO Task (id, account, status) VALUES (?1, ?2, ?3)", {name, account, status});
}
void Database::removeTask(const std::string& name) {
run("DELETE FROM Task WHERE id = ?1", {name});
}
void Database::renameTask(const std::string& name, const std::string& newName) {
run("UPDATE Task SET id = ?2 WHERE id = ?1", {name, newName});
}
void Database::setTaskAccount(const std::string& name, const std::optional<std::string>& account) {
run("UPDATE Task SET account = ?2 WHERE id = ?1", {name, account});
}
void Database::setTaskStatus(const std::string& name, const std::string& status) {
run("UPDATE Task SET status = ?2 WHERE id = ?1", {name, status});
}
void Database::setTaskNotes(const std::string& name, const std::string& notes) {
run("UPDATE Task SET notes = ?2 WHERE id = ?1", {name, notes});
}
+98
View File
@@ -0,0 +1,98 @@
#include "Display.h"
#include <algorithm>
#include <optional>
#include <vector>
#include "StringUtil.h"
#include "Table.h"
namespace {
struct TaskView {
std::string name;
std::string account; // empty = unassigned
std::string notes;
Status status; // effective status at render time
};
std::vector<TaskView> loadViews(const Database& db, std::time_t now) {
std::vector<TaskView> views;
for (const TaskRow& row : db.listTasks()) {
TaskView view;
view.name = row.id;
view.account = row.account.value_or("");
view.notes = row.notes;
std::optional<Status> stored = StatusUtil::deserialize(row.status);
view.status = StatusUtil::effective(stored.value_or(StatusUtil::make(StatusKind::Prompt)), now);
views.push_back(view);
}
return views;
}
bool taskLess(const TaskView& a, const TaskView& b) {
int rankA = StatusUtil::rank(a.status.kind);
int rankB = StatusUtil::rank(b.status.kind);
if (rankA != rankB) {
return rankA < rankB;
}
if (a.status.kind == StatusKind::Wait && a.status.waitUntil != b.status.waitUntil) {
return a.status.waitUntil < b.status.waitUntil;
}
return StringUtil::lower(a.name) < StringUtil::lower(b.name);
}
} // namespace
Display::Display(const Database& db) : db_(db) {}
DisplaySettings& Display::settings() {
return settings_;
}
const DisplaySettings& Display::settings() const {
return settings_;
}
std::string Display::renderTasks(std::time_t now) const {
std::vector<TaskView> views = loadViews(db_, now);
std::sort(views.begin(), views.end(), taskLess);
TextTable table({"TASK", "ACCOUNT", "STATUS", "NOTES"});
for (const TaskView& view : views) {
if (settings_.hiddenTaskLevels.count(view.status.kind) > 0) {
continue;
}
table.addRow({view.name,
view.account.empty() ? "-" : view.account,
StatusUtil::label(view.status),
view.notes.empty() ? "-" : StringUtil::singleLine(view.notes, 0)});
}
return table.render();
}
std::string Display::renderAccounts(std::time_t now) const {
std::vector<TaskView> views = loadViews(db_, now);
std::sort(views.begin(), views.end(), taskLess);
// One row per account. It is Busy while any of its tasks is Testing or
// Wait (those tasks are listed); otherwise it is Free.
TextTable table({"ACCOUNT", "STATUS", "TASKS"});
for (const std::string& account : db_.listAccounts()) {
std::vector<std::string> busyTasks;
for (const TaskView& view : views) {
if (view.account == account && StatusUtil::isBusy(view.status.kind)) {
busyTasks.push_back(view.name);
}
}
const AccountLevel level = busyTasks.empty() ? AccountLevel::Free : AccountLevel::Busy;
if (settings_.hiddenAccountLevels.count(level) > 0) {
continue;
}
table.addRow({account,
level == AccountLevel::Busy ? "Busy" : "Free",
busyTasks.empty() ? "-" : StringUtil::join(busyTasks, 0, ", ")});
}
return table.render();
}
+170
View File
@@ -0,0 +1,170 @@
#include "ExternalEditor.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <sstream>
#include <stdexcept>
#if defined(__unix__) || defined(__APPLE__)
#include <unistd.h>
#endif
namespace fs = std::filesystem;
namespace {
#ifdef _WIN32
const char kPathSeparator = ';';
#else
const char kPathSeparator = ':';
#endif
bool isExecutableFile(const fs::path& path) {
std::error_code error;
if (!fs::is_regular_file(path, error)) {
return false;
}
#if defined(__unix__) || defined(__APPLE__)
return access(path.c_str(), X_OK) == 0;
#else
return true;
#endif
}
bool findInPath(const std::string& program) {
if (program.find('/') != std::string::npos || program.find('\\') != std::string::npos) {
return isExecutableFile(program);
}
const char* pathVariable = std::getenv("PATH");
if (!pathVariable) {
return false;
}
std::stringstream paths(pathVariable);
std::string directory;
while (std::getline(paths, directory, kPathSeparator)) {
if (directory.empty()) {
continue;
}
if (isExecutableFile(fs::path(directory) / program)) {
return true;
}
#ifdef _WIN32
if (isExecutableFile(fs::path(directory) / (program + ".exe"))) {
return true;
}
#endif
}
return false;
}
std::string firstWord(const std::string& command) {
std::istringstream in(command);
std::string word;
in >> word;
return word;
}
std::string quotePath(const std::string& path) {
#ifdef _WIN32
return "\"" + path + "\"";
#else
std::string quoted = "'";
for (char c : path) {
if (c == '\'') {
quoted += "'\\''";
} else {
quoted.push_back(c);
}
}
return quoted + "'";
#endif
}
std::string makeTempFile(const std::string& content) {
#if defined(__unix__) || defined(__APPLE__)
std::string pattern = (fs::temp_directory_path() / "task-tracker-XXXXXX.txt").string();
std::vector<char> buffer(pattern.begin(), pattern.end());
buffer.push_back('\0');
int fd = mkstemps(buffer.data(), 4);
if (fd < 0) {
throw std::runtime_error("Cannot create a temporary file for the editor.");
}
std::size_t written = 0;
while (written < content.size()) {
ssize_t n = ::write(fd, content.data() + written, content.size() - written);
if (n <= 0) {
::close(fd);
throw std::runtime_error("Cannot write the temporary file for the editor.");
}
written += static_cast<std::size_t>(n);
}
::close(fd);
return std::string(buffer.data());
#else
fs::path path = fs::temp_directory_path() / ("task-tracker-" + std::to_string(std::rand()) + ".txt");
std::ofstream out(path, std::ios::binary);
out << content;
if (!out) {
throw std::runtime_error("Cannot write the temporary file for the editor.");
}
return path.string();
#endif
}
} // namespace
namespace ExternalEditor {
std::string defaultCommand() {
for (const char* variable : {"VISUAL", "EDITOR"}) {
const char* value = std::getenv(variable);
if (value && *value) {
return value;
}
}
for (const char* candidate : {"nano", "vim", "vi"}) {
if (findInPath(candidate)) {
return candidate;
}
}
return "";
}
bool commandExists(const std::string& command) {
const std::string program = firstWord(command);
return !program.empty() && findInPath(program);
}
std::vector<std::string> installedEditors() {
std::vector<std::string> found;
for (const char* candidate : {"nano", "vim", "nvim", "vi", "emacs", "micro", "helix", "hx", "code", "kate", "gedit"}) {
if (findInPath(candidate)) {
found.push_back(candidate);
}
}
return found;
}
std::string edit(const std::string& command, const std::string& initialText) {
const std::string path = makeTempFile(initialText);
const std::string commandLine = command + " " + quotePath(path);
const int status = std::system(commandLine.c_str());
std::ifstream in(path, std::ios::binary);
std::string content((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
in.close();
std::error_code error;
fs::remove(path, error);
if (status != 0) {
throw std::runtime_error("The editor exited with an error; nothing was changed.");
}
return content;
}
} // namespace ExternalEditor
+367
View File
@@ -0,0 +1,367 @@
#include "LineEditor.h"
#include <cctype>
#include <fstream>
#include <iostream>
#include "StringUtil.h"
#include "Terminal.h"
#if defined(__unix__) || defined(__APPLE__)
#define TT_HAVE_TERMIOS 1
#include <poll.h>
#include <termios.h>
#include <unistd.h>
#include <cerrno>
#endif
namespace {
const std::size_t kMaxHistory = 500;
#ifdef TT_HAVE_TERMIOS
// Puts the terminal into raw-ish mode and restores it on destruction.
class RawMode {
public:
RawMode();
~RawMode();
bool active() const;
private:
termios saved_;
bool active_;
};
RawMode::RawMode() : saved_(), active_(false) {
if (tcgetattr(STDIN_FILENO, &saved_) != 0) {
return;
}
termios raw = saved_;
raw.c_lflag &= static_cast<tcflag_t>(~(ICANON | ECHO | ISIG | IEXTEN));
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
active_ = (tcsetattr(STDIN_FILENO, TCSADRAIN, &raw) == 0);
}
RawMode::~RawMode() {
if (active_) {
tcsetattr(STDIN_FILENO, TCSADRAIN, &saved_);
}
}
bool RawMode::active() const {
return active_;
}
int readByte() {
unsigned char c = 0;
for (;;) {
ssize_t n = ::read(STDIN_FILENO, &c, 1);
if (n == 1) {
return c;
}
if (n == 0 || errno != EINTR) {
return -1;
}
}
}
bool byteAvailable(int milliseconds) {
pollfd descriptor;
descriptor.fd = STDIN_FILENO;
descriptor.events = POLLIN;
descriptor.revents = 0;
return poll(&descriptor, 1, milliseconds) > 0;
}
std::size_t previousChar(const std::string& text, std::size_t position) {
if (position == 0) {
return 0;
}
--position;
while (position > 0 && (static_cast<unsigned char>(text[position]) & 0xC0) == 0x80) {
--position;
}
return position;
}
std::size_t nextChar(const std::string& text, std::size_t position) {
if (position >= text.size()) {
return text.size();
}
++position;
while (position < text.size() && (static_cast<unsigned char>(text[position]) & 0xC0) == 0x80) {
++position;
}
return position;
}
std::size_t previousWord(const std::string& text, std::size_t position) {
while (position > 0 && std::isspace(static_cast<unsigned char>(text[position - 1]))) {
--position;
}
while (position > 0 && !std::isspace(static_cast<unsigned char>(text[position - 1]))) {
--position;
}
return position;
}
std::size_t nextWord(const std::string& text, std::size_t position) {
while (position < text.size() && std::isspace(static_cast<unsigned char>(text[position]))) {
++position;
}
while (position < text.size() && !std::isspace(static_cast<unsigned char>(text[position]))) {
++position;
}
return position;
}
void redraw(const std::string& prompt, const std::string& buffer, std::size_t cursor) {
std::string out = "\r" + prompt + buffer + "\x1b[K";
const std::size_t back = StringUtil::displayWidth(buffer.substr(cursor));
if (back > 0) {
out += "\x1b[" + std::to_string(back) + "D";
}
std::cout << out << std::flush;
}
std::string commonPrefix(const std::vector<std::string>& values) {
std::string prefix = values.front();
for (const std::string& value : values) {
std::size_t n = 0;
while (n < prefix.size() && n < value.size() && prefix[n] == value[n]) {
++n;
}
prefix.resize(n);
}
// never cut a UTF-8 character in half
while (!prefix.empty() && prefix.size() < values.front().size() &&
(static_cast<unsigned char>(values.front()[prefix.size()]) & 0xC0) == 0x80) {
prefix.pop_back();
}
return prefix;
}
void printCandidates(const std::vector<std::string>& candidates) {
const std::size_t lineWidth = 78;
std::string line;
std::cout << "\n";
for (const std::string& candidate : candidates) {
if (!line.empty() && StringUtil::displayWidth(line) + 2 + StringUtil::displayWidth(candidate) > lineWidth) {
std::cout << line << "\n";
line.clear();
}
line += line.empty() ? candidate : " " + candidate;
}
std::cout << line << "\n";
}
#endif // TT_HAVE_TERMIOS
} // namespace
LineEditor::LineEditor() = default;
void LineEditor::setCompleter(const CompletionFn& completer) {
completer_ = completer;
}
void LineEditor::setHistoryFile(const std::string& path) {
historyFile_ = path;
history_.clear();
std::ifstream in(path);
std::string line;
while (std::getline(in, line)) {
if (!line.empty()) {
history_.push_back(line);
}
}
in.close();
if (history_.size() > kMaxHistory) {
history_.erase(history_.begin(), history_.end() - static_cast<std::ptrdiff_t>(kMaxHistory));
std::ofstream out(path, std::ios::trunc);
for (const std::string& entry : history_) {
out << entry << "\n";
}
}
}
void LineEditor::addHistory(const std::string& line) {
if (StringUtil::trim(line).empty() || (!history_.empty() && history_.back() == line)) {
return;
}
history_.push_back(line);
if (history_.size() > kMaxHistory) {
history_.erase(history_.begin());
}
if (!historyFile_.empty()) {
std::ofstream out(historyFile_, std::ios::app);
out << line << "\n";
}
}
bool LineEditor::readLine(const std::string& prompt, std::string& line) {
#ifdef TT_HAVE_TERMIOS
if (Terminal::stdinIsInteractive() && Terminal::stdoutIsInteractive()) {
return readInteractive(prompt, line);
}
#endif
return readPlain(prompt, line);
}
bool LineEditor::readPlain(const std::string& prompt, std::string& line) {
std::cout << prompt << std::flush;
return static_cast<bool>(std::getline(std::cin, line));
}
#ifdef TT_HAVE_TERMIOS
bool LineEditor::readInteractive(const std::string& prompt, std::string& line) {
RawMode raw;
if (!raw.active()) {
return readPlain(prompt, line);
}
std::string buffer;
std::size_t cursor = 0;
std::size_t historyIndex = history_.size();
std::string pending; // the line being typed while browsing history
redraw(prompt, buffer, cursor);
for (;;) {
const int c = readByte();
if (c < 0) {
std::cout << "\n";
return false;
}
if (c == '\n' || c == '\r') {
std::cout << "\n" << std::flush;
line = buffer;
addHistory(buffer);
return true;
}
if (c == 4) { // Ctrl-D
if (buffer.empty()) {
std::cout << "\n";
return false;
}
buffer.erase(cursor, nextChar(buffer, cursor) - cursor);
} else if (c == 3) { // Ctrl-C
std::cout << "^C\n" << std::flush;
line.clear();
return true;
} else if (c == 1) { // Ctrl-A
cursor = 0;
} else if (c == 5) { // Ctrl-E
cursor = buffer.size();
} else if (c == 2) { // Ctrl-B
cursor = previousChar(buffer, cursor);
} else if (c == 6) { // Ctrl-F
cursor = nextChar(buffer, cursor);
} else if (c == 11) { // Ctrl-K
buffer.erase(cursor);
} else if (c == 21) { // Ctrl-U
buffer.erase(0, cursor);
cursor = 0;
} else if (c == 23) { // Ctrl-W
const std::size_t start = previousWord(buffer, cursor);
buffer.erase(start, cursor - start);
cursor = start;
} else if (c == 12) { // Ctrl-L
Terminal::clearScreen();
} else if (c == 127 || c == 8) { // Backspace
const std::size_t start = previousChar(buffer, cursor);
buffer.erase(start, cursor - start);
cursor = start;
} else if (c == 9) { // Tab
if (!completer_) {
continue;
}
const Completion completion = completer_(buffer.substr(0, cursor));
if (completion.candidates.empty()) {
std::cout << "\a" << std::flush;
continue;
}
const std::string word = buffer.substr(completion.start, cursor - completion.start);
std::string replacement = commonPrefix(completion.candidates);
bool showList = false;
if (completion.candidates.size() == 1) {
if (replacement.empty() || replacement.back() != '/') {
replacement += ' ';
}
} else if (replacement.size() <= word.size()) {
showList = true;
}
buffer.replace(completion.start, cursor - completion.start, replacement);
cursor = completion.start + replacement.size();
if (showList) {
printCandidates(completion.candidates);
}
} else if (c == 27) { // Escape sequences (arrows, home, end, delete...)
if (!byteAvailable(30)) {
continue;
}
const int introducer = readByte();
if (introducer != '[' && introducer != 'O') {
continue;
}
std::string parameters;
int lastByte = readByte();
while (lastByte >= 0 && (std::isdigit(lastByte) || lastByte == ';')) {
parameters.push_back(static_cast<char>(lastByte));
lastByte = readByte();
}
const bool withModifier = parameters.find(';') != std::string::npos;
if (lastByte == '~') {
if (parameters == "3") {
buffer.erase(cursor, nextChar(buffer, cursor) - cursor);
} else if (parameters == "1" || parameters == "7") {
cursor = 0;
} else if (parameters == "4" || parameters == "8") {
cursor = buffer.size();
}
} else if (lastByte == 'A') { // Up
if (historyIndex > 0) {
if (historyIndex == history_.size()) {
pending = buffer;
}
--historyIndex;
buffer = history_[historyIndex];
cursor = buffer.size();
}
} else if (lastByte == 'B') { // Down
if (historyIndex < history_.size()) {
++historyIndex;
buffer = (historyIndex == history_.size()) ? pending : history_[historyIndex];
cursor = buffer.size();
}
} else if (lastByte == 'C') { // Right
cursor = withModifier ? nextWord(buffer, cursor) : nextChar(buffer, cursor);
} else if (lastByte == 'D') { // Left
cursor = withModifier ? previousWord(buffer, cursor) : previousChar(buffer, cursor);
} else if (lastByte == 'H') {
cursor = 0;
} else if (lastByte == 'F') {
cursor = buffer.size();
}
} else if (c >= 32) { // Printable (including UTF-8 bytes)
buffer.insert(cursor, 1, static_cast<char>(c));
++cursor;
}
redraw(prompt, buffer, cursor);
}
}
#endif // TT_HAVE_TERMIOS
+570
View File
@@ -0,0 +1,570 @@
#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();
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") {
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), 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 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", 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|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)) {
endOfInput();
return 0;
}
execute(line);
}
return 0;
}
void Repl::endOfInput() {
if (!db_.dirty()) {
return;
}
if (Terminal::stdinIsInteractive()) {
if (confirm("Save changes before exiting?")) {
db_.save();
std::cout << "Saved to " << db_.path() << ".\n";
}
} else {
db_.save();
std::cout << "Input closed - changes saved to " << db_.path() << ".\n";
}
}
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;
}
if ((this->*command->handler)(args)) {
refresh();
}
flushMessages();
} catch (const std::exception& error) {
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 = std::time(nullptr);
Terminal::clearScreen();
if (settings.showTasks) {
std::cout << display_.renderTasks(now) << "\n";
}
if (settings.showAccounts) {
std::cout << display_.renderAccounts(now) << "\n";
}
}
bool Repl::confirm(const std::string& question) const {
std::cout << question << " (y/n): " << std::flush;
std::string answer;
if (!std::getline(std::cin, answer)) {
return false;
}
answer = StringUtil::lower(StringUtil::trim(answer));
return answer == "y" || answer == "yes";
}
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.");
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.");
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] + "'.");
return true;
}
bool Repl::cmdAccLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderAccounts(std::time(nullptr)) << "\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.");
}
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 if (!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()) {
status = parseStatusFrom(args, next);
}
db_.addTask(name, account, StatusUtil::serialize(status));
say("Task '" + name + "' added.");
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.");
return true;
}
bool Repl::cmdTskLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderTasks(std::time(nullptr)) << "\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] + "'.");
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] + "'.");
}
}
db_.setTaskAccount(*name, account);
say("Task '" + *name + "' now on account " + (account ? "'" + *account + "'" : std::string("(none)")) + ".");
return true;
}
bool Repl::cmdTskRestat(const Args& args) {
std::optional<std::string> name = db_.findTask(args[0]);
if (!name) {
throw std::runtime_error("No task named '" + args[0] + "'.");
}
Status status = parseStatusFrom(args, 1);
db_.setTaskStatus(*name, StatusUtil::serialize(status));
say("Task '" + *name + "' set to " + StatusUtil::label(status) + ".");
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));
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;
}
// ---- 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 << (level == AccountLevel::Busy ? " busy" : " free");
}
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::Free};
} else if (level == "busy") {
targets = {AccountLevel::Busy};
} else if (level == "free") {
targets = {AccountLevel::Free};
} else {
throw std::runtime_error("Unknown level '" + args[1] + "'. Use: busy, 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() && !confirm("Unsaved changes will be replaced. Continue?")) {
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: " << kStatusHelp << "\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() && confirm("Save changes before exiting?")) {
db_.save();
say("Saved to " + db_.path() + ".");
}
quit_ = true;
return false;
}
+253
View File
@@ -0,0 +1,253 @@
#include "Status.h"
#include <cctype>
#include <iomanip>
#include <sstream>
#include <utility>
#include "StringUtil.h"
namespace {
std::tm toLocal(std::time_t value) {
std::tm out{};
#ifdef _WIN32
localtime_s(&out, &value);
#else
localtime_r(&value, &out);
#endif
return out;
}
std::string alphanumeric(const std::string& text) {
std::string out;
for (char c : text) {
if (std::isalnum(static_cast<unsigned char>(c))) {
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
}
}
return out;
}
std::string formatTime(std::time_t value, const char* format) {
std::tm parts = toLocal(value);
std::ostringstream out;
out << std::put_time(&parts, format);
return out.str();
}
// Accepts 24-hour ("16:30", "6:40") and 12-hour ("4:30pm", "4pm", "12:05 am")
// clock times. Returns {hour, minute} in 24-hour form.
std::optional<std::pair<int, int>> parseClock(const std::string& text) {
std::string t;
for (char c : text) {
if (!std::isspace(static_cast<unsigned char>(c))) {
t.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
}
}
std::size_t i = 0;
auto readNumber = [&t, &i](int& value) {
const std::size_t begin = i;
value = 0;
while (i < t.size() && std::isdigit(static_cast<unsigned char>(t[i])) && i - begin < 2) {
value = value * 10 + (t[i] - '0');
++i;
}
return i > begin;
};
int hour = 0;
int minute = 0;
bool hasColon = false;
if (!readNumber(hour)) {
return std::nullopt;
}
if (i < t.size() && t[i] == ':') {
++i;
hasColon = true;
if (!readNumber(minute)) {
return std::nullopt;
}
}
const std::string suffix = t.substr(i);
const bool hasMeridiem = (suffix == "am" || suffix == "pm");
if (!suffix.empty() && !hasMeridiem) {
return std::nullopt;
}
if (!hasColon && !hasMeridiem) {
return std::nullopt; // a bare "6" is ambiguous
}
if (minute > 59) {
return std::nullopt;
}
if (hasMeridiem) {
if (hour < 1 || hour > 12) {
return std::nullopt;
}
hour = hour % 12 + (suffix == "pm" ? 12 : 0);
} else if (hour > 23) {
return std::nullopt;
}
return std::make_pair(hour, minute);
}
} // namespace
namespace StatusUtil {
Status make(StatusKind kind) {
Status status;
status.kind = kind;
return status;
}
std::string kindName(StatusKind kind) {
switch (kind) {
case StatusKind::Prompt: return "prompt";
case StatusKind::LimitReset: return "limit-reset";
case StatusKind::Testing: return "testing";
case StatusKind::Wait: return "wait";
case StatusKind::Uploading: return "uploading";
case StatusKind::Frozen: return "frozen";
case StatusKind::Done: return "done";
}
return "prompt";
}
std::vector<StatusKind> allKinds() {
return {StatusKind::Prompt, StatusKind::LimitReset, StatusKind::Testing, StatusKind::Wait,
StatusKind::Uploading, StatusKind::Frozen, StatusKind::Done};
}
std::optional<StatusKind> kindFromName(const std::string& name) {
const std::string wanted = alphanumeric(name);
if (wanted == "available") {
return StatusKind::Prompt; // old name
}
if (wanted == "reset") {
return StatusKind::LimitReset;
}
for (StatusKind kind : allKinds()) {
if (alphanumeric(kindName(kind)) == wanted) {
return kind;
}
}
return std::nullopt;
}
int rank(StatusKind kind) {
return static_cast<int>(kind);
}
bool isBusy(StatusKind kind) {
return kind == StatusKind::Testing || kind == StatusKind::Wait;
}
std::optional<Status> parse(const std::string& text, std::time_t now) {
std::istringstream in(text);
std::string word;
in >> word;
if (StringUtil::lower(word) == "limit") { // "limit reset" typed as two words
std::string second;
in >> second;
word += second;
}
std::optional<StatusKind> kind = kindFromName(word);
if (!kind) {
return std::nullopt;
}
// Everything after the status word, with spaces removed: "6:40 pm" -> "6:40pm".
std::string rest;
std::string piece;
while (in >> piece) {
rest += piece;
}
if (*kind != StatusKind::Wait) {
if (!rest.empty()) {
return std::nullopt;
}
return make(*kind);
}
std::optional<std::pair<int, int>> clock = parseClock(rest);
if (!clock) {
return std::nullopt;
}
std::tm target = toLocal(now);
target.tm_hour = clock->first;
target.tm_min = clock->second;
target.tm_sec = 0;
target.tm_isdst = -1;
std::time_t when = std::mktime(&target);
if (when <= now) {
target.tm_mday += 1; // already passed today -> tomorrow
target.tm_isdst = -1;
when = std::mktime(&target);
}
Status status = make(StatusKind::Wait);
status.waitUntil = when;
return status;
}
std::string serialize(const Status& status) {
if (status.kind == StatusKind::Wait) {
return "wait " + formatTime(status.waitUntil, "%Y-%m-%dT%H:%M");
}
return kindName(status.kind);
}
std::optional<Status> deserialize(const std::string& text) {
std::istringstream in(text);
std::string word;
std::string argument;
in >> word >> argument;
std::optional<StatusKind> kind = kindFromName(word);
if (!kind) {
return std::nullopt;
}
if (*kind != StatusKind::Wait) {
return make(*kind);
}
std::tm parts{};
std::istringstream timeIn(argument);
timeIn >> std::get_time(&parts, "%Y-%m-%dT%H:%M");
if (timeIn.fail()) {
return std::nullopt;
}
parts.tm_isdst = -1;
Status status = make(StatusKind::Wait);
status.waitUntil = std::mktime(&parts);
return status;
}
Status effective(const Status& status, std::time_t now) {
if (status.kind == StatusKind::Wait && now >= status.waitUntil) {
return make(StatusKind::LimitReset);
}
return status;
}
std::string label(const Status& status) {
if (status.kind == StatusKind::Wait) {
return "Wait " + formatTime(status.waitUntil, "%H:%M");
}
if (status.kind == StatusKind::LimitReset) {
return "Limit reset";
}
std::string name = kindName(status.kind);
name[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(name[0])));
return name;
}
} // namespace StatusUtil
+93
View File
@@ -0,0 +1,93 @@
#include "StringUtil.h"
#include <algorithm>
#include <cctype>
namespace StringUtil {
std::string lower(const std::string& text) {
std::string out = text;
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return out;
}
std::string trim(const std::string& text) {
std::size_t begin = 0;
std::size_t end = text.size();
while (begin < end && std::isspace(static_cast<unsigned char>(text[begin]))) {
++begin;
}
while (end > begin && std::isspace(static_cast<unsigned char>(text[end - 1]))) {
--end;
}
return text.substr(begin, end - begin);
}
bool equalsIgnoreCase(const std::string& a, const std::string& b) {
return lower(a) == lower(b);
}
bool startsWithIgnoreCase(const std::string& text, const std::string& prefix) {
return lower(text).rfind(lower(prefix), 0) == 0;
}
std::string join(const std::vector<std::string>& parts, std::size_t from, const std::string& separator) {
std::string out;
for (std::size_t i = from; i < parts.size(); ++i) {
if (i > from) {
out += separator;
}
out += parts[i];
}
return out;
}
std::size_t displayWidth(const std::string& text) {
std::size_t width = 0;
for (char c : text) {
if ((static_cast<unsigned char>(c) & 0xC0) != 0x80) {
++width;
}
}
return width;
}
std::string singleLine(const std::string& text, std::size_t maxChars) {
std::string out;
bool pendingBreak = false;
for (char c : text) {
if (c == '\n' || c == '\r') {
pendingBreak = true;
continue;
}
if (pendingBreak) {
if (!out.empty()) {
out += " / ";
}
pendingBreak = false;
}
out.push_back(c == '\t' ? ' ' : c);
}
if (maxChars == 0) {
return out;
}
std::size_t chars = 0;
std::size_t cut = 0;
for (; cut < out.size(); ++cut) {
if ((static_cast<unsigned char>(out[cut]) & 0xC0) != 0x80) {
if (chars == maxChars) {
break;
}
++chars;
}
}
if (cut < out.size()) {
out = out.substr(0, cut) + "...";
}
return out;
}
} // namespace StringUtil
+57
View File
@@ -0,0 +1,57 @@
#include "Table.h"
#include <algorithm>
#include "StringUtil.h"
TextTable::TextTable(const std::vector<std::string>& headers) : headers_(headers) {}
void TextTable::addRow(const std::vector<std::string>& cells) {
std::vector<std::string> row = cells;
row.resize(headers_.size());
rows_.push_back(row);
}
std::size_t TextTable::rowCount() const {
return rows_.size();
}
std::string TextTable::render() const {
std::vector<std::size_t> widths;
for (const std::string& header : headers_) {
widths.push_back(StringUtil::displayWidth(header));
}
for (const std::vector<std::string>& row : rows_) {
for (std::size_t c = 0; c < row.size(); ++c) {
widths[c] = std::max(widths[c], StringUtil::displayWidth(row[c]));
}
}
auto formatLine = [&widths](const std::vector<std::string>& cells) {
std::string line;
for (std::size_t c = 0; c < cells.size(); ++c) {
if (c > 0) {
line += " | ";
}
line += cells[c];
line.append(widths[c] - StringUtil::displayWidth(cells[c]), ' ');
}
return line + "\n";
};
std::string out = formatLine(headers_);
for (std::size_t c = 0; c < widths.size(); ++c) {
if (c > 0) {
out += "-+-";
}
out.append(widths[c], '-');
}
out += "\n";
for (const std::vector<std::string>& row : rows_) {
out += formatLine(row);
}
if (rows_.empty()) {
out += "(none)\n";
}
return out;
}
+35
View File
@@ -0,0 +1,35 @@
#include "Terminal.h"
#include <iostream>
#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif
namespace Terminal {
bool stdinIsInteractive() {
#ifdef _WIN32
return _isatty(_fileno(stdin)) != 0;
#else
return isatty(STDIN_FILENO) != 0;
#endif
}
bool stdoutIsInteractive() {
#ifdef _WIN32
return _isatty(_fileno(stdout)) != 0;
#else
return isatty(STDOUT_FILENO) != 0;
#endif
}
void clearScreen() {
if (stdoutIsInteractive()) {
std::cout << "\x1b[H\x1b[2J" << std::flush;
}
}
} // namespace Terminal
+90
View File
@@ -0,0 +1,90 @@
#include "Tokenizer.h"
#include <cctype>
#include <stdexcept>
namespace Tokenizer {
std::vector<std::string> split(const std::string& line) {
std::vector<std::string> tokens;
std::string current;
bool inToken = false;
bool inQuotes = false;
for (char c : line) {
if (inQuotes) {
if (c == '"') {
inQuotes = false;
} else {
current.push_back(c);
}
} else if (c == '"') {
inQuotes = true;
inToken = true;
} else if (std::isspace(static_cast<unsigned char>(c))) {
if (inToken) {
tokens.push_back(current);
current.clear();
inToken = false;
}
} else {
current.push_back(c);
inToken = true;
}
}
if (inQuotes) {
throw std::invalid_argument("Unterminated quote.");
}
if (inToken) {
tokens.push_back(current);
}
return tokens;
}
PartialLine splitPartial(const std::string& text) {
PartialLine result;
std::string current;
std::size_t currentStart = 0;
bool inToken = false;
bool inQuotes = false;
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (inQuotes) {
if (c == '"') {
inQuotes = false;
} else {
current.push_back(c);
}
} else if (c == '"') {
if (!inToken) {
inToken = true;
currentStart = i;
}
inQuotes = true;
} else if (std::isspace(static_cast<unsigned char>(c))) {
if (inToken) {
result.words.push_back(current);
current.clear();
inToken = false;
}
} else {
if (!inToken) {
inToken = true;
currentStart = i;
}
current.push_back(c);
}
}
if (inToken) {
result.current = current;
result.start = currentStart;
} else {
result.start = text.size();
}
return result;
}
} // namespace Tokenizer
+26
View File
@@ -0,0 +1,26 @@
#include <exception>
#include <iostream>
#include <string>
#include "Config.h"
#include "Database.h"
#include "Display.h"
#include "Repl.h"
int main(int argc, char** argv) {
const std::string path = (argc > 1) ? argv[1] : "tasks.db";
try {
Config config;
config.load();
Database db;
db.open(path);
Display display(db);
Repl repl(db, display, config);
return repl.run();
} catch (const std::exception& error) {
std::cerr << "Fatal: " << error.what() << "\n";
return 1;
}
}