C and Python API + Smarter account-based wait times
This commit is contained in:
1 parent
35c568333d
commit
10badb282c
18 files changed
+2202
-427
No files matched your search
+44
-11
@@ -25,34 +25,67 @@ if(NOT SQLite3_FOUND)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
add_library(sqlite3_local STATIC ${sqlite3_amalgamation_SOURCE_DIR}/sqlite3.c)
|
||||
set_target_properties(sqlite3_local PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden)
|
||||
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 -------------------------------------------------------------
|
||||
# --- Warnings ----------------------------------------------------------------
|
||||
function(lls_warnings target)
|
||||
if(MSVC)
|
||||
target_compile_options(${target} PRIVATE /W4)
|
||||
else()
|
||||
target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# --- Core: the rules, shared by the prompt and the C API ---------------------
|
||||
add_library(lls_core STATIC
|
||||
src/Database.cpp
|
||||
src/Status.cpp
|
||||
src/StringUtil.cpp
|
||||
src/Tracker.cpp
|
||||
src/Views.cpp
|
||||
)
|
||||
target_include_directories(lls_core PUBLIC include)
|
||||
target_link_libraries(lls_core PUBLIC SQLite::SQLite3)
|
||||
set_target_properties(lls_core PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
VISIBILITY_INLINES_HIDDEN ON)
|
||||
lls_warnings(lls_core)
|
||||
|
||||
# --- C API: liblls_api.so / lls_api.dll (include/lls_api.h) ------------------
|
||||
add_library(lls_api SHARED src/capi.cpp)
|
||||
target_link_libraries(lls_api PRIVATE lls_core)
|
||||
target_compile_definitions(lls_api PRIVATE LLS_BUILDING_API)
|
||||
set_target_properties(lls_api PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
VISIBILITY_INLINES_HIDDEN ON)
|
||||
lls_warnings(lls_api)
|
||||
|
||||
# --- Application: the interactive prompt -------------------------------------
|
||||
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
|
||||
src/HomeDirectory.cpp
|
||||
)
|
||||
target_link_libraries(lls PRIVATE lls_core)
|
||||
lls_warnings(lls)
|
||||
|
||||
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)
|
||||
# --- Example: the C API from C -----------------------------------------------
|
||||
option(LLS_BUILD_EXAMPLES "Build the C API example" ON)
|
||||
if(LLS_BUILD_EXAMPLES)
|
||||
add_executable(lls_example examples/example.c)
|
||||
target_link_libraries(lls_example PRIVATE lls_api)
|
||||
target_include_directories(lls_example PRIVATE include)
|
||||
endif()
|
||||
@@ -16,6 +16,10 @@ installed; otherwise CMake downloads the amalgamation (needs network once).
|
||||
cmake --build build
|
||||
./build/lls [database-file] # default: tasks.db
|
||||
|
||||
This builds three things: the prompt (`lls`), the C API as a shared library
|
||||
(`liblls_api.so` / `lls_api.dll`, header `include/lls_api.h`) and a C example
|
||||
(`lls_example`).
|
||||
|
||||
## Commands
|
||||
|
||||
acc add <name> tsk add <name> [acc] [status]
|
||||
@@ -93,6 +97,11 @@ goes back to `$VISUAL` / `$EDITOR` / nano / vim / vi. The choice is stored in
|
||||
Uploading ones) is listed in the account row as `(wait HH:MM)` or
|
||||
`(limit reset)` and keeps the account Busy. Tasks without an account keep
|
||||
their own wait.
|
||||
* Auto unwait: once an account's limit has reset (its tasks show `Limit reset`),
|
||||
explicitly setting a task on it to `testing` or `prompt` (`tsk add <task> <acc>
|
||||
testing`, `tsk restat <task> testing`) clears that finished wait, because the
|
||||
account is in use again. Adding a task without a status does not, and a wait
|
||||
that is still running is never cleared automatically (use `acc unwait`).
|
||||
* A time is only optional while the account is already waiting: then
|
||||
`tsk restat <task> wait` (or `tsk add <task> <acc> wait`) just makes the task
|
||||
follow that wait. Otherwise (no account, or its wait is over) a time is
|
||||
@@ -101,7 +110,78 @@ goes back to `$VISUAL` / `$EDITOR` / nano / vim / vi. The choice is stored in
|
||||
* Names are case-insensitive; the original spelling is kept.
|
||||
* Notes live in the database (SQLite TEXT, binary-safe, SQLite's default limit is ~1 GB). The task table shows `-` when a task has no notes, otherwise the number of lines (e.g. `3 lines`); open them with `tsk notes <name>`.
|
||||
|
||||
## C API and Python
|
||||
|
||||
`include/lls_api.h` is a plain C API over the same database and the same rules
|
||||
as the prompt (it is built on the same `Tracker` class), so a program written
|
||||
against it behaves exactly like typing the commands. Build it with the project
|
||||
(`liblls_api.so`), link against it from C/C++, or load it from Python.
|
||||
|
||||
#include "lls_api.h"
|
||||
|
||||
lls_db* db;
|
||||
if (lls_open("tasks.db", &db) != LLS_OK) { /* lls_last_error(NULL) */ }
|
||||
|
||||
lls_account_add(db, "Acrux");
|
||||
lls_task_add(db, "Breeze A", "Acrux", "testing");
|
||||
lls_account_set_wait(db, "Acrux", "4:30pm"); /* the account hit its limit */
|
||||
|
||||
lls_task_list* tasks;
|
||||
lls_tasks(db, &tasks); /* display order */
|
||||
for (int i = 0; i < lls_task_list_count(tasks); ++i) {
|
||||
const lls_task_info* t = lls_task_list_get(tasks, i);
|
||||
printf("%s %s\n", t->name, t->status_label); /* "Breeze A Wait 16:30" */
|
||||
}
|
||||
lls_task_list_free(tasks);
|
||||
|
||||
lls_save(db); /* nothing is saved automatically */
|
||||
lls_close(db);
|
||||
|
||||
* Every call that can fail returns `LLS_OK` or an error code (`LLS_E_INVALID`,
|
||||
`LLS_E_NOT_FOUND`, `LLS_E_EXISTS`, `LLS_E_DATABASE`) with the message in
|
||||
`lls_last_error(db)`. No C++ exception crosses the API.
|
||||
* After a change, `lls_last_messages(db)` holds what the prompt would have
|
||||
printed ("Account 'A' is back in use: ..."). Queries do not reset it.
|
||||
* Statuses are the text you would type: `"prompt"`, `"testing"`, `"wait 4:30pm"`,
|
||||
`"wait"` (only while the account is waiting)... Waits belong to the account.
|
||||
* `lls_tasks` / `lls_accounts` return lists in the order of the panels, with the
|
||||
computed status (`status`, `status_label`, `until`) and the stored one underneath
|
||||
(`stored_status`). Strings in a list are valid until `lls_task_list_free`.
|
||||
* `lls_set_clock(db, unix_seconds)` pretends it is another time, handy for
|
||||
tests and simulations; 0 goes back to the real clock.
|
||||
* A `lls_db` must not be shared between threads without your own locking.
|
||||
|
||||
`python/lls.py` is a ctypes binding (no compiler needed):
|
||||
|
||||
import lls # python/ is on sys.path; LLS_LIBRARY=build/liblls_api.so
|
||||
|
||||
with lls.Database("tasks.db") as db:
|
||||
db.add_account("Acrux")
|
||||
db.add_task("Breeze A", account="Acrux", status="testing")
|
||||
db.set_account_wait("Acrux", "4:30pm")
|
||||
for task in db.tasks():
|
||||
print(task.name, task.account, task.status_label, task.until)
|
||||
for account in db.accounts():
|
||||
print(account.name, account.level.name, account.tasks)
|
||||
print(db.messages)
|
||||
db.save()
|
||||
|
||||
Errors are exceptions (`lls.NotFound`, `lls.AlreadyExists`, `lls.InvalidArgument`,
|
||||
`lls.DatabaseError`, all `lls.LLSError`). The library is found through the
|
||||
`LLS_LIBRARY` environment variable, `../build` next to the module, or the
|
||||
current directory's `build`. Run the tests with
|
||||
|
||||
LLS_LIBRARY=build/liblls_api.so python3 python/test_lls.py -v
|
||||
|
||||
## Layout
|
||||
|
||||
include/ headers (declarations only, no inline definitions)
|
||||
include/ headers (declarations only, no inline definitions); lls_api.h is the public C header
|
||||
src/ implementations
|
||||
examples/ example.c - the C API from C
|
||||
python/ lls.py (ctypes binding) and test_lls.py
|
||||
|
||||
Core (static library, shared by the prompt and the C API):
|
||||
Database SQLite storage Tracker all the rules; the only thing that changes data
|
||||
Status statuses, times Views computed task / account views and their order
|
||||
Prompt only: Repl, LineEditor, Completer, Display, Table, Config, ExternalEditor...
|
||||
C API: capi.cpp (wraps Tracker + Views)
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Minimal use of the lls C API: add accounts and tasks, set a wait, print the panels. */
|
||||
#include <stdio.h>
|
||||
|
||||
#include "lls_api.h"
|
||||
|
||||
static int check(lls_db* db, int code, const char* what) {
|
||||
if (code != LLS_OK) {
|
||||
fprintf(stderr, "%s failed (%d): %s\n", what, code, lls_last_error(db));
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* path = (argc > 1) ? argv[1] : "example.db";
|
||||
|
||||
lls_db* db = NULL;
|
||||
if (lls_open(path, &db) != LLS_OK) {
|
||||
fprintf(stderr, "open failed: %s\n", lls_last_error(NULL));
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Ignore "already exists" so the example can be run twice. */
|
||||
lls_account_add(db, "Acrux");
|
||||
lls_account_add(db, "Beta");
|
||||
lls_task_add(db, "Breeze A", "Acrux", "testing");
|
||||
lls_task_add(db, "Breeze B", "Acrux", NULL); /* NULL = prompt */
|
||||
lls_task_add(db, "Breeze C", "Beta", "done");
|
||||
|
||||
/* The wait belongs to the account: Acrux's Prompt / Testing tasks now show Wait. */
|
||||
if (!check(db, lls_account_set_wait(db, "Acrux", "11:59pm"), "set_wait")) {
|
||||
lls_close(db);
|
||||
return 1;
|
||||
}
|
||||
printf("%s\n\n", lls_last_messages(db));
|
||||
|
||||
lls_task_list* tasks = NULL;
|
||||
if (check(db, lls_tasks(db, &tasks), "tasks")) {
|
||||
printf("%-10s %-8s %-12s %s\n", "TASK", "ACCOUNT", "STATUS", "NOTES");
|
||||
for (int i = 0; i < lls_task_list_count(tasks); ++i) {
|
||||
const lls_task_info* t = lls_task_list_get(tasks, i);
|
||||
printf("%-10s %-8s %-12s %d lines\n", t->name, t->account ? t->account : "-", t->status_label,
|
||||
t->notes_lines);
|
||||
}
|
||||
lls_task_list_free(tasks);
|
||||
}
|
||||
|
||||
lls_account_list* accounts = NULL;
|
||||
if (check(db, lls_accounts(db, &accounts), "accounts")) {
|
||||
printf("\n%-8s %-6s %s\n", "ACCOUNT", "LEVEL", "TASKS");
|
||||
for (int i = 0; i < lls_account_list_count(accounts); ++i) {
|
||||
const lls_account_info* a = lls_account_list_get(accounts, i);
|
||||
printf("%-8s %-6s", a->name, a->level_label);
|
||||
for (int k = 0; k < a->task_count; ++k) {
|
||||
printf(" %s", a->tasks[k]);
|
||||
}
|
||||
printf("\n");
|
||||
}
|
||||
lls_account_list_free(accounts);
|
||||
}
|
||||
|
||||
check(db, lls_save(db), "save"); /* nothing is saved automatically */
|
||||
lls_close(db);
|
||||
return 0;
|
||||
}
|
||||
+3
-10
@@ -6,14 +6,7 @@
|
||||
|
||||
#include "Database.h"
|
||||
#include "Status.h"
|
||||
|
||||
// How taken an account is, judged by its tasks:
|
||||
// Free - only Done, Frozen or Uploading tasks, or no tasks
|
||||
// Taken - has a Prompt task (and nothing Busy)
|
||||
// Busy - has a Testing, Limit reset or Wait task
|
||||
enum class AccountLevel { Busy, Taken, Free };
|
||||
|
||||
std::string accountLevelName(AccountLevel level); // "busy", "taken", "free"
|
||||
#include "Views.h"
|
||||
|
||||
struct DisplaySettings {
|
||||
bool showTasks = true; // auto-print the task table after each change
|
||||
@@ -22,8 +15,8 @@ struct DisplaySettings {
|
||||
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.
|
||||
// Renders the task and account panels as text tables, from the computed views
|
||||
// (see Views.h), honouring the hidden levels in the settings.
|
||||
class Display {
|
||||
public:
|
||||
explicit Display(const Database& db);
|
||||
|
||||
+2
-9
@@ -12,6 +12,7 @@
|
||||
#include "Database.h"
|
||||
#include "Display.h"
|
||||
#include "LineEditor.h"
|
||||
#include "Tracker.h"
|
||||
#include "Status.h"
|
||||
|
||||
// Interactive prompt: commands are typed live, one per line.
|
||||
@@ -39,8 +40,6 @@ private:
|
||||
void execute(const std::string& line);
|
||||
void refresh() const;
|
||||
void say(const std::string& message);
|
||||
void setAccountWait(const std::string& account, std::time_t until);
|
||||
void noteAccountWait(const std::string& taskName);
|
||||
void flushMessages();
|
||||
bool endOfInput(); // true if the program should exit
|
||||
|
||||
@@ -48,13 +47,6 @@ private:
|
||||
// Asks until the reply is valid (y / n, and c when allowed). nullopt if input closed.
|
||||
std::optional<Answer> ask(const std::string& question, bool allowCancel) const;
|
||||
|
||||
// Helpers
|
||||
static void requireName(const std::string& name);
|
||||
static Status parseStatusFrom(const Args& args, std::size_t from);
|
||||
static bool isBareWait(const Args& args, std::size_t from);
|
||||
std::optional<std::time_t> runningWait(const std::string& account) const;
|
||||
void makeTaskFollowAccount(const TaskRow& task);
|
||||
|
||||
// Account commands
|
||||
bool cmdAccAdd(const Args& args);
|
||||
bool cmdAccDel(const Args& args);
|
||||
@@ -92,6 +84,7 @@ private:
|
||||
Database& db_;
|
||||
Display& display_;
|
||||
Config& config_;
|
||||
Tracker tracker_;
|
||||
std::vector<Command> commands_;
|
||||
std::vector<CommandSpec> specs_;
|
||||
Completer completer_;
|
||||
|
||||
@@ -45,6 +45,12 @@ std::string clockLabel(std::time_t value); // "16:30"
|
||||
// "16:30" for today, "tomorrow 06:40" for the next day, otherwise "2026-10-26 16:30".
|
||||
std::string resetLabel(std::time_t value, std::time_t now);
|
||||
|
||||
// True when the text is just "wait" (no time given).
|
||||
bool isBareWait(const std::string& text);
|
||||
|
||||
// One-line list of the accepted statuses, for error messages and help.
|
||||
std::string helpText();
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Database.h"
|
||||
#include "Views.h"
|
||||
|
||||
enum class ErrorCode { Invalid, NotFound, Exists, Database };
|
||||
|
||||
// What went wrong, with a message that can be shown to the user as it is.
|
||||
class TrackerError : public std::runtime_error {
|
||||
public:
|
||||
TrackerError(ErrorCode code, const std::string& message);
|
||||
|
||||
ErrorCode code() const;
|
||||
|
||||
private:
|
||||
ErrorCode code_;
|
||||
};
|
||||
|
||||
// All the rules for changing accounts and tasks (waits, auto-unwait, status
|
||||
// handling...). The prompt and the C API are both thin layers over this class.
|
||||
// Each change appends human-readable notes (what the prompt prints), which
|
||||
// takeMessages() hands over.
|
||||
class Tracker {
|
||||
public:
|
||||
explicit Tracker(Database& db);
|
||||
|
||||
const Database& database() const;
|
||||
|
||||
// For tests and simulations: pretend the time is `fixedNow`. 0 = real clock.
|
||||
void setClock(std::time_t fixedNow);
|
||||
std::time_t now() const;
|
||||
|
||||
std::vector<std::string> takeMessages();
|
||||
|
||||
// ---- Accounts -----------------------------------------------------------
|
||||
void addAccount(const std::string& name);
|
||||
void deleteAccount(const std::string& name);
|
||||
void renameAccount(const std::string& name, const std::string& newName);
|
||||
|
||||
// The account hits its limit until `until` (or a clock time such as "16:30",
|
||||
// "4:30pm"; a time that has passed today means tomorrow).
|
||||
void setAccountWait(const std::string& name, std::time_t until);
|
||||
void setAccountWaitText(const std::string& name, const std::string& timeText);
|
||||
// Returns false if the account was not waiting.
|
||||
bool clearAccountWait(const std::string& name);
|
||||
|
||||
// ---- Tasks --------------------------------------------------------------
|
||||
// `statusText` is what the user would type: "testing", "wait 4pm", "wait"...
|
||||
// nullopt = Prompt, without the auto-unwait that an explicit status triggers.
|
||||
void addTask(const std::string& name, const std::optional<std::string>& account,
|
||||
const std::optional<std::string>& statusText);
|
||||
void deleteTask(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& statusText);
|
||||
// Returns false (and changes nothing) if the notes are the same already.
|
||||
bool setTaskNotes(const std::string& name, const std::string& notes);
|
||||
|
||||
// ---- Computed views -----------------------------------------------------
|
||||
std::vector<TaskInfo> tasks() const;
|
||||
std::vector<AccountInfo> accounts() const;
|
||||
|
||||
private:
|
||||
static void requireName(const std::string& name);
|
||||
Status parseStatus(const std::string& text) const;
|
||||
std::string existingAccount(const std::string& name) const;
|
||||
TaskRow existingTask(const std::string& name) const;
|
||||
std::optional<std::time_t> runningWait(const std::string& account) const;
|
||||
void resumeAccount(const std::string& account);
|
||||
void makeTaskFollowAccount(const TaskRow& task);
|
||||
void noteAccountWait(const std::string& taskName);
|
||||
void note(const std::string& message);
|
||||
|
||||
Database& db_;
|
||||
std::time_t fixedNow_;
|
||||
std::vector<std::string> messages_;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Database.h"
|
||||
#include "Status.h"
|
||||
|
||||
// How taken an account is, judged by its tasks:
|
||||
// Free - only Done, Frozen or Uploading tasks, or no tasks
|
||||
// Taken - has a Prompt task (and nothing Busy)
|
||||
// Busy - has a Testing, Limit reset or Wait task, or a wait nothing else shows
|
||||
enum class AccountLevel { Busy, Taken, Free };
|
||||
|
||||
std::string accountLevelName(AccountLevel level); // "busy", "taken", "free"
|
||||
|
||||
enum class WaitState { None, Waiting, LimitReset };
|
||||
|
||||
// A task as the user sees it: the status is computed from the stored status,
|
||||
// the account's wait and the current time.
|
||||
struct TaskInfo {
|
||||
std::string name;
|
||||
std::optional<std::string> account;
|
||||
Status status; // as shown (Wait / Limit reset come from the account)
|
||||
Status storedStatus; // what is saved underneath
|
||||
std::string notes;
|
||||
std::size_t noteLines = 0;
|
||||
};
|
||||
|
||||
struct AccountInfo {
|
||||
std::string name;
|
||||
AccountLevel level = AccountLevel::Free;
|
||||
WaitState waitState = WaitState::None;
|
||||
std::time_t waitUntil = 0; // when the wait ends (or ended); 0 if none
|
||||
// True when the account has a wait that none of its tasks shows (it has no
|
||||
// tasks, or only Done / Frozen / Uploading ones). It still makes the account Busy.
|
||||
bool waitListed = false;
|
||||
std::vector<std::string> tasks; // tasks at the account's level (Free: its Uploading/Frozen/Done ones)
|
||||
};
|
||||
|
||||
// Tasks in display order: status priority (Limit reset, Testing, Prompt, Wait,
|
||||
// Uploading, Frozen, Done); for Wait and Limit reset then time, earliest first;
|
||||
// then account A-Z (none last); then task name.
|
||||
std::vector<TaskInfo> buildTasks(const Database& db, std::time_t now);
|
||||
|
||||
// Accounts in display order, most free first: Free, Taken, Busy; within Taken
|
||||
// and Busy fewer entries first; ties by name.
|
||||
std::vector<AccountInfo> buildAccounts(const Database& db, std::time_t now);
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* lls C API - query and change the task / account database from C, or from
|
||||
* Python through ctypes / cffi (see python/lls.py).
|
||||
*
|
||||
* Model
|
||||
* - lls_open() loads the database file into memory (or starts empty). Changes
|
||||
* stay in memory until lls_save(); lls_is_dirty() tells whether there is
|
||||
* anything to save. Nothing is saved automatically.
|
||||
* - Every function that can fail returns LLS_OK (0) or an error code, and the
|
||||
* message is available from lls_last_error(). Changes also leave human
|
||||
* readable notes in lls_last_messages() (what the interactive prompt prints,
|
||||
* e.g. "Account 'A' is back in use: its finished wait was cleared.").
|
||||
* - Names are case-insensitive but keep their original spelling.
|
||||
* - Statuses are the text you would type at the prompt: "prompt", "testing",
|
||||
* "uploading", "frozen", "done", "limit-reset", "wait 4:30pm", "wait 16:30"
|
||||
* or just "wait" (only while the task's account is already waiting).
|
||||
* - A wait belongs to the account: setting a task to "wait <time>" sets its
|
||||
* account's wait, and the account's Prompt / Testing tasks then show Wait
|
||||
* until that time and Limit reset after it (see the README).
|
||||
* - Times are Unix seconds (UTC); clock-time text like "4:30pm" is read in the
|
||||
* local time zone, and a time that has passed today means tomorrow.
|
||||
*
|
||||
* Threads: a lls_db must not be used from several threads at once.
|
||||
* Strings: every const char* returned by the library is owned by it. Those in a
|
||||
* list stay valid until the list is freed; lls_last_error / lls_last_messages /
|
||||
* lls_path stay valid until the next call on the same lls_db.
|
||||
*/
|
||||
#ifndef LLS_API_H
|
||||
#define LLS_API_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
# ifdef LLS_BUILDING_API
|
||||
# define LLS_API __declspec(dllexport)
|
||||
# else
|
||||
# define LLS_API __declspec(dllimport)
|
||||
# endif
|
||||
#else
|
||||
# define LLS_API __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define LLS_API_VERSION 1
|
||||
|
||||
/* ---- Result codes --------------------------------------------------------- */
|
||||
#define LLS_OK 0
|
||||
#define LLS_E_INVALID 1 /* bad argument: NULL, invalid name, unknown status or time */
|
||||
#define LLS_E_NOT_FOUND 2 /* no such task or account */
|
||||
#define LLS_E_EXISTS 3 /* a task or account with that name already exists */
|
||||
#define LLS_E_DATABASE 4 /* SQLite or file problem */
|
||||
#define LLS_E_INTERNAL 5
|
||||
|
||||
/* ---- Status kinds (lls_task_info.status, .stored_status) ------------------ */
|
||||
#define LLS_STATUS_PROMPT 0 /* free to accept prompts */
|
||||
#define LLS_STATUS_LIMIT_RESET 1 /* the account's wait is over */
|
||||
#define LLS_STATUS_TESTING 2
|
||||
#define LLS_STATUS_WAIT 3 /* the account's wait is running */
|
||||
#define LLS_STATUS_UPLOADING 4
|
||||
#define LLS_STATUS_FROZEN 5
|
||||
#define LLS_STATUS_DONE 6
|
||||
|
||||
/* ---- Account levels (lls_account_info.level), most taken first ------------ */
|
||||
#define LLS_LEVEL_BUSY 0 /* Testing, Limit reset or Wait task, or a wait nothing else shows */
|
||||
#define LLS_LEVEL_TAKEN 1 /* has a Prompt task */
|
||||
#define LLS_LEVEL_FREE 2 /* only Uploading / Frozen / Done tasks, or none */
|
||||
|
||||
/* ---- Account wait states (lls_account_info.wait_state) -------------------- */
|
||||
#define LLS_WAIT_NONE 0
|
||||
#define LLS_WAIT_WAITING 1 /* wait_until is in the future */
|
||||
#define LLS_WAIT_LIMIT_RESET 2 /* wait_until has passed; stays until cleared */
|
||||
|
||||
typedef struct lls_db lls_db;
|
||||
typedef struct lls_task_list lls_task_list;
|
||||
typedef struct lls_account_list lls_account_list;
|
||||
|
||||
/* A task as the user sees it. */
|
||||
typedef struct lls_task_info {
|
||||
const char* name;
|
||||
const char* account; /* NULL when the task has no account */
|
||||
int status; /* LLS_STATUS_*, as shown (Wait / Limit reset come from the account) */
|
||||
const char* status_label; /* "Prompt", "Limit reset", "Wait 16:30", ... */
|
||||
int stored_status; /* LLS_STATUS_*, what is saved underneath */
|
||||
int64_t until; /* for Wait / Limit reset: when the wait ends (ended); else 0 */
|
||||
const char* notes; /* the full text, "" if none (any length) */
|
||||
int notes_lines; /* number of lines in the notes */
|
||||
} lls_task_info;
|
||||
|
||||
/* An account with its computed state. */
|
||||
typedef struct lls_account_info {
|
||||
const char* name;
|
||||
int level; /* LLS_LEVEL_* */
|
||||
const char* level_label; /* "Busy", "Taken", "Free" */
|
||||
int wait_state; /* LLS_WAIT_* */
|
||||
int64_t wait_until; /* when the wait ends (ended); 0 if none */
|
||||
int wait_listed; /* 1 if the wait is not shown by any task (it still makes the account Busy) */
|
||||
int task_count;
|
||||
const char* const* tasks; /* the tasks at the account's level (a Free account: its Uploading/Frozen/Done ones) */
|
||||
} lls_account_info;
|
||||
|
||||
/* ---- Library -------------------------------------------------------------- */
|
||||
|
||||
LLS_API int lls_api_version(void); /* compare with LLS_API_VERSION */
|
||||
|
||||
/* Name of a status kind: "prompt", "limit-reset", "testing", "wait", ... (NULL if unknown). */
|
||||
LLS_API const char* lls_status_name(int status);
|
||||
|
||||
/* Parses a clock time as `acc wait` does ("16:30", "4:30pm", "4 pm"); now = 0 means the
|
||||
* current time. Returns LLS_OK or LLS_E_INVALID. */
|
||||
LLS_API int lls_parse_time(const char* text, int64_t now, int64_t* out_until);
|
||||
|
||||
/* ---- Database ------------------------------------------------------------- */
|
||||
|
||||
/* Opens `path` (loaded into memory; created by the first lls_save if it does not exist).
|
||||
* On failure *out_db is NULL; the message is in lls_last_error(NULL). */
|
||||
LLS_API int lls_open(const char* path, lls_db** out_db);
|
||||
LLS_API void lls_close(lls_db* db); /* does not save */
|
||||
|
||||
/* Message of the last failed call on `db`; with db == NULL, of the last failed lls_open
|
||||
* on this thread. Empty string if the last call succeeded. */
|
||||
LLS_API const char* lls_last_error(const lls_db* db);
|
||||
/* Notes from the last call that changed something, one per line, no trailing newline.
|
||||
* Queries (lls_tasks / lls_accounts) do not reset them; a failed change leaves none. */
|
||||
LLS_API const char* lls_last_messages(const lls_db* db);
|
||||
|
||||
LLS_API const char* lls_path(const lls_db* db);
|
||||
LLS_API int lls_is_dirty(const lls_db* db); /* 1 if there are unsaved changes */
|
||||
LLS_API int lls_save(lls_db* db);
|
||||
/* Writes a copy. file == NULL: "<path>.<timestamp>.bak". The path used is copied to
|
||||
* used_path (if not NULL), truncated to used_path_size. */
|
||||
LLS_API int lls_backup(lls_db* db, const char* file, char* used_path, size_t used_path_size);
|
||||
/* Replaces the in-memory data with the contents of `file` (not saved until lls_save). */
|
||||
LLS_API int lls_restore(lls_db* db, const char* file);
|
||||
|
||||
/* Pretends the time is `now` (Unix seconds) for every later call: useful for tests and
|
||||
* simulations. 0 goes back to the real clock. */
|
||||
LLS_API void lls_set_clock(lls_db* db, int64_t now);
|
||||
LLS_API int64_t lls_now(const lls_db* db);
|
||||
|
||||
/* ---- Accounts ------------------------------------------------------------- */
|
||||
|
||||
LLS_API int lls_account_add(lls_db* db, const char* name);
|
||||
LLS_API int lls_account_delete(lls_db* db, const char* name); /* its tasks become unassigned */
|
||||
LLS_API int lls_account_rename(lls_db* db, const char* name, const char* new_name);
|
||||
/* The account hit its limit until a clock time ("16:30", "4:30pm") / until a Unix time. */
|
||||
LLS_API int lls_account_set_wait(lls_db* db, const char* name, const char* time_text);
|
||||
LLS_API int lls_account_set_wait_at(lls_db* db, const char* name, int64_t until);
|
||||
/* Clears its wait / limit reset. Sets *was_waiting (if not NULL) to 1 or 0. */
|
||||
LLS_API int lls_account_clear_wait(lls_db* db, const char* name, int* was_waiting);
|
||||
|
||||
/* ---- Tasks ---------------------------------------------------------------- */
|
||||
|
||||
/* account == NULL: no account. status == NULL: prompt (see the notes at the top). */
|
||||
LLS_API int lls_task_add(lls_db* db, const char* name, const char* account, const char* status);
|
||||
LLS_API int lls_task_delete(lls_db* db, const char* name);
|
||||
LLS_API int lls_task_rename(lls_db* db, const char* name, const char* new_name);
|
||||
LLS_API int lls_task_set_account(lls_db* db, const char* name, const char* account); /* NULL = none */
|
||||
LLS_API int lls_task_set_status(lls_db* db, const char* name, const char* status);
|
||||
/* Stores the text as it is (any length, any number of lines; "" clears). */
|
||||
LLS_API int lls_task_set_notes(lls_db* db, const char* name, const char* notes);
|
||||
|
||||
/* ---- Queries -------------------------------------------------------------- */
|
||||
|
||||
/* Tasks in display order: status priority (Limit reset, Testing, Prompt, Wait, Uploading,
|
||||
* Frozen, Done); Wait / Limit reset by time; then account A-Z (none last); then name. */
|
||||
LLS_API int lls_tasks(lls_db* db, lls_task_list** out_list);
|
||||
LLS_API int lls_task_list_count(const lls_task_list* list);
|
||||
LLS_API const lls_task_info* lls_task_list_get(const lls_task_list* list, int index); /* NULL if out of range */
|
||||
LLS_API void lls_task_list_free(lls_task_list* list);
|
||||
|
||||
/* Accounts in display order, most free first: Free, Taken, Busy; ties by name. */
|
||||
LLS_API int lls_accounts(lls_db* db, lls_account_list** out_list);
|
||||
LLS_API int lls_account_list_count(const lls_account_list* list);
|
||||
LLS_API const lls_account_info* lls_account_list_get(const lls_account_list* list, int index);
|
||||
LLS_API void lls_account_list_free(lls_account_list* list);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* LLS_API_H */
|
||||
Binary file not shown.
+439
@@ -0,0 +1,439 @@
|
||||
"""Python binding for the lls C API (ctypes, no compilation needed).
|
||||
|
||||
import lls
|
||||
|
||||
with lls.Database("tasks.db") as db:
|
||||
db.add_account("Acrux")
|
||||
db.add_task("Breeze A", account="Acrux", status="testing")
|
||||
db.set_account_wait("Acrux", "4:30pm") # the account hit its limit
|
||||
for task in db.tasks():
|
||||
print(task.name, task.account, task.status_label)
|
||||
db.save() # nothing is saved automatically
|
||||
|
||||
The shared library (liblls_api.so / liblls_api.dylib / lls_api.dll) is looked up in
|
||||
the LLS_LIBRARY environment variable, then in ../build next to this file, then in
|
||||
the current directory's build folder. Pass `library=` to Database to override.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
__all__ = [
|
||||
"Database", "Task", "Account", "Status", "Level", "WaitState",
|
||||
"LLSError", "InvalidArgument", "NotFound", "AlreadyExists", "DatabaseError",
|
||||
"parse_time", "find_library",
|
||||
]
|
||||
|
||||
# ---- Constants (mirror lls_api.h) --------------------------------------------------
|
||||
|
||||
OK, E_INVALID, E_NOT_FOUND, E_EXISTS, E_DATABASE, E_INTERNAL = range(6)
|
||||
|
||||
|
||||
class Status(IntEnum):
|
||||
PROMPT = 0
|
||||
LIMIT_RESET = 1
|
||||
TESTING = 2
|
||||
WAIT = 3
|
||||
UPLOADING = 4
|
||||
FROZEN = 5
|
||||
DONE = 6
|
||||
|
||||
|
||||
class Level(IntEnum):
|
||||
BUSY = 0
|
||||
TAKEN = 1
|
||||
FREE = 2
|
||||
|
||||
|
||||
class WaitState(IntEnum):
|
||||
NONE = 0
|
||||
WAITING = 1
|
||||
LIMIT_RESET = 2
|
||||
|
||||
|
||||
# ---- Errors ------------------------------------------------------------------------
|
||||
|
||||
class LLSError(Exception):
|
||||
"""Something the library refused to do; `code` is the LLS_E_* value."""
|
||||
|
||||
def __init__(self, code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class InvalidArgument(LLSError):
|
||||
"""Bad name, unknown status, unreadable time..."""
|
||||
|
||||
|
||||
class NotFound(LLSError):
|
||||
"""No such task or account."""
|
||||
|
||||
|
||||
class AlreadyExists(LLSError):
|
||||
"""A task or account with that name exists."""
|
||||
|
||||
|
||||
class DatabaseError(LLSError):
|
||||
"""SQLite or file problem."""
|
||||
|
||||
|
||||
_ERRORS = {E_INVALID: InvalidArgument, E_NOT_FOUND: NotFound, E_EXISTS: AlreadyExists, E_DATABASE: DatabaseError}
|
||||
|
||||
|
||||
# ---- Results -----------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
name: str
|
||||
account: Optional[str]
|
||||
status: Status # as shown: Wait / Limit reset come from the account
|
||||
status_label: str # "Prompt", "Limit reset", "Wait 16:30", ...
|
||||
stored_status: Status # what is saved underneath
|
||||
until: Optional[datetime] # for Wait / Limit reset: when the wait ends (ended)
|
||||
notes: str
|
||||
notes_lines: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
name: str
|
||||
level: Level
|
||||
level_label: str # "Busy", "Taken", "Free"
|
||||
wait_state: WaitState
|
||||
wait_until: Optional[datetime]
|
||||
wait_listed: bool # the wait is not shown by any task (account is still Busy)
|
||||
tasks: Tuple[str, ...] # tasks at the account's level
|
||||
|
||||
|
||||
# ---- ctypes plumbing -----------------------------------------------------------------
|
||||
|
||||
class _TaskInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("name", ctypes.c_char_p),
|
||||
("account", ctypes.c_char_p),
|
||||
("status", ctypes.c_int),
|
||||
("status_label", ctypes.c_char_p),
|
||||
("stored_status", ctypes.c_int),
|
||||
("until", ctypes.c_int64),
|
||||
("notes", ctypes.c_char_p),
|
||||
("notes_lines", ctypes.c_int),
|
||||
]
|
||||
|
||||
|
||||
class _AccountInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("name", ctypes.c_char_p),
|
||||
("level", ctypes.c_int),
|
||||
("level_label", ctypes.c_char_p),
|
||||
("wait_state", ctypes.c_int),
|
||||
("wait_until", ctypes.c_int64),
|
||||
("wait_listed", ctypes.c_int),
|
||||
("task_count", ctypes.c_int),
|
||||
("tasks", ctypes.POINTER(ctypes.c_char_p)),
|
||||
]
|
||||
|
||||
|
||||
def _library_names() -> List[str]:
|
||||
if sys.platform == "win32":
|
||||
return ["lls_api.dll"]
|
||||
if sys.platform == "darwin":
|
||||
return ["liblls_api.dylib"]
|
||||
return ["liblls_api.so"]
|
||||
|
||||
|
||||
def find_library() -> str:
|
||||
"""Path of the shared library, or raises FileNotFoundError."""
|
||||
candidates: List[Path] = []
|
||||
env = os.environ.get("LLS_LIBRARY")
|
||||
if env:
|
||||
candidates.append(Path(env))
|
||||
here = Path(__file__).resolve().parent
|
||||
for folder in (here.parent / "build", here.parent / "build" / "Release", here.parent / "build" / "Debug",
|
||||
Path.cwd() / "build", Path.cwd(), here):
|
||||
for name in _library_names():
|
||||
candidates.append(folder / name)
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
raise FileNotFoundError("lls shared library not found; build the project or set LLS_LIBRARY. Tried: " +
|
||||
", ".join(str(c) for c in candidates))
|
||||
|
||||
|
||||
_loaded = {}
|
||||
|
||||
|
||||
def _load(path: Optional[str]) -> ctypes.CDLL:
|
||||
path = path or find_library()
|
||||
if path in _loaded:
|
||||
return _loaded[path]
|
||||
|
||||
lib = ctypes.CDLL(path)
|
||||
c_int, c_str, c_p, c_i64 = ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_int64
|
||||
|
||||
def declare(name, restype, *argtypes):
|
||||
function = getattr(lib, name)
|
||||
function.restype = restype
|
||||
function.argtypes = list(argtypes)
|
||||
|
||||
declare("lls_api_version", c_int)
|
||||
declare("lls_status_name", c_str, c_int)
|
||||
declare("lls_parse_time", c_int, c_str, c_i64, ctypes.POINTER(c_i64))
|
||||
declare("lls_open", c_int, c_str, ctypes.POINTER(c_p))
|
||||
declare("lls_close", None, c_p)
|
||||
declare("lls_last_error", c_str, c_p)
|
||||
declare("lls_last_messages", c_str, c_p)
|
||||
declare("lls_path", c_str, c_p)
|
||||
declare("lls_is_dirty", c_int, c_p)
|
||||
declare("lls_save", c_int, c_p)
|
||||
declare("lls_backup", c_int, c_p, c_str, ctypes.c_char_p, ctypes.c_size_t)
|
||||
declare("lls_restore", c_int, c_p, c_str)
|
||||
declare("lls_set_clock", None, c_p, c_i64)
|
||||
declare("lls_now", c_i64, c_p)
|
||||
declare("lls_account_add", c_int, c_p, c_str)
|
||||
declare("lls_account_delete", c_int, c_p, c_str)
|
||||
declare("lls_account_rename", c_int, c_p, c_str, c_str)
|
||||
declare("lls_account_set_wait", c_int, c_p, c_str, c_str)
|
||||
declare("lls_account_set_wait_at", c_int, c_p, c_str, c_i64)
|
||||
declare("lls_account_clear_wait", c_int, c_p, c_str, ctypes.POINTER(c_int))
|
||||
declare("lls_task_add", c_int, c_p, c_str, c_str, c_str)
|
||||
declare("lls_task_delete", c_int, c_p, c_str)
|
||||
declare("lls_task_rename", c_int, c_p, c_str, c_str)
|
||||
declare("lls_task_set_account", c_int, c_p, c_str, c_str)
|
||||
declare("lls_task_set_status", c_int, c_p, c_str, c_str)
|
||||
declare("lls_task_set_notes", c_int, c_p, c_str, c_str)
|
||||
declare("lls_tasks", c_int, c_p, ctypes.POINTER(c_p))
|
||||
declare("lls_task_list_count", c_int, c_p)
|
||||
declare("lls_task_list_get", ctypes.POINTER(_TaskInfo), c_p, c_int)
|
||||
declare("lls_task_list_free", None, c_p)
|
||||
declare("lls_accounts", c_int, c_p, ctypes.POINTER(c_p))
|
||||
declare("lls_account_list_count", c_int, c_p)
|
||||
declare("lls_account_list_get", ctypes.POINTER(_AccountInfo), c_p, c_int)
|
||||
declare("lls_account_list_free", None, c_p)
|
||||
|
||||
if lib.lls_api_version() != 1:
|
||||
raise RuntimeError("lls library has API version %d, this module expects 1" % lib.lls_api_version())
|
||||
_loaded[path] = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _enc(text: Optional[str]) -> Optional[bytes]:
|
||||
return None if text is None else text.encode("utf-8")
|
||||
|
||||
|
||||
def _dec(raw: Optional[bytes]) -> Optional[str]:
|
||||
return None if raw is None else raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _when(seconds: int) -> Optional[datetime]:
|
||||
return datetime.fromtimestamp(seconds).astimezone() if seconds else None
|
||||
|
||||
|
||||
def _seconds(moment: Union[datetime, int, float]) -> int:
|
||||
return int(moment.timestamp() if isinstance(moment, datetime) else moment)
|
||||
|
||||
|
||||
def parse_time(text: str, now: Union[datetime, int, float, None] = None, library: Optional[str] = None) -> datetime:
|
||||
"""Reads a clock time like "16:30" or "4:30pm" the way `acc wait` does (a time that has
|
||||
passed today means tomorrow). Raises InvalidArgument."""
|
||||
lib = _load(library)
|
||||
out = ctypes.c_int64()
|
||||
code = lib.lls_parse_time(_enc(text), 0 if now is None else _seconds(now), ctypes.byref(out))
|
||||
if code != OK:
|
||||
raise InvalidArgument(code, "Invalid time '%s'. Use e.g. 16:30, 4:30pm or 4pm." % text)
|
||||
return _when(out.value)
|
||||
|
||||
|
||||
# ---- The database --------------------------------------------------------------------
|
||||
|
||||
class Database:
|
||||
"""A task / account database. Changes stay in memory until save()."""
|
||||
|
||||
def __init__(self, path: Union[str, os.PathLike], library: Optional[str] = None):
|
||||
self._lib = _load(library)
|
||||
self._handle = ctypes.c_void_p()
|
||||
code = self._lib.lls_open(_enc(os.fspath(path)), ctypes.byref(self._handle))
|
||||
if code != OK:
|
||||
self._handle = None
|
||||
self._raise(code, _dec(self._lib.lls_last_error(None)))
|
||||
|
||||
# -- lifecycle --
|
||||
|
||||
def close(self) -> None:
|
||||
"""Closes the database WITHOUT saving."""
|
||||
if self._handle:
|
||||
self._lib.lls_close(self._handle)
|
||||
self._handle = None
|
||||
|
||||
def __enter__(self) -> "Database":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- helpers --
|
||||
|
||||
def _raise(self, code: int, message: str) -> None:
|
||||
raise _ERRORS.get(code, LLSError)(code, message)
|
||||
|
||||
def _call(self, name: str, *args) -> None:
|
||||
if not self._handle:
|
||||
raise LLSError(E_INVALID, "The database is closed.")
|
||||
code = getattr(self._lib, name)(self._handle, *args)
|
||||
if code != OK:
|
||||
self._raise(code, _dec(self._lib.lls_last_error(self._handle)))
|
||||
|
||||
# -- state --
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return _dec(self._lib.lls_path(self._handle))
|
||||
|
||||
@property
|
||||
def dirty(self) -> bool:
|
||||
"""True if there are unsaved changes."""
|
||||
return bool(self._lib.lls_is_dirty(self._handle))
|
||||
|
||||
@property
|
||||
def messages(self) -> List[str]:
|
||||
"""What the last change did, as the interactive prompt would print it."""
|
||||
text = _dec(self._lib.lls_last_messages(self._handle))
|
||||
return text.split("\n") if text else []
|
||||
|
||||
def save(self) -> None:
|
||||
self._call("lls_save")
|
||||
|
||||
def backup(self, file: Optional[str] = None) -> str:
|
||||
"""Writes a copy and returns its path (a timestamped name if `file` is None)."""
|
||||
buffer = ctypes.create_string_buffer(4096)
|
||||
self._call("lls_backup", _enc(file), buffer, len(buffer))
|
||||
return buffer.value.decode("utf-8")
|
||||
|
||||
def restore(self, file: str) -> None:
|
||||
"""Replaces the data in memory with a file's contents (call save() to keep it)."""
|
||||
self._call("lls_restore", _enc(file))
|
||||
|
||||
def set_clock(self, now: Union[datetime, int, float, None]) -> None:
|
||||
"""Pretend it is `now` (for tests / simulations); None goes back to the real clock."""
|
||||
self._lib.lls_set_clock(self._handle, 0 if now is None else _seconds(now))
|
||||
|
||||
@property
|
||||
def now(self) -> datetime:
|
||||
return _when(self._lib.lls_now(self._handle))
|
||||
|
||||
# -- accounts --
|
||||
|
||||
def add_account(self, name: str) -> None:
|
||||
self._call("lls_account_add", _enc(name))
|
||||
|
||||
def delete_account(self, name: str) -> None:
|
||||
"""Its tasks become unassigned."""
|
||||
self._call("lls_account_delete", _enc(name))
|
||||
|
||||
def rename_account(self, name: str, new_name: str) -> None:
|
||||
self._call("lls_account_rename", _enc(name), _enc(new_name))
|
||||
|
||||
def set_account_wait(self, name: str, until: Union[str, datetime, int, float]) -> None:
|
||||
"""The account hit its limit until `until`: a clock time such as "16:30" / "4:30pm"
|
||||
(a time that has passed today means tomorrow), a datetime, or Unix seconds."""
|
||||
if isinstance(until, str):
|
||||
self._call("lls_account_set_wait", _enc(name), _enc(until))
|
||||
else:
|
||||
self._call("lls_account_set_wait_at", _enc(name), _seconds(until))
|
||||
|
||||
def clear_account_wait(self, name: str) -> bool:
|
||||
"""Clears its wait / limit reset. Returns False if it was not waiting."""
|
||||
was_waiting = ctypes.c_int(0)
|
||||
self._call("lls_account_clear_wait", _enc(name), ctypes.byref(was_waiting))
|
||||
return bool(was_waiting.value)
|
||||
|
||||
# -- tasks --
|
||||
|
||||
def add_task(self, name: str, account: Optional[str] = None, status: Optional[str] = None) -> None:
|
||||
"""`status` is what you would type at the prompt ("testing", "wait 4pm", "wait"...);
|
||||
None means prompt."""
|
||||
self._call("lls_task_add", _enc(name), _enc(account), _enc(status))
|
||||
|
||||
def delete_task(self, name: str) -> None:
|
||||
self._call("lls_task_delete", _enc(name))
|
||||
|
||||
def rename_task(self, name: str, new_name: str) -> None:
|
||||
self._call("lls_task_rename", _enc(name), _enc(new_name))
|
||||
|
||||
def set_task_account(self, name: str, account: Optional[str]) -> None:
|
||||
self._call("lls_task_set_account", _enc(name), _enc(account))
|
||||
|
||||
def set_task_status(self, name: str, status: str) -> None:
|
||||
self._call("lls_task_set_status", _enc(name), _enc(status))
|
||||
|
||||
def set_task_notes(self, name: str, notes: str) -> None:
|
||||
"""Any length, any number of lines; "" clears them."""
|
||||
self._call("lls_task_set_notes", _enc(name), _enc(notes))
|
||||
|
||||
# -- queries --
|
||||
|
||||
def tasks(self) -> List[Task]:
|
||||
"""All tasks in display order."""
|
||||
handle = ctypes.c_void_p()
|
||||
self._call("lls_tasks", ctypes.byref(handle))
|
||||
try:
|
||||
result = []
|
||||
for i in range(self._lib.lls_task_list_count(handle)):
|
||||
info = self._lib.lls_task_list_get(handle, i).contents
|
||||
result.append(Task(
|
||||
name=_dec(info.name),
|
||||
account=_dec(info.account),
|
||||
status=Status(info.status),
|
||||
status_label=_dec(info.status_label),
|
||||
stored_status=Status(info.stored_status),
|
||||
until=_when(info.until),
|
||||
notes=_dec(info.notes),
|
||||
notes_lines=info.notes_lines,
|
||||
))
|
||||
return result
|
||||
finally:
|
||||
self._lib.lls_task_list_free(handle)
|
||||
|
||||
def accounts(self) -> List[Account]:
|
||||
"""All accounts, most free first."""
|
||||
handle = ctypes.c_void_p()
|
||||
self._call("lls_accounts", ctypes.byref(handle))
|
||||
try:
|
||||
result = []
|
||||
for i in range(self._lib.lls_account_list_count(handle)):
|
||||
info = self._lib.lls_account_list_get(handle, i).contents
|
||||
result.append(Account(
|
||||
name=_dec(info.name),
|
||||
level=Level(info.level),
|
||||
level_label=_dec(info.level_label),
|
||||
wait_state=WaitState(info.wait_state),
|
||||
wait_until=_when(info.wait_until),
|
||||
wait_listed=bool(info.wait_listed),
|
||||
tasks=tuple(_dec(info.tasks[k]) for k in range(info.task_count)),
|
||||
))
|
||||
return result
|
||||
finally:
|
||||
self._lib.lls_account_list_free(handle)
|
||||
|
||||
def task(self, name: str) -> Task:
|
||||
for task in self.tasks():
|
||||
if task.name.lower() == name.lower():
|
||||
return task
|
||||
raise NotFound(E_NOT_FOUND, "No task named '%s'." % name)
|
||||
|
||||
def account(self, name: str) -> Account:
|
||||
for account in self.accounts():
|
||||
if account.name.lower() == name.lower():
|
||||
return account
|
||||
raise NotFound(E_NOT_FOUND, "No account named '%s'." % name)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Tests for the C API through the Python binding.
|
||||
|
||||
LLS_LIBRARY=build/liblls_api.so python3 python/test_lls.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import lls # noqa: E402
|
||||
|
||||
NOON = datetime(2030, 6, 15, 12, 0) # the pretend "now" used by most tests
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self.tmp.name, "tasks.db")
|
||||
self.db = lls.Database(self.path)
|
||||
self.db.set_clock(NOON)
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(self.db.close)
|
||||
|
||||
def at(self, hour, minute=0):
|
||||
self.db.set_clock(NOON.replace(hour=hour, minute=minute))
|
||||
|
||||
def statuses(self):
|
||||
return {t.name: t.status_label for t in self.db.tasks()}
|
||||
|
||||
|
||||
class CrudTests(Base):
|
||||
def test_accounts_and_tasks(self):
|
||||
self.db.add_account("Acrux")
|
||||
self.db.add_account("beta")
|
||||
self.db.add_task("Breeze A", account="acrux") # names are case-insensitive
|
||||
task = self.db.task("breeze a")
|
||||
self.assertEqual(task.name, "Breeze A")
|
||||
self.assertEqual(task.account, "Acrux") # the account's own spelling
|
||||
self.assertEqual(task.status, lls.Status.PROMPT)
|
||||
# most free first: beta has no tasks (Free), Acrux has a Prompt task (Taken)
|
||||
self.assertEqual([a.name for a in self.db.accounts()], ["beta", "Acrux"])
|
||||
|
||||
def test_errors_have_types(self):
|
||||
self.db.add_account("A")
|
||||
with self.assertRaises(lls.AlreadyExists):
|
||||
self.db.add_account("a")
|
||||
with self.assertRaises(lls.NotFound):
|
||||
self.db.delete_account("nope")
|
||||
with self.assertRaises(lls.NotFound):
|
||||
self.db.add_task("t", account="nope")
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.add_account("-")
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.add_task("t", status="sleeping")
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.set_account_wait("A", "25:99")
|
||||
self.db.add_task("t")
|
||||
with self.assertRaises(lls.AlreadyExists):
|
||||
self.db.add_task("T")
|
||||
try:
|
||||
self.db.delete_task("zzz")
|
||||
except lls.LLSError as error:
|
||||
self.assertEqual(error.code, lls.E_NOT_FOUND)
|
||||
self.assertIn("No task named 'zzz'", str(error))
|
||||
|
||||
def test_rename_reassign_delete(self):
|
||||
self.db.add_account("A")
|
||||
self.db.add_account("B")
|
||||
self.db.add_task("t1", "A")
|
||||
self.db.rename_task("t1", "t2")
|
||||
self.db.set_task_account("t2", "B")
|
||||
self.assertEqual(self.db.task("t2").account, "B")
|
||||
self.db.set_task_account("t2", None)
|
||||
self.assertIsNone(self.db.task("t2").account)
|
||||
self.db.set_task_account("t2", "A")
|
||||
self.db.rename_account("A", "Alpha") # tasks follow the rename
|
||||
self.assertEqual(self.db.task("t2").account, "Alpha")
|
||||
self.db.delete_account("Alpha") # tasks become unassigned
|
||||
self.assertIsNone(self.db.task("t2").account)
|
||||
self.db.delete_task("t2")
|
||||
self.assertEqual(self.db.tasks(), [])
|
||||
|
||||
def test_unicode(self):
|
||||
self.db.add_account("Cuenta ñandú")
|
||||
self.db.add_task("Tarea ✓", "Cuenta ñandú")
|
||||
self.db.set_task_notes("Tarea ✓", "línea uno\nlínea dos é")
|
||||
task = self.db.task("Tarea ✓")
|
||||
self.assertEqual(task.account, "Cuenta ñandú")
|
||||
self.assertEqual(task.notes, "línea uno\nlínea dos é")
|
||||
self.assertEqual(task.notes_lines, 2)
|
||||
|
||||
|
||||
class OrderingTests(Base):
|
||||
def test_task_order(self):
|
||||
for account in ("Zeta", "alpha", "Mid"):
|
||||
self.db.add_account(account)
|
||||
self.at(12)
|
||||
# waiting / limit reset come from the account
|
||||
self.db.add_task("d1", "Zeta", "done")
|
||||
self.db.add_task("f1", "alpha", "frozen")
|
||||
self.db.add_task("u1", "alpha", "uploading")
|
||||
self.db.add_task("p-zeta", "Zeta")
|
||||
self.db.add_task("p-alpha", "alpha")
|
||||
self.db.add_task("p-none")
|
||||
self.db.add_task("t1", "Zeta", "testing")
|
||||
self.db.add_task("lr", "Mid", "limit reset")
|
||||
order = [t.name for t in self.db.tasks()]
|
||||
self.assertEqual(order, ["lr", "t1", "p-alpha", "p-zeta", "p-none", "u1", "f1", "d1"])
|
||||
|
||||
def test_wait_and_limit_reset_sort_by_time_before_account(self):
|
||||
for account in ("A", "B", "C"):
|
||||
self.db.add_account(account)
|
||||
self.db.add_task("task-" + account, account)
|
||||
self.db.set_account_wait("A", "6pm")
|
||||
self.db.set_account_wait("B", "2pm")
|
||||
self.db.set_account_wait("C", "4pm")
|
||||
self.assertEqual([t.name for t in self.db.tasks()], ["task-B", "task-C", "task-A"])
|
||||
self.at(17) # B and C are over now: Limit reset first, ordered by when they ended
|
||||
self.assertEqual([t.status_label for t in self.db.tasks()], ["Limit reset", "Limit reset", "Wait 18:00"])
|
||||
self.assertEqual([t.name for t in self.db.tasks()], ["task-B", "task-C", "task-A"])
|
||||
|
||||
def test_accounts_most_free_first(self):
|
||||
for account in ("Busy1", "Taken1", "Free1", "Free2", "Busy2"):
|
||||
self.db.add_account(account)
|
||||
self.db.add_task("t", "Busy1", "testing")
|
||||
self.db.add_task("w1", "Busy2", "testing")
|
||||
self.db.add_task("w2", "Busy2", "testing")
|
||||
self.db.add_task("p", "Taken1")
|
||||
self.db.add_task("d", "Free1", "done")
|
||||
accounts = self.db.accounts()
|
||||
self.assertEqual([a.name for a in accounts], ["Free1", "Free2", "Taken1", "Busy1", "Busy2"])
|
||||
self.assertEqual([a.level for a in accounts],
|
||||
[lls.Level.FREE, lls.Level.FREE, lls.Level.TAKEN, lls.Level.BUSY, lls.Level.BUSY])
|
||||
self.assertEqual(accounts[0].tasks, ("d",)) # a Free account lists its Done task
|
||||
self.assertEqual(accounts[2].tasks, ("p",))
|
||||
self.assertEqual(accounts[4].tasks, ("w1", "w2"))
|
||||
|
||||
|
||||
class WaitTests(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.db.add_account("A")
|
||||
self.db.add_account("B")
|
||||
self.db.add_task("a-testing", "A", "testing")
|
||||
self.db.add_task("a-prompt", "A")
|
||||
self.db.add_task("a-done", "A", "done")
|
||||
self.db.add_task("b-prompt", "B")
|
||||
|
||||
def test_wait_belongs_to_the_account(self):
|
||||
self.db.set_account_wait("A", "4:30pm")
|
||||
self.assertEqual(self.statuses(), {"a-testing": "Wait 16:30", "a-prompt": "Wait 16:30",
|
||||
"a-done": "Done", "b-prompt": "Prompt"})
|
||||
self.assertEqual(self.db.task("a-testing").stored_status, lls.Status.TESTING) # kept underneath
|
||||
self.assertEqual(self.db.task("a-testing").until, NOON.replace(hour=16, minute=30).astimezone())
|
||||
account = self.db.account("A")
|
||||
self.assertEqual((account.level, account.wait_state), (lls.Level.BUSY, lls.WaitState.WAITING))
|
||||
|
||||
self.at(17) # the wait is over -> Limit reset until cleared
|
||||
self.assertEqual(self.db.task("a-prompt").status, lls.Status.LIMIT_RESET)
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
|
||||
|
||||
self.assertTrue(self.db.clear_account_wait("A"))
|
||||
self.assertFalse(self.db.clear_account_wait("A"))
|
||||
self.assertEqual(self.db.task("a-testing").status, lls.Status.TESTING)
|
||||
self.assertEqual(self.db.task("a-prompt").status, lls.Status.PROMPT)
|
||||
|
||||
def test_time_in_the_past_means_tomorrow(self):
|
||||
self.db.set_account_wait("A", "9am") # it is noon
|
||||
until = self.db.account("A").wait_until
|
||||
self.assertEqual((until.day, until.hour), (16, 9))
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.WAITING)
|
||||
|
||||
def test_set_wait_with_datetime_and_seconds(self):
|
||||
self.db.set_account_wait("A", NOON.replace(hour=15))
|
||||
self.assertEqual(self.db.task("a-prompt").status_label, "Wait 15:00")
|
||||
self.db.set_account_wait("A", int(NOON.replace(hour=20).timestamp()))
|
||||
self.assertEqual(self.db.task("a-prompt").status_label, "Wait 20:00")
|
||||
|
||||
def test_task_wait_sets_the_account_wait(self):
|
||||
self.db.set_task_status("b-prompt", "wait 3pm")
|
||||
self.assertEqual(self.db.account("B").wait_state, lls.WaitState.WAITING)
|
||||
self.assertEqual(self.db.task("b-prompt").status_label, "Wait 15:00")
|
||||
|
||||
def test_wait_without_time_only_while_account_is_waiting(self):
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.set_task_status("b-prompt", "wait")
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.add_task("new", "B", "wait")
|
||||
self.db.add_task("loose")
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.set_task_status("loose", "wait")
|
||||
self.db.set_account_wait("B", "4pm")
|
||||
self.db.add_task("follower", "B", "wait") # fine now
|
||||
self.assertEqual(self.db.task("follower").status_label, "Wait 16:00")
|
||||
self.at(17) # over: a time is required again
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
self.db.set_task_status("b-prompt", "wait")
|
||||
|
||||
def test_explicit_testing_or_prompt_clears_a_finished_wait(self):
|
||||
self.db.set_account_wait("A", "1pm")
|
||||
self.at(14)
|
||||
self.assertEqual(self.db.task("a-prompt").status, lls.Status.LIMIT_RESET)
|
||||
self.db.add_task("implicit", "A") # no status given: nothing cleared
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
|
||||
self.db.set_task_status("a-testing", "uploading") # not an "in use" status
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
|
||||
self.db.add_task("resumed", "A", "testing") # explicit: the account is in use again
|
||||
self.assertTrue(any("back in use" in m for m in self.db.messages))
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.NONE) # (queries keep the messages)
|
||||
self.assertEqual(self.db.task("resumed").status, lls.Status.TESTING)
|
||||
self.assertEqual(self.db.task("a-prompt").status, lls.Status.PROMPT)
|
||||
self.assertTrue(any("back in use" in m for m in self.db.messages))
|
||||
|
||||
def test_a_running_wait_is_never_cleared_automatically(self):
|
||||
self.db.set_account_wait("A", "6pm")
|
||||
self.db.set_task_status("a-testing", "testing")
|
||||
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.WAITING)
|
||||
self.assertEqual(self.db.task("a-testing").status, lls.Status.WAIT)
|
||||
|
||||
def test_account_wait_nothing_shows(self):
|
||||
self.db.add_account("Empty")
|
||||
self.db.set_account_wait("Empty", "5pm")
|
||||
account = self.db.account("Empty")
|
||||
self.assertEqual((account.level, account.wait_listed, account.tasks), (lls.Level.BUSY, True, ()))
|
||||
self.db.add_task("only-done", "Empty", "done")
|
||||
self.assertTrue(self.db.account("Empty").wait_listed)
|
||||
self.assertEqual(self.db.account("Empty").tasks, ())
|
||||
|
||||
def test_messages(self):
|
||||
self.db.set_account_wait("A", "4:30pm")
|
||||
self.assertEqual(len(self.db.messages), 1)
|
||||
self.assertIn("waits until 16:30", self.db.messages[0])
|
||||
with self.assertRaises(lls.NotFound):
|
||||
self.db.delete_task("zzz")
|
||||
self.assertEqual(self.db.messages, []) # a failed call leaves no notes
|
||||
|
||||
|
||||
class NotesAndPersistenceTests(Base):
|
||||
def test_notes_have_no_length_limit(self):
|
||||
self.db.add_task("t")
|
||||
text = ("x" * 99) + "\n"
|
||||
text *= 3000 # ~300 kB, 3000 lines
|
||||
text = text.rstrip("\n")
|
||||
self.db.set_task_notes("t", text)
|
||||
task = self.db.task("t")
|
||||
self.assertEqual(task.notes, text)
|
||||
self.assertEqual(task.notes_lines, 3000)
|
||||
self.db.set_task_notes("t", "")
|
||||
self.assertEqual(self.db.task("t").notes_lines, 0)
|
||||
|
||||
def test_save_reopen_and_dirty_flag(self):
|
||||
self.assertFalse(self.db.dirty)
|
||||
self.db.add_account("A")
|
||||
self.db.add_task("t", "A", "testing")
|
||||
self.db.set_task_notes("t", "one\ntwo")
|
||||
self.db.set_account_wait("A", "11pm")
|
||||
self.assertTrue(self.db.dirty)
|
||||
self.db.save()
|
||||
self.assertFalse(self.db.dirty)
|
||||
|
||||
with lls.Database(self.path) as other:
|
||||
other.set_clock(NOON)
|
||||
task = other.task("t")
|
||||
self.assertEqual((task.account, task.notes, task.status_label), ("A", "one\ntwo", "Wait 23:00"))
|
||||
|
||||
def test_unsaved_changes_are_not_written(self):
|
||||
self.db.add_account("A")
|
||||
self.db.save()
|
||||
self.db.add_account("B")
|
||||
self.db.close()
|
||||
with lls.Database(self.path) as other:
|
||||
self.assertEqual([a.name for a in other.accounts()], ["A"])
|
||||
|
||||
def test_backup_and_restore(self):
|
||||
self.db.add_account("A")
|
||||
copy = self.db.backup(os.path.join(self.tmp.name, "copy.db"))
|
||||
self.assertTrue(os.path.isfile(copy))
|
||||
auto = self.db.backup() # timestamped name next to the database
|
||||
self.assertTrue(auto.startswith(self.path) and os.path.isfile(auto))
|
||||
self.db.add_account("B")
|
||||
self.db.restore(copy)
|
||||
self.assertEqual([a.name for a in self.db.accounts()], ["A"])
|
||||
self.assertTrue(self.db.dirty)
|
||||
with self.assertRaises(lls.LLSError):
|
||||
self.db.restore(os.path.join(self.tmp.name, "missing.db"))
|
||||
|
||||
def test_opening_a_bad_path_or_file(self):
|
||||
bad = os.path.join(self.tmp.name, "not-a-db.db")
|
||||
Path(bad).write_text("this is not sqlite")
|
||||
with self.assertRaises(lls.DatabaseError):
|
||||
lls.Database(bad)
|
||||
|
||||
def test_closed_database_raises(self):
|
||||
self.db.close()
|
||||
with self.assertRaises(lls.LLSError):
|
||||
self.db.add_account("A")
|
||||
|
||||
|
||||
class UtilityTests(unittest.TestCase):
|
||||
def test_parse_time(self):
|
||||
self.assertEqual(lls.parse_time("4:30pm", now=NOON), NOON.replace(hour=16, minute=30).astimezone())
|
||||
self.assertEqual(lls.parse_time("16:30", now=NOON).hour, 16)
|
||||
self.assertEqual(lls.parse_time("12am", now=NOON).day, 16) # already passed -> tomorrow
|
||||
for bad in ("", "6", "13pm", "25:00", "soon"):
|
||||
with self.assertRaises(lls.InvalidArgument):
|
||||
lls.parse_time(bad, now=NOON)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+23
-186
@@ -1,9 +1,7 @@
|
||||
#include "Display.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "StringUtil.h"
|
||||
@@ -11,92 +9,8 @@
|
||||
|
||||
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) {
|
||||
// Each account's wait (when its limit ends), keyed by lowercase name.
|
||||
std::map<std::string, std::time_t> accountWaits;
|
||||
for (const AccountRow& account : db.listAccountRows()) {
|
||||
if (account.cooldown) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*account.cooldown)) {
|
||||
accountWaits[StringUtil::lower(account.id)] = *until;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
std::optional<std::time_t> accountWait;
|
||||
if (row.account) {
|
||||
auto found = accountWaits.find(StringUtil::lower(*row.account));
|
||||
if (found != accountWaits.end()) {
|
||||
accountWait = found->second;
|
||||
}
|
||||
}
|
||||
view.status = StatusUtil::resolve(stored.value_or(StatusUtil::make(StatusKind::Prompt)), accountWait, now);
|
||||
views.push_back(view);
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
// Task order: status priority (see StatusUtil::rank); then, for Wait and Limit
|
||||
// reset, time (earliest first: when the wait ends / ended); then account A-Z
|
||||
// (tasks without an account last); then task name.
|
||||
bool taskLess(const TaskView& a, const TaskView& b) {
|
||||
const int rankA = StatusUtil::rank(a.status.kind);
|
||||
const int rankB = StatusUtil::rank(b.status.kind);
|
||||
if (rankA != rankB) {
|
||||
return rankA < rankB;
|
||||
}
|
||||
const StatusKind kind = a.status.kind; // same kind as b: ranks are unique per kind
|
||||
if ((kind == StatusKind::Wait || kind == StatusKind::LimitReset) && a.status.waitUntil != b.status.waitUntil) {
|
||||
return a.status.waitUntil < b.status.waitUntil;
|
||||
}
|
||||
if (a.account.empty() != b.account.empty()) {
|
||||
return !a.account.empty();
|
||||
}
|
||||
const std::string accountA = StringUtil::lower(a.account);
|
||||
const std::string accountB = StringUtil::lower(b.account);
|
||||
if (accountA != accountB) {
|
||||
return accountA < accountB;
|
||||
}
|
||||
return StringUtil::lower(a.name) < StringUtil::lower(b.name);
|
||||
}
|
||||
|
||||
AccountLevel levelOf(StatusKind kind) {
|
||||
if (StatusUtil::isBusy(kind)) {
|
||||
return AccountLevel::Busy;
|
||||
}
|
||||
if (StatusUtil::isTaken(kind)) {
|
||||
return AccountLevel::Taken;
|
||||
}
|
||||
return AccountLevel::Free;
|
||||
}
|
||||
|
||||
// Free first, then Taken, then Busy.
|
||||
int levelRank(AccountLevel level) {
|
||||
switch (level) {
|
||||
case AccountLevel::Free: return 0;
|
||||
case AccountLevel::Taken: return 1;
|
||||
case AccountLevel::Busy: return 2;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// "-" when there are no notes, otherwise how many lines they have.
|
||||
std::string notesSummary(const std::string& notes) {
|
||||
const std::size_t lines = StringUtil::lineCount(notes);
|
||||
std::string notesSummary(std::size_t lines) {
|
||||
if (lines == 0) {
|
||||
return "-";
|
||||
}
|
||||
@@ -116,116 +30,39 @@ const DisplaySettings& Display::settings() const {
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const TaskInfo& task : buildTasks(db_, now)) {
|
||||
if (settings_.hiddenTaskLevels.count(task.status.kind) > 0) {
|
||||
continue;
|
||||
}
|
||||
table.addRow({view.name,
|
||||
view.account.empty() ? "-" : view.account,
|
||||
StatusUtil::label(view.status),
|
||||
notesSummary(view.notes)});
|
||||
table.addRow({task.name,
|
||||
task.account ? *task.account : "-",
|
||||
StatusUtil::label(task.status),
|
||||
notesSummary(task.noteLines)});
|
||||
}
|
||||
return table.render();
|
||||
}
|
||||
|
||||
std::string accountLevelName(AccountLevel level) {
|
||||
switch (level) {
|
||||
case AccountLevel::Busy: return "busy";
|
||||
case AccountLevel::Taken: return "taken";
|
||||
case AccountLevel::Free: return "free";
|
||||
}
|
||||
return "free";
|
||||
}
|
||||
|
||||
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. Its level is the most taken of its tasks
|
||||
// (Busy > Taken > Free); the TASKS column lists the tasks at that level
|
||||
// (for a Free account that is its Uploading, Frozen and Done tasks).
|
||||
// The RESET column is when the account's wait ends. A wait that no task is
|
||||
// showing (no tasks, or only Done / Frozen / Uploading ones) is listed as
|
||||
// "(wait)" / "(limit reset)" and keeps the account Busy.
|
||||
struct AccountView {
|
||||
std::string name;
|
||||
AccountLevel level;
|
||||
std::string reset; // when its wait ends, "-" if it has none
|
||||
std::vector<std::string> tasks;
|
||||
};
|
||||
|
||||
std::vector<AccountView> accounts;
|
||||
for (const AccountRow& row : db_.listAccountRows()) {
|
||||
std::vector<std::string> busy;
|
||||
std::vector<std::string> taken;
|
||||
std::vector<std::string> free;
|
||||
bool showsWait = false;
|
||||
for (const TaskView& view : views) {
|
||||
if (view.account != row.id) {
|
||||
continue;
|
||||
}
|
||||
const AccountLevel level = levelOf(view.status.kind);
|
||||
if (level == AccountLevel::Busy) {
|
||||
busy.push_back(view.name);
|
||||
} else if (level == AccountLevel::Taken) {
|
||||
taken.push_back(view.name);
|
||||
} else {
|
||||
free.push_back(view.name);
|
||||
}
|
||||
if (view.status.kind == StatusKind::Wait || view.status.kind == StatusKind::LimitReset) {
|
||||
showsWait = true;
|
||||
}
|
||||
}
|
||||
|
||||
std::string reset = "-";
|
||||
if (row.cooldown) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*row.cooldown)) {
|
||||
reset = StatusUtil::resetLabel(*until, now);
|
||||
if (!showsWait) {
|
||||
busy.push_back(*until > now ? "(wait)" : "(limit reset)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AccountView entry;
|
||||
entry.name = row.id;
|
||||
entry.reset = reset;
|
||||
if (!busy.empty()) {
|
||||
entry.level = AccountLevel::Busy;
|
||||
entry.tasks = busy;
|
||||
} else if (!taken.empty()) {
|
||||
entry.level = AccountLevel::Taken;
|
||||
entry.tasks = taken;
|
||||
} else {
|
||||
entry.level = AccountLevel::Free;
|
||||
entry.tasks = free;
|
||||
}
|
||||
accounts.push_back(entry);
|
||||
}
|
||||
|
||||
// Most free first: Free, then Taken, then Busy; within Taken and Busy fewer
|
||||
// tasks first (Free accounts are all equally free). listAccountRows is
|
||||
// alphabetical and stable_sort keeps that order for ties.
|
||||
std::stable_sort(accounts.begin(), accounts.end(), [](const AccountView& a, const AccountView& b) {
|
||||
if (a.level != b.level) {
|
||||
return levelRank(a.level) < levelRank(b.level);
|
||||
}
|
||||
return a.level != AccountLevel::Free && a.tasks.size() < b.tasks.size();
|
||||
});
|
||||
|
||||
// The TASKS column lists the tasks at the account's level. A wait that no
|
||||
// task is showing is listed as "(wait)" / "(limit reset)".
|
||||
TextTable table({"ACCOUNT", "STATUS", "RESET", "TASKS"});
|
||||
for (const AccountView& entry : accounts) {
|
||||
if (settings_.hiddenAccountLevels.count(entry.level) > 0) {
|
||||
for (const AccountInfo& account : buildAccounts(db_, now)) {
|
||||
if (settings_.hiddenAccountLevels.count(account.level) > 0) {
|
||||
continue;
|
||||
}
|
||||
std::string status = accountLevelName(entry.level);
|
||||
|
||||
std::vector<std::string> entries = account.tasks;
|
||||
if (account.waitListed) {
|
||||
entries.push_back(account.waitState == WaitState::Waiting ? "(wait)" : "(limit reset)");
|
||||
}
|
||||
|
||||
std::string status = accountLevelName(account.level);
|
||||
status[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(status[0])));
|
||||
table.addRow({entry.name, status, entry.reset,
|
||||
entry.tasks.empty() ? "-" : StringUtil::join(entry.tasks, 0, ", ")});
|
||||
table.addRow({account.name,
|
||||
status,
|
||||
account.waitState == WaitState::None ? "-" : StatusUtil::resetLabel(account.waitUntil, now),
|
||||
entries.empty() ? "-" : StringUtil::join(entries, 0, ", ")});
|
||||
}
|
||||
return table.render();
|
||||
}
|
||||
+32
-210
@@ -16,10 +16,6 @@ namespace {
|
||||
|
||||
const std::size_t kUnlimited = std::numeric_limits<std::size_t>::max();
|
||||
|
||||
const char* const kStatusHelp =
|
||||
"prompt, limit-reset, testing, wait <time>, uploading, frozen, done "
|
||||
"(time: 16:30, 4:30pm, 4pm)";
|
||||
|
||||
bool parseVisibility(const std::string& word) {
|
||||
const std::string value = StringUtil::lower(word);
|
||||
if (value == "show") {
|
||||
@@ -34,7 +30,7 @@ bool parseVisibility(const std::string& word) {
|
||||
} // namespace
|
||||
|
||||
Repl::Repl(Database& db, Display& display, Config& config)
|
||||
: db_(db), display_(display), config_(config), completer_(db, specs_), quit_(false) {
|
||||
: db_(db), display_(display), config_(config), tracker_(db), completer_(db, specs_), quit_(false) {
|
||||
registerCommands();
|
||||
}
|
||||
|
||||
@@ -203,11 +199,16 @@ void Repl::execute(const std::string& line) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((this->*command->handler)(args)) {
|
||||
const bool redraw = (this->*command->handler)(args);
|
||||
for (const std::string& message : tracker_.takeMessages()) {
|
||||
say(message);
|
||||
}
|
||||
if (redraw) {
|
||||
refresh();
|
||||
}
|
||||
flushMessages();
|
||||
} catch (const std::exception& error) {
|
||||
tracker_.takeMessages();
|
||||
messages_.clear();
|
||||
std::cout << "Error: " << error.what() << "\n";
|
||||
}
|
||||
@@ -219,7 +220,7 @@ void Repl::refresh() const {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::time_t now = std::time(nullptr);
|
||||
const std::time_t now = tracker_.now();
|
||||
Terminal::clearScreen();
|
||||
if (settings.showTasks) {
|
||||
std::cout << display_.renderTasks(now) << "\n";
|
||||
@@ -251,146 +252,43 @@ std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCan
|
||||
}
|
||||
}
|
||||
|
||||
bool Repl::isBareWait(const Args& args, std::size_t from) {
|
||||
return StringUtil::lower(StringUtil::join(args, from, " ")) == "wait";
|
||||
}
|
||||
|
||||
// The account's wait if it is still running (not yet a Limit reset).
|
||||
std::optional<std::time_t> Repl::runningWait(const std::string& account) const {
|
||||
std::optional<AccountRow> row = db_.getAccount(account);
|
||||
if (!row || !row->cooldown) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
|
||||
if (until && *until > std::time(nullptr)) {
|
||||
return until;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Prompt and Testing tasks follow their account's wait; anything else becomes Prompt.
|
||||
void Repl::makeTaskFollowAccount(const TaskRow& task) {
|
||||
std::optional<Status> current = StatusUtil::deserialize(task.status);
|
||||
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
|
||||
db_.setTaskStatus(task.id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
|
||||
}
|
||||
}
|
||||
|
||||
void Repl::requireName(const std::string& name) {
|
||||
if (name.empty() || name == "-") {
|
||||
throw std::runtime_error("'" + name + "' is not a valid name.");
|
||||
}
|
||||
}
|
||||
|
||||
Status Repl::parseStatusFrom(const Args& args, std::size_t from) {
|
||||
const std::string text = StringUtil::join(args, from, " ");
|
||||
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
|
||||
if (!status) {
|
||||
throw std::runtime_error("Invalid status '" + text + "'. Use: " + kStatusHelp);
|
||||
}
|
||||
return *status;
|
||||
}
|
||||
|
||||
// ---- Accounts ---------------------------------------------------------------
|
||||
|
||||
bool Repl::cmdAccAdd(const Args& args) {
|
||||
requireName(args[0]);
|
||||
if (db_.findAccount(args[0])) {
|
||||
throw std::runtime_error("Account '" + args[0] + "' already exists.");
|
||||
}
|
||||
db_.addAccount(args[0]);
|
||||
say("Account '" + args[0] + "' added.");
|
||||
tracker_.addAccount(args[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdAccDel(const Args& args) {
|
||||
std::optional<std::string> name = db_.findAccount(args[0]);
|
||||
if (!name) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
db_.removeAccount(*name);
|
||||
say("Account '" + *name + "' deleted.");
|
||||
tracker_.deleteAccount(args[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdAccRename(const Args& args) {
|
||||
std::optional<std::string> name = db_.findAccount(args[0]);
|
||||
if (!name) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
requireName(args[1]);
|
||||
if (!StringUtil::equalsIgnoreCase(*name, args[1]) && db_.findAccount(args[1])) {
|
||||
throw std::runtime_error("Account '" + args[1] + "' already exists.");
|
||||
}
|
||||
db_.renameAccount(*name, args[1]);
|
||||
say("Account '" + *name + "' renamed to '" + args[1] + "'.");
|
||||
tracker_.renameAccount(args[0], args[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Repl::setAccountWait(const std::string& account, std::time_t until) {
|
||||
db_.setAccountCooldown(account, StatusUtil::timeToText(until));
|
||||
say("Account '" + account + "' waits until " + StatusUtil::clockLabel(until) +
|
||||
": its Prompt and Testing tasks show Wait, then Limit reset (acc unwait " + account + " to clear).");
|
||||
}
|
||||
|
||||
// Tells the user when a task's own status is hidden by its account's wait.
|
||||
void Repl::noteAccountWait(const std::string& taskName) {
|
||||
std::optional<TaskRow> task = db_.getTask(taskName);
|
||||
std::optional<Status> stored = task ? StatusUtil::deserialize(task->status) : std::nullopt;
|
||||
if (!task || !task->account || !stored || !StatusUtil::followsAccountWait(stored->kind)) {
|
||||
return;
|
||||
}
|
||||
std::optional<AccountRow> account = db_.getAccount(*task->account);
|
||||
if (account && account->cooldown) {
|
||||
say("It shows as Wait / Limit reset because account '" + account->id + "' is waiting (acc unwait " +
|
||||
account->id + " to clear).");
|
||||
}
|
||||
}
|
||||
|
||||
bool Repl::cmdAccWait(const Args& args) {
|
||||
std::optional<AccountRow> account = db_.getAccount(args[0]);
|
||||
if (!account) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
const std::string text = "wait " + StringUtil::join(args, 1, " ");
|
||||
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
|
||||
if (!status) {
|
||||
throw std::runtime_error("Invalid time '" + StringUtil::join(args, 1, " ") +
|
||||
"'. Use e.g. 16:30, 4:30pm or 4pm.");
|
||||
}
|
||||
setAccountWait(account->id, status->waitUntil);
|
||||
tracker_.setAccountWaitText(args[0], StringUtil::join(args, 1, " "));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdAccUnwait(const Args& args) {
|
||||
std::optional<AccountRow> account = db_.getAccount(args[0]);
|
||||
if (!account) {
|
||||
throw std::runtime_error("No account named '" + args[0] + "'.");
|
||||
}
|
||||
if (!account->cooldown) {
|
||||
say("Account '" + account->id + "' is not waiting.");
|
||||
return false;
|
||||
}
|
||||
db_.setAccountCooldown(account->id, std::nullopt);
|
||||
say("Wait cleared on '" + account->id + "'.");
|
||||
return true;
|
||||
return tracker_.clearAccountWait(args[0]);
|
||||
}
|
||||
|
||||
bool Repl::cmdAccLs(const Args&) {
|
||||
Terminal::clearScreen();
|
||||
std::cout << display_.renderAccounts(std::time(nullptr)) << "\n";
|
||||
std::cout << display_.renderAccounts(tracker_.now()) << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Tasks ------------------------------------------------------------------
|
||||
|
||||
bool Repl::cmdTskAdd(const Args& args) {
|
||||
const std::string& name = args[0];
|
||||
requireName(name);
|
||||
if (db_.findTask(name)) {
|
||||
throw std::runtime_error("Task '" + name + "' already exists.");
|
||||
}
|
||||
|
||||
// tsk add <name> [acc] [status]: the second word is an account if one exists
|
||||
// with that name ("-" = none), otherwise it starts the status.
|
||||
std::size_t next = 1;
|
||||
std::optional<std::string> account;
|
||||
if (next < args.size()) {
|
||||
@@ -399,119 +297,50 @@ bool Repl::cmdTskAdd(const Args& args) {
|
||||
++next;
|
||||
} else if (args[next] == "-") {
|
||||
++next;
|
||||
} else if (!isBareWait(args, next) && !StatusUtil::parse(StringUtil::join(args, next, " "), std::time(nullptr))) {
|
||||
} else {
|
||||
const std::string text = StringUtil::join(args, next, " ");
|
||||
if (!StatusUtil::isBareWait(text) && !StatusUtil::parse(text, tracker_.now())) {
|
||||
throw std::runtime_error("'" + args[next] + "' is neither an existing account nor a valid status (" +
|
||||
kStatusHelp + ").");
|
||||
StatusUtil::helpText() + ").");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Status status = StatusUtil::make(StatusKind::Prompt);
|
||||
std::optional<std::string> statusText;
|
||||
if (next < args.size()) {
|
||||
if (isBareWait(args, next)) {
|
||||
// "wait" without a time: only while the account is already waiting.
|
||||
if (!account || !runningWait(*account)) {
|
||||
throw std::runtime_error("A time is required unless the task's account is already waiting: wait <time>");
|
||||
}
|
||||
} else {
|
||||
status = parseStatusFrom(args, next);
|
||||
}
|
||||
}
|
||||
|
||||
// A wait on a task that has an account is the account's wait; the task itself
|
||||
// stays Prompt and follows it.
|
||||
const bool accountWait = (status.kind == StatusKind::Wait && account);
|
||||
const Status stored = accountWait ? StatusUtil::make(StatusKind::Prompt) : status;
|
||||
|
||||
db_.addTask(name, account, StatusUtil::serialize(stored));
|
||||
say("Task '" + name + "' added.");
|
||||
if (accountWait) {
|
||||
setAccountWait(*account, status.waitUntil);
|
||||
} else {
|
||||
noteAccountWait(name);
|
||||
statusText = StringUtil::join(args, next, " ");
|
||||
}
|
||||
tracker_.addTask(args[0], account, statusText);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdTskDel(const Args& args) {
|
||||
std::optional<std::string> name = db_.findTask(args[0]);
|
||||
if (!name) {
|
||||
throw std::runtime_error("No task named '" + args[0] + "'.");
|
||||
}
|
||||
db_.removeTask(*name);
|
||||
say("Task '" + *name + "' deleted.");
|
||||
tracker_.deleteTask(args[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdTskLs(const Args&) {
|
||||
Terminal::clearScreen();
|
||||
std::cout << display_.renderTasks(std::time(nullptr)) << "\n";
|
||||
std::cout << display_.renderTasks(tracker_.now()) << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Repl::cmdTskRename(const Args& args) {
|
||||
std::optional<std::string> name = db_.findTask(args[0]);
|
||||
if (!name) {
|
||||
throw std::runtime_error("No task named '" + args[0] + "'.");
|
||||
}
|
||||
requireName(args[1]);
|
||||
if (!StringUtil::equalsIgnoreCase(*name, args[1]) && db_.findTask(args[1])) {
|
||||
throw std::runtime_error("Task '" + args[1] + "' already exists.");
|
||||
}
|
||||
db_.renameTask(*name, args[1]);
|
||||
say("Task '" + *name + "' renamed to '" + args[1] + "'.");
|
||||
tracker_.renameTask(args[0], args[1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdTskReacc(const Args& args) {
|
||||
std::optional<std::string> name = db_.findTask(args[0]);
|
||||
if (!name) {
|
||||
throw std::runtime_error("No task named '" + args[0] + "'.");
|
||||
}
|
||||
|
||||
std::optional<std::string> account;
|
||||
if (args[1] != "-") {
|
||||
account = db_.findAccount(args[1]);
|
||||
if (!account) {
|
||||
throw std::runtime_error("No account named '" + args[1] + "'.");
|
||||
account = args[1];
|
||||
}
|
||||
}
|
||||
db_.setTaskAccount(*name, account);
|
||||
say("Task '" + *name + "' now on account " + (account ? "'" + *account + "'" : std::string("(none)")) + ".");
|
||||
tracker_.setTaskAccount(args[0], account);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Repl::cmdTskRestat(const Args& args) {
|
||||
std::optional<TaskRow> task = db_.getTask(args[0]);
|
||||
if (!task) {
|
||||
throw std::runtime_error("No task named '" + args[0] + "'.");
|
||||
}
|
||||
|
||||
if (isBareWait(args, 1)) {
|
||||
// "wait" without a time: the task follows its account's running wait.
|
||||
if (!task->account || !runningWait(*task->account)) {
|
||||
throw std::runtime_error(
|
||||
task->account ? "Account '" + *task->account + "' is not waiting, so a time is required: wait <time>"
|
||||
: "Task '" + task->id + "' has no account, so a time is required: wait <time>");
|
||||
}
|
||||
makeTaskFollowAccount(*task);
|
||||
say("Task '" + task->id + "' now follows account '" + *task->account + "'.");
|
||||
noteAccountWait(task->id);
|
||||
return true;
|
||||
}
|
||||
|
||||
Status status = parseStatusFrom(args, 1);
|
||||
|
||||
if (status.kind == StatusKind::Wait && task->account) {
|
||||
// The wait belongs to the account. Keep an active task as it is so it
|
||||
// follows the wait; any other status becomes Prompt.
|
||||
makeTaskFollowAccount(*task);
|
||||
setAccountWait(*task->account, status.waitUntil);
|
||||
return true;
|
||||
}
|
||||
|
||||
db_.setTaskStatus(task->id, StatusUtil::serialize(status));
|
||||
say("Task '" + task->id + "' set to " + StatusUtil::label(status) + ".");
|
||||
noteAccountWait(task->id);
|
||||
tracker_.setTaskStatus(args[0], StringUtil::join(args, 1, " "));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -531,14 +360,7 @@ bool Repl::cmdTskNotes(const Args& args) {
|
||||
|
||||
const std::string initial = task->notes.empty() ? "" : task->notes + "\n";
|
||||
const std::string edited = StringUtil::trim(ExternalEditor::edit(command, initial));
|
||||
|
||||
if (edited == StringUtil::trim(task->notes)) {
|
||||
say("Notes unchanged.");
|
||||
return false;
|
||||
}
|
||||
db_.setTaskNotes(task->id, edited);
|
||||
say(edited.empty() ? "Notes cleared." : "Notes updated.");
|
||||
return true;
|
||||
return tracker_.setTaskNotes(task->id, edited);
|
||||
}
|
||||
|
||||
// ---- Display ----------------------------------------------------------------
|
||||
@@ -696,7 +518,7 @@ bool Repl::cmdHelp(const Args&) {
|
||||
}
|
||||
std::cout << " " << command.spec.usage << "\n " << command.spec.help << "\n";
|
||||
}
|
||||
std::cout << "\nStatuses: " << kStatusHelp << "\n"
|
||||
std::cout << "\nStatuses: " << StatusUtil::helpText() << "\n"
|
||||
<< "Keys: Up/Down history, Left/Right edit, Tab completes, Ctrl-A/E start/end, "
|
||||
"Ctrl-U/K/W delete, Ctrl-L clear\n\n";
|
||||
return false;
|
||||
|
||||
@@ -267,6 +267,19 @@ Status effective(const Status& status, std::time_t now) {
|
||||
return status;
|
||||
}
|
||||
|
||||
bool isBareWait(const std::string& text) {
|
||||
std::istringstream in(text);
|
||||
std::string word;
|
||||
std::string extra;
|
||||
in >> word;
|
||||
return !(in >> extra) && !word.empty() && alphanumeric(word) == "wait";
|
||||
}
|
||||
|
||||
std::string helpText() {
|
||||
return "prompt, limit-reset, testing, wait <time>, uploading, frozen, done "
|
||||
"(time: 16:30, 4:30pm, 4pm)";
|
||||
}
|
||||
|
||||
std::string timeToText(std::time_t value) {
|
||||
return formatTime(value, "%Y-%m-%dT%H:%M");
|
||||
}
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
#include "Tracker.h"
|
||||
|
||||
#include "StringUtil.h"
|
||||
|
||||
TrackerError::TrackerError(ErrorCode code, const std::string& message)
|
||||
: std::runtime_error(message), code_(code) {}
|
||||
|
||||
ErrorCode TrackerError::code() const {
|
||||
return code_;
|
||||
}
|
||||
|
||||
Tracker::Tracker(Database& db) : db_(db), fixedNow_(0) {}
|
||||
|
||||
const Database& Tracker::database() const {
|
||||
return db_;
|
||||
}
|
||||
|
||||
void Tracker::setClock(std::time_t fixedNow) {
|
||||
fixedNow_ = fixedNow;
|
||||
}
|
||||
|
||||
std::time_t Tracker::now() const {
|
||||
return fixedNow_ != 0 ? fixedNow_ : std::time(nullptr);
|
||||
}
|
||||
|
||||
std::vector<std::string> Tracker::takeMessages() {
|
||||
std::vector<std::string> taken;
|
||||
taken.swap(messages_);
|
||||
return taken;
|
||||
}
|
||||
|
||||
void Tracker::note(const std::string& message) {
|
||||
messages_.push_back(message);
|
||||
}
|
||||
|
||||
// ---- Helpers ------------------------------------------------------------------
|
||||
|
||||
void Tracker::requireName(const std::string& name) {
|
||||
if (name.empty() || name == "-") {
|
||||
throw TrackerError(ErrorCode::Invalid, "'" + name + "' is not a valid name.");
|
||||
}
|
||||
}
|
||||
|
||||
Status Tracker::parseStatus(const std::string& text) const {
|
||||
std::optional<Status> status = StatusUtil::parse(text, now());
|
||||
if (!status) {
|
||||
throw TrackerError(ErrorCode::Invalid, "Invalid status '" + text + "'. Use: " + StatusUtil::helpText());
|
||||
}
|
||||
return *status;
|
||||
}
|
||||
|
||||
std::string Tracker::existingAccount(const std::string& name) const {
|
||||
std::optional<std::string> found = db_.findAccount(name);
|
||||
if (!found) {
|
||||
throw TrackerError(ErrorCode::NotFound, "No account named '" + name + "'.");
|
||||
}
|
||||
return *found;
|
||||
}
|
||||
|
||||
TaskRow Tracker::existingTask(const std::string& name) const {
|
||||
std::optional<TaskRow> found = db_.getTask(name);
|
||||
if (!found) {
|
||||
throw TrackerError(ErrorCode::NotFound, "No task named '" + name + "'.");
|
||||
}
|
||||
return *found;
|
||||
}
|
||||
|
||||
// The account's wait if it is still running (not yet a Limit reset).
|
||||
std::optional<std::time_t> Tracker::runningWait(const std::string& account) const {
|
||||
std::optional<AccountRow> row = db_.getAccount(account);
|
||||
if (!row || !row->cooldown) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
|
||||
if (until && *until > now()) {
|
||||
return until;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Setting a task to Testing or Prompt after its account's limit has reset means the
|
||||
// account is in use again, so the finished wait is cleared (its Limit reset tasks
|
||||
// go back to their own status). A wait that is still running is left alone.
|
||||
void Tracker::resumeAccount(const std::string& account) {
|
||||
std::optional<AccountRow> row = db_.getAccount(account);
|
||||
if (!row || !row->cooldown) {
|
||||
return;
|
||||
}
|
||||
const std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
|
||||
if (until && *until > now()) {
|
||||
return;
|
||||
}
|
||||
db_.setAccountCooldown(row->id, std::nullopt);
|
||||
note("Account '" + row->id + "' is back in use: its finished wait (Limit reset) was cleared.");
|
||||
}
|
||||
|
||||
// Prompt and Testing tasks follow their account's wait; anything else becomes Prompt.
|
||||
void Tracker::makeTaskFollowAccount(const TaskRow& task) {
|
||||
std::optional<Status> current = StatusUtil::deserialize(task.status);
|
||||
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
|
||||
db_.setTaskStatus(task.id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
|
||||
}
|
||||
}
|
||||
|
||||
// Tells the user when a task's own status is hidden by its account's wait.
|
||||
void Tracker::noteAccountWait(const std::string& taskName) {
|
||||
std::optional<TaskRow> task = db_.getTask(taskName);
|
||||
std::optional<Status> stored = task ? StatusUtil::deserialize(task->status) : std::nullopt;
|
||||
if (!task || !task->account || !stored || !StatusUtil::followsAccountWait(stored->kind)) {
|
||||
return;
|
||||
}
|
||||
std::optional<AccountRow> account = db_.getAccount(*task->account);
|
||||
if (account && account->cooldown) {
|
||||
note("It shows as Wait / Limit reset because account '" + account->id + "' is waiting (acc unwait " +
|
||||
account->id + " to clear).");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Accounts -----------------------------------------------------------------
|
||||
|
||||
void Tracker::addAccount(const std::string& name) {
|
||||
requireName(name);
|
||||
if (db_.findAccount(name)) {
|
||||
throw TrackerError(ErrorCode::Exists, "Account '" + name + "' already exists.");
|
||||
}
|
||||
db_.addAccount(name);
|
||||
note("Account '" + name + "' added.");
|
||||
}
|
||||
|
||||
void Tracker::deleteAccount(const std::string& name) {
|
||||
const std::string account = existingAccount(name);
|
||||
db_.removeAccount(account);
|
||||
note("Account '" + account + "' deleted.");
|
||||
}
|
||||
|
||||
void Tracker::renameAccount(const std::string& name, const std::string& newName) {
|
||||
const std::string account = existingAccount(name);
|
||||
requireName(newName);
|
||||
if (!StringUtil::equalsIgnoreCase(account, newName) && db_.findAccount(newName)) {
|
||||
throw TrackerError(ErrorCode::Exists, "Account '" + newName + "' already exists.");
|
||||
}
|
||||
db_.renameAccount(account, newName);
|
||||
note("Account '" + account + "' renamed to '" + newName + "'.");
|
||||
}
|
||||
|
||||
void Tracker::setAccountWait(const std::string& name, std::time_t until) {
|
||||
const std::string account = existingAccount(name);
|
||||
db_.setAccountCooldown(account, StatusUtil::timeToText(until));
|
||||
note("Account '" + account + "' waits until " + StatusUtil::clockLabel(until) +
|
||||
": its Prompt and Testing tasks show Wait, then Limit reset (acc unwait " + account + " to clear).");
|
||||
}
|
||||
|
||||
void Tracker::setAccountWaitText(const std::string& name, const std::string& timeText) {
|
||||
const std::string account = existingAccount(name);
|
||||
std::optional<Status> status = StatusUtil::parse("wait " + timeText, now());
|
||||
if (!status) {
|
||||
throw TrackerError(ErrorCode::Invalid, "Invalid time '" + timeText + "'. Use e.g. 16:30, 4:30pm or 4pm.");
|
||||
}
|
||||
setAccountWait(account, status->waitUntil);
|
||||
}
|
||||
|
||||
bool Tracker::clearAccountWait(const std::string& name) {
|
||||
std::optional<AccountRow> account = db_.getAccount(name);
|
||||
if (!account) {
|
||||
throw TrackerError(ErrorCode::NotFound, "No account named '" + name + "'.");
|
||||
}
|
||||
if (!account->cooldown) {
|
||||
note("Account '" + account->id + "' is not waiting.");
|
||||
return false;
|
||||
}
|
||||
db_.setAccountCooldown(account->id, std::nullopt);
|
||||
note("Wait cleared on '" + account->id + "'.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Tasks --------------------------------------------------------------------
|
||||
|
||||
void Tracker::addTask(const std::string& name, const std::optional<std::string>& accountName,
|
||||
const std::optional<std::string>& statusText) {
|
||||
requireName(name);
|
||||
if (db_.findTask(name)) {
|
||||
throw TrackerError(ErrorCode::Exists, "Task '" + name + "' already exists.");
|
||||
}
|
||||
|
||||
std::optional<std::string> account;
|
||||
if (accountName) {
|
||||
account = existingAccount(*accountName);
|
||||
}
|
||||
|
||||
Status status = StatusUtil::make(StatusKind::Prompt);
|
||||
if (statusText) {
|
||||
if (StatusUtil::isBareWait(*statusText)) {
|
||||
// "wait" without a time: only while the account is already waiting.
|
||||
if (!account || !runningWait(*account)) {
|
||||
throw TrackerError(ErrorCode::Invalid,
|
||||
"A time is required unless the task's account is already waiting: wait <time>");
|
||||
}
|
||||
} else {
|
||||
status = parseStatus(*statusText);
|
||||
}
|
||||
}
|
||||
|
||||
// A wait on a task that has an account is the account's wait; the task itself
|
||||
// stays Prompt and follows it.
|
||||
const bool accountWait = (status.kind == StatusKind::Wait && account);
|
||||
const Status stored = accountWait ? StatusUtil::make(StatusKind::Prompt) : status;
|
||||
|
||||
db_.addTask(name, account, StatusUtil::serialize(stored));
|
||||
note("Task '" + name + "' added.");
|
||||
if (accountWait) {
|
||||
setAccountWait(*account, status.waitUntil);
|
||||
} else {
|
||||
if (statusText && account && (status.kind == StatusKind::Testing || status.kind == StatusKind::Prompt)) {
|
||||
resumeAccount(*account);
|
||||
}
|
||||
noteAccountWait(name);
|
||||
}
|
||||
}
|
||||
|
||||
void Tracker::deleteTask(const std::string& name) {
|
||||
const TaskRow task = existingTask(name);
|
||||
db_.removeTask(task.id);
|
||||
note("Task '" + task.id + "' deleted.");
|
||||
}
|
||||
|
||||
void Tracker::renameTask(const std::string& name, const std::string& newName) {
|
||||
const TaskRow task = existingTask(name);
|
||||
requireName(newName);
|
||||
if (!StringUtil::equalsIgnoreCase(task.id, newName) && db_.findTask(newName)) {
|
||||
throw TrackerError(ErrorCode::Exists, "Task '" + newName + "' already exists.");
|
||||
}
|
||||
db_.renameTask(task.id, newName);
|
||||
note("Task '" + task.id + "' renamed to '" + newName + "'.");
|
||||
}
|
||||
|
||||
void Tracker::setTaskAccount(const std::string& name, const std::optional<std::string>& accountName) {
|
||||
const TaskRow task = existingTask(name);
|
||||
std::optional<std::string> account;
|
||||
if (accountName) {
|
||||
account = existingAccount(*accountName);
|
||||
}
|
||||
db_.setTaskAccount(task.id, account);
|
||||
note("Task '" + task.id + "' now on account " + (account ? "'" + *account + "'" : std::string("(none)")) + ".");
|
||||
}
|
||||
|
||||
void Tracker::setTaskStatus(const std::string& name, const std::string& statusText) {
|
||||
const TaskRow task = existingTask(name);
|
||||
|
||||
if (StatusUtil::isBareWait(statusText)) {
|
||||
// "wait" without a time: the task follows its account's running wait.
|
||||
if (!task.account || !runningWait(*task.account)) {
|
||||
throw TrackerError(
|
||||
ErrorCode::Invalid,
|
||||
task.account ? "Account '" + *task.account + "' is not waiting, so a time is required: wait <time>"
|
||||
: "Task '" + task.id + "' has no account, so a time is required: wait <time>");
|
||||
}
|
||||
makeTaskFollowAccount(task);
|
||||
note("Task '" + task.id + "' now follows account '" + *task.account + "'.");
|
||||
noteAccountWait(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const Status status = parseStatus(statusText);
|
||||
|
||||
if (status.kind == StatusKind::Wait && task.account) {
|
||||
// The wait belongs to the account. Keep an active task as it is so it
|
||||
// follows the wait; any other status becomes Prompt.
|
||||
makeTaskFollowAccount(task);
|
||||
setAccountWait(*task.account, status.waitUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
db_.setTaskStatus(task.id, StatusUtil::serialize(status));
|
||||
note("Task '" + task.id + "' set to " + StatusUtil::label(status) + ".");
|
||||
if (task.account && (status.kind == StatusKind::Testing || status.kind == StatusKind::Prompt)) {
|
||||
resumeAccount(*task.account);
|
||||
}
|
||||
noteAccountWait(task.id);
|
||||
}
|
||||
|
||||
bool Tracker::setTaskNotes(const std::string& name, const std::string& notes) {
|
||||
const TaskRow task = existingTask(name);
|
||||
if (task.notes == notes) {
|
||||
note("Notes unchanged.");
|
||||
return false;
|
||||
}
|
||||
db_.setTaskNotes(task.id, notes);
|
||||
note(notes.empty() ? "Notes cleared." : "Notes updated.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Computed views -------------------------------------------------------------
|
||||
|
||||
std::vector<TaskInfo> Tracker::tasks() const {
|
||||
return buildTasks(db_, now());
|
||||
}
|
||||
|
||||
std::vector<AccountInfo> Tracker::accounts() const {
|
||||
return buildAccounts(db_, now());
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
#include "Views.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
#include "StringUtil.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Each account's wait (when its limit ends), keyed by lowercase name.
|
||||
std::map<std::string, std::time_t> loadAccountWaits(const Database& db) {
|
||||
std::map<std::string, std::time_t> waits;
|
||||
for (const AccountRow& account : db.listAccountRows()) {
|
||||
if (account.cooldown) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*account.cooldown)) {
|
||||
waits[StringUtil::lower(account.id)] = *until;
|
||||
}
|
||||
}
|
||||
}
|
||||
return waits;
|
||||
}
|
||||
|
||||
bool taskLess(const TaskInfo& a, const TaskInfo& b) {
|
||||
const int rankA = StatusUtil::rank(a.status.kind);
|
||||
const int rankB = StatusUtil::rank(b.status.kind);
|
||||
if (rankA != rankB) {
|
||||
return rankA < rankB;
|
||||
}
|
||||
const StatusKind kind = a.status.kind; // same kind as b: ranks are unique per kind
|
||||
if ((kind == StatusKind::Wait || kind == StatusKind::LimitReset) && a.status.waitUntil != b.status.waitUntil) {
|
||||
return a.status.waitUntil < b.status.waitUntil;
|
||||
}
|
||||
if (a.account.has_value() != b.account.has_value()) {
|
||||
return a.account.has_value();
|
||||
}
|
||||
const std::string accountA = StringUtil::lower(a.account.value_or(""));
|
||||
const std::string accountB = StringUtil::lower(b.account.value_or(""));
|
||||
if (accountA != accountB) {
|
||||
return accountA < accountB;
|
||||
}
|
||||
return StringUtil::lower(a.name) < StringUtil::lower(b.name);
|
||||
}
|
||||
|
||||
AccountLevel levelOf(StatusKind kind) {
|
||||
if (StatusUtil::isBusy(kind)) {
|
||||
return AccountLevel::Busy;
|
||||
}
|
||||
if (StatusUtil::isTaken(kind)) {
|
||||
return AccountLevel::Taken;
|
||||
}
|
||||
return AccountLevel::Free;
|
||||
}
|
||||
|
||||
// Free first, then Taken, then Busy.
|
||||
int levelRank(AccountLevel level) {
|
||||
switch (level) {
|
||||
case AccountLevel::Free: return 0;
|
||||
case AccountLevel::Taken: return 1;
|
||||
case AccountLevel::Busy: return 2;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Entries the account lists: its tasks, plus the wait if it is listed on its own.
|
||||
std::size_t entryCount(const AccountInfo& account) {
|
||||
return account.tasks.size() + (account.waitListed ? 1 : 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string accountLevelName(AccountLevel level) {
|
||||
switch (level) {
|
||||
case AccountLevel::Busy: return "busy";
|
||||
case AccountLevel::Taken: return "taken";
|
||||
case AccountLevel::Free: return "free";
|
||||
}
|
||||
return "free";
|
||||
}
|
||||
|
||||
std::vector<TaskInfo> buildTasks(const Database& db, std::time_t now) {
|
||||
const std::map<std::string, std::time_t> waits = loadAccountWaits(db);
|
||||
|
||||
std::vector<TaskInfo> tasks;
|
||||
for (const TaskRow& row : db.listTasks()) {
|
||||
TaskInfo info;
|
||||
info.name = row.id;
|
||||
info.account = row.account;
|
||||
info.notes = row.notes;
|
||||
info.noteLines = StringUtil::lineCount(row.notes);
|
||||
info.storedStatus = StatusUtil::deserialize(row.status).value_or(StatusUtil::make(StatusKind::Prompt));
|
||||
|
||||
std::optional<std::time_t> accountWait;
|
||||
if (row.account) {
|
||||
auto found = waits.find(StringUtil::lower(*row.account));
|
||||
if (found != waits.end()) {
|
||||
accountWait = found->second;
|
||||
}
|
||||
}
|
||||
info.status = StatusUtil::resolve(info.storedStatus, accountWait, now);
|
||||
tasks.push_back(info);
|
||||
}
|
||||
|
||||
std::sort(tasks.begin(), tasks.end(), taskLess);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
std::vector<AccountInfo> buildAccounts(const Database& db, std::time_t now) {
|
||||
const std::vector<TaskInfo> tasks = buildTasks(db, now); // already in display order
|
||||
|
||||
std::vector<AccountInfo> accounts;
|
||||
for (const AccountRow& row : db.listAccountRows()) {
|
||||
std::vector<std::string> busy;
|
||||
std::vector<std::string> taken;
|
||||
std::vector<std::string> free;
|
||||
bool showsWait = false;
|
||||
for (const TaskInfo& task : tasks) {
|
||||
if (!task.account || !StringUtil::equalsIgnoreCase(*task.account, row.id)) {
|
||||
continue;
|
||||
}
|
||||
switch (levelOf(task.status.kind)) {
|
||||
case AccountLevel::Busy: busy.push_back(task.name); break;
|
||||
case AccountLevel::Taken: taken.push_back(task.name); break;
|
||||
case AccountLevel::Free: free.push_back(task.name); break;
|
||||
}
|
||||
if (task.status.kind == StatusKind::Wait || task.status.kind == StatusKind::LimitReset) {
|
||||
showsWait = true;
|
||||
}
|
||||
}
|
||||
|
||||
AccountInfo info;
|
||||
info.name = row.id;
|
||||
if (row.cooldown) {
|
||||
if (std::optional<std::time_t> until = StatusUtil::timeFromText(*row.cooldown)) {
|
||||
info.waitUntil = *until;
|
||||
info.waitState = (*until > now) ? WaitState::Waiting : WaitState::LimitReset;
|
||||
info.waitListed = !showsWait;
|
||||
}
|
||||
}
|
||||
|
||||
if (!busy.empty() || info.waitListed) {
|
||||
info.level = AccountLevel::Busy;
|
||||
info.tasks = busy;
|
||||
} else if (!taken.empty()) {
|
||||
info.level = AccountLevel::Taken;
|
||||
info.tasks = taken;
|
||||
} else {
|
||||
info.level = AccountLevel::Free;
|
||||
info.tasks = free;
|
||||
}
|
||||
accounts.push_back(info);
|
||||
}
|
||||
|
||||
// Most free first. db.listAccountRows is alphabetical and stable_sort keeps
|
||||
// that order for ties; Free accounts are all equally free.
|
||||
std::stable_sort(accounts.begin(), accounts.end(), [](const AccountInfo& a, const AccountInfo& b) {
|
||||
if (a.level != b.level) {
|
||||
return levelRank(a.level) < levelRank(b.level);
|
||||
}
|
||||
return a.level != AccountLevel::Free && entryCount(a) < entryCount(b);
|
||||
});
|
||||
return accounts;
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
// C API over the Tracker (see include/lls_api.h). No C++ exception ever leaves
|
||||
// this file: failures become return codes plus a message.
|
||||
|
||||
#include "lls_api.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Database.h"
|
||||
#include "Status.h"
|
||||
#include "Tracker.h"
|
||||
#include "Views.h"
|
||||
|
||||
struct lls_db {
|
||||
lls_db();
|
||||
|
||||
Database database;
|
||||
Tracker tracker;
|
||||
std::string lastError;
|
||||
std::string lastMessages;
|
||||
};
|
||||
|
||||
lls_db::lls_db() : database(), tracker(database) {}
|
||||
|
||||
struct lls_task_list {
|
||||
std::vector<TaskInfo> items;
|
||||
std::vector<std::string> labels;
|
||||
std::vector<lls_task_info> infos;
|
||||
};
|
||||
|
||||
struct lls_account_list {
|
||||
std::vector<AccountInfo> items;
|
||||
std::vector<std::vector<const char*>> taskNames;
|
||||
std::vector<lls_account_info> infos;
|
||||
};
|
||||
|
||||
// The constants in lls_api.h are the numbers of the C++ enums.
|
||||
static_assert(static_cast<int>(StatusKind::Prompt) == LLS_STATUS_PROMPT, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::LimitReset) == LLS_STATUS_LIMIT_RESET, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::Testing) == LLS_STATUS_TESTING, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::Wait) == LLS_STATUS_WAIT, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::Uploading) == LLS_STATUS_UPLOADING, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::Frozen) == LLS_STATUS_FROZEN, "status numbering");
|
||||
static_assert(static_cast<int>(StatusKind::Done) == LLS_STATUS_DONE, "status numbering");
|
||||
static_assert(static_cast<int>(AccountLevel::Busy) == LLS_LEVEL_BUSY, "level numbering");
|
||||
static_assert(static_cast<int>(AccountLevel::Taken) == LLS_LEVEL_TAKEN, "level numbering");
|
||||
static_assert(static_cast<int>(AccountLevel::Free) == LLS_LEVEL_FREE, "level numbering");
|
||||
static_assert(static_cast<int>(WaitState::None) == LLS_WAIT_NONE, "wait state numbering");
|
||||
static_assert(static_cast<int>(WaitState::Waiting) == LLS_WAIT_WAITING, "wait state numbering");
|
||||
static_assert(static_cast<int>(WaitState::LimitReset) == LLS_WAIT_LIMIT_RESET, "wait state numbering");
|
||||
|
||||
namespace {
|
||||
|
||||
thread_local std::string g_openError;
|
||||
|
||||
int toCode(ErrorCode code) {
|
||||
switch (code) {
|
||||
case ErrorCode::Invalid: return LLS_E_INVALID;
|
||||
case ErrorCode::NotFound: return LLS_E_NOT_FOUND;
|
||||
case ErrorCode::Exists: return LLS_E_EXISTS;
|
||||
case ErrorCode::Database: return LLS_E_DATABASE;
|
||||
}
|
||||
return LLS_E_INTERNAL;
|
||||
}
|
||||
|
||||
std::string required(const char* text, const char* what) {
|
||||
if (!text) {
|
||||
throw TrackerError(ErrorCode::Invalid, std::string(what) + " must not be NULL.");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::optional<std::string> optionalText(const char* text) {
|
||||
if (!text) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::string(text);
|
||||
}
|
||||
|
||||
// Runs `action`, turning exceptions into a return code and filling the
|
||||
// handle's last error / last messages. A query leaves the messages of the last
|
||||
// change alone, so they can still be read after looking at the data.
|
||||
template <typename Action>
|
||||
int guarded(lls_db* db, Action action, bool isQuery = false) {
|
||||
if (!db) {
|
||||
g_openError = "The database handle is NULL.";
|
||||
return LLS_E_INVALID;
|
||||
}
|
||||
db->lastError.clear();
|
||||
if (!isQuery) {
|
||||
db->lastMessages.clear();
|
||||
}
|
||||
|
||||
int code = LLS_OK;
|
||||
try {
|
||||
action();
|
||||
} catch (const TrackerError& error) {
|
||||
code = toCode(error.code());
|
||||
db->lastError = error.what();
|
||||
} catch (const std::exception& error) {
|
||||
code = LLS_E_DATABASE;
|
||||
db->lastError = error.what();
|
||||
} catch (...) {
|
||||
code = LLS_E_INTERNAL;
|
||||
db->lastError = "Unknown error.";
|
||||
}
|
||||
|
||||
std::vector<std::string> messages = db->tracker.takeMessages();
|
||||
if (code == LLS_OK && !isQuery) {
|
||||
for (const std::string& message : messages) {
|
||||
if (!db->lastMessages.empty()) {
|
||||
db->lastMessages += "\n";
|
||||
}
|
||||
db->lastMessages += message;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
const char* const kLevelLabels[] = {"Busy", "Taken", "Free"};
|
||||
const char* const kStatusNames[] = {"prompt", "limit-reset", "testing", "wait", "uploading", "frozen", "done"};
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
// ---- Library ----------------------------------------------------------------
|
||||
|
||||
int lls_api_version(void) {
|
||||
return LLS_API_VERSION;
|
||||
}
|
||||
|
||||
const char* lls_status_name(int status) {
|
||||
if (status < 0 || status > LLS_STATUS_DONE) {
|
||||
return nullptr;
|
||||
}
|
||||
return kStatusNames[status];
|
||||
}
|
||||
|
||||
int lls_parse_time(const char* text, int64_t now, int64_t* out_until) {
|
||||
if (!text || !out_until) {
|
||||
return LLS_E_INVALID;
|
||||
}
|
||||
try {
|
||||
const std::time_t at = (now != 0) ? static_cast<std::time_t>(now) : std::time(nullptr);
|
||||
std::optional<Status> status = StatusUtil::parse(std::string("wait ") + text, at);
|
||||
if (!status) {
|
||||
return LLS_E_INVALID;
|
||||
}
|
||||
*out_until = static_cast<int64_t>(status->waitUntil);
|
||||
return LLS_OK;
|
||||
} catch (...) {
|
||||
return LLS_E_INTERNAL;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Database ---------------------------------------------------------------
|
||||
|
||||
int lls_open(const char* path, lls_db** out_db) {
|
||||
if (out_db) {
|
||||
*out_db = nullptr;
|
||||
}
|
||||
g_openError.clear();
|
||||
if (!path || !out_db) {
|
||||
g_openError = "path and out_db must not be NULL.";
|
||||
return LLS_E_INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
std::unique_ptr<lls_db> db(new lls_db);
|
||||
db->database.open(path);
|
||||
*out_db = db.release();
|
||||
return LLS_OK;
|
||||
} catch (const std::exception& error) {
|
||||
g_openError = error.what();
|
||||
return LLS_E_DATABASE;
|
||||
} catch (...) {
|
||||
g_openError = "Unknown error.";
|
||||
return LLS_E_INTERNAL;
|
||||
}
|
||||
}
|
||||
|
||||
void lls_close(lls_db* db) {
|
||||
delete db;
|
||||
}
|
||||
|
||||
const char* lls_last_error(const lls_db* db) {
|
||||
return db ? db->lastError.c_str() : g_openError.c_str();
|
||||
}
|
||||
|
||||
const char* lls_last_messages(const lls_db* db) {
|
||||
return db ? db->lastMessages.c_str() : "";
|
||||
}
|
||||
|
||||
const char* lls_path(const lls_db* db) {
|
||||
return db ? db->database.path().c_str() : "";
|
||||
}
|
||||
|
||||
int lls_is_dirty(const lls_db* db) {
|
||||
return (db && db->database.dirty()) ? 1 : 0;
|
||||
}
|
||||
|
||||
int lls_save(lls_db* db) {
|
||||
return guarded(db, [&] { db->database.save(); });
|
||||
}
|
||||
|
||||
int lls_backup(lls_db* db, const char* file, char* used_path, size_t used_path_size) {
|
||||
return guarded(db, [&] {
|
||||
const std::string used = db->database.backup(file ? file : "");
|
||||
if (used_path && used_path_size > 0) {
|
||||
std::snprintf(used_path, used_path_size, "%s", used.c_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int lls_restore(lls_db* db, const char* file) {
|
||||
return guarded(db, [&] { db->database.restore(required(file, "file")); });
|
||||
}
|
||||
|
||||
void lls_set_clock(lls_db* db, int64_t now) {
|
||||
if (db) {
|
||||
db->tracker.setClock(static_cast<std::time_t>(now));
|
||||
}
|
||||
}
|
||||
|
||||
int64_t lls_now(const lls_db* db) {
|
||||
return db ? static_cast<int64_t>(db->tracker.now()) : 0;
|
||||
}
|
||||
|
||||
// ---- Accounts ---------------------------------------------------------------
|
||||
|
||||
int lls_account_add(lls_db* db, const char* name) {
|
||||
return guarded(db, [&] { db->tracker.addAccount(required(name, "name")); });
|
||||
}
|
||||
|
||||
int lls_account_delete(lls_db* db, const char* name) {
|
||||
return guarded(db, [&] { db->tracker.deleteAccount(required(name, "name")); });
|
||||
}
|
||||
|
||||
int lls_account_rename(lls_db* db, const char* name, const char* new_name) {
|
||||
return guarded(db, [&] { db->tracker.renameAccount(required(name, "name"), required(new_name, "new_name")); });
|
||||
}
|
||||
|
||||
int lls_account_set_wait(lls_db* db, const char* name, const char* time_text) {
|
||||
return guarded(db, [&] {
|
||||
db->tracker.setAccountWaitText(required(name, "name"), required(time_text, "time_text"));
|
||||
});
|
||||
}
|
||||
|
||||
int lls_account_set_wait_at(lls_db* db, const char* name, int64_t until) {
|
||||
return guarded(db, [&] {
|
||||
db->tracker.setAccountWait(required(name, "name"), static_cast<std::time_t>(until));
|
||||
});
|
||||
}
|
||||
|
||||
int lls_account_clear_wait(lls_db* db, const char* name, int* was_waiting) {
|
||||
return guarded(db, [&] {
|
||||
const bool cleared = db->tracker.clearAccountWait(required(name, "name"));
|
||||
if (was_waiting) {
|
||||
*was_waiting = cleared ? 1 : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Tasks ------------------------------------------------------------------
|
||||
|
||||
int lls_task_add(lls_db* db, const char* name, const char* account, const char* status) {
|
||||
return guarded(db, [&] {
|
||||
db->tracker.addTask(required(name, "name"), optionalText(account), optionalText(status));
|
||||
});
|
||||
}
|
||||
|
||||
int lls_task_delete(lls_db* db, const char* name) {
|
||||
return guarded(db, [&] { db->tracker.deleteTask(required(name, "name")); });
|
||||
}
|
||||
|
||||
int lls_task_rename(lls_db* db, const char* name, const char* new_name) {
|
||||
return guarded(db, [&] { db->tracker.renameTask(required(name, "name"), required(new_name, "new_name")); });
|
||||
}
|
||||
|
||||
int lls_task_set_account(lls_db* db, const char* name, const char* account) {
|
||||
return guarded(db, [&] { db->tracker.setTaskAccount(required(name, "name"), optionalText(account)); });
|
||||
}
|
||||
|
||||
int lls_task_set_status(lls_db* db, const char* name, const char* status) {
|
||||
return guarded(db, [&] { db->tracker.setTaskStatus(required(name, "name"), required(status, "status")); });
|
||||
}
|
||||
|
||||
int lls_task_set_notes(lls_db* db, const char* name, const char* notes) {
|
||||
return guarded(db, [&] { db->tracker.setTaskNotes(required(name, "name"), required(notes, "notes")); });
|
||||
}
|
||||
|
||||
// ---- Queries ----------------------------------------------------------------
|
||||
|
||||
int lls_tasks(lls_db* db, lls_task_list** out_list) {
|
||||
if (out_list) {
|
||||
*out_list = nullptr;
|
||||
}
|
||||
return guarded(db, [&] {
|
||||
if (!out_list) {
|
||||
throw TrackerError(ErrorCode::Invalid, "out_list must not be NULL.");
|
||||
}
|
||||
|
||||
std::unique_ptr<lls_task_list> list(new lls_task_list);
|
||||
list->items = db->tracker.tasks();
|
||||
const std::size_t count = list->items.size();
|
||||
list->labels.resize(count);
|
||||
list->infos.resize(count); // sized once: the pointers below stay valid
|
||||
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const TaskInfo& task = list->items[i];
|
||||
list->labels[i] = StatusUtil::label(task.status);
|
||||
|
||||
lls_task_info& info = list->infos[i];
|
||||
info.name = task.name.c_str();
|
||||
info.account = task.account ? task.account->c_str() : nullptr;
|
||||
info.status = static_cast<int>(task.status.kind);
|
||||
info.status_label = list->labels[i].c_str();
|
||||
info.stored_status = static_cast<int>(task.storedStatus.kind);
|
||||
const bool timed = task.status.kind == StatusKind::Wait || task.status.kind == StatusKind::LimitReset;
|
||||
info.until = timed ? static_cast<int64_t>(task.status.waitUntil) : 0;
|
||||
info.notes = task.notes.c_str();
|
||||
info.notes_lines = static_cast<int>(task.noteLines);
|
||||
}
|
||||
*out_list = list.release();
|
||||
}, true);
|
||||
}
|
||||
|
||||
int lls_task_list_count(const lls_task_list* list) {
|
||||
return list ? static_cast<int>(list->infos.size()) : 0;
|
||||
}
|
||||
|
||||
const lls_task_info* lls_task_list_get(const lls_task_list* list, int index) {
|
||||
if (!list || index < 0 || static_cast<std::size_t>(index) >= list->infos.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &list->infos[static_cast<std::size_t>(index)];
|
||||
}
|
||||
|
||||
void lls_task_list_free(lls_task_list* list) {
|
||||
delete list;
|
||||
}
|
||||
|
||||
int lls_accounts(lls_db* db, lls_account_list** out_list) {
|
||||
if (out_list) {
|
||||
*out_list = nullptr;
|
||||
}
|
||||
return guarded(db, [&] {
|
||||
if (!out_list) {
|
||||
throw TrackerError(ErrorCode::Invalid, "out_list must not be NULL.");
|
||||
}
|
||||
|
||||
std::unique_ptr<lls_account_list> list(new lls_account_list);
|
||||
list->items = db->tracker.accounts();
|
||||
const std::size_t count = list->items.size();
|
||||
list->taskNames.resize(count);
|
||||
list->infos.resize(count);
|
||||
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
const AccountInfo& account = list->items[i];
|
||||
for (const std::string& task : account.tasks) {
|
||||
list->taskNames[i].push_back(task.c_str());
|
||||
}
|
||||
|
||||
lls_account_info& info = list->infos[i];
|
||||
info.name = account.name.c_str();
|
||||
info.level = static_cast<int>(account.level);
|
||||
info.level_label = kLevelLabels[static_cast<int>(account.level)];
|
||||
info.wait_state = static_cast<int>(account.waitState);
|
||||
info.wait_until = static_cast<int64_t>(account.waitUntil);
|
||||
info.wait_listed = account.waitListed ? 1 : 0;
|
||||
info.task_count = static_cast<int>(list->taskNames[i].size());
|
||||
info.tasks = list->taskNames[i].empty() ? nullptr : list->taskNames[i].data();
|
||||
}
|
||||
*out_list = list.release();
|
||||
}, true);
|
||||
}
|
||||
|
||||
int lls_account_list_count(const lls_account_list* list) {
|
||||
return list ? static_cast<int>(list->infos.size()) : 0;
|
||||
}
|
||||
|
||||
const lls_account_info* lls_account_list_get(const lls_account_list* list, int index) {
|
||||
if (!list || index < 0 || static_cast<std::size_t>(index) >= list->infos.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &list->infos[static_cast<std::size_t>(index)];
|
||||
}
|
||||
|
||||
void lls_account_list_free(lls_account_list* list) {
|
||||
delete list;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
Reference in new issue
Block a user