summaryrefslogtreecommitdiff
path: root/src/kd_deck.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/kd_deck.c')
-rw-r--r--src/kd_deck.c1228
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);
+}