diff options
Diffstat (limited to 'ttt.c')
| -rw-r--r-- | ttt.c | 1841 |
1 files changed, 1841 insertions, 0 deletions
@@ -0,0 +1,1841 @@ +// Compilation command: +// - release dynamics libs : gcc main.c -lncursesw -ltinfo -o ttt -Wall -Werror -pedantic -O2 -m64 -s +// - release static libs : gcc main.c -lncursesw -ltinfo -o ttt -Wall -Werror -pedantic -O2 -m64 -s -static-pie +// - debug : gcc main.c -lncursesw -ltinfo -o ttt -Wall -Werror -pedantic -g3 -m64 +// +// Compiler flags: +// -l : libraries to link +// -o : output file name +// -Wall : enables all compiler's warning messages +// -Werror : make all warnings into errors +// -pedantic : issue all the warnings demanded by strict ISO C +// -O : code optimization level (commonly accepted as best: 2) +// -g : debug information level (max: 3) +// -m64 : 64b architecture +// -D : defines for preprocessor +// -static-pie : link statically producing an position-independent executable +// -DNDEBUG : remove assertions from code (not used) +// +// Usage hints: +// - To change the app data path, overwride the environment variable HOME (USERPROFILE for windows users). + + +#include <assert.h> +#include <errno.h> +#include <inttypes.h> +#include <limits.h> +#include <locale.h> +#include <ncurses.h> +#include <signal.h> +#include <stdarg.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <time.h> + +#define VERSION "1.0" // Use only 3 chars (to fit layouts). +#define TASK_NAME_LENGTH 57 // Task name length. +#define TASK_NAME_BYTES (TASK_NAME_LENGTH+1) +#define FIRST_DAY_OF_WEEK 1 // (0-6, Sunday = 0). +#define NUM_WEEK_DAYS 7 // Just to avoid magic numbers. + +#if defined(_WIN64) +#define HOME_PATH_ENV "USERPROFILE" +#else +#define HOME_PATH_ENV "HOME" +#endif +#define APP_FOLDER_NAME ".task_time_tracker" +#define DB_FILE_NAME "database.bin" +#define AR_FILE_NAME "archive.csv" + +typedef struct { + int64_t times[NUM_WEEK_DAYS]; + char name[TASK_NAME_BYTES]; +} task_st; + +typedef struct { + task_st *tasks; + size_t count; // Will always be equal or less than capacity. + size_t capacity; // Will always be equal or less than PTRDIFF_MAX (see MAX_DATABASE_TASKS). + ptrdiff_t active_task; // Will always be less than capacity/count. + ptrdiff_t selected_task; // Will always be less than capacity/count. + int64_t modified_on; + int64_t total_times[NUM_WEEK_DAYS]; +} database_st; + +#define DB_FILE_SIGN_STR "TTT:B:01" +const char DB_FILE_SIGN[] = DB_FILE_SIGN_STR; +const size_t DB_FILE_SIGN_LENGTH = sizeof(DB_FILE_SIGN_STR)-1; +const size_t SIZEOF_TASK_ST = sizeof(task_st); +const size_t SIZEOF_DATABASE_ST = sizeof(database_st); +const size_t SIZEOF_CHAR = sizeof(char); +const size_t SIZEOF_INT64 = sizeof(int64_t); +const int64_t SECONDS_IN_MINUTE = (int64_t)60; +const int64_t SECONDS_IN_HOUR = (int64_t)60*SECONDS_IN_MINUTE; +const int64_t SECONDS_IN_DAY = (int64_t)24*SECONDS_IN_HOUR; +const int64_t SECONDS_IN_YEAR = (int64_t)365*SECONDS_IN_DAY; +const size_t MAX_DATABASE_TASKS = (PTRDIFF_MAX < (SIZE_MAX / SIZEOF_TASK_ST)) ? PTRDIFF_MAX : (SIZE_MAX / SIZEOF_TASK_ST); + + +database_st database = { .tasks = NULL }; +database_st archive = { .tasks = NULL }; +database_st *db = NULL; +bool is_autosave_enabled = true; +int countdown_to_autosave = -1; +char *app_folder = NULL; +char *db_file_path = NULL; +char *ar_file_path = NULL; +char *string_buffer = NULL; // A temporary buffer for localized actions. Please avoid data leaks and out-of-bounds errors. +size_t string_buffer_size = 0; +int size_x, size_y, pos_x, pos_y; + + + +typedef enum { + STYLE_SELECTED = 1, + STYLE_SELECTED_INVERTED, + STYLE_ACTIVE, + STYLE_ACTIVE_SELECTED, + STYLE_ERROR, +} styles_et; + + + +WINDOW *error_window = NULL; +time_t error_time_limit = 0; + +void print_error(const char *format, ...) { + va_list args; + va_start(args, format); + if (stdscr == NULL || isendwin() == true) { // Not in ncurses mode. + vfprintf(stderr, format, args); + fprintf(stderr, "\n"); + } + else { + int w_size_x = size_x > 120 ? 120 : size_x - 2; + int w_size_y = 4; + if (error_window == NULL) { + error_window = newwin(w_size_y, w_size_x, (size_y - w_size_y) / 2, (size_x - w_size_x) / 2); + wattron(error_window, COLOR_PAIR(STYLE_ERROR)); + wborder(error_window, ' ', ' ', 0, 0, ACS_HLINE, ACS_HLINE, ACS_HLINE, ACS_HLINE); + mvwprintw(error_window, 0, 1, " Error "); + wmove(error_window, 1, 0); + } + else { + waddch(error_window, ' '); + } + vw_printw(error_window, format, args); + error_time_limit = time(NULL) + 5; + } + + va_end(args); +} + +void draw_error_window() { + if (error_window == NULL) { + return; + } + + // Hide error window after time-limit or if terminal is shrank. + int w_size_x, w_size_y; + getmaxyx(error_window, w_size_y, w_size_x); + if (time(NULL) >= error_time_limit + || size_x - w_size_x < 2 + || size_y - w_size_y < 2 + ) { + delwin(error_window); + error_window = NULL; + return; + } + + // Adjust error window position. + int pos_x = (size_x - w_size_x) / 2; + int pos_y = (size_y - w_size_y) / 2; + mvwin(error_window, pos_y, pos_x); + + // Avoid being overwritten by main window content. + refresh(); + touchwin(error_window); + wrefresh(error_window); +} + +void trigger_autosave() { + countdown_to_autosave = 13375; // ms +} + +void show_processing() { + mvaddch(0, 0, ACS_DIAMOND); + refresh(); +} + +// Checks if file is exists and is accessible. +// Returns true when the file exists and is accessible. +bool is_file_accessible(const char *path) { + assert(path != NULL); + FILE *file = fopen(path, "r+"); + bool is_file_accessible = file != NULL; + if (is_file_accessible) { + fclose(file); + } + return is_file_accessible; +} + +// Returns true if string to_compare is equal to any of the other passed strings, false otherwise. +bool is_equal_to_any(const char *to_compare, const char *test_a, const char *test_b) { + return strncmp(to_compare, test_a, strlen(test_a)+1) == 0 + || strncmp(to_compare, test_b, strlen(test_b)+1) == 0; +} + +// Given an UTF8 encoded string, truncate it to length bytes without breaking any UTF8 character. +// The string should have capacity for at least length + 1. +// The terminating null byte ('\0') is not included in length. +// Returns the truncated string length. +size_t truncate_string_utf8(char *string, size_t length) { + assert(string != NULL); + + // Find index of first continuation byte. + size_t idx = length; + while (idx > 0 && ((string[idx - 1] & 0xC0) == 0x80)) { + idx--; + } + size_t continuation_bytes = length - idx; + + // If string starts with continuation bytes, it's an invalid UTF8 string. + if (idx == 0 && continuation_bytes > 0) { + length = 0; + } + // If length truncates some continuation bytes, remove incomplete UTF8 character. + else if (idx > 0 // string is not empty + // continuation bytes are not complete + && !(continuation_bytes == 0 && (string[idx - 1] & 0x80) == 0x00) + && !(continuation_bytes == 1 && (string[idx - 1] & 0xE0) == 0xC0) + && !(continuation_bytes == 2 && (string[idx - 1] & 0xF0) == 0xE0) + && !(continuation_bytes == 3 && (string[idx - 1] & 0xF8) == 0xF0) + ) { + length -= (continuation_bytes + 1); // Remove '+ 1' start byte. + } + + string[length] = '\0'; + return length; +} + +// Returns true when the string is empty or consists of white space characters. +bool is_empty_string(const char *string) { + for (int idx = 0; string[idx] != '\0'; idx++) { + switch(string[idx]) { + case ' ': + case '\t': + case '\v': + case '\f': + case '\r': + case '\n': + break; + default: + return false; + } + } + return true; +} + +// Uses strchr to replace all instances of find by replace. +// Returns string. +char *replace_char(char *string, char find, char replace) { + char *idx = string; + while((idx = strchr(idx, find)) != NULL) { + *idx = replace; + idx++; + } + return string; +} + +// Prints, on row y and column x, the time using 5 characters centered on space. +// Returns the result of a call to mvprintw. +int mvprintw_time(int y, int x, intmax_t time, int space) { + const int TIME_CHARS = 5; + assert(space >= TIME_CHARS); + + int left_padding = (space - TIME_CHARS) / 2; + int right_padding = space - TIME_CHARS - left_padding; + + if (time < 0) { + return mvprintw(y, x, "%*s - %*s", left_padding, "", right_padding, ""); + } + else if (time == 0) { + return mvprintw(y, x, "%*s 0 %*s", left_padding, "", right_padding, ""); + } + else if (time < SECONDS_IN_MINUTE) { + return mvprintw(y, x, "%*s%3jds %*s", left_padding, "", time, right_padding, ""); + } + else if (time < (intmax_t)100 * SECONDS_IN_HOUR) { + intmax_t hours = (double)time / (double)SECONDS_IN_HOUR; + intmax_t minutes = (time - (hours * SECONDS_IN_HOUR) ) / SECONDS_IN_MINUTE; + return mvprintw(y, x, "%*s%02jd:%02jd%*s", left_padding, "", hours, minutes, right_padding, ""); + } + else if (time < (intmax_t)(9999.5 * SECONDS_IN_DAY)) { + double value = (double)time / (double)SECONDS_IN_DAY; + int decimals = + time >= 99.95 * SECONDS_IN_DAY ? 0 : + time >= 9.995 * SECONDS_IN_DAY ? 1 : + 2; + return mvprintw(y, x, "%*s%4.*fd%*s", left_padding, "", decimals, value, right_padding, ""); + } + else if (time < (intmax_t)(9999.5 * SECONDS_IN_YEAR)) { + double value = (double)time / (double)SECONDS_IN_YEAR; + int decimals = + time >= 99.95 * SECONDS_IN_YEAR ? 0 : + time >= 9.995 * SECONDS_IN_YEAR ? 1 : + 2; + return mvprintw(y, x, "%*s%4.*fy%*s", left_padding, "", decimals, value, right_padding, ""); + } + else { + return mvprintw(y, x, "%*s ∞ %*s", left_padding, "", right_padding, ""); + } +} + +int64_t add_int64(int64_t x, int64_t y) { + int64_t result; +#ifdef __GNUC__ + bool overflow = __builtin_add_overflow(x, y, &result); + if (overflow) { + result = ((uint64_t)x >> 63) + INT64_MAX; // Equivalent to (x > 0 ? INT64_MAX : INT64_MIN) + } +#else + result = + (y > 0 && x > INT64_MAX - y) ? INT64_MAX : + (y < 0 && x < INT64_MIN - y) ? INT64_MIN : + x + y; +#endif + return result; +} + +int64_t sub_int64(int64_t x, int64_t y) { + int64_t result; +#ifdef __GNUC__ + bool overflow = __builtin_sub_overflow(x, y, &result); + if (overflow) { + result = ((uint64_t)x >> 63) + INT64_MAX; // Equivalent to (x > 0 ? INT64_MAX : INT64_MIN) + } +#else + result = + (y < 0 && x > INT64_MAX + y) ? INT64_MAX : + (y > 0 && x < INT64_MIN + y) ? INT64_MIN : + x - y; +#endif + return result; +} + +// Returns active task or NULL if none applies. +task_st *get_active_task(database_st *db) { + assert(db != NULL); + + task_st *task = NULL; + if (db->active_task >= 0) { + task = db->tasks + db->active_task; + } + return task; +} + +// Returns selected task or NULL if none applies. +task_st *get_selected_task(database_st *db) { + assert(db != NULL); + + task_st *task = NULL; + if (db->selected_task >= 0) { + task = db->tasks + db->selected_task; + } + return task; +} + +// Creates new task stored at location given by task pointer. +// If necessary, expands database capacity. +// Returns success. +bool create_task(database_st *db, task_st **task) { + assert(db != NULL); + assert(task != NULL); + + if (db->count >= MAX_DATABASE_TASKS) { + print_error("Database reached maximum capacity."); + return false; + } + + // If necessary, expand database capacity. + size_t current_capacity = db->capacity; + if ((db->count + 1) > current_capacity) { + size_t new_capacity = + current_capacity == 0 ? 2 : + current_capacity > (MAX_DATABASE_TASKS >> 1) ? MAX_DATABASE_TASKS : + current_capacity << 1; + + task_st *new_tasks = realloc(db->tasks, new_capacity * SIZEOF_TASK_ST); + if (new_tasks == NULL) { + print_error("Failed to expand database."); + return false; + } + + db->capacity = new_capacity; + db->tasks = new_tasks; + } + + // Prepare new task. + *task = &db->tasks[db->count]; + memset(*task, 0, SIZEOF_TASK_ST); + + db->count++; + + // Adjust selected task. + if (db->selected_task < 0) { + db->selected_task = db->count-1; + } + + return true; +} + +// Duplicates the given task. Duplicated task is appended to the database. +// Returns success. +bool duplicate_task(database_st *db, task_st *task) { + assert(db != NULL); + assert(task != NULL); + + // Create new task and keep task_idx (relative pointer) of original task). + ptrdiff_t task_idx = task - db->tasks; + task_st *new_task; + if (create_task(db, &new_task) == false) { + return false; + } + + // If original task belonged to database, fix its pointer. + if (0 <= task_idx && task_idx < db->count - 1) { // Compensate '- 1' for the new task. + task = db->tasks + task_idx; + } + + memcpy(new_task, task, SIZEOF_TASK_ST); + + // Add task time values to total times. + for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { + db->total_times[idx] = add_int64(db->total_times[idx], new_task->times[idx]); + } + + return true; +} + +// Deletes task from database. +// If possible, shrinks the database capacity. +// Returns success. +bool delete_task(database_st *db, task_st *task) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + + // Remove task timer values from total timers. + for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { + db->total_times[idx] = sub_int64(db->total_times[idx], task->times[idx]); + } + + // Move tasks after the index position to their new positions. + ptrdiff_t index = task - db->tasks; + memmove(task, task + 1, (db->count - index - 1) * SIZEOF_TASK_ST); + db->count--; + + // Adjust selected task. + if (db->selected_task >= db->count) { + db->selected_task--; + } + + // Adjust active task. + if (db->active_task > index) { + db->active_task--; + } + else if (db->active_task == index) { + db->active_task = -1; + } + + // If possible, shrink database capacity. + size_t current_capacity = db->capacity; + if (db->count <= (current_capacity >> 2)) { + size_t new_capacity = current_capacity >> 1; + task_st *new_tasks = realloc(db->tasks, new_capacity * SIZEOF_TASK_ST); + if (new_tasks == NULL && new_capacity > 0) { + print_error("Failed to shrink database."); + return false; + } + db->capacity = new_capacity; + db->tasks = new_tasks; + } + + return true; +} + +// Moves task to index. +// Index gets clamped to [0, db->count[. +void move_task_to_index(database_st *db, task_st *task, ptrdiff_t index) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + + ptrdiff_t target_index = index < 0 ? 0 + : index >= db->count ? db->count - 1 + : index; + + task_st *target_task = db->tasks + target_index; + + if (target_task == task) { + return; + } + + // Move task to new location. + task_st temp_task; + memcpy(&temp_task, task, SIZEOF_TASK_ST); + if (target_task > task) { + memmove(task, task + 1, (target_task - task) * SIZEOF_TASK_ST); + } + else { + memmove(target_task + 1, target_task, (task - target_task) * SIZEOF_TASK_ST); + } + memcpy(target_task, &temp_task, SIZEOF_TASK_ST); + + // Adjust active and selected tasks. + ptrdiff_t source_index = task - db->tasks; + if (db->active_task == source_index) { + db->active_task = target_index; + } + else if (source_index < db->active_task && db->active_task <= target_index) { + db->active_task--; + } + else if (target_index <= db->active_task && db->active_task < source_index) { + db->active_task++; + } + db->selected_task = target_index; +} + +// Updates the times on the active task (and adjusts database totals). +void update_times(database_st *db) { + assert(db != NULL); + + // Get current UTC time. + time_t stop_time = time(NULL); + + // Get last modified on UTC time. + time_t start_time = db->modified_on; + + // Keep track of this update. + db->modified_on = stop_time; + + if (db->active_task < 0) { + return; + } + + task_st *active_task = db->tasks + db->active_task; + uint8_t start_week_day; + while (start_time < stop_time) { + + start_week_day = localtime(&start_time)->tm_wday; + + // Get next day in local time. + struct tm *start_of_day_tm = localtime(&start_time); + start_of_day_tm->tm_sec = 0; + start_of_day_tm->tm_min = 0; + start_of_day_tm->tm_hour = 0; + time_t start_of_day = mktime(start_of_day_tm); + time_t next_day = start_of_day + SECONDS_IN_DAY; + time_t next_start = next_day < stop_time ? next_day : stop_time; + time_t elapsed_time = next_start - start_time; + active_task->times[start_week_day] += elapsed_time; + db->total_times[start_week_day] += elapsed_time; + + start_time = next_start; + } +} + +// Recalculates database totals. +void update_total_times(database_st *db) { + assert(db != NULL); + + int64_t *totals = db->total_times; + memset(totals, 0, NUM_WEEK_DAYS * SIZEOF_INT64); + for (size_t idx = 0; idx < db->count; idx++) { + int64_t *times = db->tasks[idx].times; + totals[0] = add_int64(totals[0], times[0]); + totals[1] = add_int64(totals[1], times[1]); + totals[2] = add_int64(totals[2], times[2]); + totals[3] = add_int64(totals[3], times[3]); + totals[4] = add_int64(totals[4], times[4]); + totals[5] = add_int64(totals[5], times[5]); + totals[6] = add_int64(totals[6], times[6]); + } +} + +// Resets the times of the provided task (and adjusts database totals). +void reset_task_times(database_st *db, task_st *task) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + + // Make sure we sync before applying the changes. + update_times(db); + + for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { + int64_t *timer = &task->times[idx]; + int64_t *total = &db->total_times[idx]; + *total = sub_int64(*total, *timer); + *timer = 0; + } +} + +// Sets the time on the day and task provided (and adjusts database totals). +void set_task_time(database_st *db, task_st *task, int day, int64_t time) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + + // Make sure we sync before applying the changes. + update_times(db); + + int64_t *timer = &task->times[day]; + int64_t *total = &db->total_times[day]; + *total = sub_int64(*total, *timer); + *timer = time; + *total = add_int64(*total, *timer); +} + +// Adds the time on the day and task provided (and adjusts database totals). +void add_task_time(database_st *db, task_st *task, int day, int64_t time) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + + // Make sure we sync before applying the changes. + update_times(db); + + task->times[day] = add_int64(task->times[day], time); + db->total_times[day] = add_int64(db->total_times[day], time); +} + +// Resets database to the initial state and deallocates all memory taken by tasks. +void reset_database(database_st *db) { + assert(db != NULL); + + free(db->tasks); + memset(db, 0, SIZEOF_DATABASE_ST); + db->active_task = -1; + db->selected_task = -1; +} + +// Stores data from database into binary file. +// Returns success. +bool store_database(const database_st *db, const char *path) { + assert(db != NULL); + assert(path != NULL); + + // Open file. + FILE *file = fopen(path, "wb"); + if (file == NULL) { + print_error("Failed to open file '%s' while storing database: %s.", path, strerror(errno)); + return false; + } + + fwrite(DB_FILE_SIGN, SIZEOF_CHAR, DB_FILE_SIGN_LENGTH, file); + fwrite(db, SIZEOF_DATABASE_ST, 1, file); + fwrite(db->tasks, SIZEOF_TASK_ST, db->count, file); + + fclose(file); + return true; +} + +// Loads data from binary file into database. +// Returns success. +bool load_database(database_st *db, const char *path) { + assert(db != NULL); + assert(path != NULL); + + // Open file. + FILE *file = fopen(path, "rb"); + if (file == NULL) { + print_error("Failed to open file '%s' while loading database: %s.", path, strerror(errno)); + return false; + } + + // Validate file signature. + char file_signature[DB_FILE_SIGN_LENGTH]; + fread(&file_signature, SIZEOF_CHAR, DB_FILE_SIGN_LENGTH, file); + if (strncmp(file_signature, DB_FILE_SIGN, DB_FILE_SIGN_LENGTH) != 0) { + print_error("Invalid file signature."); + fclose(file); + return false; + } + + // Read database structure. + fread(db, SIZEOF_DATABASE_ST, 1, file); + + // Reserve database capacity for tasks. + size_t capacity_bytes = db->capacity * SIZEOF_TASK_ST; + db->tasks = malloc(capacity_bytes); + if (db->tasks == NULL && capacity_bytes > 0) { + print_error("Failed to allocate memory while loading database: %s.", strerror(errno)); + return false; + } + + // Read database tasks. + fread(db->tasks, SIZEOF_TASK_ST, db->count, file); + + // Make sure we are reading all the file. + assert(fgetc(file) == EOF); + + fclose(file); + return true; +} + +// Exports data into CSV file. +// Returns success. +bool export_to_csv(const database_st *db, const char *path) { + assert(db != NULL); + assert(path != NULL); + + FILE *file = fopen(path, "w"); + if (file == NULL) { + print_error("Failed to open file '%s' while exporting to CSV: %s.", path, strerror(errno)); + return false; + } + + fprintf(file, "%s,%s,%s,%s,%s,%s,%s,%s\n", + "task", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" + ); + + char name[TASK_NAME_BYTES]; + for (size_t idx = 0; idx < db->count; idx++) { + task_st *task = &db->tasks[idx]; + memcpy(name, task->name, TASK_NAME_BYTES); + replace_char(name, ',', ' '); + fprintf(file, "%s,%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 "\n", + name, task->times[0], task->times[1], task->times[2], task->times[3], task->times[4], task->times[5], task->times[6] + ); + } + + fclose(file); + return true; +} + +// Imports CSV file into database. +// Returns success. +bool import_from_csv(database_st *db, const char *path) { + assert(db != NULL); + assert(path != NULL); + + FILE *file = fopen(path, "r"); + if (file == NULL) { + print_error("Failed to open file '%s' while importing from CSV: %s.", path, strerror(errno)); + return false; + } + + // Skip header line. + fscanf(file, "%*[^\n]\n"); + + // Parse CSV file. + char *csv_buffer = NULL; + size_t csv_buffer_size = 0; + while(getline(&csv_buffer, &csv_buffer_size, file) != -1) { // Check if reached EOF. + + // Find task name string limits. + char *name_delimiter = strchr(csv_buffer, ','); + if (name_delimiter == NULL) { + continue; + } + size_t name_length = (name_delimiter - csv_buffer); + if (name_length > TASK_NAME_LENGTH) { + name_length = TASK_NAME_LENGTH; + } + + // Prepare new task. + task_st *task; + if (create_task(db, &task) == false) { + return false; + } + + // Import task name. + memcpy(task->name, csv_buffer, name_length); + truncate_string_utf8(task->name, name_length); + + // Parse task times. + if (sscanf(name_delimiter + 1, + "%" SCNd64 ",%" SCNd64 ",%" SCNd64 ",%" SCNd64 ",%" SCNd64 ",%" SCNd64 ",%" SCNd64, + &task->times[0], &task->times[1], &task->times[2], &task->times[3], &task->times[4], &task->times[5], &task->times[6] + ) != NUM_WEEK_DAYS + ) { + replace_char(csv_buffer, '\n', ' '); + print_error("Discarding invalid line '%s' and continuing.", csv_buffer); + delete_task(db, task); + continue; + } + + // Add task timer values to total timers. + for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { + db->total_times[idx] = add_int64(db->total_times[idx], task->times[idx]); + } + } + + fclose(file); + free(csv_buffer); + return true; +} + +// Appends task to the end of the CSV file. +// Returns success. +bool append_to_csv(task_st *task, const char *path) { + assert(task != NULL); + assert(path != NULL); + + FILE *file = fopen(path, "a+"); + if (file == NULL) { + print_error("Failed to open file '%s' while appending to CSV: %s.", path, strerror(errno)); + return false; + } + + char last_char; + fseek(file, -1, SEEK_END); + fread(&last_char, SIZEOF_CHAR, 1, file); + if (last_char != '\n') { + fprintf(file, "\n"); + } + + char name[TASK_NAME_BYTES]; + memcpy(name, task->name, TASK_NAME_BYTES); + replace_char(name, ',', ' '); + fprintf(file, "%s,%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 ",%" PRId64 "\n", + name, task->times[0], task->times[1], task->times[2], task->times[3], task->times[4], task->times[5], task->times[6] + ); + + fclose(file); + return true; +} + +// Selects task by index. +// Index gets clamped to [0, db->count[. +void select_task_by_index(database_st *db, ptrdiff_t index) { + assert(db != NULL); + db->selected_task = db->count == 0 ? -1 + : index < 0 ? 0 + : index >= db->count ? db->count - 1 + : index; +} + +// Selects task by delta relative to currently selected task. +void select_task_by_delta(database_st *db, ptrdiff_t delta) { + assert(db != NULL); + ptrdiff_t idx = (delta > 0 && db->selected_task > PTRDIFF_MAX - delta) ? PTRDIFF_MAX + : (delta < 0 && db->selected_task < PTRDIFF_MIN + delta) ? PTRDIFF_MIN + : db->selected_task + delta; + select_task_by_index(db, idx); +} + +// Selects task. +void select_task(database_st *db, task_st *task) { + assert(db != NULL); + assert(task != NULL); + assert(task >= db->tasks && task - db->tasks < db->count); + db->selected_task = task - db->tasks; +} + +// Set active task. +// Passing task as NULL de-activates any previously active task. +void set_active_task(database_st *db, task_st *task) { + assert(db != NULL); + assert(task == NULL || (task >= db->tasks && task < &db->tasks[db->count])); + update_times(db); + db->active_task = (task == NULL) ? -1 : task - db->tasks; +} + +// Returns true when database is full. +bool is_database_full(database_st *db) { + assert(db != NULL); + return db->count >= MAX_DATABASE_TASKS; +} + +#define INPUT_TIMEOUT_MS 1000 +#define INPUT_AWAIT_INF -1 + +#define NUM_HEADER_ROWS 1 +#define NUM_FOOTER_ROWS 1 +#define NUM_COLUMNS 9 + +#define L_TITLE_IDX 0 +#define L_DAYS_IDX 1 +#define L_TOTAL_IDX 8 + +typedef enum { + L_NORMAL, + L_COMPACT, + NUM_LAYOUTS, +} layouts_et; + +typedef struct { + char *header; + int width; + int alignment_offset; + char alignment; +} column_st; + +typedef struct { + column_st columns[NUM_COLUMNS]; + char *archive_title; +} layout_st; + +layout_st layouts[NUM_LAYOUTS]; +int layout_tasks_rows; +bool is_terminal_too_small = true; + + +void initialize_tui() { + + // Normal layout. + layouts[L_NORMAL] = (layout_st) { + .archive_title = " Archive ", + .columns = { + { .header = " Task Time Tracker v" VERSION " ", .width = -1, .alignment = 'L' }, + { .header = " Sun ", .width = 7, .alignment = 'C' }, + { .header = " Mon ", .width = 7, .alignment = 'C' }, + { .header = " Tue ", .width = 7, .alignment = 'C' }, + { .header = " Wed ", .width = 7, .alignment = 'C' }, + { .header = " Thu ", .width = 7, .alignment = 'C' }, + { .header = " Fri ", .width = 7, .alignment = 'C' }, + { .header = " Sat ", .width = 7, .alignment = 'C' }, + { .header = " Total ", .width = 9, .alignment = 'C' }, + } + }; + + // Compact layout. + layouts[L_COMPACT] = (layout_st) { + .archive_title = " Archive ", + .columns = { + { .header = " TTT " VERSION " ", .width = -1, .alignment = 'L' }, + { .header = " S ", .width = 5, .alignment = 'C' }, + { .header = " M ", .width = 5, .alignment = 'C' }, + { .header = " T ", .width = 5, .alignment = 'C' }, + { .header = " W ", .width = 5, .alignment = 'C' }, + { .header = " T ", .width = 5, .alignment = 'C' }, + { .header = " F ", .width = 5, .alignment = 'C' }, + { .header = " S ", .width = 5, .alignment = 'C' }, + { .header = " # ", .width = 5, .alignment = 'C' }, + } + }; + + // Calculate alignment_offsets. + for (layout_st *layout = layouts; layout < layouts + NUM_LAYOUTS; layout++) { + for (column_st *col = layout->columns; col < layout->columns + NUM_COLUMNS; col++) { + int offset; + switch(col->alignment) { + default: + case 'L': + offset = 0; + break; + + case 'C': + offset = ((col->width - strlen(col->header)) / 2); + break; + + case 'R': + offset = (col->width - strlen(col->header)); + break; + } + col->alignment_offset = offset; + } + } + + setlocale(LC_ALL, "C.UTF-8"); // Sets locale for C library functions; Allows usage of UTF-8. + initscr(); // Start curses mode. + cbreak(); // Line buffering disabled; pass on everty thing to me. + keypad(stdscr, TRUE); // I need that nifty F1. + curs_set(0); // Set cursor invisible. + noecho(); // Disable echoing input characters. + + // Initialize pairs of colors. + start_color(); + use_default_colors(); // Using default (-1) instead of COLOR_BLACK. + init_pair(STYLE_SELECTED, COLOR_BLACK, COLOR_CYAN); + init_pair(STYLE_SELECTED_INVERTED, COLOR_CYAN, -1); + init_pair(STYLE_ACTIVE, COLOR_BLUE, -1); + init_pair(STYLE_ACTIVE_SELECTED, COLOR_WHITE, COLOR_BLUE); + init_pair(STYLE_ERROR, COLOR_RED, -1); +} + +void update_layout() { + // Calculate number of available rows to display tasks. + layout_tasks_rows = (size_y - NUM_HEADER_ROWS - NUM_FOOTER_ROWS); + + // Calculate first column width: expands to fill the remaining space dynamically. + for (layout_st *layout = layouts; layout <= &layouts[NUM_LAYOUTS - 1]; layout++) { + layout->columns[0].width = size_x - (NUM_COLUMNS - 1) - 2; + for (int idx = 1; idx < NUM_COLUMNS; idx++) { + layout->columns[0].width -= layout->columns[idx].width; + } + } +} + +void draw_tui(database_st *db, layout_st *layout) { + + const static int adjust_first_day_of_week[] = { + (0 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (1 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (2 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (3 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (4 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (5 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + (6 + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS, + }; + + int x, y; + column_st *col; + + // Get context information. + task_st *active_task = get_active_task(db); + task_st *selected_task = get_selected_task(db); + time_t now_utc = time(NULL); + int now_week_day = localtime(&now_utc)->tm_wday; + + // Reset theme and clear screen. + attrset(A_NORMAL); + erase(); + + // Draw outer border. + box(stdscr, 0, 0); + + // Draw table grids. + y = 0; + x = 0; + for (int idx = 0; idx < NUM_COLUMNS - 1; idx++) { + x += 1 + layout->columns[idx].width; + mvaddch(y, x, ACS_TTEE); + for (y = 1; y < size_y - 1; y++) { + mvaddch(y, x, ACS_VLINE); + } + mvaddch(size_y - 1, x, ACS_BTEE); + } + + + /////////////////////////////////////////////////////////////////////////// + // Draw headers. + y = 0; + x = 0; + + // Headers : title + x++; + col = &layout->columns[L_TITLE_IDX]; + mvaddstr(y, x + col->alignment_offset, (db == &archive ? layout->archive_title : col->header)); + x += col->width; + + // Headers : days + for (int raw_idx = 0; raw_idx < NUM_WEEK_DAYS; raw_idx++) { + int idx = adjust_first_day_of_week[raw_idx]; + x++; + + // Apply theme. + if (idx == now_week_day && active_task != NULL) { + attron(COLOR_PAIR(STYLE_ACTIVE) | A_BOLD); + } + else if (idx == now_week_day) { + attron(COLOR_PAIR(STYLE_SELECTED_INVERTED) | A_BOLD); + } + + col = &layout->columns[L_DAYS_IDX + idx]; + mvaddstr(y, x + col->alignment_offset, col->header); + x += col->width; + + // Reset theme. + attrset(A_NORMAL); + } + + // Headers : total + x++; + col = &layout->columns[L_TOTAL_IDX]; + mvaddstr(y, x + col->alignment_offset, col->header); + + + /////////////////////////////////////////////////////////////////////////// + // Draw tasks. + + uint64_t total_time = 0; + int column_width; + + y = 0; + // Pagination based on currently selected task (show page where selected task is). + size_t idx_start = (db->selected_task / layout_tasks_rows) * layout_tasks_rows; + // Display up to rows allowed by the layout, or less if reached end of database. + size_t idx_stop = idx_start + (layout_tasks_rows > db->count - idx_start ? db->count - idx_start : layout_tasks_rows); + for (size_t idx = idx_start; idx < idx_stop; idx++) { + task_st *task = &db->tasks[idx]; + y++; + x = 0; + + // Apply theme. + if (task == active_task && task == selected_task) { + attron(COLOR_PAIR(STYLE_ACTIVE_SELECTED) | A_BOLD); + } + else if (task == selected_task) { + attron(COLOR_PAIR(STYLE_SELECTED)); + } + else if (task == active_task) { + attron(COLOR_PAIR(STYLE_ACTIVE) | A_BOLD); + } + + // Task title. + x++; + column_width = layout->columns[L_TITLE_IDX].width; + mvprintw(y, x, "%-*.*s", column_width, column_width, task->name); + x += column_width; + + // Task times. + total_time = 0; + for (int idx = 0; idx < NUM_WEEK_DAYS; idx++) { + x++; + + int day_idx = (idx + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; + + column_width = layout->columns[L_DAYS_IDX + day_idx].width; + int64_t task_stime = task->times[day_idx]; + total_time = add_int64(total_time, task_stime); + mvprintw_time(y, x, task_stime, column_width); + x += column_width; + } + + // Task total. + x++; + mvprintw_time(y, x, total_time, layout->columns[L_TOTAL_IDX].width); + + // Reset theme. + attrset(A_NORMAL); + } + + + /////////////////////////////////////////////////////////////////////////// + // Draw selected/total tasks. + int size = snprintf(NULL, 0, " %td/%zd ", db->selected_task + 1, db->count); + if (size <= layout->columns[L_TITLE_IDX].width) { + mvprintw(size_y - 1, 1, " %td/%zd ", db->selected_task + 1, db->count); + } + else { + mvprintw(size_y - 1, 1, "%td", db->selected_task + 1); + } + + /////////////////////////////////////////////////////////////////////////// + // Draw daily totals. + y = size_y - 1; + x = 0 + 1 + layout->columns[L_TITLE_IDX].width; + total_time = 0; + for (int raw_idx = 0; raw_idx < NUM_WEEK_DAYS; raw_idx++) { + int idx = adjust_first_day_of_week[raw_idx]; + int64_t daily_total = db->total_times[idx]; + x++; + + // Apply theme. + if (idx == now_week_day && active_task != NULL) { + attron(COLOR_PAIR(STYLE_ACTIVE) | A_BOLD); + } + else if (idx == now_week_day) { + attron(COLOR_PAIR(STYLE_SELECTED_INVERTED) | A_BOLD); + } + + column_width = layout->columns[L_DAYS_IDX + idx].width; + total_time = add_int64(total_time, daily_total); + mvprintw_time(y, x, daily_total, column_width); + x += column_width; + + // Reset theme. + attrset(A_NORMAL); + } + x++; + mvprintw_time(y, x, total_time, layout->columns[L_TOTAL_IDX].width); +} + +void *mem_alloc(size_t mem_size, const char *error_tag) { + void *mem_pointer = malloc(mem_size); + if (mem_pointer == NULL && mem_size > 0) { + print_error("Failed to allocate memory (%s): %s.", (error_tag == NULL ? "undefined" : error_tag), strerror(errno)); + exit(EXIT_FAILURE); + } + return mem_pointer; +} + +void free_memory() { + reset_database(&database); + reset_database(&archive); + + free(string_buffer); string_buffer = NULL; + free(app_folder); app_folder = NULL; + free(db_file_path); db_file_path = NULL; + free(ar_file_path); ar_file_path = NULL; +} + +bool initialize_app_folder() { + size_t temp_size; + + char *home_path = getenv(HOME_PATH_ENV); + if (home_path == NULL) { + home_path = "."; + } + temp_size = strlen(home_path) + 1 + strlen(APP_FOLDER_NAME) + 1; // Add space for folder separator and '\0'. + app_folder = mem_alloc(temp_size, "app folder"); + snprintf(app_folder, temp_size, "%s/%s", home_path, APP_FOLDER_NAME); + + // Create app folder. + mkdir(app_folder, 0740); + if (errno != 0 && errno != EEXIST) { + print_error("Failed to create app folder '%s': %s.", app_folder, strerror(errno)); + return false; + } + + // Set database file path. + temp_size = strlen(app_folder) + 1 + strlen(DB_FILE_NAME) + 1; // Add space for folder separator and '\0'. + db_file_path = mem_alloc(temp_size, "database file path"); + snprintf(db_file_path, temp_size, "%s/%s", app_folder, DB_FILE_NAME); + + // Set archive file path. + temp_size = strlen(app_folder) + 1 + strlen(AR_FILE_NAME) + 1; // Add space for folder separator and '\0'. + ar_file_path = mem_alloc(temp_size, "archive file path"); + snprintf(ar_file_path, temp_size, "%s/%s", app_folder, AR_FILE_NAME); + + return true; +} + +void exit_gracefully(int signal) { + flushinp(); + ungetch('q'); +} + +void read_input_to_string_buffer_with_space(int row, int column, int style, int length, int space) { + assert(length < string_buffer_size); + assert(space < string_buffer_size); + + attron(style | A_UNDERLINE); + mvprintw(row, column, "%*s", space, ""); + echo(); + curs_set(1); + memset(string_buffer, 0, string_buffer_size); + mvgetnstr(row, column, string_buffer, length); + truncate_string_utf8(string_buffer, length); + noecho(); + curs_set(0); + attrset(A_NORMAL); +} + +void read_input_to_string_buffer(int row, int column, int style, int length) { + read_input_to_string_buffer_with_space(row, column, style, length, length); +} + +// Returns success. +bool read_input_to_int(int row, int style, const char *message, intmax_t *result) { + assert(message != NULL); + assert(result != NULL); + + attron(style); + move(row, 1); + addch(ACS_CKBOARD); + addstr(message); + attrset(A_NORMAL); + + // Get line number. + int input_pos_x = getcurx(stdscr); + int input_width = size_x - input_pos_x - 1; + read_input_to_string_buffer(row, input_pos_x, style, input_width); + + char *parser; + errno = 0; + *result = strtoimax(string_buffer, &parser, 10); + + bool success = (errno == 0 || errno == ERANGE) // No error OR value was clamped to limits (acceptable). + && parser != string_buffer; // If no digits are found, parser will return the address of the input string. + + return success; +} + +// Retuns true if user presses enter, false otherwise. +bool read_enter_confirmation(int row, int style, const char *message) { + assert(message != NULL); + + attron(style); + move(row, 1); + for (int idx = 0; idx < size_x - 2; idx++) { + addch(ACS_CKBOARD); + } + mvaddstr(row, 2, message); + attrset(A_NORMAL); + + return getch() == '\n'; +} + +int main(int argc, char *argv[]) { + + if (initialize_app_folder() == false) { + print_error("Failed to initialize app folder."); + free_memory(); + return EXIT_FAILURE; + } + + db = &database; + reset_database(&database); + reset_database(&archive); + + if (is_file_accessible(db_file_path) == false) { + if (store_database(&database, db_file_path) == false) { + print_error("Failed to initialize database."); + free_memory(); + return EXIT_FAILURE; + } + } + + if (is_file_accessible(ar_file_path) == false) { + if (export_to_csv(&archive, ar_file_path) == false) { + print_error("Failed to initialize archive."); + free_memory(); + return EXIT_FAILURE; + } + } + + if (argc > 1) { + bool is_exit_requested = false; + for (unsigned idx = 1; idx < argc; idx++) { + if (is_equal_to_any(argv[idx], "--help", "-h")) { + fprintf(stdout, + "Usage: ttt [OPTION]... [FILE]...\n" + " -i, --import-csv [FILE] Import CSV file to database (discard first row).\n" + " -e, --export-csv [FILE] Export database to CSV file.\n" + " -n, --no-autosave Disable autosave feature (only save on exit).\n" + " -h, --help Display this help and exit.\n" + " -v, --version Output version information and exit.\n" + "\n" + "In app commands\n" + " a, A Archive selected task (except if active).\n" + " u, U Unarchive selected task.\n" + " t, T Select currently active task (if any).\n" + " d, D Duplicate selected task.\n" + " n, N Create new task.\n" + " m, M Move selected task to position.\n" + " g, G Select task by position.\n" + " q, Q Save changes and exit.\n" + " F2 Rename selected task.\n" + " F5 Recalculate total times.\n" + " TAB Toggle archive view.\n" + " BACKSPACE Reset times for selected task.\n" + " DELETE Delete selected task (except if active).\n" + " SPACE, ENTER Toggle selected task as active/inactive.\n" + " 1, 2, 3, 4, 5, 6, 7 Edit selected task time for the Nth day of week:\n" + " =# sets # seconds;\n" + " -# subtracts # seconds;\n" + " # adds # seconds;\n" + " #m specifies # as minutes;\n" + " #h specifies # as hours;\n" + " #d specifies # as days;\n" + " #y specifies # as years.\n" + " UP Select task above.\n" + " DOWN Select task below.\n" + " PAGE-UP Select task 1 page above.\n" + " PAGE-DOWN Select task 1 page below.\n" + " HOME Select first/top task.\n" + " END Select last/bottom task.\n" + "\n" + "Notes\n" + "- All data files are stored in '$" HOME_PATH_ENV "/.task_time_tracker'.\n" + " If $" HOME_PATH_ENV " is undefined, './.task_time_tracker' will be used.\n" + " The database entries are stored in binary format on the 'database.bin' file.\n" + " The archived entries are stored in CSV format on the 'archive.csv' file.\n" + "- During intensive tasks such as saving to file or recalculating totals times,\n" + " a diamond symbol is shown on the top left corner.\n" + ); + free_memory(); + return EXIT_SUCCESS; + } + + if (is_equal_to_any(argv[idx], "--version", "-v")) { + fprintf(stdout, "Task Time Tracker version " VERSION "\n"); + free_memory(); + return EXIT_SUCCESS; + } + + if (is_equal_to_any(argv[idx], "--import-csv", "-i")) { + idx++; + if (idx >= argc) { + print_error("Missing CSV file path to import."); + free_memory(); + return EXIT_FAILURE; + } + if (load_database(&database, db_file_path) == false) { + print_error("Failed to load database."); + free_memory(); + return EXIT_FAILURE; + } + if (import_from_csv(&database, argv[idx]) == false) { + print_error("Failed to import CSV file."); + free_memory(); + return EXIT_FAILURE; + } + if (store_database(&database, db_file_path) == false) { + print_error("Failed to store database."); + free_memory(); + return EXIT_FAILURE; + } + reset_database(&database); + is_exit_requested = true; + continue; + } + + if (is_equal_to_any(argv[idx], "--export-csv", "-e")) { + idx++; + if (idx >= argc) { + print_error("Missing CSV file path to export."); + free_memory(); + return EXIT_FAILURE; + } + if (load_database(&database, db_file_path) == false) { + print_error("Failed to load database."); + free_memory(); + return EXIT_FAILURE; + } + if (export_to_csv(&database, argv[idx]) == false) { + print_error("Failed to export CSV file."); + free_memory(); + return EXIT_FAILURE; + } + reset_database(&database); + is_exit_requested = true; + continue; + } + + if (is_equal_to_any(argv[idx], "--no-autosave", "-n")) { + is_autosave_enabled = false; + continue; + } + + print_error("%s: invalid option '%s'.\nTry '%s --help' for more information.", argv[0], argv[idx], argv[0]); + free_memory(); + return EXIT_FAILURE; + } + + if (is_exit_requested) { + free_memory(); + return EXIT_SUCCESS; + } + } + + if (load_database(&database, db_file_path) == false) { + print_error("Failed to load database."); + free_memory(); + return EXIT_FAILURE; + } + + initialize_tui(); + + signal(SIGTERM, exit_gracefully); + signal(SIGINT, exit_gracefully); + signal(SIGQUIT, exit_gracefully); + signal(SIGHUP, exit_gracefully); + + flushinp(); + ungetch(KEY_RESIZE); + for (int key; ((key = getch()) != 'q') && (key != 'Q'); ) { + + static layout_st *layout = &layouts[L_COMPACT]; + task_st *active_task = get_active_task(db); + task_st *selected_task = get_selected_task(db); + int action_style = A_BOLD | COLOR_PAIR(selected_task == active_task && selected_task != NULL ? STYLE_ACTIVE : STYLE_SELECTED_INVERTED); + int error_style = A_BOLD | COLOR_PAIR(STYLE_ERROR); + int selected_task_row = is_terminal_too_small ? 0 + : (db->selected_task < 0) ? 1 + : (db->selected_task % layout_tasks_rows) + NUM_HEADER_ROWS; + + timeout(INPUT_AWAIT_INF); + update_times(&database); + + switch(key) { + + // When getch() times out. + case ERR: { + if (is_autosave_enabled && countdown_to_autosave > 0) { + countdown_to_autosave -= INPUT_TIMEOUT_MS; + if (countdown_to_autosave <= 0) { + show_processing(); + if (db == &archive) { + export_to_csv(&archive, ar_file_path); + } + store_database(&database, db_file_path); + } + } + break; + } + + // When terminal is resized. + case KEY_RESIZE: { + clear(); + getmaxyx(stdscr, size_y, size_x); + is_terminal_too_small = size_x < 60 || size_y < 3; + size_t new_size = 2047 | TASK_NAME_BYTES | (size_x + 1); + if (string_buffer_size < new_size) { + string_buffer_size = new_size; + string_buffer = realloc(string_buffer, string_buffer_size); + if (string_buffer == NULL && string_buffer_size > 0) { + print_error("Failed to allocate memory for string buffer: %s.", strerror(errno)); + flushinp(); + ungetch('q'); + break; + } + } + update_layout(); + layout = &layouts[size_x > 100 ? L_NORMAL : L_COMPACT]; + break; + } + + case 'n': + case 'N':{ + if (is_database_full(db)) { + read_enter_confirmation(selected_task_row, error_style, " Unable to create entry: database is full. "); + break; + } + + // Create new task. + task_st *new_task; + if (create_task(db, &new_task) == false) { + break; + } + + // Set new task name. + time_t now_utc = time(NULL); + struct tm *now_local = localtime(&now_utc); + strftime(new_task->name, TASK_NAME_BYTES, "%Y-%m-%d %H:%M:%S", now_local); + + // Select new task. + select_task(db, new_task); + selected_task = get_selected_task(db); + trigger_autosave(); + + // Force rename action. + flushinp(); + ungetch(KEY_F(2)); + break; + } + + case KEY_F(2): { + if (selected_task == NULL) { + break; + } + + read_input_to_string_buffer_with_space(selected_task_row, 1, action_style, TASK_NAME_LENGTH, size_x - 2); + if (is_empty_string(string_buffer) == false) { + replace_char(string_buffer, '\t', ' '); + replace_char(string_buffer, '\v', ' '); + replace_char(string_buffer, '\f', ' '); + replace_char(string_buffer, '\r', ' '); + memcpy(selected_task->name, string_buffer, TASK_NAME_BYTES); + trigger_autosave(); + } + break; + } + + case KEY_BACKSPACE: { + if (selected_task == NULL) { + break; + } + + if (read_enter_confirmation(selected_task_row, action_style, " Press enter to reset task. ") == true) { + reset_task_times(db, selected_task); + trigger_autosave(); + } + break; + } + + case KEY_DC: { // Delete + if (selected_task == NULL || selected_task == active_task) { + break; + } + + if (read_enter_confirmation(selected_task_row, action_style, " Press enter to delete task. ") == true) { + delete_task(db, selected_task); + trigger_autosave(); + } + break; + } + + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': { + if (selected_task == NULL) { + break; + } + + // Prepare position to input time operation. + int selected_day = key - '1'; + int input_width = layout->columns[L_DAYS_IDX + selected_day].width; + int input_pos_x = 1 + layout->columns[L_TITLE_IDX].width; + for (int col = 0; col < selected_day; col++) { + input_pos_x += 1 + layout->columns[L_DAYS_IDX + col].width; + } + input_pos_x++; + + // Get input string. + read_input_to_string_buffer(selected_task_row, input_pos_x, action_style, input_width); + char *input = string_buffer; + + // Abort if input if empty. + if (is_empty_string(input) == true) { + break; + } + + // Search for assign '=' operator and discard everything before (if found). + char *assign_str = strchr(input, '='); + bool is_assign = assign_str != NULL; + if (is_assign == true) { + input = assign_str + 1; + } + + // Try to parse a number and abort if it fails. + char *parser; + long double input_float = strtold(input, &parser); + if (parser == input) { + break; + } + input = parser; + + // Try to parse a character representing the time multiplier. + long double multiplier = 1.0; + for (int i=0; i < strlen(input); i++) { + char ch = input[i]; + if (ch == 'm' || ch == 'M') { + multiplier = SECONDS_IN_MINUTE; + break; + } + else if (ch == 'h' || ch == 'H') { + multiplier = SECONDS_IN_HOUR; + break; + } + else if (ch == 'd' || ch == 'D') { + multiplier = SECONDS_IN_DAY; + break; + } + else if (ch == 'y' || ch == 'Y') { + multiplier = SECONDS_IN_YEAR; + break; + } + } + + // Process input and check if it's valid. + long double input_time = input_float * multiplier; + bool is_result_valid = (input_time >= (long double)INT64_MIN && input_time <= (long double)INT64_MAX); + if (is_result_valid == false) { + break; + } + + // Apply changes. + int64_t time = input_time; + int day = (selected_day + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; + if (is_assign == true) { + set_task_time(db, selected_task, day, time); + } + else { + add_task_time(db, selected_task, day, time); + } + trigger_autosave(); + break; + } + + case 'm': + case 'M': { + if (selected_task == NULL) { + break; + } + + intmax_t value; + if (read_input_to_int(selected_task_row, action_style, " Move to: ", &value) == false) { + break; + } + + ptrdiff_t target_index = (value < 1 ? 1 : value > MAX_DATABASE_TASKS ? MAX_DATABASE_TASKS : value) - 1; + move_task_to_index(db, selected_task, target_index); + trigger_autosave(); + break; + } + + case 'g': + case 'G': { + if (selected_task == NULL) { + break; + } + + intmax_t value; + if (read_input_to_int(selected_task_row, action_style, " Go to: ", &value) == false) { + break; + } + + ptrdiff_t target_index = (value < 1 ? 1 : value > MAX_DATABASE_TASKS ? MAX_DATABASE_TASKS : value) - 1; + select_task_by_index(db, target_index); + break; + } + + case 'd': + case 'D':{ + if (selected_task == NULL) { + break; + } + + if (is_database_full(db)) { + read_enter_confirmation(selected_task_row, error_style, " Unable to duplicate entry: database is full. "); + break; + } + + if (duplicate_task(db, selected_task) == false) { + break; + } + trigger_autosave(); + break; + } + + case KEY_F(5): { + update_total_times(db); + trigger_autosave(); + break; + } + + case 't': + case 'T': { + if (active_task == NULL) { + break; + } + select_task(db, active_task); + break; + } + + case '\n': + case ' ': { + if (db != &database || selected_task == NULL) { + break; + } + set_active_task(db, (active_task == selected_task) ? NULL : selected_task); + active_task = get_active_task(db); + trigger_autosave(); + break; + } + + case '\t': { + if (db == &database) { + if (import_from_csv(&archive, ar_file_path) == false) { + reset_database(&archive); + print_error("Failed to load archive."); + break; + } + db = &archive; + } + else { + if (export_to_csv(&archive, ar_file_path) == false) { + print_error("Failed to store archive."); + break; + } + reset_database(&archive); + db = &database; + } + break; + } + + case 'a': + case 'A': { + if (db != &database || selected_task == NULL || selected_task == active_task) { + break; + } + if (append_to_csv(selected_task, ar_file_path) == false) { + print_error("Failed to archive entry."); + break; + } + delete_task(&database, selected_task); + trigger_autosave(); + break; + } + + case 'u': + case 'U': { + if (db != &archive || selected_task == NULL) { + break; + } + if (is_database_full(&database)) { + read_enter_confirmation(selected_task_row, error_style, " Unable to unarchive entry: database is full. "); + break; + } + if (duplicate_task(&database, selected_task) == false) { + print_error("Failed to unarchive entry."); + break; + } + delete_task(&archive, selected_task); + trigger_autosave(); + break; + } + + case KEY_HOME: { + select_task_by_index(db, 0); + break; + } + + case KEY_UP: { + select_task_by_delta(db, -1); + break; + } + + case KEY_PPAGE: { + select_task_by_delta(db, -layout_tasks_rows); + break; + } + + case KEY_END: { + select_task_by_index(db, db->count-1); + break; + } + + case KEY_DOWN: { + select_task_by_delta(db, 1); + break; + } + + case KEY_NPAGE: { + select_task_by_delta(db, layout_tasks_rows); + break; + } + } + + if (is_terminal_too_small) { + const char *INVALID_WINDOW_MESSAGE = "Terminal is too small: minimum 60x3."; + const int INVALID_WINDOW_MESSAGE_LENGTH = strlen(INVALID_WINDOW_MESSAGE); + mvaddstr(size_y / 2, (size_x - INVALID_WINDOW_MESSAGE_LENGTH) / 2, INVALID_WINDOW_MESSAGE); + } + else { + draw_tui(db, layout); + draw_error_window(); + } + + timeout(INPUT_TIMEOUT_MS); + } + + // Save any unsaved changes. + show_processing(); + bool error_saving = false; + if (db == &archive) { + if (export_to_csv(&archive, ar_file_path) == false) { + print_error("Failed to save archive."); + error_saving |= true; + } + } + if (countdown_to_autosave > 0 || is_autosave_enabled == false) { + if (store_database(&database, db_file_path) == false) { + print_error("Failed to save database."); + error_saving |= true; + } + } + if (error_saving) { + print_error("Press any key to close."); + draw_error_window(); + timeout(INPUT_AWAIT_INF); + getch(); + } + + endwin(); + free_memory(); + return error_saving ? EXIT_FAILURE : EXIT_SUCCESS; +} |
