aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bstree.c183
-rw-r--r--src/components.c239
-rw-r--r--src/config.c264
-rw-r--r--src/fs.c144
-rw-r--r--src/log.c25
-rw-r--r--src/render.c234
-rw-r--r--src/revela.c74
-rw-r--r--src/site.c269
-rw-r--r--src/tests/config.c54
-rw-r--r--src/tests/fs.c74
10 files changed, 1560 insertions, 0 deletions
diff --git a/src/bstree.c b/src/bstree.c
new file mode 100644
index 0000000..f0312a8
--- /dev/null
+++ b/src/bstree.c
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2021 Yaroslav de la Peña Smirnov
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+#include "bstree.h"
+
+#include <stdlib.h>
+
+struct bstree *
+bstree_new(bst_cmp_fn bstcmp, bst_free_fn bstfree)
+{
+ struct bstree *tree = malloc(sizeof *tree);
+ tree->root = NULL;
+ tree->cmp = bstcmp;
+ tree->free = bstfree;
+
+ return tree;
+}
+
+struct bstnode *
+bstree_add(struct bstree *tree, void *val)
+{
+ struct bstnode **cur;
+ struct bstnode *prev = NULL;
+ cur = &tree->root;
+ while (*cur != NULL) {
+ prev = *cur;
+ if (tree->cmp(val, prev->value) < 0) {
+ cur = &prev->left;
+ } else {
+ cur = &prev->right;
+ }
+ }
+ *cur = calloc(1, sizeof **cur);
+ (*cur)->value = val;
+ (*cur)->parent = prev;
+
+ return *cur;
+}
+
+struct bstnode *
+bstree_search(struct bstree *tree, void *val)
+{
+ struct bstnode *node = tree->root;
+ while (node != NULL) {
+ int res;
+ if ((res = tree->cmp(val, node->value)) == 0) {
+ break;
+ }
+ if (res < 0) {
+ node = node->left;
+ } else {
+ node = node->right;
+ }
+ }
+
+ return node;
+}
+
+#define bstree_extreme(node, d) \
+ while (node != NULL && node->d != NULL) { \
+ node = node->d; \
+ }
+
+struct bstnode *
+bstree_max(struct bstnode *node)
+{
+ bstree_extreme(node, right)
+ return node;
+}
+
+struct bstnode *
+bstree_min(struct bstnode *node)
+{
+ bstree_extreme(node, left)
+ return node;
+}
+
+#define bstree_xcessor(na, d, m) \
+ if (na->d != NULL) { \
+ return bstree_##m(na->d); \
+ } \
+ struct bstnode *nb = na->parent; \
+ while (nb != NULL && nb->d == na) { \
+ na = nb; \
+ nb = nb->parent; \
+ } \
+ return nb
+
+struct bstnode *
+bstree_successor(struct bstnode *na)
+{
+ bstree_xcessor(na, right, max);
+}
+
+struct bstnode *
+bstree_predecessor(struct bstnode *na)
+{
+ bstree_xcessor(na, left, min);
+}
+
+bool
+bstree_inorder_walk(struct bstnode *node, bst_walk_cb cb, void *data)
+{
+ if (node->left != NULL) {
+ if (!bstree_inorder_walk(node->left, cb, data)) return false;
+ }
+ if (!cb(node, data)) return false;
+ if (node->right != NULL) {
+ if (!bstree_inorder_walk(node->right, cb, data)) return false;
+ }
+ return true;
+}
+
+static void
+bstree_transplant(struct bstree *tree, struct bstnode *a, struct bstnode *b)
+{
+ if (a->parent == NULL) {
+ tree->root = b;
+ } else if (a == a->parent->left) {
+ a->parent->left = b;
+ } else {
+ a->parent->right = b;
+ }
+ if (b != NULL) {
+ b->parent = a->parent;
+ }
+}
+
+void
+bstree_remove(struct bstree *tree, struct bstnode *na)
+{
+ if (na->left == NULL) {
+ bstree_transplant(tree, na, na->right);
+ } else if (na->right == NULL) {
+ bstree_transplant(tree, na, na->left);
+ } else {
+ struct bstnode *nb = bstree_min(na->right);
+ if (nb->parent != na) {
+ bstree_transplant(tree, nb, nb->right);
+ nb->right = na->right;
+ nb->right->parent = nb;
+ }
+ bstree_transplant(tree, na, nb);
+ nb->left = na->left;
+ nb->left->parent = nb;
+ }
+ tree->free(na->value);
+ free(na);
+}
+
+static void
+bstree_subdestroy(struct bstree *tree, struct bstnode *node)
+{
+ if (node->right) bstree_subdestroy(tree, node->right);
+ if (node->left) bstree_subdestroy(tree, node->left);
+ tree->free(node->value);
+ free(node);
+}
+
+void
+bstree_destroy(struct bstree *tree)
+{
+ if (tree->root) bstree_subdestroy(tree, tree->root);
+ free(tree);
+}
diff --git a/src/components.c b/src/components.c
new file mode 100644
index 0000000..082eab9
--- /dev/null
+++ b/src/components.c
@@ -0,0 +1,239 @@
+#include "components.h"
+
+#include <stdio.h>
+#include <ctype.h>
+#include <stdint.h>
+#include <string.h>
+#include <stdlib.h>
+#include <limits.h>
+
+#include "fs.h"
+#include "log.h"
+#include "site.h"
+
+#define MAXTIME \
+ ((unsigned long long)1 << ((sizeof(time_t) * CHAR_BIT) - 1)) - 1
+
+#define TSTAMP_CMP(T, a, b) \
+ (((T *)a)->tstamp > ((T *)b)->tstamp) - (((T *)a)->tstamp < ((T *)b)->tstamp)
+
+/*
+ * Reverses the order of the parent directories, changes '/' and spaces to '-',
+ * lowers the case of letters. E.g. "2019/Spain/Santiago de Compostela" ->
+ * "santiago-de-compostela-spain-2019". Returns pointer to slug inside of url.
+ */
+static const char *
+slugify(const char *path, const char *base_url, char **url)
+{
+ size_t baselen = strlen(base_url),
+ pathlen, totlen;
+
+ if (!strcmp(path, CONTENTDIR)) {
+ pathlen = strlen(DEFAULTALBUM);
+ totlen = baselen + pathlen + 2;
+ *url = malloc(totlen);
+ strncpy(*url, base_url, baselen);
+ (*url)[baselen] = '/';
+ strncpy(*url + baselen + 1, DEFAULTALBUM, pathlen + 1);
+
+ return *url + baselen + 1;
+ }
+
+ path += strlen(CONTENTDIR) + 1;
+ pathlen = strlen(path);
+ totlen = baselen + pathlen + 2;
+ const char *start = path;
+ const char *end = strchr(start, '/');
+ *url = malloc(totlen);
+ strncpy(*url, base_url, baselen);
+ (*url)[baselen] = '/';
+ char *slug = *url + baselen + 1;
+ size_t offset = pathlen;
+ while (end != NULL) {
+ offset -= end - start;
+ memcpy(slug + offset, start, end - start);
+ slug[--offset] = '-';
+ start = end + 1;
+ end = strchr(start, '/');
+ }
+ memcpy(slug, start, path + pathlen - start);
+ for (size_t i = 0; i < pathlen; i++) {
+ if (isspace(slug[i])) {
+ slug[i] = '-';
+ continue;
+ }
+ slug[i] = tolower(slug[i]);
+ }
+ (*url)[totlen - 1] = '\0';
+ return slug;
+}
+
+static void
+image_date_from_stat(struct image *image, const struct stat *pstat,
+ struct tm *date)
+{
+ image->tstamp = pstat->st_ctim.tv_sec;
+ localtime_r(&image->tstamp, date);
+}
+
+/*
+ * If exif data is present and either the tag DateTimeOriginal or
+ * CreateDate/DateTimeDigitized is present, then the date and time are taken
+ * from either (DateTimeOriginal takes precedence). Otherwise it uses the file's
+ * creation time (st_ctim).
+ */
+static void
+image_set_date(struct image *image, const struct stat *pstat)
+{
+ struct tm date = {0};
+
+ if (image->exif_data == NULL) {
+ log_printl(LOG_DEBUG, "No exif data present in %s", image->source);
+ log_printl(LOG_DEBUG, "Using date from stat for file %s", image->source);
+ image_date_from_stat(image, pstat, &date);
+ goto out;
+ }
+
+ ExifEntry *entry = exif_content_get_entry(
+ image->exif_data->ifd[EXIF_IFD_EXIF], EXIF_TAG_DATE_TIME_ORIGINAL);
+ if (entry == NULL) {
+ entry = exif_content_get_entry(
+ image->exif_data->ifd[EXIF_IFD_EXIF], EXIF_TAG_DATE_TIME_DIGITIZED);
+ if (entry == NULL) {
+ log_printl(LOG_DEBUG, "No date exif tags present in %s",
+ image->source);
+ log_printl(LOG_DEBUG, "Using date from stat for file %s",
+ image->source);
+ image_date_from_stat(image, pstat, &date);
+ goto out;
+ }
+ }
+
+ char buf[32];
+ exif_entry_get_value(entry, buf, 32);
+ if (strptime(buf, "%Y:%m:%d %H:%M:%S", &date) == NULL) {
+ image_date_from_stat(image, pstat, &date);
+ goto out;
+ }
+ image->tstamp = mktime(&date);
+
+out:
+ /* TODO: honor user's locale and/or give an option to set the date format */
+ strftime(image->datestr, 24, "%Y-%m-%d %H:%M:%S", &date);
+}
+
+struct image *
+image_new(char *src, const struct stat *pstat, struct album *album)
+{
+ struct image *image = calloc(1, sizeof *image);
+ if (image == NULL) {
+ log_printl_errno(LOG_FATAL, "Memory allocation error");
+ return NULL;
+ }
+ char noext[NAME_MAX + 1];
+
+ image->album = album;
+ image->source = src;
+ image->basename = rbasename(src);
+
+ if ((image->ext = delext(image->basename, noext, NAME_MAX + 1)) == NULL) {
+ log_printl(LOG_FATAL, "Can't read %s, file name too long",
+ image->basename);
+ free(image);
+ return NULL;
+ }
+
+ size_t relstart = album->slug - album->url;
+ image->url = joinpath(album->url, noext);
+ image->url_image = joinpath(image->url, image->basename);
+ image->url_thumb = malloc(strlen(image->url) + strlen(PHOTO_THUMB_SUFFIX) +
+ strlen(image->basename) + 2);
+ image->dst = image->url + relstart;
+ image->dst_image = image->url_image + relstart;
+ image->dst_thumb = image->url_thumb + relstart;
+ sprintf(image->url_thumb, "%s/%s" PHOTO_THUMB_SUFFIX "%s", image->url,
+ noext, image->ext);
+
+ image->exif_data = exif_data_new_from_file(image->source);
+ image->modtime = pstat->st_mtim;
+ image_set_date(image, pstat);
+ image->map = hashmap_new_with_cap(8);
+ image->thumb = hashmap_new_with_cap(4);
+
+ return image;
+}
+
+int
+image_cmp(const void *a, const void *b)
+{
+ return TSTAMP_CMP(struct image, a, b);
+}
+
+void
+image_destroy(void *data)
+{
+ struct image *image = data;
+ free(image->source);
+ free(image->url);
+ free(image->url_image);
+ free(image->url_thumb);
+ if (image->exif_data) {
+ exif_data_unref(image->exif_data);
+ }
+ hashmap_free(image->map);
+ hashmap_free(image->thumb);
+ free(image);
+}
+
+struct album *
+album_new(struct album_config *conf, struct site_config *sconf, const char *src,
+ const char *rsrc, const struct stat *dstat)
+{
+ struct album *album = calloc(1, sizeof *album);
+ if (album == NULL) {
+ log_printl_errno(LOG_FATAL, "Memory allocation error");
+ return NULL;
+ }
+ album->config = conf;
+ album->source = strdup(src);
+ album->slug = slugify(rsrc, sconf->base_url, &album->url);
+ album->images = bstree_new(image_cmp, image_destroy);
+ album->tstamp = MAXTIME;
+ album->map = hashmap_new_with_cap(8);
+ album->thumbs = vector_new(128);
+ album->previews = vector_new(sconf->max_previews);
+
+ return album;
+}
+
+int
+album_cmp(const void *a, const void *b)
+{
+ return TSTAMP_CMP(struct album, a, b);
+}
+
+void
+album_add_image(struct album *album, struct image *image)
+{
+ if (image->tstamp < album->tstamp) {
+ album->tstamp = image->tstamp;
+ album->datestr = image->datestr;
+ }
+ bstree_add(album->images, image);
+}
+
+void
+album_destroy(void *data)
+{
+ struct album *album = data;
+ if (album->config != NULL) {
+ album_config_destroy(album->config);
+ }
+ free(album->source);
+ free(album->url);
+ bstree_destroy(album->images);
+ hashmap_free(album->map);
+ vector_free(album->thumbs);
+ vector_free(album->previews);
+ free(album);
+}
diff --git a/src/config.c b/src/config.c
new file mode 100644
index 0000000..e9dd609
--- /dev/null
+++ b/src/config.c
@@ -0,0 +1,264 @@
+#include "config.h"
+
+#include <errno.h>
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+
+#include "fs.h"
+#include "log.h"
+#include "parcini.h"
+
+typedef enum kv_handler_result (*ini_keyvalue_handler_fn)(struct parcini_line *, void *dst);
+
+enum config_key_result {
+ CONFIG_KEY_NONE,
+ CONFIG_KEY_OK,
+ CONFIG_KEY_BADKEY,
+ CONFIG_KEY_BADVALUE,
+};
+
+enum kv_handler_result {
+ KV_HANDLER_OK,
+ KV_HANDLER_NOMATCH,
+ KV_HANDLER_BADVALUE,
+};
+
+static int
+site_config_images_keyvalue_handler(struct parcini_line *parsed,
+ struct image_config *iconfig)
+{
+ int res = CONFIG_KEY_BADKEY;
+ if (!strcmp(parsed->key, "strip")) {
+ res = parcini_value_handle(&parsed->value, PARCINI_VALUE_BOOLEAN,
+ &iconfig->strip) ? CONFIG_KEY_OK : CONFIG_KEY_BADVALUE;
+ }
+ if (!strcmp(parsed->key, "quality")) {
+ long int temp;
+ res = parcini_value_handle(&parsed->value, PARCINI_VALUE_INTEGER,
+ &temp) ? CONFIG_KEY_OK : CONFIG_KEY_BADVALUE;
+ if (res == CONFIG_KEY_OK) {
+ if (temp > 100 || temp < 0) {
+ res = CONFIG_KEY_BADVALUE;
+ } else {
+ iconfig->quality = (uint8_t)temp;
+ }
+ }
+ }
+ if (!strcmp(parsed->key, "max_width")) {
+ long int temp;
+ res = parcini_value_handle(&parsed->value, PARCINI_VALUE_INTEGER,
+ &temp) ? CONFIG_KEY_OK : CONFIG_KEY_BADVALUE;
+ if (res == CONFIG_KEY_OK) {
+ if (temp < 1) {
+ res = CONFIG_KEY_BADVALUE;
+ } else {
+ iconfig->max_width = (size_t)temp;
+ }
+ }
+ }
+ if (!strcmp(parsed->key, "max_height")) {
+ long int temp;
+ res = parcini_value_handle(&parsed->value, PARCINI_VALUE_INTEGER,
+ &temp) ? CONFIG_KEY_OK : CONFIG_KEY_BADVALUE;
+ if (res == CONFIG_KEY_OK) {
+ if (temp < 1) {
+ res = CONFIG_KEY_BADVALUE;
+ } else {
+ iconfig->max_height = (size_t)temp;
+ }
+ }
+ }
+ if (!strcmp(parsed->key, "smart_resize")) {
+ res = parcini_value_handle(&parsed->value, PARCINI_VALUE_BOOLEAN,
+ &iconfig->smart_resize) ? CONFIG_KEY_OK : CONFIG_KEY_BADVALUE;
+ }
+
+ return res;
+}
+
+#define MATCHSK(s, k, p) !strcmp(s, p->section) && !strcmp(k, p->key)
+
+static enum kv_handler_result
+site_config_keyvalue_handler(struct parcini_line *parsed, void *dst)
+{
+ struct site_config *config = dst;
+ enum config_key_result subconf = CONFIG_KEY_NONE;
+ if (!strcmp(parsed->section, "images")) {
+ subconf = site_config_images_keyvalue_handler(parsed, &config->images);
+ } else if (!strcmp(parsed->section, "thumbnails")) {
+ subconf = site_config_images_keyvalue_handler(parsed,
+ &config->thumbnails);
+ }
+ switch (subconf) {
+ case CONFIG_KEY_OK:
+ return KV_HANDLER_OK;
+ case CONFIG_KEY_BADKEY:
+ goto out;
+ case CONFIG_KEY_BADVALUE:
+ return KV_HANDLER_BADVALUE;
+ default:
+ break;
+ }
+ if (MATCHSK("", "title", parsed)) {
+ free(config->title);
+ return parcini_value_handle(&parsed->value, PARCINI_VALUE_STRING,
+ &config->title)?
+ KV_HANDLER_OK : KV_HANDLER_BADVALUE;
+ }
+ if (MATCHSK("", "base_url", parsed)) {
+ free(config->base_url);
+ return parcini_value_handle(&parsed->value, PARCINI_VALUE_STRING,
+ &config->base_url)?
+ KV_HANDLER_OK : KV_HANDLER_BADVALUE;
+ }
+ if (MATCHSK("", "max_previews", parsed)) {
+ return parcini_value_handle(&parsed->value, PARCINI_VALUE_INTEGER,
+ &config->max_previews)?
+ KV_HANDLER_OK : KV_HANDLER_BADVALUE;
+ }
+
+
+out:
+ return KV_HANDLER_NOMATCH;
+}
+
+static enum kv_handler_result
+album_config_keyvalue_handler(struct parcini_line *parsed, void *dst)
+{
+ struct album_config *config = dst;
+ if (MATCHSK("", "title", parsed)) {
+ free(config->title);
+ return parcini_value_handle(&parsed->value, PARCINI_VALUE_STRING,
+ &config->title) ?
+ KV_HANDLER_OK : KV_HANDLER_BADVALUE;
+ }
+ if (MATCHSK("", "desc", parsed)) {
+ free(config->desc);
+ return parcini_value_handle(&parsed->value, PARCINI_VALUE_STRING,
+ &config->desc) ?
+ KV_HANDLER_OK : KV_HANDLER_BADVALUE;
+ }
+
+ return KV_HANDLER_NOMATCH;
+}
+
+static bool ini_handler(const char *fpath, ini_keyvalue_handler_fn kvhandler,
+ void *dst)
+{
+ struct parcini_line parsed;
+ enum parcini_result res;
+ parcini_t *parser = parcini_from_file(fpath);
+ if (parser == NULL){
+ log_printl_errno(LOG_FATAL, "Couldn't open %s", fpath);
+ return false;
+ }
+
+ bool ok = true;
+ enum kv_handler_result hres;
+ while ((res = parcini_parse_next_line(parser, &parsed)) != PARCINI_EOF
+ && ok) {
+ switch (res) {
+ case PARCINI_KEYVALUE:
+ if ((hres = kvhandler(&parsed, dst)) == KV_HANDLER_BADVALUE) {
+ log_printl(LOG_ERROR, "Error parsing %s:%ld, bad value for key"
+ "%s", fpath, parsed.lineno, parsed.key);
+ ok = false;
+ } else if(hres == KV_HANDLER_NOMATCH) {
+ log_printl(LOG_ERROR,
+ "Warning: key '%s' in section '%s' is not a valid "
+ "section-key combination for %s, ignoring.",
+ parsed.key, parsed.section, fpath);
+ }
+ continue;
+
+ case PARCINI_EMPTY_LINE:
+ case PARCINI_SECTION:
+ continue;
+
+ case PARCINI_STREAM_ERROR:
+ case PARCINI_MEMORY_ERROR:
+ log_printl_errno(LOG_FATAL, "Error reading %s", fpath);
+ ok = false;
+ break;
+
+ default:
+ log_printl(LOG_FATAL, "Error parsing %s:%ld", fpath, parsed.lineno);
+ ok = false;
+ break;
+ }
+ }
+
+ parcini_destroy(parser);
+ return ok;
+}
+
+bool
+site_config_read_ini(const char *wdir, struct site_config *config)
+{
+ if (wdir == NULL) {
+ return ini_handler(SITE_CONF, site_config_keyvalue_handler,
+ config);
+ }
+ char *fpath = joinpath(wdir, SITE_CONF);
+ bool ok = ini_handler(fpath, site_config_keyvalue_handler, config);
+ free(fpath);
+ return ok;
+}
+
+bool
+album_config_read_ini(const char *fpath, struct album_config *config)
+{
+ bool ok = ini_handler(fpath, album_config_keyvalue_handler, config);
+ return ok;
+}
+
+struct site_config *
+site_config_init(void)
+{
+ struct site_config *config = calloc(1, sizeof *config);
+ if (config != NULL) {
+ config->max_previews = 10;
+ config->images = (struct image_config) {
+ .strip = true,
+ .quality = 80,
+ .max_width = 3000,
+ .max_height = 2000,
+ .smart_resize = true,
+ };
+ config->thumbnails = (struct image_config) {
+ .strip = true,
+ .quality = 60,
+ .max_width = 400,
+ .max_height = 270,
+ .smart_resize = true,
+ };
+ }
+
+ return config;
+}
+
+struct album_config *
+album_config_init(void)
+{
+ struct album_config *config = calloc(1, sizeof *config);
+ return config;
+}
+
+void
+site_config_destroy(struct site_config *config)
+{
+ free(config->title);
+ free(config->base_url);
+ free(config);
+}
+
+void
+album_config_destroy(struct album_config *config)
+{
+ free(config->title);
+ free(config->desc);
+ free(config);
+}
diff --git a/src/fs.c b/src/fs.c
new file mode 100644
index 0000000..e4a6d7b
--- /dev/null
+++ b/src/fs.c
@@ -0,0 +1,144 @@
+#include "fs.h"
+
+#include "log.h"
+#include "config.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <ctype.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <limits.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+#define CONTENTDIR "content"
+#define DEFAULTALBUM "unorganized"
+
+/*
+ * File extensions based on which we add files to the list of images to be
+ * processed.
+ */
+static const char *img_extensions[] = {
+ "jpg",
+ "jpeg",
+ "png",
+ "tiff",
+ NULL,
+};
+
+const char *
+rbasename(const char *path)
+{
+ char *delim = strrchr(path, '/');
+ if (delim == NULL) {
+ return path;
+ }
+ return delim + 1;
+}
+
+bool
+nmkdir(const char *path, struct stat *dstat, bool dry)
+{
+ if (dry) {
+ if (stat(path, dstat)) {
+ if (errno == ENOENT) {
+ log_printl(LOG_DETAIL, "Created directory %s", path);
+ return true;
+ }
+ log_printl_errno(LOG_FATAL, "Can't read %s", path);
+ return false;
+ }
+ if (!S_ISDIR(dstat->st_mode)) {
+ log_printl(LOG_FATAL, "%s is not a directory", path);
+ return false;
+ }
+ return true;
+ }
+
+ if(mkdir(path, 0755) < 0) {
+ if (errno == EEXIST) {
+ if (stat(path, dstat)) {
+ log_printl_errno(LOG_FATAL, "Can't read %s", path);
+ return false;
+ }
+ if (!S_ISDIR(dstat->st_mode)) {
+ log_printl(LOG_FATAL, "%s is not a directory", path);
+ return false;
+ }
+ } else {
+ log_printl_errno(LOG_FATAL, "Can't make directory %s", path);
+ return false;
+ }
+ } else {
+ log_printl(LOG_DETAIL, "Created directory %s", path);
+ }
+
+ return true;
+}
+
+char *
+joinpath(const char *restrict a, const char *restrict b)
+{
+ char *fpath = malloc(strlen(a) + strlen(b) + 2);
+ joinpathb(fpath, a, b);
+
+ return fpath;
+}
+
+bool
+isimage(const char *fname)
+{
+ char *ext = strrchr(fname, '.');
+ if (!ext || *(ext + 1) == '\0') return false;
+
+ ext++;
+ for (size_t i = 0; img_extensions[i] != NULL; i++) {
+ if (!strcasecmp(ext, img_extensions[i])) return true;
+ }
+
+ return false;
+}
+
+const char *
+delext(const char *restrict basename, char *restrict buf, size_t n)
+{
+ const char *ext = strrchr(basename, '.');
+ size_t i = ext - basename;
+ if (i + 1 > n) return NULL;
+ memcpy(buf, basename, i);
+ buf[i] = '\0';
+
+ return ext;
+}
+
+int
+file_is_uptodate(const char *path, const struct timespec *srcmtim)
+{
+ struct stat dststat;
+ if (stat(path, &dststat)) {
+ if (errno != ENOENT) {
+ log_printl_errno(LOG_FATAL, "Can't read file %s", path);
+ return -1;
+ }
+ return 0;
+ } else if (dststat.st_mtim.tv_sec != srcmtim->tv_sec
+ || dststat.st_mtim.tv_nsec != srcmtim->tv_nsec) {
+ return 0;
+ }
+
+ return 1;
+}
+
+void
+setdatetime(const char *path, const struct timespec *mtim)
+{
+ struct timespec tms[] = {
+ { .tv_sec = mtim->tv_sec, .tv_nsec = mtim->tv_nsec },
+ { .tv_sec = mtim->tv_sec, .tv_nsec = mtim->tv_nsec },
+ };
+ if (utimensat(AT_FDCWD, path, tms, 0) == -1) {
+ log_printl_errno(LOG_ERROR, "Warning: couldn't set times of %s", path);
+ }
+}
diff --git a/src/log.c b/src/log.c
new file mode 100644
index 0000000..4ff617f
--- /dev/null
+++ b/src/log.c
@@ -0,0 +1,25 @@
+#include "log.h"
+
+#include <stdio.h>
+#include <stdarg.h>
+#include <string.h>
+#include <stdbool.h>
+
+static enum log_level log_verbosity = LOG_INFO;
+
+void
+log_set_verbosity(enum log_level lvl)
+{
+ log_verbosity = lvl;
+}
+
+void
+log_printf(enum log_level lvl, const char *restrict fmt, ...)
+{
+ if (lvl > log_verbosity) return;
+ FILE *out = lvl < LOG_INFO ? stderr : stdout;
+ va_list args;
+ va_start(args, fmt);
+ vfprintf(out, fmt, args);
+ va_end(args);
+}
diff --git a/src/render.c b/src/render.c
new file mode 100644
index 0000000..7c72cad
--- /dev/null
+++ b/src/render.c
@@ -0,0 +1,234 @@
+#include "render.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "fs.h"
+#include "log.h"
+#include "site.h"
+
+static bool
+images_walk(struct bstnode *node, void *data)
+{
+ struct image *image = node->value;
+
+ hashmap_insert(image->map, "source", image->url_image);
+ hashmap_insert(image->map, "date", image->datestr);
+ struct bstnode *prev = bstree_predecessor(node),
+ *next = bstree_successor(node);
+ char *url;
+ if (prev) {
+ url = ((struct image *)prev->value)->url;
+ hashmap_insert(image->map, "prev", url);
+ }
+ if (next) {
+ url = ((struct image *)next->value)->url;
+ hashmap_insert(image->map, "next", url);
+ }
+
+ hashmap_insert(image->thumb, "link", image->url);
+ hashmap_insert(image->thumb, "source", image->url_thumb);
+
+ vector_push(image->album->thumbs, image->thumb);
+
+ return true;
+}
+
+static struct hashmap *
+years_push_new_year(struct vector *years, char *yearstr)
+{
+ struct hashmap *year = hashmap_new_with_cap(4);
+ struct vector *albums = vector_new(8);
+ hashmap_insert(year, "name", yearstr);
+ hashmap_insert(year, "albums", albums);
+ vector_push(years, year);
+
+ return year;
+}
+
+static void
+years_push_album(struct vector *years, struct album *album)
+{
+ struct hashmap *year;
+ struct vector *albums;
+ if (years->size == 0) {
+ year = years_push_new_year(years, album->year);
+ } else {
+ year = years->values[years->size - 1];
+ char *yearstr = hashmap_get(year, "name");
+ if (strcmp(yearstr, album->year)) {
+ year = years_push_new_year(years, album->year);
+ }
+ }
+ albums = hashmap_get(year, "albums");
+ vector_push(albums, album->map);
+}
+
+static void
+year_walk(const void *k, void *v)
+{
+ if (!strcmp(k, "albums")) {
+ struct vector *albums = v;
+ vector_free(albums);
+ }
+}
+
+static void
+years_destroy(struct vector *years)
+{
+ for (size_t i = 0; i < years->size; i++) {
+ struct hashmap *year = years->values[i];
+ hashmap_walk(year, year_walk);
+ hashmap_free(year);
+ }
+ vector_free(years);
+}
+
+static bool
+albums_walk(struct bstnode *node, void *data)
+{
+ struct album *album = node->value;
+ struct render *r = data;
+
+ hashmap_insert(album->map, "title", album->config->title);
+ hashmap_insert(album->map, "desc", album->config->desc);
+ hashmap_insert(album->map, "link", album->url);
+
+ bstree_inorder_walk(album->images->root, images_walk, NULL);
+
+ for (uint32_t i = 0;
+ i < album->thumbs->size && i < album->previews->cap; i++) {
+ vector_push(album->previews, album->thumbs->values[i]);
+ }
+
+ hashmap_insert(album->map, "thumbs", album->thumbs);
+ hashmap_insert(album->map, "previews", album->previews);
+
+ years_push_album(r->years, album);
+ vector_push(r->albums, album->map);
+
+ return true;
+}
+
+static bool
+render(struct env *env, const char *tmpl, const char *opath,
+ struct hashmap *vars)
+{
+ bool ok = true;
+ char *output = template(env, tmpl, vars);
+ size_t outlen = strlen(output);
+ FILE *f = fopen(opath, "w");
+ if (fwrite(output, 1, outlen, f) != outlen) {
+ ok = false;
+ log_printl_errno(LOG_FATAL, "Can't write %s", opath);
+ }
+ fclose(f);
+ free(output);
+ return ok;
+}
+
+#define RENDER_MAKE_START \
+ bool ok = true; \
+ int isupdate = file_is_uptodate(path, &r->modtime); \
+ if (isupdate == -1) return false; \
+ if (isupdate == 1) return true; \
+ log_print(LOG_INFO, "Rendering %s...", path); \
+ if (r->dry_run) goto done
+
+#define RENDER_MAKE_END \
+ setdatetime(path, &r->modtime); \
+done: \
+ log_printf(LOG_INFO, " done.\n"); \
+ return ok
+
+bool
+render_make_index(struct render *r, const char *path)
+{
+ RENDER_MAKE_START;
+
+ hashmap_insert(r->common_vars, "years", r->years);
+ hashmap_insert(r->common_vars, "albums", r->years);
+ ok = render(r->env, "index.html", path, r->common_vars);
+ hashmap_remove(r->common_vars, "years");
+ hashmap_remove(r->common_vars, "albums");
+
+ RENDER_MAKE_END;
+}
+
+bool
+render_make_album(struct render *r, const char *path, const struct album *album)
+{
+ hashmap_insert(r->common_vars, "album", album->map);
+
+ RENDER_MAKE_START;
+
+ ok = render(r->env, "album.html", path, r->common_vars);
+ /*
+ * Since we actually still want this album's map for the image inside the
+ * album, we don't remove it.
+ */
+
+ RENDER_MAKE_END;
+}
+
+bool
+render_make_image(struct render *r, const char *path, const struct image *image)
+{
+ RENDER_MAKE_START;
+
+ hashmap_insert(r->common_vars, "image", image->map);
+ ok = render(r->env, "image.html", path, r->common_vars);
+ hashmap_remove(r->common_vars, "image");
+
+ RENDER_MAKE_END;
+}
+
+bool
+render_init(struct render *r, const char *root, struct site_config *conf,
+ struct bstree *albums)
+{
+ char *tmplpath = joinpath(root, TEMPLATESDIR);
+ struct stat tstat;
+ if (stat(tmplpath, &tstat) == -1) {
+ if (errno == ENOENT) {
+ free(tmplpath);
+ tmplpath = joinpath(SITE_DEFAULT_RESOURCES, TEMPLATESDIR);
+ } else {
+ log_printl_errno(LOG_FATAL, "Unable to read templates dir");
+ return false;
+ }
+ }
+
+ r->modtime = tstat.st_mtim;
+
+ if (r->dry_run) return true;
+
+ r->env = env_new(tmplpath);
+ if (r->env == NULL) {
+ log_printl_errno(LOG_FATAL, "Couldn't initialize template engine");
+ return false;
+ }
+ r->years = vector_new(8);
+ r->albums = vector_new(64);
+
+ r->common_vars = hashmap_new_with_cap(8);
+ hashmap_insert(r->common_vars, "title", conf->title);
+ if (strlen(conf->base_url) == 0) {
+ hashmap_insert(r->common_vars, "index", "/");
+ } else {
+ hashmap_insert(r->common_vars, "index", conf->base_url);
+ }
+
+ bstree_inorder_walk(albums->root, albums_walk, (void *)r);
+
+ return true;
+}
+
+void
+render_deinit(struct render *r)
+{
+ env_free(r->env);
+ years_destroy(r->years);
+ vector_free(r->albums);
+ hashmap_free(r->common_vars);
+}
diff --git a/src/revela.c b/src/revela.c
new file mode 100644
index 0000000..89a7a7a
--- /dev/null
+++ b/src/revela.c
@@ -0,0 +1,74 @@
+#include <stdio.h>
+#include <unistd.h>
+#include <stdlib.h>
+#include <string.h>
+#include <dirent.h>
+
+#include "fs.h"
+#include "log.h"
+#include "site.h"
+#include "config.h"
+#include "bstree.h"
+#include "template.h"
+
+static const char *usage =
+ "Usage: %s [options] [-i <input dir>] -o <output dir>\n";
+
+static struct site site = {0};
+static enum log_level loglvl = LOG_DETAIL;
+
+static void
+bad_arguments(const char *cmd)
+{
+ fprintf(stderr, usage, cmd);
+ exit(1);
+}
+
+static void
+parse_arguments(int argc, char *argv[])
+{
+ int opt;
+ char *cmd = argv[0];
+ while ((opt = getopt(argc, argv, "i:o:n")) != -1) {
+ switch (opt) {
+ case 'i':
+ site.root_dir = strdup(optarg);
+ break;
+ case 'o':
+ site.output_dir = optarg;
+ break;
+ case 'n':
+ site.dry_run = true;
+ break;
+ default:
+ bad_arguments(cmd);
+ }
+ }
+ if (site.output_dir == NULL) {
+ bad_arguments(cmd);
+ }
+}
+
+int
+main(int argc, char *argv[])
+{
+ int ret = EXIT_SUCCESS;
+
+ parse_arguments(argc, argv);
+
+#ifdef DEBUG
+ log_set_verbosity(LOG_DEBUG);
+#else
+ log_set_verbosity(loglvl);
+#endif
+
+ ret = site_init(&site) && site_load(&site) && site_build(&site)
+ ? EXIT_SUCCESS : EXIT_FAILURE;
+
+ if (site.dry_run) {
+ log_printl(LOG_INFO, "==== [DRY RUN] ====");
+ }
+
+ site_deinit(&site);
+ return ret;
+}
diff --git a/src/site.c b/src/site.c
new file mode 100644
index 0000000..1acdc7c
--- /dev/null
+++ b/src/site.c
@@ -0,0 +1,269 @@
+#include "site.h"
+
+#include <unistd.h>
+#include <stdlib.h>
+#include <string.h>
+#include <dirent.h>
+#include <limits.h>
+
+#include "fs.h"
+#include "log.h"
+#include "hashmap.h"
+
+/* TODO: Probably shouldn't use PATH_MAX, but i'll leave it for now */
+/* TODO: handle error cases for paths that are too long */
+
+#define THUMB_SUFFIX "_thumb"
+
+static bool
+wand_passfail(MagickWand *wand, MagickPassFail status)
+{
+ if (status != MagickPass) {
+ char *desc;
+ ExceptionType severity;
+ desc = MagickGetException(wand, &severity);
+ log_printl(LOG_FATAL, "GraphicsMagick error: %.1024s severity: %d\n",
+ desc, severity);
+ return false;
+ }
+
+ return true;
+}
+
+#define TRYWAND(w, f) if (!wand_passfail(w, f)) goto magick_fail
+
+static bool
+optimize_image(MagickWand *wand, const char *dst,
+ const struct image_config *conf,
+ const struct timespec *srcmtim, bool dry)
+{
+ int update = file_is_uptodate(dst, srcmtim);
+ if (update == -1) return false;
+ if (update == 1) return true;
+
+ log_print(LOG_DETAIL, "Converting %s...", dst);
+ if (dry) goto out;
+
+ unsigned long nx = conf->max_width, ny = conf->max_height;
+ if (conf->strip) {
+ TRYWAND(wand, MagickStripImage(wand));
+ }
+ TRYWAND(wand, MagickSetCompressionQuality(wand, conf->quality));
+ unsigned long x = MagickGetImageWidth(wand),
+ y = MagickGetImageHeight(wand);
+ /* Resize only if the image is actually bigger. No point in making small
+ * images bigger. */
+ if (x > nx || y > ny) {
+ if (conf->smart_resize) {
+ double ratio = (double)x / y;
+ if (x > y) {
+ ny = ny / ratio;
+ } else {
+ nx = nx * ratio;
+ }
+ }
+ TRYWAND(wand, MagickResizeImage(wand, nx, ny, LanczosFilter, 0));
+ }
+ TRYWAND(wand, MagickWriteImage(wand, dst));
+ setdatetime(dst, srcmtim);
+
+out:
+ log_printf(LOG_DETAIL, " done.\n");
+ return true;
+magick_fail:
+ return false;
+}
+
+static bool
+images_walk(struct bstnode *node, void *data)
+{
+ struct site *site = data;
+ struct image *image = node->value;
+ struct stat dstat;
+
+ log_printl(LOG_DEBUG, "Image: %s, datetime %s", image->basename,
+ image->datestr);
+
+ if (!nmkdir(image->dst, &dstat, site->dry_run)) return false;
+
+ if (!site->dry_run) {
+ TRYWAND(site->wand, MagickReadImage(site->wand, image->source));
+ }
+ if (!optimize_image(site->wand, image->dst_image, &site->config->images,
+ &image->modtime, site->dry_run)) {
+ goto magick_fail;
+ }
+ if (!optimize_image(site->wand, image->dst_thumb, &site->config->thumbnails,
+ &image->modtime, site->dry_run)) {
+ goto magick_fail;
+ }
+ if (!site->dry_run) {
+ MagickRemoveImage(site->wand);
+ }
+
+ char htmlpath[PATH_MAX];
+ joinpathb(htmlpath, image->dst, "index.html");
+ return render_make_image(&site->render, htmlpath, image);
+magick_fail:
+ return false;
+}
+
+static bool
+albums_walk(struct bstnode *node, void *data)
+{
+ struct site *site = data;
+ struct album *album = node->value;
+ struct stat dstat;
+ if (!nmkdir(album->slug, &dstat, site->dry_run)) return false;
+
+ char htmlpath[PATH_MAX];
+ joinpathb(htmlpath, album->slug, "index.html");
+ if (!render_make_album(&site->render, htmlpath, album)) return false;
+
+ log_printl(LOG_DEBUG, "Album: %s, datetime %s", album->slug, album->datestr);
+ return bstree_inorder_walk(album->images->root, images_walk, site);
+}
+
+/*
+ * Recursively traverse the content directory. If there are images in the
+ * directory, "create" an album. If an album.ini was found, then the title and
+ * description in that file are used. Otherwise, the date of the album is used
+ * as its title. If the images are in the root of the content directory, then a
+ * special "unorganized" album will be created. The title and description will
+ * be used, but the slug will always be "unorganized".
+ */
+static bool
+traverse(struct site *site, const char *path, struct stat *dstat)
+{
+ bool ok = true;
+ DIR *dir = opendir(path);
+ if (!dir) {
+ log_printl_errno(LOG_FATAL, "Can't open directory %s", path);
+ return false;
+ }
+ struct dirent *ent;
+ struct album_config *album_conf = calloc(1, sizeof *album_conf);
+ struct album *album = album_new(album_conf, site->config, path,
+ path + site->rel_content_dir, dstat);
+ if (album == NULL) {
+ closedir(dir);
+ return false;
+ }
+ while ((ent = readdir(dir))) {
+ if (!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) {
+ continue;
+ }
+
+ struct stat fstats;
+ char *subpath = joinpath(path, ent->d_name);
+ if (stat(subpath, &fstats)) {
+ log_printl_errno(LOG_FATAL, "Can't read %s", subpath);
+ ok = false;
+ goto fail;
+ }
+
+ if (S_ISDIR(fstats.st_mode)) {
+ ok = traverse(site, subpath, &fstats);
+ if (!ok) goto fail;
+ } else if (!strcmp(ent->d_name, ALBUM_CONF)) {
+ ok = album_config_read_ini(subpath, album_conf);
+ if (!ok) goto fail;
+ } else if (isimage(subpath)) {
+ struct image *image = image_new(subpath, &fstats, album);
+ if (image == NULL) {
+ free(subpath);
+ goto fail;
+ }
+ album_add_image(album, image);
+ continue;
+ }
+ free(subpath);
+ }
+
+ if (album->images->root != NULL) {
+ bstree_add(site->albums, album);
+ closedir(dir);
+ return true;
+ }
+
+fail:
+ album_destroy(album);
+ closedir(dir);
+ return ok;
+}
+
+bool
+site_build(struct site *site)
+{
+ struct stat dstat;
+ if (!nmkdir(site->output_dir, &dstat, false)) return false;
+
+ if (chdir(site->output_dir)) {
+ log_printl_errno(LOG_FATAL, "Can't change to directory %s",
+ site->output_dir);
+ return false;
+ }
+
+ if (!render_make_index(&site->render, "index.html")) return false;
+
+ if (!bstree_inorder_walk(site->albums->root, albums_walk, (void *)site)) {
+ return false;
+ }
+ /* TODO: static files and css */
+ chdir(site->root_dir);
+ return true;
+}
+
+bool
+site_load(struct site *site)
+{
+ struct stat cstat;
+ if (stat(site->content_dir, &cstat)) {
+ log_printl_errno(LOG_FATAL, "Can't read %s", site->content_dir);
+ return false;
+ }
+
+ if (!traverse(site, site->content_dir, &cstat)) return false;
+
+ return render_init(&site->render, site->root_dir, site->config, site->albums);
+}
+
+bool
+site_init(struct site *site)
+{
+ site->config = site_config_init();
+ if (!site_config_read_ini(site->root_dir, site->config)) return false;
+ site->albums = bstree_new(album_cmp, album_destroy);
+
+ if (site->root_dir == NULL) {
+ site->root_dir = malloc(PATH_MAX);
+ if (getcwd(site->root_dir, PATH_MAX) == NULL) {
+ log_printl_errno(LOG_FATAL, "Couldn't get working directory");
+ return false;
+ }
+ }
+
+ site->content_dir = joinpath(site->root_dir, CONTENTDIR);
+ site->rel_content_dir = strlen(site->root_dir) + 1;
+ InitializeMagick(NULL);
+ site->wand = NewMagickWand();
+ site->render.dry_run = site->dry_run;
+
+ return true;
+}
+
+void
+site_deinit(struct site *site)
+{
+ if (site->albums) bstree_destroy(site->albums);
+ site_config_destroy(site->config);
+ free(site->content_dir);
+ free(site->root_dir);
+ if (site->wand != NULL) {
+ DestroyMagickWand(site->wand);
+ DestroyMagick();
+ }
+ if (!site->dry_run) {
+ render_deinit(&site->render);
+ }
+}
diff --git a/src/tests/config.c b/src/tests/config.c
new file mode 100644
index 0000000..8d86a2f
--- /dev/null
+++ b/src/tests/config.c
@@ -0,0 +1,54 @@
+#include <string.h>
+
+#include "tests/tests.h"
+#include "config.h"
+#include "log.h"
+
+#define TESTS_DIR "tests"
+#define TEST_ALBUM "tests/album.ini"
+
+static void
+test_site_config_read_ini(void)
+{
+ struct site_config *config = site_config_init();
+ asserteq(site_config_read_ini(TESTS_DIR, config), true);
+ asserteq(strcmp(config->title, "An example gallery"), 0);
+ asserteq(strcmp(config->base_url, "http://www.example.com/photos"), 0);
+ asserteq(config->max_previews, 20);
+ asserteq(config->images.strip, false);
+ asserteq(config->images.quality, 80);
+ asserteq(config->images.max_width, 3000);
+ asserteq(config->images.max_height, 2000);
+ asserteq(config->images.smart_resize, true);
+ asserteq(config->thumbnails.strip, true);
+ asserteq(config->thumbnails.quality, 75);
+ asserteq(config->thumbnails.max_width, 400);
+ asserteq(config->thumbnails.max_height, 270);
+ asserteq(config->thumbnails.smart_resize, true);
+ site_config_destroy(config);
+}
+
+static void
+test_album_config_read_ini(void)
+{
+ struct album_config *config = album_config_init();
+ asserteq(album_config_read_ini(TEST_ALBUM, config), true);
+ asserteq(strcmp(config->title, "An example album"), 0);
+ asserteq(strcmp(config->desc, "Example description"), 0);
+ album_config_destroy(config);
+}
+
+static void
+init(void)
+{
+ log_set_verbosity(LOG_SILENT);
+}
+
+int
+main(void)
+{
+ INIT_TESTS();
+ init();
+ RUN_TEST(test_site_config_read_ini);
+ RUN_TEST(test_album_config_read_ini);
+}
diff --git a/src/tests/fs.c b/src/tests/fs.c
new file mode 100644
index 0000000..db8ab96
--- /dev/null
+++ b/src/tests/fs.c
@@ -0,0 +1,74 @@
+#include "tests/tests.h"
+#include "fs.h"
+
+#include <time.h>
+#include <string.h>
+
+#define DATETIME_TEST_FILE "tests/empty"
+
+static void
+test_rbasename(void)
+{
+ char *a = "/path/to/hello.jpg";
+ const char *ob;
+ ob = rbasename(a);
+ asserteq(strcmp(ob, "hello.jpg"), 0);
+}
+
+static void
+test_joinpath(void)
+{
+ char *a = "hello", *b = "world.jpeg";
+ char *joined = joinpath(a, b);
+ asserteq(strcmp(joined, "hello/world.jpeg"), 0);
+ free(joined);
+}
+
+static void
+test_isimage(void)
+{
+ char *a = "hello.jpg", *b = "goodbye.jpeg", *c = "iamge.png", *d = "b.tiff";
+ char *notimg = "image.exe";
+ asserteq(isimage(a), true);
+ asserteq(isimage(b), true);
+ asserteq(isimage(c), true);
+ asserteq(isimage(d), true);
+ asserteq(isimage(notimg), false);
+}
+
+static void
+test_delext(void)
+{
+ char *na = "hello.jpg", *nb = "goodbye.tar.gz";
+ size_t bla = 16, blb = 16, blc = 4;
+ char bufa[bla], bufb[blb], bufc[blc];
+ delext(na, bufa, bla);
+ asserteq(strcmp(bufa, "hello"), 0);
+ delext(nb, bufb, blb);
+ asserteq(strcmp(bufb, "goodbye.tar"), 0);
+ delext(nb, bufc, blc);
+}
+
+static void
+test_setdatetime_uptodate(void)
+{
+ time_t now = time(NULL);
+ struct timespec mtim = {
+ .tv_sec = now,
+ .tv_nsec = 690,
+ };
+ asserteq(file_is_uptodate(DATETIME_TEST_FILE, &mtim), 0);
+ setdatetime(DATETIME_TEST_FILE, &mtim);
+ asserteq(file_is_uptodate(DATETIME_TEST_FILE, &mtim), 1);
+}
+
+int
+main(void)
+{
+ INIT_TESTS();
+ RUN_TEST(test_rbasename);
+ RUN_TEST(test_joinpath);
+ RUN_TEST(test_isimage);
+ RUN_TEST(test_delext);
+ RUN_TEST(test_setdatetime_uptodate);
+}