Add source code.
This commit is contained in:
1 parent
dab19ace02
commit
0b8a7b3cdc
31 files changed
+2809
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
*.db
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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_;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user