// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #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. #define HOME_PATH_ENV "HOME" // #if defined(_WIN64) #define HOME_PATH_ENV "USERPROFILE" #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; // Limited to PTRDIFF_MAX. 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; 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) { // TODO Rename to truncate_utf_string assert(string != NULL); // Count continuation bytes. size_t idx = length; while (idx > 0 && ((string[idx - 1] & 0xC0) == 0x80)) { idx--; } int continuation_bytes = length - idx; // Adjust length if missing continuation bytes. if (idx > 0 // 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); } string[length] = '\0'; return length; } // Returns true when the string is empty or consists of white space characters. bool is_empty_string(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 time into string using 5 characters centered on space. // The string buffer should be able to store space UTF8 characters plus '\0'. char *sprint_time5_utf8(char *string, intmax_t time, int space) { const int TIME_CHARS = 5; assert(space >= TIME_CHARS); int buffer_space = space * 4 + 1; // Each that UTF8 char can have 4 bytes. int left_padding = (space - TIME_CHARS) / 2; int right_padding = space - TIME_CHARS - left_padding; if (time < 0) { snprintf(string, buffer_space, "%*s - %*s", left_padding, "", right_padding, ""); } else if (time == 0) { snprintf(string, buffer_space, "%*s 0 %*s", left_padding, "", right_padding, ""); } else if (time < SECONDS_IN_MINUTE) { snprintf(string, buffer_space, "%*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; snprintf(string, buffer_space, "%*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; snprintf(string, buffer_space, "%*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; snprintf(string, buffer_space, "%*s%4.*fy%*s", left_padding, "", decimals, value, right_padding, ""); } else { snprintf(string, buffer_space, "%*s ∞ %*s", left_padding, "", right_padding, ""); } return string; } 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) { fprintf(stderr, "Database reached maximum capacity.\n"); 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) { fprintf(stderr, "Failed to expand database.\n"); 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); task_st *new_task; if (create_task(db, &new_task) == false) { return false; } 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 the provided task. // 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) { // TODO Can we compare ptrdiff_t with size_t? 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) { fprintf(stderr, "Failed to shrink database.\n"); return false; } db->capacity = new_capacity; db->tasks = new_tasks; } return true; } // Deletes the provided task. If possible, shrinks the database capacity. // Returns success. bool move_task(database_st *db, task_st *task, size_t target) { assert(db != NULL); assert(task != NULL); assert(task >= db->tasks && task < &db->tasks[db->count]); assert(target >= 0 && target < db->count); // Move tasks after the index position to their new positions. ptrdiff_t index = task - db->tasks; task_st *target_task = &db->tasks[target]; ptrdiff_t target_index = target_task - db->tasks; if (target_task == task) { return true; } task_st temp_task; memcpy(&temp_task, task, SIZEOF_TASK_ST); // TODO Simplify code if (target_index > index) { memmove(task, task + 1, (target_index - index) * SIZEOF_TASK_ST); } else { memmove(target_task + 1, target_task, (index - target_index) * SIZEOF_TASK_ST); } memcpy(target_task, &temp_task, SIZEOF_TASK_ST); if (db->active_task == index) { db->active_task = target_index; } else if (db->active_task > index && db->active_task <= target_index) { db->active_task--; } else if (db->active_task >= target_index && db->active_task < index) { db->active_task++; } db->selected_task = target_index; return true; // TODO } // 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); memset(db->total_times, 0, NUM_WEEK_DAYS * SIZEOF_INT64); for (size_t idx = 0; idx < db->count; idx++) { int64_t *times = db->tasks[idx].times; db->total_times[0] = add_int64(db->total_times[0], times[0]); db->total_times[1] = add_int64(db->total_times[1], times[1]); db->total_times[2] = add_int64(db->total_times[2], times[2]); db->total_times[3] = add_int64(db->total_times[3], times[3]); db->total_times[4] = add_int64(db->total_times[4], times[4]); db->total_times[5] = add_int64(db->total_times[5], times[5]); db->total_times[6] = add_int64(db->total_times[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); } // 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) { fprintf(stderr, "Failed to open file '%s' while storing database: %s.\n", 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) { fprintf(stderr, "Failed to open file '%s' while loading database: %s.\n", 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) { fprintf(stderr, "Invalid file signature.\n"); 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) { fprintf(stderr, "Failed to allocate memory while loading database: %s.\n", 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) { fprintf(stderr, "Failed to open file '%s' while exporting to CSV: %s.\n", 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]; task_st *limit = db->tasks + db->count; for (task_st *task = db->tasks; task < limit; task++) { // TODO Simplify for loop. 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) { fprintf(stderr, "Failed to open file '%s' while importing from CSV: %s.\n", 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; create_task(db, &task); // 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', ' '); fprintf(stderr, "Discarding invalid line '%s' and continuing.\n", 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) { fprintf(stderr, "Failed to open file '%s' while appending to CSV: %s.\n", 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; } // TODO Add description. void select_task_by_index(database_st *db, ptrdiff_t idx) { assert(db != NULL); db->selected_task = db->count == 0 ? -1 : idx < 0 ? 0 : idx >= db->count ? db->count - 1 : idx; } // TODO Add description. 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); } // TODO Add description. 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; } // TODO Add description. 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; } #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 { // TODO Improve theme names. THEME_A = 1, THEME_B, THEME_C, THEME_D, THEME_E, } themes_et; 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 v" 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(); init_pair(THEME_A, COLOR_BLUE, COLOR_BLACK); init_pair(THEME_B, COLOR_BLACK, COLOR_CYAN); init_pair(THEME_C, COLOR_WHITE, COLOR_BLUE); init_pair(THEME_D, COLOR_CYAN, COLOR_BLACK); init_pair(THEME_E, COLOR_BLUE, COLOR_BLACK); } 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(THEME_E) | A_BOLD); } else if(idx == now_week_day) { attron(COLOR_PAIR(THEME_D) | 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; // TODO This is some sort of pagination to allow scrolling through the tasks. // TODO How does this behaves when no task is selected? Well! y = 0; size_t idx_start = (db->selected_task / layout_tasks_rows) * layout_tasks_rows; 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(THEME_C) | A_BOLD); } else if (task == selected_task) { attron(COLOR_PAIR(THEME_B)); } else if(task == active_task) { attron(COLOR_PAIR(THEME_A) | A_BOLD); } // Task title. x++; column_width = layout->columns[L_TITLE_IDX].width; snprintf(string_buffer, string_buffer_size, "%*s", column_width, ""); mvaddnstr(y, x, string_buffer, column_width); mvaddnstr(y, x, task->name, column_width); 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); sprint_time5_utf8(string_buffer, task_stime, column_width); mvaddstr(y, x, string_buffer); x += column_width; } // Task total. x++; sprint_time5_utf8(string_buffer, total_time, layout->columns[L_TOTAL_IDX].width); mvaddstr(y, x, string_buffer); // Reset theme. attrset(A_NORMAL); } /////////////////////////////////////////////////////////////////////////// // Draw selected/total tasks. snprintf(string_buffer, string_buffer_size, " %td/%zd ", db->selected_task + 1, db->count); if (strlen(string_buffer) > layout->columns[L_TITLE_IDX].width) { snprintf(string_buffer, string_buffer_size, "%td", db->selected_task + 1); } mvaddstr(size_y - 1, 1, string_buffer); /////////////////////////////////////////////////////////////////////////// // 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++; column_width = layout->columns[L_DAYS_IDX + idx].width; total_time = add_int64(total_time, daily_total); sprint_time5_utf8(string_buffer, daily_total, column_width); // Apply theme. if (idx == now_week_day && active_task != NULL) { attron(COLOR_PAIR(THEME_E) | A_BOLD); } else if(idx == now_week_day) { attron(COLOR_PAIR(THEME_D) | A_BOLD); } mvaddstr(y, x, string_buffer); x += column_width; // Reset theme. attrset(A_NORMAL); } x++; sprint_time5_utf8(string_buffer, total_time, layout->columns[L_TOTAL_IDX].width); mvaddstr(y, x, string_buffer); } void *mem_alloc(size_t mem_size, const char *error_tag) { void *mem_pointer = malloc(mem_size); if(mem_pointer == NULL && mem_size > 0) { fprintf(stderr, "Failed to allocate memory (%s): %s.\n", (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) { fprintf(stderr, "Failed to create app folder '%s': %s.\n", 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 length, int space) { assert(length < string_buffer_size); assert(space < string_buffer_size); snprintf(string_buffer, string_buffer_size, "%*s", space, ""); attron(A_UNDERLINE); mvaddstr(row, column, string_buffer); echo(); curs_set(1); mvgetnstr(row, column, string_buffer, length); truncate_string_utf8(string_buffer, length); noecho(); curs_set(0); attroff(A_UNDERLINE); } void read_input_to_string_buffer(int row, int column, int length) { read_input_to_string_buffer_with_space(row, column, length, length); } int main(int argc, char *argv[]) { if (initialize_app_folder() == false) { return EXIT_FAILURE; } db = &database; reset_database(&database); reset_database(&archive); if (is_file_accessible(db_file_path) == false) { store_database(&database, db_file_path); // TODO Check for error. } if (is_file_accessible(ar_file_path) == false) { export_to_csv(&archive, ar_file_path); // TODO Check for error. } if (argc > 1) { bool is_exit_requested = false; for (unsigned idx = 1; idx < argc; idx++) { if (is_equal_to_any(argv[idx], "--help", "-h")) { // TODO Maybe rearrange the order of the command. 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" "Commands\n" " a, A Archive selected task (except if active).\n" " u, U Unarchive selected task.\n" " c, C Select currently active task.\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" " #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" " During intensive tasks such as, saving to file or recalculating totals times,\n" " a diamond simbol is shown on the top left corner. This should only be visible\n" " on databases with more than 1000000 tasks.\n" ); 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) { fprintf(stdout, "Missing CSV file path to import.\n"); free_memory(); return EXIT_FAILURE; } // TODO No error checking... oh noes! load_database(&database, db_file_path); import_from_csv(&database, argv[idx]); store_database(&database, db_file_path); reset_database(&database); is_exit_requested = true; continue; } if (is_equal_to_any(argv[idx], "--export-csv", "-e")) { idx++; if (idx >= argc) { fprintf(stdout, "Missing CSV file path to export.\n"); free_memory(); return EXIT_FAILURE; } // TODO No error checking... oh noes! load_database(&database, db_file_path); export_to_csv(&database, argv[idx]); reset_database(&database); is_exit_requested = true; continue; } if (is_equal_to_any(argv[idx], "--no-autosave", "-n")) { is_autosave_enabled = false; continue; } fprintf(stdout, "Unkown option '%s'.\nUse '%s --help' for list of options.\n", argv[idx], argv[0]); return EXIT_FAILURE; } if (is_exit_requested) { return EXIT_SUCCESS; } } initialize_tui(); signal(SIGTERM, exit_gracefully); signal(SIGINT, exit_gracefully); signal(SIGQUIT, exit_gracefully); signal(SIGHUP, exit_gracefully); load_database(&database, db_file_path); 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 selected_task_row = is_terminal_too_small ? 0 : (db->selected_task % layout_tasks_rows) + NUM_HEADER_ROWS; int selected_task_theme = selected_task == active_task ? THEME_E : THEME_D; 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) { fprintf(stderr, "Failed to allocate memory for string buffer: %s.\n", strerror(errno)); flushinp(); ungetch('q'); break; } } update_layout(); layout = &layouts[size_x > 100 ? L_NORMAL : L_COMPACT]; break; } case 'n': case 'N':{ // Create new task. task_st *new_task; if (create_task(db, &new_task) == false) { // TODO ERROR 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. TODO Maybe do this on the database? 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; } attron(COLOR_PAIR(selected_task_theme) | A_BOLD); read_input_to_string_buffer_with_space(selected_task_row, 1, TASK_NAME_LENGTH, size_x - 2); attrset(A_NORMAL); 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; } attron(COLOR_PAIR(selected_task_theme) | A_BOLD); move(selected_task_row, 1); for (int idx = 0; idx < size_x - 2; idx++) { addch(ACS_CKBOARD); } mvaddstr(selected_task_row, 2, " Press enter to reset task. "); attrset(A_NORMAL); if (getch() == '\n') { reset_task_times(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 row to input new task name. 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 time delta. attron(COLOR_PAIR(selected_task_theme) | A_BOLD); read_input_to_string_buffer(selected_task_row, input_pos_x, input_width); attrset(A_NORMAL); // TODO Check if parsed OK. For that, I need to read the manual to know what strtoX returns. // TODO It seems that the float parsing may return INF or NAN. Take special care with those. // TODO Once I know the parse was OK, I'll check the remaining of the string for multiplies: // s/S - second (default if none is found) // m/M - minute // h/H - hour // d/D - day // y/Y - year // TODO Review code char *input = string_buffer; if (is_empty_string(input) == true) { break; } char *assign_str = strchr(input, '='); bool is_assign = assign_str != NULL; if (is_assign == true) { input = assign_str + 1; } char *parser; long double input_float = strtold(input, &parser); long double multiplier = 1.0; for (int i=0; i < strlen(parser); i++) { char ch = parser[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; } } long double result = input_float * multiplier; int64_t seconds = result; bool is_result_valid = (result >= (long double)INT64_MIN && result <= (long double)INT64_MAX); char action = is_assign ? '=': result >= 0 ? '+' : '-'; // TODO TEST // fprintf(stderr, "%c : %Lf x %Lf = %Lf\n", action, input_float, multiplier, result); // fprintf(stderr, "[%20" PRId64 "\n", INT64_MIN); // fprintf(stderr, " %20.0Lf\n", result); // fprintf(stderr, " %20" PRId64 " is %s\n", seconds, is_result_valid ? "valid" : "INVALID"); // fprintf(stderr, " %+20" PRId64 "]\n", INT64_MAX); if (is_result_valid == false) { break; } // Make sure we sync before applying the changes. update_times(db); int day = (selected_day + FIRST_DAY_OF_WEEK) % NUM_WEEK_DAYS; int64_t time = selected_task->times[day]; time = (action == '=' ? 0 : time) + seconds; // Adust time. set_task_time(db, selected_task, day, time); trigger_autosave(); break; } case KEY_DC: { // Delete if (selected_task == NULL || selected_task == active_task) { break; } attron(COLOR_PAIR(selected_task_theme) | A_BOLD); move(selected_task_row, 1); for (int idx = 0; idx < size_x - 2; idx++) { addch(ACS_CKBOARD); } mvaddstr(selected_task_row, 2, " Press enter to delete task. "); attrset(A_NORMAL); if (getch() == '\n') { delete_task(db, selected_task); trigger_autosave(); } break; } case 'm': case 'M': { if (selected_task == NULL) { break; } attron(COLOR_PAIR(selected_task_theme) | A_BOLD); move(selected_task_row, 1); addch(ACS_CKBOARD); addstr(" Move to: "); // Get line number. int input_pos_x = getcurx(stdscr); int input_width = size_x - input_pos_x - 1; read_input_to_string_buffer(selected_task_row, input_pos_x, input_width); attrset(A_NORMAL); char *parser; // TODO Rename var. intmax_t input = strtoimax(string_buffer, &parser, 10) - 1; // TODO Add comment about this comparison... is checking what? - ALSO ADD THIS BELOW // If endptr is not NULL, strtol() stores the address of the first invalid character in *endptr. If there were no digits at all, strtol() stores the original value of nptr in *endptr (and returns 0). In particular, if *nptr is not '\0' but **endptr is '\0' on return, the entire string is valid. if (parser == string_buffer) { break; } // TODO Implement move-task-to logic. size_t target = input < 0 ? 0 : input >= db->count ? db->count - 1 : input; move_task(db, selected_task, target); trigger_autosave(); break; } case 'g': case 'G': { if (selected_task == NULL) { break; } attron(COLOR_PAIR(selected_task_theme) | A_BOLD); move(selected_task_row, 1); addch(ACS_CKBOARD); addstr(" Go to: "); // Get line number. int input_pos_x = getcurx(stdscr); int input_width = size_x - input_pos_x - 1; read_input_to_string_buffer(selected_task_row, input_pos_x, input_width); attrset(A_NORMAL); char *parser; intmax_t input = strtoimax(string_buffer, &parser, 10) - 1; // TODO Add comment about this comparison... is checking what? - ALSO ADD THIS BELOW // If endptr is not NULL, strtol() stores the address of the first invalid character in *endptr. If there were no digits at all, strtol() stores the original value of nptr in *endptr (and returns 0). In particular, if *nptr is not '\0' but **endptr is '\0' on return, the entire string is valid. if (parser == string_buffer) { break; } select_task_by_index(db, input < 0 ? 0 : input > MAX_DATABASE_TASKS ? MAX_DATABASE_TASKS : input); break; } case 'd': case 'D':{ if (selected_task == NULL) { break; } duplicate_task(db, selected_task); trigger_autosave(); break; } case KEY_F(5): { update_total_times(db); trigger_autosave(); break; } case 'c': case 'C': { 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) { reset_database(&archive); // TODO Not needed because we never leave things hanging. import_from_csv(&archive, ar_file_path); db = &archive; } else { export_to_csv(&archive, ar_file_path); reset_database(&archive); db = &database; } break; } case 'a': case 'A': { if (db != &database || selected_task == NULL || selected_task == active_task) { break; } append_to_csv(selected_task, ar_file_path); delete_task(db, selected_task); trigger_autosave(); break; } case 'u': case 'U': { if (db != &archive || selected_task == NULL) { break; } duplicate_task(&database, selected_task); delete_task(db, 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); } timeout(INPUT_TIMEOUT_MS); } // Save any unsaved changes. show_processing(); if (db == &archive) { export_to_csv(&archive, ar_file_path); } if (countdown_to_autosave > 0 || is_autosave_enabled == false) { store_database(&database, db_file_path); } free_memory(); endwin(); return EXIT_SUCCESS; }