C and Python API + Smarter account-based wait times

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

No files matched your search

+44 -11
View File
@@ -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()
+81 -1
View File
@@ -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)
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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_;
+6
View File
@@ -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();
+83
View File
@@ -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_;
};
+51
View File
@@ -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);
+185
View File
@@ -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
View File
@@ -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)
+314
View File
@@ -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
View File
@@ -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
View File
@@ -16,10 +16,6 @@ namespace {
const std::size_t kUnlimited = std::numeric_limits<std::size_t>::max();
const char* const kStatusHelp =
"prompt, limit-reset, testing, wait <time>, uploading, frozen, done "
"(time: 16:30, 4:30pm, 4pm)";
bool parseVisibility(const std::string& word) {
const std::string value = StringUtil::lower(word);
if (value == "show") {
@@ -34,7 +30,7 @@ bool parseVisibility(const std::string& word) {
} // namespace
Repl::Repl(Database& db, Display& display, Config& config)
: db_(db), display_(display), config_(config), completer_(db, specs_), quit_(false) {
: db_(db), display_(display), config_(config), tracker_(db), completer_(db, specs_), quit_(false) {
registerCommands();
}
@@ -203,11 +199,16 @@ void Repl::execute(const std::string& line) {
return;
}
if ((this->*command->handler)(args)) {
const bool redraw = (this->*command->handler)(args);
for (const std::string& message : tracker_.takeMessages()) {
say(message);
}
if (redraw) {
refresh();
}
flushMessages();
} catch (const std::exception& error) {
tracker_.takeMessages();
messages_.clear();
std::cout << "Error: " << error.what() << "\n";
}
@@ -219,7 +220,7 @@ void Repl::refresh() const {
return;
}
const std::time_t now = std::time(nullptr);
const std::time_t now = tracker_.now();
Terminal::clearScreen();
if (settings.showTasks) {
std::cout << display_.renderTasks(now) << "\n";
@@ -251,146 +252,43 @@ std::optional<Repl::Answer> Repl::ask(const std::string& question, bool allowCan
}
}
bool Repl::isBareWait(const Args& args, std::size_t from) {
return StringUtil::lower(StringUtil::join(args, from, " ")) == "wait";
}
// The account's wait if it is still running (not yet a Limit reset).
std::optional<std::time_t> Repl::runningWait(const std::string& account) const {
std::optional<AccountRow> row = db_.getAccount(account);
if (!row || !row->cooldown) {
return std::nullopt;
}
std::optional<std::time_t> until = StatusUtil::timeFromText(*row->cooldown);
if (until && *until > std::time(nullptr)) {
return until;
}
return std::nullopt;
}
// Prompt and Testing tasks follow their account's wait; anything else becomes Prompt.
void Repl::makeTaskFollowAccount(const TaskRow& task) {
std::optional<Status> current = StatusUtil::deserialize(task.status);
if (!current || !StatusUtil::followsAccountWait(current->kind)) {
db_.setTaskStatus(task.id, StatusUtil::serialize(StatusUtil::make(StatusKind::Prompt)));
}
}
void Repl::requireName(const std::string& name) {
if (name.empty() || name == "-") {
throw std::runtime_error("'" + name + "' is not a valid name.");
}
}
Status Repl::parseStatusFrom(const Args& args, std::size_t from) {
const std::string text = StringUtil::join(args, from, " ");
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
if (!status) {
throw std::runtime_error("Invalid status '" + text + "'. Use: " + kStatusHelp);
}
return *status;
}
// ---- Accounts ---------------------------------------------------------------
bool Repl::cmdAccAdd(const Args& args) {
requireName(args[0]);
if (db_.findAccount(args[0])) {
throw std::runtime_error("Account '" + args[0] + "' already exists.");
}
db_.addAccount(args[0]);
say("Account '" + args[0] + "' added.");
tracker_.addAccount(args[0]);
return true;
}
bool Repl::cmdAccDel(const Args& args) {
std::optional<std::string> name = db_.findAccount(args[0]);
if (!name) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
db_.removeAccount(*name);
say("Account '" + *name + "' deleted.");
tracker_.deleteAccount(args[0]);
return true;
}
bool Repl::cmdAccRename(const Args& args) {
std::optional<std::string> name = db_.findAccount(args[0]);
if (!name) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
requireName(args[1]);
if (!StringUtil::equalsIgnoreCase(*name, args[1]) && db_.findAccount(args[1])) {
throw std::runtime_error("Account '" + args[1] + "' already exists.");
}
db_.renameAccount(*name, args[1]);
say("Account '" + *name + "' renamed to '" + args[1] + "'.");
tracker_.renameAccount(args[0], args[1]);
return true;
}
void Repl::setAccountWait(const std::string& account, std::time_t until) {
db_.setAccountCooldown(account, StatusUtil::timeToText(until));
say("Account '" + account + "' waits until " + StatusUtil::clockLabel(until) +
": its Prompt and Testing tasks show Wait, then Limit reset (acc unwait " + account + " to clear).");
}
// Tells the user when a task's own status is hidden by its account's wait.
void Repl::noteAccountWait(const std::string& taskName) {
std::optional<TaskRow> task = db_.getTask(taskName);
std::optional<Status> stored = task ? StatusUtil::deserialize(task->status) : std::nullopt;
if (!task || !task->account || !stored || !StatusUtil::followsAccountWait(stored->kind)) {
return;
}
std::optional<AccountRow> account = db_.getAccount(*task->account);
if (account && account->cooldown) {
say("It shows as Wait / Limit reset because account '" + account->id + "' is waiting (acc unwait " +
account->id + " to clear).");
}
}
bool Repl::cmdAccWait(const Args& args) {
std::optional<AccountRow> account = db_.getAccount(args[0]);
if (!account) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
const std::string text = "wait " + StringUtil::join(args, 1, " ");
std::optional<Status> status = StatusUtil::parse(text, std::time(nullptr));
if (!status) {
throw std::runtime_error("Invalid time '" + StringUtil::join(args, 1, " ") +
"'. Use e.g. 16:30, 4:30pm or 4pm.");
}
setAccountWait(account->id, status->waitUntil);
tracker_.setAccountWaitText(args[0], StringUtil::join(args, 1, " "));
return true;
}
bool Repl::cmdAccUnwait(const Args& args) {
std::optional<AccountRow> account = db_.getAccount(args[0]);
if (!account) {
throw std::runtime_error("No account named '" + args[0] + "'.");
}
if (!account->cooldown) {
say("Account '" + account->id + "' is not waiting.");
return false;
}
db_.setAccountCooldown(account->id, std::nullopt);
say("Wait cleared on '" + account->id + "'.");
return true;
return tracker_.clearAccountWait(args[0]);
}
bool Repl::cmdAccLs(const Args&) {
Terminal::clearScreen();
std::cout << display_.renderAccounts(std::time(nullptr)) << "\n";
std::cout << display_.renderAccounts(tracker_.now()) << "\n";
return false;
}
// ---- Tasks ------------------------------------------------------------------
bool Repl::cmdTskAdd(const Args& args) {
const std::string& name = args[0];
requireName(name);
if (db_.findTask(name)) {
throw std::runtime_error("Task '" + name + "' already exists.");
}
// tsk add <name> [acc] [status]: the second word is an account if one exists
// with that name ("-" = none), otherwise it starts the status.
std::size_t next = 1;
std::optional<std::string> account;
if (next < args.size()) {
@@ -399,119 +297,50 @@ bool Repl::cmdTskAdd(const Args& args) {
++next;
} else if (args[next] == "-") {
++next;
} else if (!isBareWait(args, next) && !StatusUtil::parse(StringUtil::join(args, next, " "), std::time(nullptr))) {
} 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;
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"