diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-11 19:21:34 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-11 19:21:34 -0500 |
| commit | f1ae8c36c94a26e8aff2339e241542f430c96913 (patch) | |
| tree | 998bc5dceffa7ddd691953717e966502dc4ff622 /src/kd_deck.c | |
Diffstat (limited to 'src/kd_deck.c')
| -rw-r--r-- | src/kd_deck.c | 1228 |
1 files changed, 1228 insertions, 0 deletions
diff --git a/src/kd_deck.c b/src/kd_deck.c new file mode 100644 index 0000000..f638aa0 --- /dev/null +++ b/src/kd_deck.c @@ -0,0 +1,1228 @@ +#define _POSIX_C_SOURCE 200809L + +#include "kd_deck.h" + +#include <ctype.h> +#include <dirent.h> +#include <errno.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <unistd.h> + +#define KD_LINE_MAX 8192 +#define KD_STATE_TMP_SUFFIX ".tmp" + +typedef struct KDNameList { + char **names; + size_t count; + size_t capacity; +} KDNameList; + +typedef struct KDJukugoName { + char id[64]; + int has_front; + int has_answer; +} KDJukugoName; + +typedef struct KDJukugoNameList { + KDJukugoName *names; + size_t count; + size_t capacity; +} KDJukugoNameList; + +static char *kd_strdup(const char *source) +{ + size_t length; + char *copy; + + if (source == NULL) { + return NULL; + } + length = strlen(source); + copy = (char *)malloc(length + 1U); + if (copy != NULL) { + memcpy(copy, source, length + 1U); + } + return copy; +} + +static int kd_path(char *target, size_t target_size, + const char *left, const char *right) +{ + int written; + + written = snprintf(target, target_size, "%s/%s", left, right); + if (written < 0 || (size_t)written >= target_size) { + return 0; + } + return 1; +} + +void kd_add_message(KDDeck *deck, int is_warning, const char *format, ...) +{ + char buffer[2048]; + va_list args; + KDMessage *replacement; + size_t capacity; + char *copy; + + if (deck == NULL || format == NULL) { + return; + } + va_start(args, format); + (void)vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + if (deck->message_count == deck->message_capacity) { + capacity = deck->message_capacity == 0U ? 16U : + deck->message_capacity * 2U; + replacement = (KDMessage *)realloc(deck->messages, + capacity * sizeof(KDMessage)); + if (replacement == NULL) { + (void)fprintf(stderr, "kanji-deck: out of memory adding message\n"); + return; + } + deck->messages = replacement; + deck->message_capacity = capacity; + } + copy = kd_strdup(buffer); + if (copy == NULL) { + return; + } + deck->messages[deck->message_count].text = copy; + deck->messages[deck->message_count].is_warning = is_warning; + deck->message_count++; + + (void)fprintf(stderr, "%s: %s\n", is_warning ? "WARNING" : "INFO", buffer); +} + +void kd_deck_init(KDDeck *deck) +{ + if (deck != NULL) { + memset(deck, 0, sizeof(*deck)); + } +} + +static void kd_card_free(KDCard *card) +{ + free(card->jukugo); + memset(card, 0, sizeof(*card)); +} + +void kd_deck_free(KDDeck *deck) +{ + size_t i; + + if (deck == NULL) { + return; + } + for (i = 0U; i < deck->card_count; i++) { + kd_card_free(&deck->cards[i]); + } + for (i = 0U; i < deck->message_count; i++) { + free(deck->messages[i].text); + } + free(deck->cards); + free(deck->items); + free(deck->reviews); + free(deck->messages); + memset(deck, 0, sizeof(*deck)); +} + +static int kd_reserve(void **buffer, size_t *capacity, size_t needed, + size_t element_size) +{ + void *replacement; + size_t next_capacity; + + if (needed <= *capacity) { + return 1; + } + next_capacity = *capacity == 0U ? 8U : *capacity; + while (next_capacity < needed) { + if (next_capacity > ((size_t)-1) / 2U) { + return 0; + } + next_capacity *= 2U; + } + if (element_size != 0U && next_capacity > ((size_t)-1) / element_size) { + return 0; + } + replacement = realloc(*buffer, next_capacity * element_size); + if (replacement == NULL) { + return 0; + } + *buffer = replacement; + *capacity = next_capacity; + return 1; +} + +static int kd_is_directory(const char *path) +{ + struct stat info; + + if (stat(path, &info) != 0) { + return 0; + } + return S_ISDIR(info.st_mode) != 0; +} + +static int kd_is_regular_file(const char *path) +{ + struct stat info; + + if (stat(path, &info) != 0) { + return 0; + } + return S_ISREG(info.st_mode) != 0; +} + +static int kd_numeric_id(const char *name) +{ + const unsigned char *cursor; + + if (name == NULL || name[0] == '\0' || strlen(name) >= 64U) { + return 0; + } + for (cursor = (const unsigned char *)name; *cursor != '\0'; cursor++) { + if (!isdigit(*cursor)) { + return 0; + } + } + return 1; +} + +static int kd_name_compare(const void *left, const void *right) +{ + const char *const *a; + const char *const *b; + + a = (const char *const *)left; + b = (const char *const *)right; + return strcmp(*a, *b); +} + +static void kd_name_list_free(KDNameList *list) +{ + size_t i; + + for (i = 0U; i < list->count; i++) { + free(list->names[i]); + } + free(list->names); + memset(list, 0, sizeof(*list)); +} + +static int kd_name_list_add(KDNameList *list, const char *name) +{ + char **replacement; + char *copy; + size_t next_capacity; + + if (list->count == list->capacity) { + next_capacity = list->capacity == 0U ? 8U : list->capacity * 2U; + replacement = (char **)realloc(list->names, + next_capacity * sizeof(char *)); + if (replacement == NULL) { + return 0; + } + list->names = replacement; + list->capacity = next_capacity; + } + copy = kd_strdup(name); + if (copy == NULL) { + return 0; + } + list->names[list->count++] = copy; + return 1; +} + +static int kd_collect_ids(KDDeck *deck, const char *directory, + KDNameList *ids) +{ + DIR *dir; + struct dirent *entry; + char path[KD_PATH_MAX]; + + dir = opendir(directory); + if (dir == NULL) { + kd_add_message(deck, 1, "Cannot read directory '%s': %s", + directory, strerror(errno)); + return 0; + } + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) { + continue; + } + if (!kd_path(path, sizeof(path), directory, entry->d_name)) { + kd_add_message(deck, 1, "Path is too long under '%s'", directory); + continue; + } + if (!kd_numeric_id(entry->d_name) || !kd_is_directory(path)) { + kd_add_message(deck, 1, + "Wrong entry in '%s': '%s' (kanji directories must be numeric IDs)", + directory, entry->d_name); + continue; + } + if (!kd_name_list_add(ids, entry->d_name)) { + (void)closedir(dir); + return 0; + } + } + (void)closedir(dir); + qsort(ids->names, ids->count, sizeof(char *), kd_name_compare); + return 1; +} + +static int kd_append_card(KDDeck *deck, const char *id, const char *path, + size_t *index_out) +{ + KDCard *card; + + if (!kd_reserve((void **)&deck->cards, &deck->card_capacity, + deck->card_count + 1U, sizeof(KDCard))) { + return 0; + } + card = &deck->cards[deck->card_count]; + memset(card, 0, sizeof(*card)); + (void)snprintf(card->id, sizeof(card->id), "%s", id); + (void)snprintf(card->dir_path, sizeof(card->dir_path), "%s", path); + if (!kd_path(card->image_path, sizeof(card->image_path), path, "kanji.ppm") || + !kd_path(card->answer_image_path, sizeof(card->answer_image_path), path, "kanji_ans.ppm") || + !kd_path(card->url_path, sizeof(card->url_path), path, "URL.txt")) { + kd_add_message(deck, 1, "Paths are too long for card '%s'", id); + return 0; + } + card->has_image = kd_is_regular_file(card->image_path); + card->has_answer_image = kd_is_regular_file(card->answer_image_path); + card->has_url = kd_is_regular_file(card->url_path); + card->url[0] = '\0'; + if (card->has_url) { + FILE *url_file; + url_file = fopen(card->url_path, "r"); + if (url_file != NULL) { + if (fgets(card->url, sizeof(card->url), url_file) != NULL) { + { + size_t url_length; + while (card->url[0] != '\0' && isspace((unsigned char)card->url[0])) { + (void)memmove(card->url, card->url + 1, strlen(card->url)); + } + url_length = strlen(card->url); + while (url_length > 0U && isspace((unsigned char)card->url[url_length - 1U])) { + card->url[--url_length] = '\0'; + } + } + } + if (ferror(url_file)) { + kd_add_message(deck, 1, "Card %s: error reading URL.txt", id); + card->url[0] = '\0'; + } + (void)fclose(url_file); + } else { + kd_add_message(deck, 1, "Card %s: cannot read URL.txt: %s", id, strerror(errno)); + card->url[0] = '\0'; + } + } + *index_out = deck->card_count; + deck->card_count++; + return 1; +} + +const char *kd_card_url(const KDCard *card) +{ + if (card == NULL || !card->has_url || card->url[0] == '\0') { + return NULL; + } + return card->url; +} + +static int kd_jukugo_compare(const void *left, const void *right) +{ + const KDJukugoName *a; + const KDJukugoName *b; + + a = (const KDJukugoName *)left; + b = (const KDJukugoName *)right; + return strcmp(a->id, b->id); +} + +static KDJukugoName *kd_jukugo_name_get(KDJukugoNameList *list, + const char *id) +{ + size_t i; + KDJukugoName *replacement; + size_t next_capacity; + + for (i = 0U; i < list->count; i++) { + if (strcmp(list->names[i].id, id) == 0) { + return &list->names[i]; + } + } + if (list->count == list->capacity) { + next_capacity = list->capacity == 0U ? 8U : list->capacity * 2U; + replacement = (KDJukugoName *)realloc(list->names, + next_capacity * sizeof(KDJukugoName)); + if (replacement == NULL) { + return NULL; + } + list->names = replacement; + list->capacity = next_capacity; + } + memset(&list->names[list->count], 0, sizeof(KDJukugoName)); + (void)snprintf(list->names[list->count].id, + sizeof(list->names[list->count].id), "%s", id); + list->count++; + return &list->names[list->count - 1U]; +} + +static int kd_jukugo_filename(const char *name, char *id, size_t id_size, + int *is_answer) +{ + size_t length; + size_t prefix_length; + size_t i; + const char *suffix; + + length = strlen(name); + if (length > 8U && strcmp(name + length - 8U, "_ans.ppm") == 0) { + suffix = "_ans.ppm"; + *is_answer = 1; + } else if (length > 4U && strcmp(name + length - 4U, ".ppm") == 0) { + suffix = ".ppm"; + *is_answer = 0; + } else { + return 0; + } + prefix_length = length - strlen(suffix); + if (prefix_length == 0U || prefix_length >= id_size) { + return 0; + } + for (i = 0U; i < prefix_length; i++) { + if (!isdigit((unsigned char)name[i])) { + return 0; + } + } + memcpy(id, name, prefix_length); + id[prefix_length] = '\0'; + return 1; +} + +static int kd_append_jukugo(KDCard *card, const char *id, + const char *directory) +{ + KDJukugo *jukugo; + char filename[96]; + int written; + + if (!kd_reserve((void **)&card->jukugo, &card->jukugo_capacity, + card->jukugo_count + 1U, sizeof(KDJukugo))) { + return 0; + } + jukugo = &card->jukugo[card->jukugo_count]; + memset(jukugo, 0, sizeof(*jukugo)); + (void)snprintf(jukugo->id, sizeof(jukugo->id), "%s", id); + written = snprintf(filename, sizeof(filename), "%s.ppm", id); + if (written < 0 || (size_t)written >= sizeof(filename) || + !kd_path(jukugo->front_path, sizeof(jukugo->front_path), directory, filename)) { + return 0; + } + written = snprintf(filename, sizeof(filename), "%s_ans.ppm", id); + if (written < 0 || (size_t)written >= sizeof(filename) || + !kd_path(jukugo->answer_path, sizeof(jukugo->answer_path), directory, filename)) { + return 0; + } + card->jukugo_count++; + return 1; +} + +static int kd_scan_jukugo(KDDeck *deck, KDCard *card, size_t card_index, + const char *directory) +{ + DIR *dir; + struct dirent *entry; + KDJukugoNameList found; + KDJukugoName *item; + char path[KD_PATH_MAX]; + char id[64]; + int is_answer; + size_t i; + + memset(&found, 0, sizeof(found)); + dir = opendir(directory); + if (dir == NULL) { + kd_add_message(deck, 1, "Card %s: cannot read jukugo directory '%s': %s", + card->id, directory, strerror(errno)); + return 0; + } + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) { + continue; + } + if (!kd_path(path, sizeof(path), directory, entry->d_name)) { + kd_add_message(deck, 1, "Card %s: jukugo path is too long", card->id); + continue; + } + if (!kd_jukugo_filename(entry->d_name, id, sizeof(id), &is_answer) || + !kd_is_regular_file(path)) { + kd_add_message(deck, 1, + "Card %s: wrong jukugo entry '%s'; expected N.ppm and N_ans.ppm", + card->id, entry->d_name); + continue; + } + item = kd_jukugo_name_get(&found, id); + if (item == NULL) { + (void)closedir(dir); + free(found.names); + return 0; + } + if (is_answer) { + item->has_answer = 1; + } else { + item->has_front = 1; + } + } + (void)closedir(dir); + qsort(found.names, found.count, sizeof(KDJukugoName), kd_jukugo_compare); + + for (i = 0U; i < found.count; i++) { + item = &found.names[i]; + if (item->has_front && !item->has_answer) { + kd_add_message(deck, 1, + "Card %s jukugo %s: missing '%s_ans.ppm' answer image", + card->id, item->id, item->id); + } else if (!item->has_front && item->has_answer) { + kd_add_message(deck, 1, + "Card %s jukugo %s: answer image has no '%s.ppm' front image", + card->id, item->id, item->id); + } else if (item->has_front && item->has_answer) { + if (!kd_append_jukugo(card, item->id, directory)) { + free(found.names); + return 0; + } + if (!kd_reserve((void **)&deck->items, &deck->item_capacity, + deck->item_count + 1U, sizeof(KDStudyItem))) { + free(found.names); + return 0; + } + memset(&deck->items[deck->item_count], 0, sizeof(KDStudyItem)); + (void)snprintf(deck->items[deck->item_count].key, + sizeof(deck->items[deck->item_count].key), "J:%s:%s", card->id, item->id); + deck->items[deck->item_count].card_index = card_index; + deck->items[deck->item_count].jukugo_index = (int)(card->jukugo_count - 1U); + deck->item_count++; + } + } + free(found.names); + return 1; +} + +static int kd_scan_card_children(KDDeck *deck, KDCard *card) +{ + DIR *dir; + struct dirent *entry; + char path[KD_PATH_MAX]; + int saw_jukugo; + + saw_jukugo = 0; + dir = opendir(card->dir_path); + if (dir == NULL) { + kd_add_message(deck, 1, "Cannot read card directory '%s': %s", + card->dir_path, strerror(errno)); + return 0; + } + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) { + continue; + } + if (strcmp(entry->d_name, "kanji.ppm") == 0 || + strcmp(entry->d_name, "kanji_ans.ppm") == 0 || + strcmp(entry->d_name, "URL.txt") == 0) { + continue; + } + if (strcmp(entry->d_name, "jukugo") == 0) { + saw_jukugo = 1; + if (!kd_path(path, sizeof(path), card->dir_path, "jukugo")) { + kd_add_message(deck, 1, "Card %s: jukugo path is too long", card->id); + } else if (!kd_is_directory(path)) { + kd_add_message(deck, 1, + "Card %s: 'jukugo' exists but is not a directory", card->id); + } + continue; + } + kd_add_message(deck, 1, + "Card %s: unknown entry '%s' (expected kanji.ppm, kanji_ans.ppm, URL.txt, jukugo/)", + card->id, entry->d_name); + } + (void)closedir(dir); + + if (!card->has_image) { + kd_add_message(deck, 1, "Card %s: missing required kanji.ppm image", card->id); + } + if (!card->has_answer_image) { + kd_add_message(deck, 1, "Card %s: missing required kanji_ans.ppm answer image", card->id); + } + if (!card->has_url) { + kd_add_message(deck, 1, "Card %s: missing URL.txt", card->id); + } else if (card->url[0] == '\0') { + kd_add_message(deck, 1, "Card %s: URL.txt is empty", card->id); + } + if (saw_jukugo) { + if (kd_path(path, sizeof(path), card->dir_path, "jukugo") && + kd_is_directory(path)) { + (void)kd_scan_jukugo(deck, card, (size_t)(card - deck->cards), path); + } + } + return 1; +} + +static int kd_append_main_item(KDDeck *deck, size_t card_index) +{ + KDStudyItem *item; + KDCard *card; + + if (!kd_reserve((void **)&deck->items, &deck->item_capacity, + deck->item_count + 1U, sizeof(KDStudyItem))) { + return 0; + } + card = &deck->cards[card_index]; + item = &deck->items[deck->item_count]; + memset(item, 0, sizeof(*item)); + (void)snprintf(item->key, sizeof(item->key), "K:%s", card->id); + item->card_index = card_index; + item->jukugo_index = KD_NO_JUKUGO; + deck->item_count++; + return 1; +} + +static int kd_review_index(const KDDeck *deck, const char *key) +{ + size_t i; + + for (i = 0U; i < deck->review_count; i++) { + if (strcmp(deck->reviews[i].key, key) == 0) { + return (int)i; + } + } + return -1; +} + +static int kd_review_key_valid(const char *key) +{ + const unsigned char *cursor; + + if (key[0] == 'K' && key[1] == ':') { + cursor = (const unsigned char *)key + 2; + } else if (key[0] == 'J' && key[1] == ':') { + cursor = (const unsigned char *)key + 2; + } else { + return 0; + } + if (*cursor == '\0') { + return 0; + } + for (; *cursor != '\0'; cursor++) { + if (!isdigit(*cursor) && *cursor != ':') { + return 0; + } + } + return 1; +} + +static int kd_append_review(KDDeck *deck, const KDReview *review) +{ + if (!kd_reserve((void **)&deck->reviews, &deck->review_capacity, + deck->review_count + 1U, sizeof(KDReview))) { + return 0; + } + deck->reviews[deck->review_count++] = *review; + return 1; +} + +static int kd_load_state(KDDeck *deck) +{ + FILE *file; + char line[KD_LINE_MAX]; + int line_number; + KDReview review; + char key[KD_KEY_MAX]; + int converted; + int existing; + + file = fopen(deck->state_path, "r"); + if (file == NULL) { + if (errno != ENOENT) { + kd_add_message(deck, 1, "Cannot read progress file '%s': %s", + deck->state_path, strerror(errno)); + } + return 1; + } + line_number = 0; + while (fgets(line, sizeof(line), file) != NULL) { + line_number++; + if (line[0] == '\n' || line[0] == '\0' || line[0] == '#') { + continue; + } + review.relearning = 0; + converted = sscanf(line, "%159s %ld %ld %ld %ld %ld %ld %ld %d", + key, &review.interval_days, &review.ease_milli, + &review.repetitions, &review.lapses, &review.due_epoch, + &review.last_epoch, &review.introduced_day, &review.relearning); + if ((converted != 8 && converted != 9) || !kd_review_key_valid(key) || + review.interval_days < 0L || review.ease_milli < 1000L || + review.ease_milli > 5000L || review.repetitions < 0L || + review.lapses < 0L || review.relearning < 0 || + review.relearning > 1) { + kd_add_message(deck, 1, + "Progress file '%s': malformed line %d; ignored", + deck->state_path, line_number); + continue; + } + (void)snprintf(review.key, sizeof(review.key), "%s", key); + existing = kd_review_index(deck, review.key); + if (existing >= 0) { + deck->reviews[existing] = review; + kd_add_message(deck, 1, + "Progress file '%s': duplicate key '%s'; later row used", + deck->state_path, key); + } else if (!kd_append_review(deck, &review)) { + (void)fclose(file); + return 0; + } + } + if (ferror(file)) { + kd_add_message(deck, 1, "Error reading progress file '%s'", deck->state_path); + } + (void)fclose(file); + return 1; +} + +static int kd_reconcile_reviews(KDDeck *deck) +{ + size_t i; + int index; + KDReview review; + KDStudyItem *item; + KDCard *card; + const char *description; + + for (i = 0U; i < deck->item_count; i++) { + item = &deck->items[i]; + index = kd_review_index(deck, item->key); + if (index < 0) { + memset(&review, 0, sizeof(review)); + (void)snprintf(review.key, sizeof(review.key), "%s", item->key); + review.interval_days = 0L; + review.ease_milli = 2500L; + review.due_epoch = (long)time(NULL); + review.last_epoch = 0L; + review.introduced_day = -1L; + if (!kd_append_review(deck, &review)) { + return 0; + } + index = (int)(deck->review_count - 1U); + card = &deck->cards[item->card_index]; + if (item->jukugo_index == KD_NO_JUKUGO) { + description = card->image_path; + kd_add_message(deck, 0, "New kanji prompt detected: %s (%s)", + item->key, description); + } else { + description = card->jukugo[item->jukugo_index].front_path; + kd_add_message(deck, 0, "New jukugo prompt detected: %s (%s)", + item->key, description); + } + } + item->review_index = (size_t)index; + } + return 1; +} + +int kd_deck_save(KDDeck *deck) +{ + FILE *file; + char temporary[KD_PATH_MAX]; + size_t i; + int written; + int failed; + + written = snprintf(temporary, sizeof(temporary), "%s%s", + deck->state_path, KD_STATE_TMP_SUFFIX); + if (written < 0 || (size_t)written >= sizeof(temporary)) { + kd_add_message(deck, 1, "Progress path is too long; cannot save"); + return 0; + } + file = fopen(temporary, "w"); + if (file == NULL) { + kd_add_message(deck, 1, "Cannot write '%s': %s", temporary, strerror(errno)); + return 0; + } + failed = 0; + if (fprintf(file, "# key interval_days ease_milli reps lapses due_epoch last_epoch introduced_day relearning\n") < 0) { + failed = 1; + } + for (i = 0U; i < deck->review_count && !failed; i++) { + if (fprintf(file, "%s %ld %ld %ld %ld %ld %ld %ld %d\n", + deck->reviews[i].key, + deck->reviews[i].interval_days, + deck->reviews[i].ease_milli, + deck->reviews[i].repetitions, + deck->reviews[i].lapses, + deck->reviews[i].due_epoch, + deck->reviews[i].last_epoch, + deck->reviews[i].introduced_day, + deck->reviews[i].relearning) < 0) { + failed = 1; + } + } + if (fflush(file) != 0) { + failed = 1; + } + if (fclose(file) != 0) { + failed = 1; + } + if (failed) { + (void)remove(temporary); + kd_add_message(deck, 1, "Failed writing progress file '%s'", temporary); + return 0; + } + if (rename(temporary, deck->state_path) != 0) { + kd_add_message(deck, 1, "Cannot replace '%s': %s", + deck->state_path, strerror(errno)); + (void)remove(temporary); + return 0; + } + return 1; +} + +int kd_deck_scan(KDDeck *deck, const char *root_path, const char *state_path) +{ + KDNameList ids; + size_t i; + size_t card_index; + char path[KD_PATH_MAX]; + struct stat info; + + if (deck == NULL || root_path == NULL || state_path == NULL) { + return 0; + } + kd_deck_free(deck); + kd_deck_init(deck); + if (snprintf(deck->root_path, sizeof(deck->root_path), "%s", root_path) < 0 || + strlen(root_path) >= sizeof(deck->root_path) || + snprintf(deck->state_path, sizeof(deck->state_path), "%s", state_path) < 0 || + strlen(state_path) >= sizeof(deck->state_path)) { + kd_add_message(deck, 1, "Cards/progress path is too long"); + return 0; + } + + if (stat(deck->root_path, &info) != 0 && errno == ENOENT) { + if (mkdir(deck->root_path, 0755) == 0) { + kd_add_message(deck, 0, "Created missing cards directory '%s'", + deck->root_path); + } else { + kd_add_message(deck, 1, "Cannot create cards directory '%s': %s", + deck->root_path, strerror(errno)); + return 0; + } + } + if (!kd_is_directory(deck->root_path)) { + kd_add_message(deck, 1, "Cards path '%s' is not a directory", deck->root_path); + return 0; + } + + memset(&ids, 0, sizeof(ids)); + if (!kd_collect_ids(deck, deck->root_path, &ids)) { + kd_name_list_free(&ids); + return 0; + } + for (i = 0U; i < ids.count; i++) { + if (!kd_path(path, sizeof(path), deck->root_path, ids.names[i])) { + kd_add_message(deck, 1, "Card path is too long for '%s'", ids.names[i]); + continue; + } + if (!kd_append_card(deck, ids.names[i], path, &card_index)) { + kd_name_list_free(&ids); + kd_add_message(deck, 1, "Out of memory while scanning cards"); + return 0; + } + if (deck->cards[card_index].has_image) { + if (!kd_append_main_item(deck, card_index)) { + kd_name_list_free(&ids); + return 0; + } + } + (void)kd_scan_card_children(deck, &deck->cards[card_index]); + } + kd_name_list_free(&ids); + + if (!kd_load_state(deck) || !kd_reconcile_reviews(deck)) { + kd_add_message(deck, 1, "Unable to initialize review state (out of memory)"); + return 0; + } + (void)kd_deck_save(deck); + return 1; +} + +long kd_today_key(time_t now) +{ + struct tm *local; + + local = localtime(&now); + if (local == NULL) { + return (long)(now / (time_t)86400); + } + return (long)(local->tm_year + 1900) * 1000L + (long)local->tm_yday; +} + +size_t kd_new_today(const KDDeck *deck, time_t now) +{ + size_t i; + size_t count; + long today; + const KDReview *review; + + today = kd_today_key(now); + count = 0U; + for (i = 0U; i < deck->item_count; i++) { + review = &deck->reviews[deck->items[i].review_index]; + if (deck->items[i].jukugo_index == KD_NO_JUKUGO && + review->introduced_day == today) { + count++; + } + } + return count; +} + +size_t kd_count_due(const KDDeck *deck, time_t now) +{ + size_t i; + size_t count; + const KDReview *review; + + count = 0U; + for (i = 0U; i < deck->item_count; i++) { + review = &deck->reviews[deck->items[i].review_index]; + if (review->introduced_day >= 0L && + review->due_epoch <= (long)now) { + count++; + } + } + return count; +} + +size_t kd_count_unintroduced(const KDDeck *deck) +{ + size_t i; + size_t count; + const KDReview *review; + + count = 0U; + for (i = 0U; i < deck->item_count; i++) { + review = &deck->reviews[deck->items[i].review_index]; + if (deck->items[i].jukugo_index == KD_NO_JUKUGO && + review->introduced_day < 0L) { + count++; + } + } + return count; +} + +int +kd_find_kanji_item_by_number(const KDDeck *deck, const char *number, + size_t *item_index) +{ + const KDStudyItem *item; + const KDCard *card; + char *endptr; + unsigned long wanted; + unsigned long card_number; + size_t i; + const char *digit; + + if (deck == NULL || number == NULL || item_index == NULL || + number[0] == '\0') { + return 0; + } + for (digit = number; *digit != '\0'; digit++) { + if (!isdigit((unsigned char)*digit)) { + return 0; + } + } + errno = 0; + wanted = strtoul(number, &endptr, 10); + if (errno != 0 || endptr == number || *endptr != '\0' || wanted == 0UL) { + return 0; + } + for (i = 0U; i < deck->item_count; i++) { + item = &deck->items[i]; + if (item->jukugo_index != KD_NO_JUKUGO || + item->card_index >= deck->card_count) { + continue; + } + card = &deck->cards[item->card_index]; + errno = 0; + card_number = strtoul(card->id, &endptr, 10); + if (errno == 0 && endptr != card->id && *endptr == '\0' && + card_number == wanted) { + *item_index = i; + return 1; + } + } + return 0; +} + +static unsigned long kd_parse_numeric_id(const char *text, unsigned long fallback) +{ + char *endptr; + unsigned long value; + + errno = 0; + value = strtoul(text, &endptr, 10); + if (errno != 0 || endptr == text || *endptr != '\0') { + return fallback; + } + return value; +} + +int kd_select_next(KDDeck *deck, time_t now, size_t *item_index) +{ + size_t i; + size_t chosen; + unsigned long chosen_card; + unsigned long candidate_card; + unsigned long chosen_jukugo; + unsigned long candidate_jukugo; + long earliest; + long today; + int chosen_kind; + int candidate_kind; + KDReview *review; + const KDStudyItem *item; + const KDCard *card; + const char *jukugo_id; + + if (deck == NULL || item_index == NULL) { + return 0; + } + today = kd_today_key(now); + + chosen = (size_t)-1; + earliest = 0L; + chosen_card = 0UL; + for (i = 0U; i < deck->item_count; i++) { + item = &deck->items[i]; + if (item->jukugo_index != KD_NO_JUKUGO) { + continue; + } + review = &deck->reviews[item->review_index]; + if (review->introduced_day < 0L || review->due_epoch > (long)now) { + continue; + } + card = &deck->cards[item->card_index]; + candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index); + if (chosen == (size_t)-1 || review->due_epoch < earliest || + (review->due_epoch == earliest && candidate_card < chosen_card)) { + chosen = i; + earliest = review->due_epoch; + chosen_card = candidate_card; + } + } + if (chosen != (size_t)-1) { + *item_index = chosen; + return 1; + } + + chosen = (size_t)-1; + earliest = 0L; + chosen_card = 0UL; + chosen_jukugo = 0UL; + for (i = 0U; i < deck->item_count; i++) { + item = &deck->items[i]; + if (item->jukugo_index == KD_NO_JUKUGO) { + continue; + } + review = &deck->reviews[item->review_index]; + if (review->introduced_day < 0L || review->due_epoch > (long)now) { + continue; + } + card = &deck->cards[item->card_index]; + candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index); + jukugo_id = card->jukugo[(size_t)item->jukugo_index].id; + candidate_jukugo = kd_parse_numeric_id(jukugo_id, + (unsigned long)item->jukugo_index); + if (chosen == (size_t)-1 || review->due_epoch < earliest || + (review->due_epoch == earliest && + (candidate_card < chosen_card || + (candidate_card == chosen_card && candidate_jukugo < chosen_jukugo)))) { + chosen = i; + earliest = review->due_epoch; + chosen_card = candidate_card; + chosen_jukugo = candidate_jukugo; + } + } + if (chosen != (size_t)-1) { + *item_index = chosen; + return 1; + } + + chosen = (size_t)-1; + chosen_card = 0UL; + chosen_jukugo = 0UL; + chosen_kind = 0; + for (i = 0U; i < deck->item_count; i++) { + item = &deck->items[i]; + review = &deck->reviews[item->review_index]; + if (review->introduced_day >= 0L) { + continue; + } + card = &deck->cards[item->card_index]; + candidate_card = kd_parse_numeric_id(card->id, (unsigned long)item->card_index); + candidate_kind = item->jukugo_index == KD_NO_JUKUGO ? 0 : 1; + candidate_jukugo = 0UL; + if (candidate_kind != 0) { + jukugo_id = card->jukugo[(size_t)item->jukugo_index].id; + candidate_jukugo = kd_parse_numeric_id(jukugo_id, + (unsigned long)item->jukugo_index); + } + if (chosen == (size_t)-1 || candidate_card < chosen_card || + (candidate_card == chosen_card && candidate_kind < chosen_kind) || + (candidate_card == chosen_card && candidate_kind == chosen_kind && + candidate_jukugo < chosen_jukugo)) { + chosen = i; + chosen_card = candidate_card; + chosen_kind = candidate_kind; + chosen_jukugo = candidate_jukugo; + } + } + if (chosen != (size_t)-1) { + review = &deck->reviews[deck->items[chosen].review_index]; + review->introduced_day = today; + review->due_epoch = (long)now; + *item_index = chosen; + (void)kd_deck_save(deck); + return 1; + } + return 0; +} + +void kd_grade(KDDeck *deck, size_t item_index, int remembered, time_t now) +{ + KDReview *review; + long next_interval; + + if (item_index >= deck->item_count) { + return; + } + review = &deck->reviews[deck->items[item_index].review_index]; + review->last_epoch = (long)now; + if (remembered) { + if (review->relearning) { + review->relearning = 0; + review->due_epoch = (long)now + review->interval_days * 86400L; + review->repetitions++; + (void)kd_deck_save(deck); + return; + } + if (review->repetitions <= 0L) { + next_interval = 1L; + } else if (review->repetitions == 1L) { + next_interval = 3L; + } else { + next_interval = (review->interval_days * review->ease_milli) / 1000L; + if (next_interval < 1L) { + next_interval = 1L; + } + } + if (next_interval > KD_MAX_INTERVAL_DAYS) { + next_interval = KD_MAX_INTERVAL_DAYS; + } + review->interval_days = next_interval; + review->repetitions++; + review->ease_milli += 50L; + if (review->ease_milli > 3000L) { + review->ease_milli = 3000L; + } + review->due_epoch = (long)now + next_interval * 86400L; + } else { + if (review->interval_days < 1L) { + review->interval_days = 1L; + } + review->interval_days = 1L; + if (review->repetitions < 2L) { + review->repetitions = 2L; + } + review->lapses++; + review->ease_milli -= 200L; + if (review->ease_milli < 1300L) { + review->ease_milli = 1300L; + } + review->relearning = 1; + review->due_epoch = (long)now + 600L; + } + (void)kd_deck_save(deck); +} + +static void kd_reset_review_for_relearning(KDReview *review, time_t now) +{ + review->interval_days = 1L; + if (review->repetitions < 2L) { + review->repetitions = 2L; + } + review->lapses++; + review->ease_milli -= 200L; + if (review->ease_milli < 1300L) { + review->ease_milli = 1300L; + } + review->last_epoch = (long)now; + review->relearning = 1; + review->due_epoch = (long)now + 600L; + if (review->introduced_day < 0L) { + review->introduced_day = kd_today_key(now); + } +} + +int kd_relearn_prompt(KDDeck *deck, size_t item_index, time_t now) +{ + KDReview *review; + + if (deck == NULL || item_index >= deck->item_count) { + return 0; + } + review = &deck->reviews[deck->items[item_index].review_index]; + kd_reset_review_for_relearning(review, now); + kd_add_message(deck, 0, "Relearning prompt %s: post-lapse interval set to 1 day; due in 10 minutes", + deck->items[item_index].key); + return kd_deck_save(deck); +} + +int kd_relearn_card(KDDeck *deck, size_t card_index, time_t now) +{ + size_t i; + size_t reset_count; + + if (deck == NULL || card_index >= deck->card_count) { + return 0; + } + reset_count = 0U; + for (i = 0U; i < deck->item_count; i++) { + if (deck->items[i].card_index == card_index) { + kd_reset_review_for_relearning( + &deck->reviews[deck->items[i].review_index], now); + reset_count++; + } + } + if (reset_count == 0U) { + return 0; + } + kd_add_message(deck, 0, + "Relearning kanji %s and %lu associated prompt(s): 10-minute step, then 1-day interval", + deck->cards[card_index].id, + (unsigned long)(reset_count - 1U)); + return kd_deck_save(deck); +} |
