From e3a41da5a0a3d70ac53591f2b66144f2be2b3871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaroslav=20de=20la=20Pe=C3=B1a=20Smirnov?= Date: Sun, 7 Nov 2021 02:02:45 +0300 Subject: Initial commit. Almost functional but still missing features and lacking testing. --- src/bstree.c | 183 ++++++++++++++++++++++++++++++++++++ src/components.c | 239 +++++++++++++++++++++++++++++++++++++++++++++++ src/config.c | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/fs.c | 144 ++++++++++++++++++++++++++++ src/log.c | 25 +++++ src/render.c | 234 ++++++++++++++++++++++++++++++++++++++++++++++ src/revela.c | 74 +++++++++++++++ src/site.c | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tests/config.c | 54 +++++++++++ src/tests/fs.c | 74 +++++++++++++++ 10 files changed, 1560 insertions(+) create mode 100644 src/bstree.c create mode 100644 src/components.c create mode 100644 src/config.c create mode 100644 src/fs.c create mode 100644 src/log.c create mode 100644 src/render.c create mode 100644 src/revela.c create mode 100644 src/site.c create mode 100644 src/tests/config.c create mode 100644 src/tests/fs.c (limited to 'src') 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 + +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 +#include +#include +#include +#include +#include + +#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 +#include +#include +#include +#include +#include + +#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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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 +#include +#include +#include + +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 +#include + +#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 +#include +#include +#include +#include + +#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 ] -o \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 +#include +#include +#include +#include + +#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 + +#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 +#include + +#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); +} -- cgit v1.2.3